first commit
This commit is contained in:
+394
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* @fileoverview Processes Markdown files for consumption by ESLint.
|
||||
* @author Brandon Mills
|
||||
*/
|
||||
//-----------------------------------------------------------------------------
|
||||
// Imports
|
||||
//-----------------------------------------------------------------------------
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
//-----------------------------------------------------------------------------
|
||||
// Type Definitions
|
||||
//-----------------------------------------------------------------------------
|
||||
/**
|
||||
* @import { LintMessage, RuleTextEdit, SourceRange } from "@eslint/core";
|
||||
* @import { Node, Parent, Code, Html } from "mdast";
|
||||
* @import { Block, RangeMap } from "./types.js";
|
||||
*/
|
||||
//-----------------------------------------------------------------------------
|
||||
// Helpers
|
||||
//-----------------------------------------------------------------------------
|
||||
const UNSATISFIABLE_RULES = new Set([
|
||||
"eol-last", // The Markdown parser strips trailing newlines in code fences
|
||||
"unicode-bom", // Code blocks will begin in the middle of Markdown files
|
||||
]);
|
||||
const SUPPORTS_AUTOFIX = true;
|
||||
const BOM = "\uFEFF";
|
||||
/**
|
||||
* @type {Map<string, Block[]>}
|
||||
*/
|
||||
const blocksCache = new Map();
|
||||
/**
|
||||
* Performs a depth-first traversal of the Markdown AST.
|
||||
* @param {Node} node A Markdown AST node.
|
||||
* @param {{[key: string]: (() => void) | ((node: Code) => void) | ((node: Html) => void)}} callbacks A map of node types to callbacks.
|
||||
* @returns {void}
|
||||
*/
|
||||
function traverse(node, callbacks) {
|
||||
if (callbacks[node.type]) {
|
||||
/** @type {(node: Node) => void} */ (callbacks[node.type])(node);
|
||||
}
|
||||
else {
|
||||
/** @type {() => void} */ (callbacks["*"])();
|
||||
}
|
||||
const parent = /** @type {Parent} */ (node);
|
||||
if (typeof parent.children !== "undefined") {
|
||||
for (let i = 0; i < parent.children.length; i++) {
|
||||
traverse(parent.children[i], callbacks);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Extracts `eslint-*` or `global` comments from HTML comments if present.
|
||||
* @param {string} html The text content of an HTML AST node.
|
||||
* @returns {string} The comment's text without the opening and closing tags or
|
||||
* an empty string if the text is not an ESLint HTML comment.
|
||||
*/
|
||||
function getComment(html) {
|
||||
const commentStart = "<!--";
|
||||
const commentEnd = "-->";
|
||||
const regex = /^(?:eslint\b|global\s)/u;
|
||||
if (html.slice(0, commentStart.length) !== commentStart ||
|
||||
html.slice(-commentEnd.length) !== commentEnd) {
|
||||
return "";
|
||||
}
|
||||
const comment = html.slice(commentStart.length, -commentEnd.length);
|
||||
if (!regex.test(comment.trim())) {
|
||||
return "";
|
||||
}
|
||||
return comment;
|
||||
}
|
||||
// Before a code block, blockquote characters (`>`) are also considered
|
||||
// "whitespace".
|
||||
const leadingWhitespaceRegex = /^[>\s]*/u;
|
||||
/**
|
||||
* Gets the offset for the first column of the node's first line in the
|
||||
* original source text.
|
||||
* @param {Code} node A Markdown code block AST node.
|
||||
* @returns {number} The offset for the first column of the node's first line.
|
||||
*/
|
||||
function getBeginningOfLineOffset(node) {
|
||||
return node.position.start.offset - node.position.start.column + 1;
|
||||
}
|
||||
/**
|
||||
* Gets the leading text, typically whitespace with possible blockquote chars,
|
||||
* used to indent a code block.
|
||||
* @param {string} text The text of the file.
|
||||
* @param {Code} node A Markdown code block AST node.
|
||||
* @returns {string} The text from the start of the first line to the opening
|
||||
* fence of the code block.
|
||||
*/
|
||||
function getIndentText(text, node) {
|
||||
return leadingWhitespaceRegex.exec(text.slice(getBeginningOfLineOffset(node)))[0];
|
||||
}
|
||||
/**
|
||||
* When applying fixes, the postprocess step needs to know how to map fix ranges
|
||||
* from their location in the linted JS to the original offset in the Markdown.
|
||||
* Configuration comments and indentation trimming both complicate this process.
|
||||
*
|
||||
* Configuration comments appear in the linted JS but not in the Markdown code
|
||||
* block. Fixes to configuration comments would cause undefined behavior and
|
||||
* should be ignored during postprocessing. Fixes to actual code after
|
||||
* configuration comments need to be mapped back to the code block after
|
||||
* removing any offset due to configuration comments.
|
||||
*
|
||||
* Fenced code blocks can be indented by up to three spaces at the opening
|
||||
* fence. Inside of a list, for example, this indent can be in addition to the
|
||||
* indent already required for list item children. Leading whitespace inside
|
||||
* indented code blocks is trimmed up to the level of the opening fence and does
|
||||
* not appear in the linted code. Further, lines can have less leading
|
||||
* whitespace than the opening fence, so not all lines are guaranteed to have
|
||||
* the same column offset as the opening fence.
|
||||
*
|
||||
* The source code of a non-configuration-comment line in the linted JS is a
|
||||
* suffix of the corresponding line in the Markdown code block. There are no
|
||||
* differences within the line, so the mapping need only provide the offset
|
||||
* 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
|
||||
* 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,
|
||||
* and the `md` key is the offset delta that, when added to the JS range,
|
||||
* returns the corresponding location in the original Markdown source.
|
||||
*/
|
||||
function getBlockRangeMap(text, node, comments) {
|
||||
/*
|
||||
* The parser sets the fenced code block's start offset to wherever content
|
||||
* should normally begin (typically the first column of the line, but more
|
||||
* inside a list item, for example). The code block's opening fence may be
|
||||
* further indented by up to three characters. If the code block has
|
||||
* additional indenting, the opening fence's first backtick may be up to
|
||||
* three whitespace characters after the start offset.
|
||||
*/
|
||||
const startOffset = getBeginningOfLineOffset(node);
|
||||
/*
|
||||
* Extract the Markdown source to determine the leading whitespace for each
|
||||
* line.
|
||||
*/
|
||||
const code = text.slice(startOffset, node.position.end.offset);
|
||||
const lines = code.split("\n");
|
||||
/*
|
||||
* The parser trims leading whitespace from each line of code within the
|
||||
* fenced code block up to the opening fence's first backtick. The first
|
||||
* backtick's column is the AST node's starting column plus any additional
|
||||
* indentation.
|
||||
*/
|
||||
const baseIndent = getIndentText(text, node).length;
|
||||
/*
|
||||
* Track the length of any inserted configuration comments at the beginning
|
||||
* of the linted JS and start the JS offset lookup keys at this index.
|
||||
*/
|
||||
const commentLength = comments.reduce((len, comment) => len + comment.length + 1, 0);
|
||||
/*
|
||||
* In case there are configuration comments, initialize the map so that the
|
||||
* first lookup index is always 0. If there are no configuration comments,
|
||||
* the lookup index will also be 0, and the lookup should always go to the
|
||||
* last range that matches, skipping this initialization entry.
|
||||
*/
|
||||
const rangeMap = [
|
||||
{
|
||||
indent: baseIndent,
|
||||
js: 0,
|
||||
md: 0,
|
||||
},
|
||||
];
|
||||
// Start the JS offset after any configuration comments.
|
||||
let jsOffset = commentLength;
|
||||
/*
|
||||
* Start the Markdown offset at the beginning of the block's first line of
|
||||
* actual code. The first line of the block is always the opening fence, so
|
||||
* the code begins on the second line.
|
||||
*/
|
||||
let mdOffset = startOffset + lines[0].length + 1;
|
||||
/*
|
||||
* For each line, determine how much leading whitespace was trimmed due to
|
||||
* indentation. Increase the JS lookup offset by the length of the line
|
||||
* post-trimming and the Markdown offset by the total line length.
|
||||
*/
|
||||
for (let i = 0; i + 1 < lines.length; i++) {
|
||||
const line = lines[i + 1];
|
||||
const leadingWhitespaceLength = leadingWhitespaceRegex.exec(line)[0].length;
|
||||
// The parser trims leading whitespace up to the level of the opening
|
||||
// fence, so keep any additional indentation beyond that.
|
||||
const trimLength = Math.min(baseIndent, leadingWhitespaceLength);
|
||||
rangeMap.push({
|
||||
indent: trimLength,
|
||||
js: jsOffset,
|
||||
// Advance `trimLength` character from the beginning of the Markdown
|
||||
// line to the beginning of the equivalent JS line, then compute the
|
||||
// delta.
|
||||
md: mdOffset + trimLength - jsOffset,
|
||||
});
|
||||
// Accumulate the current line in the offsets, and don't forget the
|
||||
// newline.
|
||||
mdOffset += line.length + 1;
|
||||
jsOffset += line.length - trimLength + 1;
|
||||
}
|
||||
return rangeMap;
|
||||
}
|
||||
const codeBlockFileNameRegex = /filename=(?<quote>["'])(?<filename>.*?)\1/u;
|
||||
/**
|
||||
* Parses the file name from a block meta, if available.
|
||||
* @param {Block} block A code block.
|
||||
* @returns {string | null | undefined} The filename, if parsed from block meta.
|
||||
*/
|
||||
function fileNameFromMeta(block) {
|
||||
return block.meta
|
||||
?.match(codeBlockFileNameRegex)
|
||||
?.groups.filename.replaceAll(/\s+/gu, "_");
|
||||
}
|
||||
const languageToFileExtension = {
|
||||
javascript: "js",
|
||||
ecmascript: "js",
|
||||
typescript: "ts",
|
||||
markdown: "md",
|
||||
};
|
||||
/**
|
||||
* Extracts lintable code blocks from Markdown text.
|
||||
* @param {string} sourceText The text of the file.
|
||||
* @param {string} filename The filename of the file.
|
||||
* @returns {Array<{ filename: string, text: string }>} Source code blocks to lint.
|
||||
*/
|
||||
function preprocess(sourceText, filename) {
|
||||
const text = sourceText.startsWith(BOM) ? sourceText.slice(1) : sourceText;
|
||||
const ast = fromMarkdown(text);
|
||||
/** @type {Block[]} */
|
||||
const blocks = [];
|
||||
blocksCache.set(filename, blocks);
|
||||
/**
|
||||
* During the depth-first traversal, keep track of any sequences of HTML
|
||||
* comment nodes containing `eslint-*` or `global` comments. If a code
|
||||
* block immediately follows such a sequence, insert the comments at the
|
||||
* top of the code block. Any non-ESLint comment or other node type breaks
|
||||
* and empties the sequence.
|
||||
* @type {string[]}
|
||||
*/
|
||||
let htmlComments = [];
|
||||
traverse(ast, {
|
||||
"*"() {
|
||||
htmlComments = [];
|
||||
},
|
||||
/**
|
||||
* Visit a code node.
|
||||
* @param {Code} node The visited node.
|
||||
* @returns {void}
|
||||
*/
|
||||
code(node) {
|
||||
if (node.lang) {
|
||||
/** @type {string[]} */
|
||||
const comments = [];
|
||||
for (const comment of htmlComments) {
|
||||
if (comment.trim() === "eslint-skip") {
|
||||
htmlComments = [];
|
||||
return;
|
||||
}
|
||||
comments.push(`/*${comment}*/`);
|
||||
}
|
||||
htmlComments = [];
|
||||
blocks.push({
|
||||
...node,
|
||||
baseIndentText: getIndentText(text, node),
|
||||
comments,
|
||||
rangeMap: getBlockRangeMap(text, node, comments),
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Visit an HTML node.
|
||||
* @param {Html} node The visited node.
|
||||
* @returns {void}
|
||||
*/
|
||||
html(node) {
|
||||
const comment = getComment(node.value);
|
||||
if (comment) {
|
||||
htmlComments.push(comment);
|
||||
}
|
||||
else {
|
||||
htmlComments = [];
|
||||
}
|
||||
},
|
||||
});
|
||||
return blocks.map((block, index) => {
|
||||
const [language] = block.lang.trim().split(" ");
|
||||
const fileExtension = Object.hasOwn(languageToFileExtension, language)
|
||||
? languageToFileExtension[
|
||||
/** @type {keyof typeof languageToFileExtension} */ (language)]
|
||||
: language;
|
||||
return {
|
||||
filename: fileNameFromMeta(block) ?? `${index}.${fileExtension}`,
|
||||
text: [...block.comments, block.value, ""].join("\n"),
|
||||
};
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Adjusts a fix in a code block.
|
||||
* @param {Block} block A code block.
|
||||
* @param {RuleTextEdit} fix A fix to adjust.
|
||||
* @returns {RuleTextEdit} The fix with adjusted ranges.
|
||||
*/
|
||||
function adjustFix(block, fix) {
|
||||
return {
|
||||
range: /** @type {SourceRange} */ (fix.range.map(range => {
|
||||
// Advance through the block's range map to find the last
|
||||
// matching range by finding the first range too far and
|
||||
// then going back one.
|
||||
let i = 1;
|
||||
while (i < block.rangeMap.length &&
|
||||
block.rangeMap[i].js <= range) {
|
||||
i++;
|
||||
}
|
||||
// Apply the mapping delta for this range.
|
||||
return range + block.rangeMap[i - 1].md;
|
||||
})),
|
||||
text: fix.text.replace(/\n/gu, `\n${block.baseIndentText}`),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Creates a map function that adjusts messages in a code block.
|
||||
* @param {Block} block A code block.
|
||||
* @returns {(message: LintMessage) => LintMessage | null} A function that adjusts messages in a code block.
|
||||
*/
|
||||
function adjustBlock(block) {
|
||||
const leadingCommentLines = block.comments.reduce((count, comment) => count + comment.split("\n").length, 0);
|
||||
const blockStart = block.position.start.line;
|
||||
/**
|
||||
* Adjusts ESLint messages to point to the correct location in the Markdown.
|
||||
* @param {LintMessage} message A message from ESLint.
|
||||
* @returns {LintMessage} The same message, but adjusted to the correct location.
|
||||
*/
|
||||
return function adjustMessage(message) {
|
||||
if (!Number.isInteger(message.line)) {
|
||||
return {
|
||||
...message,
|
||||
line: blockStart,
|
||||
column: block.position.start.column,
|
||||
};
|
||||
}
|
||||
const lineInCode = message.line - leadingCommentLines;
|
||||
if (lineInCode < 1 || lineInCode >= block.rangeMap.length) {
|
||||
return null;
|
||||
}
|
||||
/** @type {Pick<LintMessage, "line" | "column" | "endLine" | "suggestions">} */
|
||||
const out = {
|
||||
line: lineInCode + blockStart,
|
||||
column: message.column + block.rangeMap[lineInCode].indent,
|
||||
};
|
||||
if (Number.isInteger(message.endLine)) {
|
||||
out.endLine = message.endLine - leadingCommentLines + blockStart;
|
||||
}
|
||||
if (Array.isArray(message.suggestions)) {
|
||||
out.suggestions = message.suggestions.map(suggestion => ({
|
||||
...suggestion,
|
||||
fix: adjustFix(block, suggestion.fix),
|
||||
}));
|
||||
}
|
||||
const adjustedFix = {};
|
||||
if (message.fix) {
|
||||
adjustedFix.fix = adjustFix(block, message.fix);
|
||||
}
|
||||
return { ...message, ...out, ...adjustedFix };
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Excludes unsatisfiable rules from the list of messages.
|
||||
* @param {LintMessage} message A message from the linter.
|
||||
* @returns {boolean} True if the message should be included in output.
|
||||
*/
|
||||
function excludeUnsatisfiableRules(message) {
|
||||
return message && !UNSATISFIABLE_RULES.has(message.ruleId);
|
||||
}
|
||||
/**
|
||||
* Transforms generated messages for output.
|
||||
* @param {Array<LintMessage[]>} messages An array containing one array of messages
|
||||
* for each code block returned from `preprocess`.
|
||||
* @param {string} filename The filename of the file
|
||||
* @returns {LintMessage[]} A flattened array of messages with mapped locations.
|
||||
*/
|
||||
function postprocess(messages, filename) {
|
||||
const blocks = blocksCache.get(filename);
|
||||
blocksCache.delete(filename);
|
||||
return messages.flatMap((group, i) => {
|
||||
const adjust = adjustBlock(blocks[i]);
|
||||
return group.map(adjust).filter(excludeUnsatisfiableRules);
|
||||
});
|
||||
}
|
||||
export const processor = {
|
||||
meta: {
|
||||
name: "@eslint/markdown/markdown",
|
||||
version: "8.0.3", // x-release-please-version
|
||||
},
|
||||
preprocess,
|
||||
postprocess,
|
||||
supportsAutofix: SUPPORTS_AUTOFIX,
|
||||
};
|
||||
Reference in New Issue
Block a user