@eslint/markdown
Version:
The official ESLint language plugin for Markdown
275 lines (274 loc) • 10.1 kB
JavaScript
/**
* @fileoverview The MarkdownSourceCode class.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { VisitNodeStep, TextSourceCodeBase, ConfigCommentParser, Directive, } from "@eslint/plugin-kit";
import { findOffsets } from "../util.js";
//-----------------------------------------------------------------------------
// Types
//-----------------------------------------------------------------------------
/**
* @import { Root, Node, Html } from "mdast";
* @import { TraversalStep, SourceLocation, 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.
*/
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 {Html} node The HTML node to extract comments from.
* @returns {Array<InlineConfigComment>} 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");
end.offset = start.offset + comment.length;
comments.push(new InlineConfigComment({
value: match[1].trim(),
position: {
start,
end,
},
}));
}
}
return comments;
}
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* Markdown Source Code Object
* @extends {TextSourceCodeBase<{LangOptions: MarkdownLanguageOptions, RootNode: Root, SyntaxElementWithLoc: Node, ConfigNode: { value: string; position: SourceLocation }}>}
*/
export class MarkdownSourceCode extends TextSourceCodeBase {
/**
* Cached traversal steps.
* @type {Array<VisitNodeStep>|undefined}
*/
#steps;
/**
* Cache of parent nodes.
* @type {WeakMap<Node, Node>}
*/
#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 });
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 {Node|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(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<FileProblem>,directives:Array<Directive>}} 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: 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:SourceLocation}>}} Information
* that ESLint needs to further process the rule configurations.
*/
applyInlineConfig() {
const problems = [];
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 = []);
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(node);
}
// then visit the children
if (node.children) {
node.children.forEach(child => {
visit(child, node);
});
}
// then add the exit step
steps.push(new VisitNodeStep({
target: node,
phase: 2,
args: [node, parent],
}));
};
visit(this.ast);
return steps.values();
}
}