diff --git a/lib/config-list.ts b/lib/config-list.ts index b05e9f45..c507098e 100644 --- a/lib/config-list.ts +++ b/lib/config-list.ts @@ -1,4 +1,3 @@ -import { EOL } from 'node:os'; import { BEGIN_CONFIG_LIST_MARKER, END_CONFIG_LIST_MARKER, @@ -6,7 +5,9 @@ import { import { markdownTable } from 'markdown-table'; import type { ConfigsToRules, ConfigEmojis, Plugin, Config } from './types.js'; import { ConfigFormat, configNameToDisplay } from './config-format.js'; -import { sanitizeMarkdownTable } from './string.js'; +import { getEndOfLine, sanitizeMarkdownTable } from './string.js'; + +const EOL = getEndOfLine(); /** * Check potential locations for the config description. diff --git a/lib/generator.ts b/lib/generator.ts index f6860277..57c211e1 100644 --- a/lib/generator.ts +++ b/lib/generator.ts @@ -1,4 +1,3 @@ -import { EOL } from 'node:os'; import { existsSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; import { getAllNamedOptions, hasOptions } from './rule-options.js'; @@ -34,6 +33,7 @@ import { OPTION_TYPE, RuleModule } from './types.js'; import { replaceRulePlaceholder } from './rule-link.js'; import { updateRuleOptionsList } from './rule-options-list.js'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { getEndOfLine } from './string.js'; function stringOrArrayWithFallback( stringOrArray: undefined | T, @@ -63,6 +63,8 @@ function stringOrArrayToArrayWithFallback( // eslint-disable-next-line complexity export async function generate(path: string, options?: GenerateOptions) { + const EOL = getEndOfLine(); + const plugin = await loadPlugin(path); const pluginPrefix = await getPluginPrefix(path); const configsToRules = await resolveConfigsToRules(plugin); diff --git a/lib/markdown.ts b/lib/markdown.ts index bffba98e..5eec590a 100644 --- a/lib/markdown.ts +++ b/lib/markdown.ts @@ -1,4 +1,4 @@ -import { EOL } from 'node:os'; +import { getEndOfLine } from './string.js'; // General helpers for dealing with markdown files / content. @@ -14,6 +14,8 @@ export function replaceOrCreateHeader( newHeader: string, marker: string, ) { + const EOL = getEndOfLine(); + const lines = markdown.split(EOL); const titleLineIndex = lines.findIndex((line) => line.startsWith('# ')); @@ -45,6 +47,8 @@ export function findSectionHeader( markdown: string, str: string, ): string | undefined { + const EOL = getEndOfLine(); + // Get all the matching strings. const regexp = new RegExp(`## .*${str}.*${EOL}`, 'giu'); const sectionPotentialMatches = [...markdown.matchAll(regexp)].map( @@ -68,6 +72,8 @@ export function findSectionHeader( } export function findFinalHeaderLevel(str: string) { + const EOL = getEndOfLine(); + const lines = str.split(EOL); const finalHeader = lines.reverse().find((line) => line.match('^(#+) .+$')); return finalHeader ? finalHeader.indexOf(' ') : undefined; diff --git a/lib/rule-doc-notices.ts b/lib/rule-doc-notices.ts index bc250307..f3801ed4 100644 --- a/lib/rule-doc-notices.ts +++ b/lib/rule-doc-notices.ts @@ -1,4 +1,3 @@ -import { EOL } from 'node:os'; import { END_RULE_HEADER_MARKER } from './comment-markers.js'; import { EMOJI_DEPRECATED, @@ -27,9 +26,12 @@ import { toSentenceCase, removeTrailingPeriod, addTrailingPeriod, + getEndOfLine, } from './string.js'; import { ConfigFormat, configNameToDisplay } from './config-format.js'; +const EOL = getEndOfLine(); + function severityToTerminology(severity: SEVERITY_TYPE) { switch (severity) { case SEVERITY_TYPE.error: { diff --git a/lib/rule-list-legend.ts b/lib/rule-list-legend.ts index fa99f156..47f3d259 100644 --- a/lib/rule-list-legend.ts +++ b/lib/rule-list-legend.ts @@ -1,4 +1,3 @@ -import { EOL } from 'node:os'; import { EMOJI_DEPRECATED, EMOJI_FIXABLE, @@ -18,6 +17,9 @@ import { } from './types.js'; import { RULE_TYPE_MESSAGES_LEGEND, RULE_TYPES } from './rule-type.js'; import { ConfigFormat, configNameToDisplay } from './config-format.js'; +import { getEndOfLine } from './string.js'; + +const EOL = getEndOfLine(); export const SEVERITY_TYPE_TO_WORD: { [key in SEVERITY_TYPE]: string; diff --git a/lib/rule-list.ts b/lib/rule-list.ts index d5cef65e..0baeeac6 100644 --- a/lib/rule-list.ts +++ b/lib/rule-list.ts @@ -1,4 +1,3 @@ -import { EOL } from 'node:os'; import { BEGIN_RULE_LIST_MARKER, END_RULE_LIST_MARKER, @@ -34,7 +33,11 @@ import type { import { EMOJIS_TYPE } from './rule-type.js'; import { hasOptions } from './rule-options.js'; import { getLinkToRule } from './rule-link.js'; -import { capitalizeOnlyFirstLetter, sanitizeMarkdownTable } from './string.js'; +import { + capitalizeOnlyFirstLetter, + getEndOfLine, + sanitizeMarkdownTable, +} from './string.js'; import { noCase } from 'change-case'; import { getProperty } from 'dot-prop'; import { boolean, isBooleanable } from 'boolean'; @@ -269,6 +272,8 @@ function generateRuleListMarkdownForRulesAndHeaders( ignoreConfig: readonly string[], urlRuleDoc?: string | UrlRuleDocFunction, ): string { + const EOL = getEndOfLine(); + const parts: string[] = []; for (const { title, rules } of rulesAndHeaders) { @@ -416,6 +421,8 @@ export function updateRulesList( urlConfigs?: string, urlRuleDoc?: string | UrlRuleDocFunction, ): string { + const EOL = getEndOfLine(); + let listStartIndex = markdown.indexOf(BEGIN_RULE_LIST_MARKER); let listEndIndex = markdown.indexOf(END_RULE_LIST_MARKER); diff --git a/lib/rule-options-list.ts b/lib/rule-options-list.ts index 47468513..408d35c2 100644 --- a/lib/rule-options-list.ts +++ b/lib/rule-options-list.ts @@ -1,4 +1,3 @@ -import { EOL } from 'node:os'; import { BEGIN_RULE_OPTIONS_LIST_MARKER, END_RULE_OPTIONS_LIST_MARKER, @@ -6,7 +5,9 @@ import { import { markdownTable } from 'markdown-table'; import type { RuleModule } from './types.js'; import { RuleOption, getAllNamedOptions } from './rule-options.js'; -import { sanitizeMarkdownTable } from './string.js'; +import { getEndOfLine, sanitizeMarkdownTable } from './string.js'; + +const EOL = getEndOfLine(); export enum COLUMN_TYPE { // Alphabetical order. diff --git a/lib/string.ts b/lib/string.ts index e8210267..2172e6fc 100644 --- a/lib/string.ts +++ b/lib/string.ts @@ -1,4 +1,7 @@ import { EOL } from 'node:os'; +import editorconfig from 'editorconfig'; + +const endOfLine = getEndOfLine(); export function toSentenceCase(str: string) { return str.replace(/^\w/u, function (txt) { @@ -24,7 +27,7 @@ export function capitalizeOnlyFirstLetter(str: string) { function sanitizeMarkdownTableCell(text: string): string { return text .replaceAll('|', String.raw`\|`) - .replaceAll(new RegExp(EOL, 'gu'), '
'); + .replaceAll(new RegExp(endOfLine, 'gu'), '
'); } export function sanitizeMarkdownTable( @@ -32,3 +35,22 @@ export function sanitizeMarkdownTable( ): readonly (readonly string[])[] { return text.map((row) => row.map((col) => sanitizeMarkdownTableCell(col))); } + +// Gets the end of line string while respecting the +// `.editorconfig` and falling back to `EOL` from `node:os`. +export function getEndOfLine() { + // The passed `markdown.md` argument is used as an example + // of a markdown file in the plugin root folder in order to + // check for any specific markdown configurations. + const config = editorconfig.parseSync('markdown.md'); + + let endOfLine = EOL; + + if (config.end_of_line === 'lf') { + endOfLine = '\n'; + } else if (config.end_of_line === 'crlf') { + endOfLine = '\r\n'; + } + + return endOfLine; +} diff --git a/package-lock.json b/package-lock.json index 5f931ad3..a966e3fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "cosmiconfig": "^9.0.0", "deepmerge": "^4.2.2", "dot-prop": "^9.0.0", + "editorconfig": "^2.0.0", "jest-diff": "^29.2.1", "json-schema": "^0.4.0", "json-schema-traverse": "^1.0.0", @@ -1687,6 +1688,11 @@ "@octokit/openapi-types": "^22.2.0" } }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==" + }, "node_modules/@pkgr/core": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", @@ -4166,6 +4172,45 @@ "dev": true, "license": "MIT" }, + "node_modules/editorconfig": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-2.0.0.tgz", + "integrity": "sha512-s1NQ63WQ7RNXH6Efb2cwuyRlfpbtdZubvfNe4vCuoyGPewNPY7vah8JUSOFBiJ+jr99Qh8t0xKv0oITc1dclgw==", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^11.0.0", + "minimatch": "9.0.2", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/editorconfig/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "engines": { + "node": ">=16" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.2.tgz", + "integrity": "sha512-PZOT9g5v2ojiTL7r1xF6plNHLtOeTpSlDI007As2NlA2aYBMfVom17yqa6QzhmDP8QOhn7LjHTg7DFCVSSa6yg==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", diff --git a/package.json b/package.json index 4b43e54a..2cd69669 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "cosmiconfig": "^9.0.0", "deepmerge": "^4.2.2", "dot-prop": "^9.0.0", + "editorconfig": "^2.0.0", "jest-diff": "^29.2.1", "json-schema": "^0.4.0", "json-schema-traverse": "^1.0.0", diff --git a/test/lib/generate/__snapshots__/editor-config-test.ts.snap b/test/lib/generate/__snapshots__/editor-config-test.ts.snap new file mode 100644 index 00000000..0937a71c --- /dev/null +++ b/test/lib/generate/__snapshots__/editor-config-test.ts.snap @@ -0,0 +1,118 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`string (getEndOfLine) generates using the correct end of line when .editorconfig exists generates using crlf end of line from .editorconfig 1`] = ` +"## Rules + + +💼 Configurations enabled in. + +| Name | 💼 | +| :------------------- | :------------------------------------- | +| [a](docs/rules/a.md) | ![badge-a][] ![badge-B][] ![badge-c][] | +| [B](docs/rules/B.md) | | +| [c](docs/rules/c.md) | | + + +" +`; + +exports[`string (getEndOfLine) generates using the correct end of line when .editorconfig exists generates using crlf end of line from .editorconfig 2`] = ` +"# test/a + +💼 This rule is enabled in the following configs: \`a\`, \`B\`, \`c\`. + + +" +`; + +exports[`string (getEndOfLine) generates using the correct end of line when .editorconfig exists generates using crlf end of line from .editorconfig 3`] = ` +"# test/B + + +" +`; + +exports[`string (getEndOfLine) generates using the correct end of line when .editorconfig exists generates using crlf end of line from .editorconfig 4`] = ` +"# test/c + + +" +`; + +exports[`string (getEndOfLine) generates using the correct end of line when .editorconfig exists generates using lf end of line from .editorconfig 1`] = ` +"## Rules + + +💼 Configurations enabled in. + +| Name | 💼 | +| :------------------- | :------------------------------------- | +| [a](docs/rules/a.md) | ![badge-a][] ![badge-B][] ![badge-c][] | +| [B](docs/rules/B.md) | | +| [c](docs/rules/c.md) | | + + +" +`; + +exports[`string (getEndOfLine) generates using the correct end of line when .editorconfig exists generates using lf end of line from .editorconfig 2`] = ` +"# test/a + +💼 This rule is enabled in the following configs: \`a\`, \`B\`, \`c\`. + + +" +`; + +exports[`string (getEndOfLine) generates using the correct end of line when .editorconfig exists generates using lf end of line from .editorconfig 3`] = ` +"# test/B + + +" +`; + +exports[`string (getEndOfLine) generates using the correct end of line when .editorconfig exists generates using lf end of line from .editorconfig 4`] = ` +"# test/c + + +" +`; + +exports[`string (getEndOfLine) generates using the correct end of line when .editorconfig exists generates using the end of line from .editorconfig while respecting the .md specific end of line setting 1`] = ` +"## Rules + + +💼 Configurations enabled in. + +| Name | 💼 | +| :------------------- | :------------------------------------- | +| [a](docs/rules/a.md) | ![badge-a][] ![badge-B][] ![badge-c][] | +| [B](docs/rules/B.md) | | +| [c](docs/rules/c.md) | | + + +" +`; + +exports[`string (getEndOfLine) generates using the correct end of line when .editorconfig exists generates using the end of line from .editorconfig while respecting the .md specific end of line setting 2`] = ` +"# test/a + +💼 This rule is enabled in the following configs: \`a\`, \`B\`, \`c\`. + + +" +`; + +exports[`string (getEndOfLine) generates using the correct end of line when .editorconfig exists generates using the end of line from .editorconfig while respecting the .md specific end of line setting 3`] = ` +"# test/B + + +" +`; + +exports[`string (getEndOfLine) generates using the correct end of line when .editorconfig exists generates using the end of line from .editorconfig while respecting the .md specific end of line setting 4`] = ` +"# test/c + + +" +`; diff --git a/test/lib/generate/editor-config-test.ts b/test/lib/generate/editor-config-test.ts new file mode 100644 index 00000000..fa4b6946 --- /dev/null +++ b/test/lib/generate/editor-config-test.ts @@ -0,0 +1,150 @@ +import { getEndOfLine } from '../../../lib/string.js'; +import { generate } from '../../../lib/generator.js'; +import mockFs from 'mock-fs'; +import { jest } from '@jest/globals'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { readFileSync } from 'node:fs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const PATH_NODE_MODULES = resolve(__dirname, '..', '..', '..', 'node_modules'); + +describe('string (getEndOfLine)', function () { + describe('returns the correct end of line when .editorconfig exists', function () { + afterEach(function () { + mockFs.restore(); + jest.resetModules(); + }); + + it('returns lf end of line when .editorconfig is configured with lf', function () { + mockFs({ + '.editorconfig': ` + root = true + + [*] + end_of_line = lf`, + }); + + expect(getEndOfLine()).toStrictEqual('\n'); + }); + + it('returns crlf end of line when .editorconfig is configured with crlf', function () { + mockFs({ + '.editorconfig': ` + root = true + + [*] + end_of_line = crlf`, + }); + + expect(getEndOfLine()).toStrictEqual('\r\n'); + }); + + it('respects the .md specific end of line settings when .editorconfig is configured', function () { + mockFs({ + '.editorconfig': ` + root = true + + [*] + end_of_line = lf + + [*.md] + end_of_line = crlf`, + }); + + expect(getEndOfLine()).toStrictEqual('\r\n'); + }); + }); + + describe('generates using the correct end of line when .editorconfig exists', function () { + const pluginFsMock = { + 'package.json': JSON.stringify({ + name: 'eslint-plugin-test', + exports: 'index.js', + type: 'module', + }), + + 'index.js': ` + export default { + rules: { + 'c': { meta: { docs: {} }, create(context) {} }, + 'a': { meta: { docs: {} }, create(context) {} }, + 'B': { meta: { docs: {} }, create(context) {} }, + }, + configs: { + 'c': { rules: { 'test/a': 'error', } }, + 'a': { rules: { 'test/a': 'error', } }, + 'B': { rules: { 'test/a': 'error', } }, + } + };`, + + 'docs/rules/a.md': '', + 'docs/rules/B.md': '', + 'docs/rules/c.md': '', + + // Needed for some of the test infrastructure to work. + node_modules: mockFs.load(PATH_NODE_MODULES), + }; + + afterEach(function () { + mockFs.restore(); + jest.resetModules(); + }); + + it('generates using lf end of line from .editorconfig', async function () { + mockFs({ + ...pluginFsMock, + 'README.md': '## Rules\n', + '.editorconfig': ` + root = true + + [*] + end_of_line = lf`, + }); + await generate('.'); + expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); + expect(readFileSync('docs/rules/a.md', 'utf8')).toMatchSnapshot(); + expect(readFileSync('docs/rules/B.md', 'utf8')).toMatchSnapshot(); + expect(readFileSync('docs/rules/c.md', 'utf8')).toMatchSnapshot(); + }); + + it('generates using crlf end of line from .editorconfig', async function () { + mockFs({ + ...pluginFsMock, + 'README.md': '## Rules\r\n', + '.editorconfig': ` + root = true + + [*] + end_of_line = crlf`, + }); + + await generate('.'); + expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); + expect(readFileSync('docs/rules/a.md', 'utf8')).toMatchSnapshot(); + expect(readFileSync('docs/rules/B.md', 'utf8')).toMatchSnapshot(); + expect(readFileSync('docs/rules/c.md', 'utf8')).toMatchSnapshot(); + }); + + it('generates using the end of line from .editorconfig while respecting the .md specific end of line setting', async function () { + mockFs({ + ...pluginFsMock, + 'README.md': '## Rules\r\n', + '.editorconfig': ` + root = true + + [*] + end_of_line = lf + + [*.md] + end_of_line = crlf`, + }); + await generate('.'); + expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); + expect(readFileSync('docs/rules/a.md', 'utf8')).toMatchSnapshot(); + expect(readFileSync('docs/rules/B.md', 'utf8')).toMatchSnapshot(); + expect(readFileSync('docs/rules/c.md', 'utf8')).toMatchSnapshot(); + }); + }); +}); diff --git a/test/lib/string-test.ts b/test/lib/string-test.ts index 8c033fd1..bfadc7d5 100644 --- a/test/lib/string-test.ts +++ b/test/lib/string-test.ts @@ -2,7 +2,9 @@ import { addTrailingPeriod, removeTrailingPeriod, toSentenceCase, + getEndOfLine, } from '../../lib/string.js'; +import { EOL } from 'node:os'; describe('strings', function () { describe('#addTrailingPeriod', function () { @@ -34,4 +36,10 @@ describe('strings', function () { expect(toSentenceCase('Hello World')).toStrictEqual('Hello World'); }); }); + + describe('#getEndOfLine', function () { + it('handles when .editorconfig is not available and fallbacks to `EOL` from `node:os`', function () { + expect(getEndOfLine()).toStrictEqual(EOL); + }); + }); });