/** * @fileoverview The MarkdownSourceCode class. * @author Nicholas C. Zakas */ //----------------------------------------------------------------------------- // Imports //----------------------------------------------------------------------------- import { VisitNodeStep, TextSourceCodeBase, ConfigCommentParser, Directive, } from "@eslint/plugin-kit"; import { lineEndingPattern } from "../util.js"; //----------------------------------------------------------------------------- // Types //----------------------------------------------------------------------------- /** * @import { Position } from "unist"; * @import { Parent, Root, Node, Html } from "mdast"; * @import { TraversalStep, FileProblem, DirectiveType, RulesConfig } from "@eslint/core"; * @import { MarkdownLanguageOptions } from "../types.js"; */ //----------------------------------------------------------------------------- // Helpers //----------------------------------------------------------------------------- const commentParser = new ConfigCommentParser(); const configCommentStart = /)/u; const htmlComment = //gsu; /** * Represents an inline config comment in the source code. */ export class InlineConfigComment { /** * The comment text. * @type {string} */ value; /** * The position of the comment in the source code. * @type {Position} */ position; /** * Creates a new instance. * @param {Object} options The options for the instance. * @param {string} options.value The comment text. * @param {Position} 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 {Html} node The HTML node to extract comments from. * @param {MarkdownSourceCode} sourceCode The Markdown source code object. * @returns {Array} The inline configuration comments found in the node. */ function extractInlineConfigCommentsFromHTML(node, sourceCode) { if (!configCommentStart.test(node.value)) { return []; } /** @type {Array} */ const comments = []; /** @type {RegExpExecArray | null} */ let match; while ((match = htmlComment.exec(node.value))) { if (configCommentStart.test(match[0])) { // calculate offset of the comment inside the node const startOffset = match.index + node.position.start.offset; const endOffset = startOffset + match[0].length; comments.push(new InlineConfigComment({ value: match[1].trim(), position: { start: { ...sourceCode.getLocFromIndex(startOffset), offset: startOffset, }, end: { ...sourceCode.getLocFromIndex(endOffset), offset: endOffset, }, }, })); } } return comments; } //----------------------------------------------------------------------------- // Exports //----------------------------------------------------------------------------- /** * Markdown Source Code Object * @extends {TextSourceCodeBase<{LangOptions: MarkdownLanguageOptions, RootNode: Root, SyntaxElementWithLoc: Node, ConfigNode: { value: string; position: Position }}>} */ export class MarkdownSourceCode extends TextSourceCodeBase { /** * Cached traversal steps. * @type {Array|undefined} */ #steps; /** * Cache of parent nodes. * @type {WeakMap} */ #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 {Root} */ ast = undefined; /** * Creates a new instance. * @param {Object} options The options for the instance. * @param {string} options.text The source code text. * @param {Root} options.ast The root AST node. */ constructor({ text, ast }) { super({ ast, text, lineEndingPattern }); this.ast = ast; // need to traverse the source code to get the inline config nodes this.traverse(); } /** * Returns the parent of the given node. * @param {Node} node The node to get the parent of. * @returns {Parent|undefined} The parent of the node. */ getParent(node) { 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) { this.#inlineConfigComments = this.#htmlNodes.flatMap(htmlNode => extractInlineConfigCommentsFromHTML(htmlNode, this)); } 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() { /** @type {Array} */ const problems = []; /** @type {Array} */ const directives = []; this.getInlineConfigNodes().forEach(comment => { // Step 1: Parse the directive const { label, value, justification: justificationPart, } = commentParser.parseDirective(comment.value); // Step 2: Validate the directive does not span multiple lines if (label === "eslint-disable-line" && comment.position.start.line !== comment.position.end.line) { const message = `${label} comment should not span multiple lines.`; problems.push({ ruleId: null, message, loc: comment.position, }); return; } // Step 3: 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 }; } /** * Returns inline rule configurations along with any problems * encountered while parsing the configurations. * @returns {{problems:Array,configs:Array<{config:{rules:RulesConfig},loc:Position}>}} Information * that ESLint needs to further process the rule configurations. */ applyInlineConfig() { /** @type {Array} */ const problems = []; /** @type {Array<{config:{rules:RulesConfig},loc:Position}>} */ const configs = []; this.getInlineConfigNodes().forEach(comment => { const { label, value } = commentParser.parseDirective(comment.value); if (label === "eslint") { const parseResult = commentParser.parseJSONLikeConfig(value); if (parseResult.ok) { configs.push({ config: { rules: parseResult.config, }, loc: comment.position, }); } else { problems.push({ ruleId: null, message: /** @type {{ok: false, error: { message: string }}} */ (parseResult).error.message, loc: comment.position, }); } } }); return { configs, problems, }; } /** * Traverse the source code and return the steps that were taken. * @returns {Iterable} The steps that were taken while traversing the source code. */ traverse() { // Because the AST doesn't mutate, we can cache the steps if (this.#steps) { return this.#steps.values(); } /** @type {Array} */ const steps = (this.#steps = []); /** * Recursively visits a node and its children. * @param {Node} node The node to visit. * @param {Parent} [parent] The parent of the node. * @returns {void} */ const visit = (node, parent) => { // first set the parent this.#parents.set(node, parent); // then add the step steps.push(new VisitNodeStep({ target: node, phase: 1, args: [node, parent], })); // save HTML nodes if (node.type === "html") { this.#htmlNodes.push(/** @type {Html} */ (node)); } // then visit the children if ("children" in node) { const parentNode = /** @type {Parent} */ (node); parentNode.children.forEach(child => { visit(child, parentNode); }); } // then add the exit step steps.push(new VisitNodeStep({ target: node, phase: 2, args: [node, parent], })); }; visit(this.ast); return steps.values(); } }