diff --git a/docs/processors/markdown.md b/docs/processors/markdown.md index b1e2877ec..8a70d18b7 100644 --- a/docs/processors/markdown.md +++ b/docs/processors/markdown.md @@ -183,6 +183,8 @@ Comment bodies are passed through unmodified, so the plugin supports any [config This example enables the `alert` global variable, disables the `no-alert` rule, and configures the `quotes` rule to prefer single quotes: + + ````markdown @@ -195,6 +197,8 @@ alert('Hello, world!'); Each code block in a file is linted separately, so configuration comments apply only to the code block that immediately follows. + + ````markdown Assuming `no-alert` is enabled in `eslint.config.js`, the first code block will have no error from `no-alert`: diff --git a/src/processor.js b/src/processor.js index 38e9ef56b..e46f1a9c3 100644 --- a/src/processor.js +++ b/src/processor.js @@ -17,6 +17,8 @@ import { fromMarkdown } from "mdast-util-from-markdown"; * @import { LintMessage, RuleTextEdit, SourceRange } from "@eslint/core"; * @import { Node, Parent, Code, Html } from "mdast"; * @import { Block, RangeMap } from "./types.js"; + * @typedef { Block['comments'][number] } Comment + * @typedef {{ comment: Comment, startLine: number, endLine: number, jsOffset: number }} CommentMapping */ //----------------------------------------------------------------------------- @@ -30,6 +32,8 @@ const UNSATISFIABLE_RULES = new Set([ const SUPPORTS_AUTOFIX = true; const BOM = "\uFEFF"; +const unusedDirectiveMessagePattern = + /^Unused eslint-(?:disable|enable) directive/u; /** * @type {Map} @@ -138,7 +142,7 @@ function getIndentText(text, node) { * delta at the beginning of each line. * @param {string} text The text of the file. * @param {Code} node A Markdown code block AST node. - * @param {string[]} comments List of configuration comment strings that will be + * @param {Comment[]} comments List of configuration comment objects that will be * inserted at the beginning of the code block. * @returns {RangeMap[]} A list of offset-based adjustments, where lookups are * done based on the `js` key, which represents the range in the linted JS, @@ -176,7 +180,7 @@ function getBlockRangeMap(text, node, comments) { * of the linted JS and start the JS offset lookup keys at this index. */ const commentLength = comments.reduce( - (len, comment) => len + comment.length + 1, + (len, comment) => len + comment.text.length + 1, 0, ); @@ -237,6 +241,67 @@ function getBlockRangeMap(text, node, comments) { return rangeMap; } +/** + * Determines whether a message reports an unused directive. + * @param {LintMessage} message The message to check. + * @returns {boolean} True if the message reports an unused directive. + */ +function isUnusedDirectiveMessage(message) { + return ( + message.ruleId === null && + unusedDirectiveMessagePattern.test(message.message) + ); +} + +/** + * Adjusts an unused directive message in an inserted JS comment. + * @param {LintMessage} message The message to adjust. + * @param {CommentMapping[]} commentMappings Precomputed comment line ranges and offsets. + * @returns {LintMessage} The adjusted message, if it can be mapped. + */ +function adjustCommentMessage(message, commentMappings) { + const mapping = commentMappings.find( + m => message.line >= m.startLine && message.line < m.endLine, + ); + + if (!mapping) { + return message; + } + + const { comment: foundComment, jsOffset } = mapping; + const { start, end } = foundComment.position; + const { fix, ...messageWithoutFix } = message; + + const adjustedMessage = /** @type {LintMessage} */ ({ + ...messageWithoutFix, + line: start.line, + column: start.column, + }); + + if (fix) { + const isFullRemoval = + fix.range[0] <= jsOffset && + fix.range[1] >= jsOffset + foundComment.text.length; + + if (isFullRemoval) { + adjustedMessage.fix = { + range: [start.offset, end.offset], + text: fix.text, + }; + } else { + // '4' is the length of '", + "", + "```js", + "var answer = 42;", + "```", + ].join("\n"); + + const results = await unusedDisableESLint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual( + results[0].messages[0].message, + "Unused eslint-disable directive (no problems were reported from 'no-console').", + ); + assert.strictEqual(results[0].messages[0].line, 1); + assert.strictEqual(results[0].messages[0].column, 1); + assert.deepStrictEqual(results[0].messages[0].fix, { + range: [0, 34], + text: " ", + }); + }); + + it("reports unused disable directives correctly among multiple comments", async () => { + const code = [ + "# Title", + "", + "", + "", + "", + "```js", + "var message = 'single quotes';", + "```", + ].join("\n"); + + const results = await unusedDisableESLint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual( + results[0].messages[0].message, + "Unused eslint-disable directive (no problems were reported from 'no-console').", + ); + assert.strictEqual(results[0].messages[0].line, 4); + assert.strictEqual(results[0].messages[0].column, 1); + }); + + it("reports unused disable-next-line directives", async () => { + const code = [ + "", + "", + "```js", + "var answer = 42;", + "```", + ].join("\n"); + + const results = await unusedDisableESLint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual( + results[0].messages[0].message, + "Unused eslint-disable directive (no problems were reported from 'no-console').", + ); + assert.strictEqual(results[0].messages[0].line, 1); + assert.strictEqual(results[0].messages[0].column, 1); + }); + + it("reports unused disable directives when paired with eslint-enable", async () => { + const code = [ + "", + "", + "", + "```js", + "var answer = 42;", + "```", + ].join("\n"); + + const results = await unusedDisableESLint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual( + results[0].messages[0].message, + "Unused eslint-disable directive (no problems were reported from 'no-console').", + ); + assert.strictEqual(results[0].messages[0].line, 1); + assert.strictEqual(results[0].messages[0].column, 1); + }); + + it("reports unused disable directives spanning multiple lines", async () => { + const code = [ + "", + "", + "```js", + "var answer = 42;", + "```", + ].join("\n"); + + const results = await unusedDisableESLint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual( + results[0].messages[0].message, + "Unused eslint-disable directive (no problems were reported from 'no-console').", + ); + assert.strictEqual(results[0].messages[0].line, 1); + assert.strictEqual(results[0].messages[0].column, 1); + }); + }); + + describe("autofixing unused disable directives", () => { + let fixUnusedEslint; + + beforeEach(() => { + fixUnusedEslint = initLegacyESLint("eslintrc.json", { + fix: true, + reportUnusedDisableDirectives: "error", + }); + }); + + it("removes the entire HTML comment when all rules are unused", async () => { + const code = [ + "", + "", + "```js", + "var answer = 42;", + "```", + ].join("\n"); + + const expected = [ + " ", + "", + "```js", + "var answer = 42;", + "```", + ].join("\n"); + + const results = await fixUnusedEslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results[0].output, expected); + }); + + it("partially removes unused rules from the end of an HTML comment", async () => { + const code = [ + "", + "", + "```js", + "var answer = missingVariable;", + "```", + ].join("\n"); + + const expected = [ + "", + "", + "```js", + "var answer = missingVariable;", + "```", + ].join("\n"); + + const results = await fixUnusedEslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results[0].output, expected); + }); + + it("partially removes unused rules from the beginning of an HTML comment", async () => { + const code = [ + "", + "", + "```js", + "var answer = missingVariable;", + "```", + ].join("\n"); + + const expected = [ + "", + "", + "```js", + "var answer = missingVariable;", + "```", + ].join("\n"); + + const results = await fixUnusedEslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results[0].output, expected); + }); + + it("partially removes unused rules from the middle of an HTML comment", async () => { + const code = [ + "", + "", + "```js", + "console.log(missingVariable);", + "```", + ].join("\n"); + + const expected = [ + "", + "", + "```js", + "console.log(missingVariable);", + "```", + ].join("\n"); + + const results = await fixUnusedEslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results[0].output, expected); + }); + + it("removes unused rules from a multiline HTML comment", async () => { + const code = [ + "", + "", + "```js", + "console.log(missingVariable);", + "```", + ].join("\n"); + + const expected = [ + "", + "", + "```js", + "console.log(missingVariable);", + "```", + ].join("\n"); + + const results = await fixUnusedEslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results[0].output, expected); + }); + }); }); describe("should fix code", () => { @@ -1569,6 +1840,285 @@ describe("FlatESLint", () => { ); assert.strictEqual(results[0].messages[0].line, 5); }); + + describe("unused disable directives", () => { + let unusedDisableESLint; + + beforeEach(() => { + unusedDisableESLint = initFlatESLint("eslint.config.js", { + overrideConfig: { + linterOptions: { + reportUnusedDisableDirectives: "error", + }, + }, + }); + }); + + it("reports unused disable directives", async () => { + const code = [ + "", + "", + "```js", + "const answer = 42;", + "```", + ].join("\n"); + + const results = await unusedDisableESLint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual( + results[0].messages[0].message, + "Unused eslint-disable directive (no problems were reported from 'no-console').", + ); + assert.strictEqual(results[0].messages[0].line, 1); + assert.strictEqual(results[0].messages[0].column, 1); + assert.deepStrictEqual(results[0].messages[0].fix, { + range: [0, 34], + text: " ", + }); + }); + + it("reports unused disable directives correctly among multiple comments", async () => { + const code = [ + "# Title", + "", + "", + "", + "", + "```js", + "const message = 'single quotes';", + "```", + ].join("\n"); + + const results = await unusedDisableESLint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual( + results[0].messages[0].message, + "Unused eslint-disable directive (no problems were reported from 'no-console').", + ); + assert.strictEqual(results[0].messages[0].line, 4); + assert.strictEqual(results[0].messages[0].column, 1); + }); + + it("reports unused disable-next-line directives", async () => { + const code = [ + "", + "", + "```js", + "const answer = 42;", + "```", + ].join("\n"); + + const results = await unusedDisableESLint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual( + results[0].messages[0].message, + "Unused eslint-disable directive (no problems were reported from 'no-console').", + ); + assert.strictEqual(results[0].messages[0].line, 1); + assert.strictEqual(results[0].messages[0].column, 1); + }); + + it("reports unused disable directives when paired with eslint-enable", async () => { + const code = [ + "", + "", + "", + "```js", + "const answer = 42;", + "```", + ].join("\n"); + + const results = await unusedDisableESLint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual( + results[0].messages[0].message, + "Unused eslint-disable directive (no problems were reported from 'no-console').", + ); + assert.strictEqual(results[0].messages[0].line, 1); + assert.strictEqual(results[0].messages[0].column, 1); + }); + + it("reports unused disable directives spanning multiple lines", async () => { + const code = [ + "", + "", + "```js", + "const answer = 42;", + "```", + ].join("\n"); + + const results = await unusedDisableESLint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual( + results[0].messages[0].message, + "Unused eslint-disable directive (no problems were reported from 'no-console').", + ); + assert.strictEqual(results[0].messages[0].line, 1); + assert.strictEqual(results[0].messages[0].column, 1); + }); + }); + + describe("autofixing unused disable directives", () => { + let fixUnusedEslint; + + beforeEach(() => { + fixUnusedEslint = initFlatESLint("eslint.config.js", { + fix: true, + overrideConfig: { + linterOptions: { + reportUnusedDisableDirectives: "error", + }, + }, + }); + }); + + it("removes the entire HTML comment when all rules are unused", async () => { + const code = [ + "", + "", + "```js", + "const answer = 42;", + "```", + ].join("\n"); + + const expected = [ + " ", + "", + "```js", + "const answer = 42;", + "```", + ].join("\n"); + + const results = await fixUnusedEslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results[0].output, expected); + }); + + it("partially removes unused rules from the end of an HTML comment", async () => { + const code = [ + "", + "", + "```js", + "const answer = missingVariable;", + "```", + ].join("\n"); + + const expected = [ + "", + "", + "```js", + "const answer = missingVariable;", + "```", + ].join("\n"); + + const results = await fixUnusedEslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results[0].output, expected); + }); + + it("partially removes unused rules from the beginning of an HTML comment", async () => { + const code = [ + "", + "", + "```js", + "const answer = missingVariable;", + "```", + ].join("\n"); + + const expected = [ + "", + "", + "```js", + "const answer = missingVariable;", + "```", + ].join("\n"); + + const results = await fixUnusedEslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results[0].output, expected); + }); + + it("partially removes unused rules from the middle of an HTML comment", async () => { + const code = [ + "", + "", + "```js", + "console.log(missingVariable);", + "```", + ].join("\n"); + + const expected = [ + "", + "", + "```js", + "console.log(missingVariable);", + "```", + ].join("\n"); + + const results = await fixUnusedEslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results[0].output, expected); + }); + + it("removes unused rules from a multiline HTML comment", async () => { + const code = [ + "", + "", + "```js", + "console.log(missingVariable);", + "```", + ].join("\n"); + + const expected = [ + "", + "", + "```js", + "console.log(missingVariable);", + "```", + ].join("\n"); + + const results = await fixUnusedEslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results[0].output, expected); + }); + }); }); describe("should fix code", () => { diff --git a/tests/types/types.test.ts b/tests/types/types.test.ts index a0a55d11e..e07910686 100644 --- a/tests/types/types.test.ts +++ b/tests/types/types.test.ts @@ -58,7 +58,15 @@ const validBlock: Block = { // `BlockBase` properties baseIndentText: " ", - comments: ["// A comment"], + comments: [ + { + text: "eslint-disable", + position: { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 15, offset: 14 }, + }, + }, + ], rangeMap: [{ indent: 2, js: 0, md: 4 }], }; @@ -72,7 +80,9 @@ validBlock.data satisfies CodeData | undefined; // Verify `Block` has `BlockBase` properties validBlock.baseIndentText satisfies string; -validBlock.comments satisfies string[]; +validBlock.comments satisfies { text: string; position: Position }[]; +validBlock.comments[0].text satisfies string; +validBlock.comments[0].position satisfies Position; validBlock.rangeMap satisfies RangeMap[]; // Verify `RangeMap` structure