280 lines
10 KiB
JavaScript
280 lines
10 KiB
JavaScript
/**
|
|
* @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 = /<!--\s*eslint(?:-enable|-disable(?:(?:-next)?-line)?)?(?:\s|-->)/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<InlineConfigComment>} The inline configuration comments found in the node.
|
|
*/
|
|
function extractInlineConfigCommentsFromHTML(node, sourceCode) {
|
|
if (!configCommentStart.test(node.value)) {
|
|
return [];
|
|
}
|
|
/** @type {Array<InlineConfigComment>} */
|
|
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<VisitNodeStep>|undefined}
|
|
*/
|
|
#steps;
|
|
/**
|
|
* Cache of parent nodes.
|
|
* @type {WeakMap<Node, Parent|undefined>}
|
|
*/
|
|
#parents = new WeakMap();
|
|
/**
|
|
* Collection of HTML nodes. Used to find directive comments.
|
|
* @type {Array<Html>}
|
|
*/
|
|
#htmlNodes = [];
|
|
/**
|
|
* Collection of inline configuration comments.
|
|
* @type {Array<InlineConfigComment>}
|
|
*/
|
|
#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<InlineConfigComment>} 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<FileProblem>,directives:Array<Directive>}} Information
|
|
* that ESLint needs to further process the directives.
|
|
*/
|
|
getDisableDirectives() {
|
|
/** @type {Array<FileProblem>} */
|
|
const problems = [];
|
|
/** @type {Array<Directive>} */
|
|
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<FileProblem>,configs:Array<{config:{rules:RulesConfig},loc:Position}>}} Information
|
|
* that ESLint needs to further process the rule configurations.
|
|
*/
|
|
applyInlineConfig() {
|
|
/** @type {Array<FileProblem>} */
|
|
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<TraversalStep>} 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<VisitNodeStep>} */
|
|
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();
|
|
}
|
|
}
|