From e8babf444394865bfbf3e4d0fbf58f6839694268 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 13 Sep 2024 15:19:27 -0400 Subject: [PATCH] feat: Add disable comment support --- README.md | 15 +- package.json | 2 +- src/language/markdown-source-code.js | 185 +++++++++++++++++++- src/rules/no-html.js | 56 +++--- tests/language/markdown-source-code.test.js | 107 ++++++++++- tests/plugin.test.js | 84 +++++++++ tests/rules/no-html.test.js | 42 +++++ 7 files changed, 467 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 8484ee3e..a3b09585 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Lint JS, JSX, TypeScript, and more inside Markdown. ### Installing -Install the plugin alongside ESLint v8 or greater: +Install the plugin alongside ESLint v9 or greater: ```sh npm install --save-dev eslint @eslint/markdown @@ -80,6 +80,19 @@ export default [ ]; ``` +You can individually disable rules in Markdown using HTML comments, such as: + +```markdown + +Hello world! + + +Goodbye world! + + +[Object] +``` + ### Languages | **Language Name** | **Description** | diff --git a/package.json b/package.json index 6e664473..a12c71aa 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "c8": "^10.1.2", "chai": "^5.1.1", "dedent": "^1.5.3", - "eslint": "^9.8.0", + "eslint": "^9.10.0", "eslint-config-eslint": "^11.0.0", "globals": "^15.1.0", "lint-staged": "^15.2.9", diff --git a/src/language/markdown-source-code.js b/src/language/markdown-source-code.js index d57f6f93..cc8049a6 100644 --- a/src/language/markdown-source-code.js +++ b/src/language/markdown-source-code.js @@ -7,7 +7,13 @@ // Imports //----------------------------------------------------------------------------- -import { VisitNodeStep, TextSourceCodeBase } from "@eslint/plugin-kit"; +import { + VisitNodeStep, + TextSourceCodeBase, + ConfigCommentParser, + Directive, +} from "@eslint/plugin-kit"; +import { findOffsets } from "../util.js"; //----------------------------------------------------------------------------- // Types @@ -15,6 +21,7 @@ import { VisitNodeStep, TextSourceCodeBase } from "@eslint/plugin-kit"; /** @typedef {import("mdast").Root} RootNode */ /** @typedef {import("mdast").Node} MarkdownNode */ +/** @typedef {import("mdast").Html} HTMLNode */ /** @typedef {import("@eslint/core").Language} Language */ /** @typedef {import("@eslint/core").File} File */ /** @typedef {import("@eslint/core").TraversalStep} TraversalStep */ @@ -23,6 +30,104 @@ import { VisitNodeStep, TextSourceCodeBase } from "@eslint/plugin-kit"; /** @typedef {import("@eslint/core").ParseResult} ParseResult */ /** @typedef {import("@eslint/core").SourceLocation} SourceLocation */ /** @typedef {import("@eslint/core").SourceRange} SourceRange */ +/** @typedef {import("@eslint/core").FileProblem} FileProblem */ +/** @typedef {import("@eslint/core").DirectiveType} DirectiveType */ + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +const commentParser = new ConfigCommentParser(); +const configCommentStart = + //gu; + +/** + * Represents an inline config comment in the source code. + */ +class InlineConfigComment { + /** + * The comment text. + * @type {string} + */ + value; + + /** + * The position of the comment in the source code. + * @type {SourceLocation} + */ + position; + + /** + * Creates a new instance. + * @param {Object} options The options for the instance. + * @param {string} options.value The comment text. + * @param {SourceLocation} options.position The position of the comment in the source code. + */ + constructor({ value, position }) { + this.value = value.trim(); + this.position = position; + } +} + +/** + * Extracts inline configuration comments from an HTML node. + * @param {HTMLNode} node The HTML node to extract comments from. + * @returns {Array} The inline configuration comments found in the node. + */ +function extractInlineConfigCommentsFromHTML(node) { + if (!configCommentStart.test(node.value)) { + return []; + } + const comments = []; + + let match; + + while ((match = htmlComment.exec(node.value))) { + if (configCommentStart.test(match[0])) { + const comment = match[0]; + + // calculate location of the comment inside the node + const start = { + ...node.position.start, + }; + + const end = { + ...node.position.start, + }; + + const { + lineOffset: startLineOffset, + columnOffset: startColumnOffset, + } = findOffsets(node.value, match.index); + + start.line += startLineOffset; + start.column += startColumnOffset; + start.offset += match.index; + + const commentLineCount = comment.split("\n").length - 1; + + end.line = start.line + commentLineCount; + end.column = + commentLineCount === 0 + ? start.column + comment.length + : comment.length - comment.lastIndexOf("\n") - 1; + end.offset = start.offset + comment.length; + + comments.push( + new InlineConfigComment({ + value: match[1], + position: { + start, + end, + }, + }), + ); + } + } + + return comments; +} //----------------------------------------------------------------------------- // Exports @@ -44,6 +149,18 @@ export class MarkdownSourceCode extends TextSourceCodeBase { */ #parents = new WeakMap(); + /** + * Collection of HTML nodes. Used to find directive comments. + * @type {Array} + */ + #htmlNodes = []; + + /** + * Collection of inline configuration comments. + * @type {Array} + */ + #inlineConfigComments = []; + /** * The AST of the source code. * @type {RootNode} @@ -59,6 +176,9 @@ export class MarkdownSourceCode extends TextSourceCodeBase { constructor({ text, ast }) { super({ ast, text }); this.ast = ast; + + // need to traverse the source code to get the inline config nodes + this.traverse(); } /** @@ -70,6 +190,64 @@ export class MarkdownSourceCode extends TextSourceCodeBase { return this.#parents.get(node); } + /** + * Returns an array of all inline configuration nodes found in the + * source code. + * @returns {Array} An array of all inline configuration nodes. + */ + getInlineConfigNodes() { + if (this.#inlineConfigComments.length === 0) { + this.#inlineConfigComments = this.#htmlNodes.flatMap( + extractInlineConfigCommentsFromHTML, + ); + } + + return this.#inlineConfigComments; + } + + /** + * Returns an all directive nodes that enable or disable rules along with any problems + * encountered while parsing the directives. + * @returns {{problems:Array,directives:Array}} Information + * that ESLint needs to further process the directives. + */ + getDisableDirectives() { + const problems = []; + const directives = []; + + this.getInlineConfigNodes().forEach(comment => { + // Step 1: Parse the directive + const { + label, + value, + justification: justificationPart, + } = commentParser.parseDirective(comment.value); + + // Step 2: Extract the directive value and create the Directive object + switch (label) { + case "eslint-disable": + case "eslint-enable": + case "eslint-disable-next-line": + case "eslint-disable-line": { + const directiveType = label.slice("eslint-".length); + + directives.push( + new Directive({ + type: /** @type {DirectiveType} */ (directiveType), + node: comment, + value, + justification: justificationPart, + }), + ); + } + + // no default + } + }); + + return { problems, directives }; + } + /** * Traverse the source code and return the steps that were taken. * @returns {Iterable} The steps that were taken while traversing the source code. @@ -96,6 +274,11 @@ export class MarkdownSourceCode extends TextSourceCodeBase { }), ); + // save HTML nodes + if (node.type === "html") { + this.#htmlNodes.push(node); + } + // then visit the children if (node.children) { node.children.forEach(child => { diff --git a/src/rules/no-html.js b/src/rules/no-html.js index 9a858da8..450a2a95 100644 --- a/src/rules/no-html.js +++ b/src/rules/no-html.js @@ -3,12 +3,24 @@ * @author Nicholas C. Zakas */ +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import { findOffsets } from "../util.js"; + //----------------------------------------------------------------------------- // Type Definitions //----------------------------------------------------------------------------- /** @typedef {import("eslint").Rule.RuleModule} RuleModule */ +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +const htmlTagPattern = /<([a-z0-9]+(?:-[a-z0-9]+)*)/giu; + //----------------------------------------------------------------------------- // Rule Definition //----------------------------------------------------------------------------- @@ -48,28 +60,32 @@ export default { return { html(node) { - // don't care about closing tags - if (node.value.startsWith(" + +This is a paragraph with an inline config comment. + +Something something`; const ast = fromMarkdown(markdownText); @@ -78,6 +86,77 @@ describe("MarkdownSourceCode", () => { }); }); + describe("getInlineConfigNodes()", () => { + it("should return the inline config nodes", () => { + const nodes = sourceCode.getInlineConfigNodes(); + assert.strictEqual(nodes.length, 4); + + /* eslint-disable no-restricted-properties -- Needed to avoid extra asserts. */ + + assert.deepEqual(nodes[0], { + value: "eslint-disable-next-line no-console", + position: { + start: { line: 13, column: 1, offset: 140 }, + end: { line: 13, column: 45, offset: 184 }, + }, + }); + + assert.deepEqual(nodes[1], { + value: "eslint-disable-line no-console", + position: { + start: { line: 15, column: 52, offset: 237 }, + end: { line: 15, column: 91, offset: 276 }, + }, + }); + + assert.deepEqual(nodes[2], { + value: "eslint-enable no-console -- ok to use console here", + position: { + start: { line: 17, column: 1, offset: 278 }, + end: { line: 19, column: 3, offset: 337 }, + }, + }); + + assert.deepEqual(nodes[3], { + value: "eslint-disable semi", + position: { + start: { line: 19, column: 23, offset: 356 }, + end: { line: 19, column: 51, offset: 384 }, + }, + }); + + /* eslint-enable no-restricted-properties -- Needed to avoid extra asserts. */ + }); + }); + + describe("getDisableDirectives()", () => { + it("should return the disable directives", () => { + const { problems, directives } = sourceCode.getDisableDirectives(); + + assert.strictEqual(problems.length, 0); + assert.strictEqual(directives.length, 4); + + assert.strictEqual(directives[0].type, "disable-next-line"); + assert.strictEqual(directives[0].value, "no-console"); + assert.strictEqual(directives[0].justification, ""); + + assert.strictEqual(directives[1].type, "disable-line"); + assert.strictEqual(directives[1].value, "no-console"); + assert.strictEqual(directives[1].justification, ""); + + assert.strictEqual(directives[2].type, "enable"); + assert.strictEqual(directives[2].value, "no-console"); + assert.strictEqual( + directives[2].justification, + "ok to use console here", + ); + + assert.strictEqual(directives[3].type, "disable"); + assert.strictEqual(directives[3].value, "semi"); + assert.strictEqual(directives[3].justification, ""); + }); + }); + describe("traverse()", () => { it("should traverse the AST", () => { const steps = sourceCode.traverse(); @@ -113,6 +192,32 @@ describe("MarkdownSourceCode", () => { [1, "text", " paragraph."], [2, "text", " paragraph."], [2, "paragraph", void 0], + [1, "html", ""], + [2, "html", ""], + [1, "paragraph", void 0], + [ + 1, + "text", + "This is a paragraph with an inline config comment. ", + ], + [ + 2, + "text", + "This is a paragraph with an inline config comment. ", + ], + [1, "html", ""], + [2, "html", ""], + [2, "paragraph", void 0], + [ + 1, + "html", + "Something something", + ], + [ + 2, + "html", + "Something something", + ], [2, "root", void 0], ]); }); diff --git a/tests/plugin.test.js b/tests/plugin.test.js index 4848a52b..ad6d0897 100644 --- a/tests/plugin.test.js +++ b/tests/plugin.test.js @@ -2263,4 +2263,88 @@ describe("FlatESLint", () => { }); }); }); + + describe("Configuration Comments", () => { + const config = { + files: ["*.md"], + plugins: { + markdown: plugin, + }, + language: "markdown/commonmark", + rules: { + "markdown/no-html": "error", + }, + }; + + let eslint; + + beforeEach(() => { + eslint = new ESLint({ + overrideConfigFile: true, + overrideConfig: config, + }); + }); + + it("should report html without any configuration comments present", async () => { + const code = "Hello world"; + const results = await eslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual( + results[0].messages[0].message, + 'HTML element "b" is not allowed.', + ); + }); + + it("should report html when a disable configuration comment is present and followed by an enable configuration comment", async () => { + const code = + "Hello worldGoodbye"; + const results = await eslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual( + results[0].messages[0].message, + 'HTML element "i" is not allowed.', + ); + }); + + it("should not report html when a disable configuration comment is present", async () => { + const code = + "\nHello world"; + const results = await eslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 0); + }); + + it("should not report html when a disable-line configuration comment is present", async () => { + const code = + "Hello world"; + const results = await eslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 0); + }); + + it("should not report html when a disable-next-line configuration comment is present", async () => { + const code = + "\nHello world"; + const results = await eslint.lintText(code, { + filePath: "test.md", + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 0); + }); + }); }); diff --git a/tests/rules/no-html.test.js b/tests/rules/no-html.test.js index 4ed85a9b..8f396c80 100644 --- a/tests/rules/no-html.test.js +++ b/tests/rules/no-html.test.js @@ -72,6 +72,48 @@ ruleTester.run("no-html", rule, { }, ], }, + { + code: "Hello world!Goodbye world!", + options: [{ allowed: ["em"] }], + errors: [ + { + messageId: "disallowedElement", + line: 1, + column: 1, + endLine: 1, + endColumn: 4, + data: { + name: "b", + }, + }, + { + messageId: "disallowedElement", + line: 1, + column: 20, + endLine: 1, + endColumn: 23, + data: { + name: "i", + }, + }, + ], + }, + { + code: "Hello world!", + options: [{ allowed: ["em"] }], + errors: [ + { + messageId: "disallowedElement", + line: 1, + column: 12, + endLine: 1, + endColumn: 15, + data: { + name: "b", + }, + }, + ], + }, { code: "Hello world!", options: [{ allowed: ["em"] }],