first commit

This commit is contained in:
2026-08-06 12:00:39 +10:00
commit 91221f5a8e
3259 changed files with 569069 additions and 0 deletions
@@ -0,0 +1,85 @@
/**
* @import { DocumentNode, AnyNode } from "@humanwhocodes/momoa";
* @import { Language, OkParseResult, ParseResult, File } from "@eslint/core";
* @typedef {OkParseResult<DocumentNode>} JSONOkParseResult
* @typedef {ParseResult<DocumentNode>} JSONParseResult
* @typedef {Object} JSONLanguageOptions
* @property {boolean} [allowTrailingCommas] Whether to allow trailing commas in JSONC mode.
*/
/**
* JSON Language Object
* @implements {Language<{ LangOptions: JSONLanguageOptions; Code: JSONSourceCode; RootNode: DocumentNode; Node: AnyNode }>}
*/
export class JSONLanguage implements Language {
/**
* Creates a new instance.
* @param {Object} options The options to use for this instance.
* @param {"json"|"jsonc"|"json5"} options.mode The parser mode to use.
*/
constructor({ mode }: {
mode: "json" | "jsonc" | "json5";
});
/**
* The type of file to read.
* @type {"text"}
*/
fileType: "text";
/**
* The line number at which the parser starts counting.
* @type {0|1}
*/
lineStart: 0 | 1;
/**
* The column number at which the parser starts counting.
* @type {0|1}
*/
columnStart: 0 | 1;
/**
* The name of the key that holds the type of the node.
* @type {string}
*/
nodeTypeKey: string;
/**
* The visitor keys.
* @type {Record<string, string[]>}
*/
visitorKeys: Record<string, string[]>;
/**
* Validates the language options.
* @param {JSONLanguageOptions} languageOptions The language options to validate.
* @returns {void}
* @throws {Error} When the language options are invalid.
*/
validateLanguageOptions(languageOptions: JSONLanguageOptions): void;
/**
* Parses the given file into an AST.
* @param {File} file The virtual file to parse.
* @param {{languageOptions: JSONLanguageOptions}} context The options to use for parsing.
* @returns {JSONParseResult} The result of parsing.
*/
parse(file: File, context: {
languageOptions: JSONLanguageOptions;
}): JSONParseResult;
/**
* Creates a new `JSONSourceCode` object from the given information.
* @param {File} file The virtual file to create a `JSONSourceCode` object from.
* @param {JSONOkParseResult} parseResult The result returned from `parse()`.
* @returns {JSONSourceCode} The new `JSONSourceCode` object.
*/
createSourceCode(file: File, parseResult: JSONOkParseResult): JSONSourceCode;
#private;
}
export type JSONOkParseResult = OkParseResult<DocumentNode>;
export type JSONParseResult = ParseResult<DocumentNode>;
export type JSONLanguageOptions = {
/**
* Whether to allow trailing commas in JSONC mode.
*/
allowTrailingCommas?: boolean;
};
import type { Language } from "@eslint/core";
import type { File } from "@eslint/core";
import { JSONSourceCode } from "./json-source-code.js";
import type { DocumentNode } from "@humanwhocodes/momoa";
import type { OkParseResult } from "@eslint/core";
import type { ParseResult } from "@eslint/core";
+143
View File
@@ -0,0 +1,143 @@
/**
* @fileoverview The JSONLanguage class.
* @author Nicholas C. Zakas
*/
//------------------------------------------------------------------------------
// Imports
//------------------------------------------------------------------------------
import { parse } from "@humanwhocodes/momoa";
import { JSONSourceCode } from "./json-source-code.js";
import { visitorKeys } from "@humanwhocodes/momoa";
//-----------------------------------------------------------------------------
// Types
//-----------------------------------------------------------------------------
/**
* @import { DocumentNode, AnyNode } from "@humanwhocodes/momoa";
* @import { Language, OkParseResult, ParseResult, File } from "@eslint/core";
* @typedef {OkParseResult<DocumentNode>} JSONOkParseResult
* @typedef {ParseResult<DocumentNode>} JSONParseResult
* @typedef {Object} JSONLanguageOptions
* @property {boolean} [allowTrailingCommas] Whether to allow trailing commas in JSONC mode.
*/
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* JSON Language Object
* @implements {Language<{ LangOptions: JSONLanguageOptions; Code: JSONSourceCode; RootNode: DocumentNode; Node: AnyNode }>}
*/
export class JSONLanguage {
/**
* The type of file to read.
* @type {"text"}
*/
fileType = "text";
/**
* The line number at which the parser starts counting.
* @type {0|1}
*/
lineStart = 1;
/**
* The column number at which the parser starts counting.
* @type {0|1}
*/
columnStart = 1;
/**
* The name of the key that holds the type of the node.
* @type {string}
*/
nodeTypeKey = "type";
/**
* The parser mode.
* @type {"json"|"jsonc"|"json5"}
*/
#mode = "json";
/**
* The visitor keys.
* @type {Record<string, string[]>}
*/
visitorKeys = Object.fromEntries([...visitorKeys]);
/**
* Creates a new instance.
* @param {Object} options The options to use for this instance.
* @param {"json"|"jsonc"|"json5"} options.mode The parser mode to use.
*/
constructor({ mode }) {
this.#mode = mode;
}
/**
* Validates the language options.
* @param {JSONLanguageOptions} languageOptions The language options to validate.
* @returns {void}
* @throws {Error} When the language options are invalid.
*/
validateLanguageOptions(languageOptions) {
if (languageOptions.allowTrailingCommas !== undefined) {
if (typeof languageOptions.allowTrailingCommas !== "boolean") {
throw new Error("allowTrailingCommas must be a boolean if provided.");
}
// we know that allowTrailingCommas is a boolean here
// only allowed in JSONC mode
if (this.#mode !== "jsonc") {
throw new Error("allowTrailingCommas option is only available in JSONC.");
}
}
}
/**
* Parses the given file into an AST.
* @param {File} file The virtual file to parse.
* @param {{languageOptions: JSONLanguageOptions}} context The options to use for parsing.
* @returns {JSONParseResult} The result of parsing.
*/
parse(file, context) {
// Note: BOM already removed
const text = /** @type {string} */ (file.body);
const allowTrailingCommas = context?.languageOptions?.allowTrailingCommas;
/*
* Check for parsing errors first. If there's a parsing error, nothing
* else can happen. However, a parsing error does not throw an error
* from this method - it's just considered a fatal error message, a
* problem that ESLint identified just like any other.
*/
try {
const root = parse(text, {
mode: this.#mode,
ranges: true,
tokens: true,
allowTrailingCommas,
});
return {
ok: true,
ast: root,
};
}
catch (ex) {
// error messages end with (line:column) so we strip that off for ESLint
const message = ex.message
.slice(0, ex.message.lastIndexOf("("))
.trim();
return {
ok: false,
errors: [
{
...ex,
message,
},
],
};
}
}
/* eslint-disable class-methods-use-this -- Required to complete interface. */
/**
* Creates a new `JSONSourceCode` object from the given information.
* @param {File} file The virtual file to create a `JSONSourceCode` object from.
* @param {JSONOkParseResult} parseResult The result returned from `parse()`.
* @returns {JSONSourceCode} The new `JSONSourceCode` object.
*/
createSourceCode(file, parseResult) {
return new JSONSourceCode({
text: /** @type {string} */ (file.body),
ast: parseResult.ast,
});
}
}
@@ -0,0 +1,123 @@
/**
* JSON Source Code Object
* @extends {TextSourceCodeBase<{LangOptions: JSONLanguageOptions, RootNode: DocumentNode, SyntaxElementWithLoc: JSONSyntaxElement, ConfigNode: Token}>}
*/
export class JSONSourceCode extends TextSourceCodeBase<{
LangOptions: JSONLanguageOptions;
RootNode: DocumentNode;
SyntaxElementWithLoc: JSONSyntaxElement;
ConfigNode: Token;
}> {
/**
* Creates a new instance.
* @param {Object} options The options for the instance.
* @param {string} options.text The source code text.
* @param {DocumentNode} options.ast The root AST node.
*/
constructor({ text, ast }: {
text: string;
ast: DocumentNode;
});
/**
* The comment tokens in the source code.
* @type {Array<Token>|undefined}
*/
comments: Array<Token> | undefined;
/**
* Returns an array of all inline configuration nodes found in the
* source code.
* @returns {Array<Token>} An array of all inline configuration nodes.
*/
getInlineConfigNodes(): Array<Token>;
/**
* Returns directives 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(): {
problems: Array<FileProblem>;
directives: Array<Directive>;
};
/**
* Returns inline rule configurations along with any problems
* encountered while parsing the configurations.
* @returns {{problems:Array<FileProblem>,configs:Array<{config:{rules:RulesConfig},loc:LocationRange}>}} Information
* that ESLint needs to further process the rule configurations.
*/
applyInlineConfig(): {
problems: Array<FileProblem>;
configs: Array<{
config: {
rules: RulesConfig;
};
loc: LocationRange;
}>;
};
/**
* Returns the parent of the given node.
* @param {AnyNode} node The node to get the parent of.
* @returns {AnyNode|undefined} The parent of the node.
*/
getParent(node: AnyNode): AnyNode | undefined;
/**
* Traverse the source code and return the steps that were taken.
* @returns {Iterable<JSONTraversalStep>} The steps that were taken while traversing the source code.
*/
traverse(): Iterable<JSONTraversalStep>;
/**
* Gets the token before the given node or token, optionally including comments.
* @param {AnyNode|Token} nodeOrToken The node or token to get the previous token for.
* @param {Object} [options] Options object.
* @param {boolean} [options.includeComments] If true, return comments when they are present.
* @returns {Token|null} The previous token or comment, or null if there is none.
*/
getTokenBefore(nodeOrToken: AnyNode | Token, { includeComments }?: {
includeComments?: boolean;
}): Token | null;
/**
* Gets the token after the given node or token, skipping any comments unless includeComments is true.
* @param {AnyNode|Token} nodeOrToken The node or token to get the next token for.
* @param {Object} [options] Options object.
* @param {boolean} [options.includeComments=false] If true, return comments when they are present.
* @returns {Token|null} The next token or comment, or null if there is none.
*/
getTokenAfter(nodeOrToken: AnyNode | Token, { includeComments }?: {
includeComments?: boolean;
}): Token | null;
#private;
}
import type { JSONLanguageOptions } from "./json-language.js";
import type { DocumentNode } from "@humanwhocodes/momoa";
import type { JSONSyntaxElement } from "../types.js";
import type { Token } from "@humanwhocodes/momoa";
import { TextSourceCodeBase } from "@eslint/plugin-kit";
import type { FileProblem } from "@eslint/core";
import { Directive } from "@eslint/plugin-kit";
import type { RulesConfig } from "@eslint/core";
import type { LocationRange } from "@humanwhocodes/momoa";
import type { AnyNode } from "@humanwhocodes/momoa";
/**
* A class to represent a step in the traversal process.
*/
declare class JSONTraversalStep extends VisitNodeStep {
/**
* Creates a new instance.
* @param {Object} options The options for the step.
* @param {AnyNode} options.target The target of the step.
* @param {1|2} options.phase The phase of the step.
* @param {Array<any>} options.args The arguments of the step.
*/
constructor({ target, phase, args }: {
target: AnyNode;
phase: 1 | 2;
args: Array<any>;
});
/**
* The target of the step.
* @type {AnyNode}
*/
target: AnyNode;
}
import { VisitNodeStep } from "@eslint/plugin-kit";
export {};
@@ -0,0 +1,327 @@
/**
* @fileoverview The JSONSourceCode class.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { iterator } from "@humanwhocodes/momoa";
import { VisitNodeStep, TextSourceCodeBase, ConfigCommentParser, Directive, } from "@eslint/plugin-kit";
//-----------------------------------------------------------------------------
// Types
//-----------------------------------------------------------------------------
/**
* @import { DocumentNode, AnyNode, Token, LocationRange } from "@humanwhocodes/momoa";
* @import { FileProblem, DirectiveType, RulesConfig } from "@eslint/core";
* @import { JSONSyntaxElement } from "../types.js";
* @import { JSONLanguageOptions } from "./json-language.js";
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const commentParser = new ConfigCommentParser();
const INLINE_CONFIG = /^\s*eslint(?:-enable|-disable(?:(?:-next)?-line)?)?(?:\s|$)/u;
/**
* A class to represent a step in the traversal process.
*/
class JSONTraversalStep extends VisitNodeStep {
/**
* The target of the step.
* @type {AnyNode}
*/
target = undefined;
/**
* Creates a new instance.
* @param {Object} options The options for the step.
* @param {AnyNode} options.target The target of the step.
* @param {1|2} options.phase The phase of the step.
* @param {Array<any>} options.args The arguments of the step.
*/
constructor({ target, phase, args }) {
super({ target, phase, args });
this.target = target;
}
}
/**
* Processes tokens to extract comments and their starting tokens.
* @param {Array<Token>} tokens The tokens to process.
* @returns {{ comments: Array<Token>, starts: Map<number, number>, ends: Map<number, number>}}
* An object containing an array of comments, a map of starting token range to token index, and
* a map of ending token range to token index.
*/
function processTokens(tokens) {
/** @type {Array<Token>} */
const comments = [];
/** @type {Map<number, number>} */
const starts = new Map();
/** @type {Map<number, number>} */
const ends = new Map();
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token.type.endsWith("Comment")) {
comments.push(token);
}
starts.set(token.range[0], i);
ends.set(token.range[1], i);
}
return { comments, starts, ends };
}
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* JSON Source Code Object
* @extends {TextSourceCodeBase<{LangOptions: JSONLanguageOptions, RootNode: DocumentNode, SyntaxElementWithLoc: JSONSyntaxElement, ConfigNode: Token}>}
*/
export class JSONSourceCode extends TextSourceCodeBase {
/**
* Cached traversal steps.
* @type {Array<JSONTraversalStep>|undefined}
*/
#steps;
/**
* Cache of parent nodes.
* @type {WeakMap<AnyNode, AnyNode>}
*/
#parents = new WeakMap();
/**
* Collection of inline configuration comments.
* @type {Array<Token>}
*/
#inlineConfigComments;
/**
* The AST of the source code.
* @type {DocumentNode}
*/
ast = undefined;
/**
* The comment tokens in the source code.
* @type {Array<Token>|undefined}
*/
comments;
/**
* A map of token start positions to their corresponding index.
* @type {Map<number, number>}
*/
#tokenStarts;
/**
* A map of token end positions to their corresponding index.
* @type {Map<number, number>}
*/
#tokenEnds;
/**
* Creates a new instance.
* @param {Object} options The options for the instance.
* @param {string} options.text The source code text.
* @param {DocumentNode} options.ast The root AST node.
*/
constructor({ text, ast }) {
super({ text, ast, lineEndingPattern: /\r\n|[\r\n]/u });
this.ast = ast;
const { comments, starts, ends } = processTokens(this.ast.tokens ?? []);
this.comments = comments;
this.#tokenStarts = starts;
this.#tokenEnds = ends;
}
/**
* Returns the value of the given comment.
* @param {Token} comment The comment to get the value of.
* @returns {string} The value of the comment.
* @throws {Error} When an unexpected comment type is passed.
*/
#getCommentValue(comment) {
if (comment.type === "LineComment") {
return this.getText(comment).slice(2); // strip leading `//`
}
if (comment.type === "BlockComment") {
return this.getText(comment).slice(2, -2); // strip leading `/*` and trailing `*/`
}
throw new Error(`Unexpected comment type '${comment.type}'`);
}
/**
* Returns an array of all inline configuration nodes found in the
* source code.
* @returns {Array<Token>} An array of all inline configuration nodes.
*/
getInlineConfigNodes() {
if (!this.#inlineConfigComments) {
this.#inlineConfigComments = this.comments.filter(comment => INLINE_CONFIG.test(this.#getCommentValue(comment)));
}
return this.#inlineConfigComments ?? [];
}
/**
* Returns directives 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 => {
const { label, value, justification } = commentParser.parseDirective(this.#getCommentValue(comment));
// `eslint-disable-line` directives are not allowed to span multiple lines as it would be confusing to which lines they apply
if (label === "eslint-disable-line" &&
comment.loc.start.line !== comment.loc.end.line) {
const message = `${label} comment should not span multiple lines.`;
problems.push({
ruleId: null,
message,
loc: comment.loc,
});
return;
}
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,
}));
}
// 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:LocationRange}>}} Information
* that ESLint needs to further process the rule configurations.
*/
applyInlineConfig() {
/** @type {Array<FileProblem>} */
const problems = [];
/** @type {Array<{config:{rules:RulesConfig},loc:LocationRange}>} */
const configs = [];
this.getInlineConfigNodes().forEach(comment => {
const { label, value } = commentParser.parseDirective(this.#getCommentValue(comment));
if (label === "eslint") {
const parseResult = commentParser.parseJSONLikeConfig(value);
if (parseResult.ok) {
configs.push({
config: {
rules: parseResult.config,
},
loc: comment.loc,
});
}
else {
problems.push({
ruleId: null,
message:
/** @type {{ok: false, error: { message: string }}} */ (parseResult).error.message,
loc: comment.loc,
});
}
}
});
return {
configs,
problems,
};
}
/**
* Returns the parent of the given node.
* @param {AnyNode} node The node to get the parent of.
* @returns {AnyNode|undefined} The parent of the node.
*/
getParent(node) {
return this.#parents.get(node);
}
/**
* Traverse the source code and return the steps that were taken.
* @returns {Iterable<JSONTraversalStep>} 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<JSONTraversalStep>} */
const steps = (this.#steps = []);
for (const { node, parent, phase } of iterator(this.ast)) {
if (parent) {
this.#parents.set(
/** @type {AnyNode} */ (node),
/** @type {AnyNode} */ (parent));
}
steps.push(new JSONTraversalStep({
target: /** @type {AnyNode} */ (node),
phase: phase === "enter" ? 1 : 2,
args: [node, parent],
}));
}
return steps;
}
/**
* Gets the token before the given node or token, optionally including comments.
* @param {AnyNode|Token} nodeOrToken The node or token to get the previous token for.
* @param {Object} [options] Options object.
* @param {boolean} [options.includeComments] If true, return comments when they are present.
* @returns {Token|null} The previous token or comment, or null if there is none.
*/
getTokenBefore(nodeOrToken, { includeComments = false } = {}) {
const index = this.#tokenStarts.get(nodeOrToken.range[0]);
if (index === undefined) {
return null;
}
let previousIndex = index - 1;
if (previousIndex < 0) {
return null;
}
const tokens = this.ast.tokens;
let tokenOrComment = tokens[previousIndex];
if (includeComments) {
return tokenOrComment;
}
// skip comments
while (tokenOrComment?.type.endsWith("Comment")) {
previousIndex--;
if (previousIndex < 0) {
return null;
}
tokenOrComment = tokens[previousIndex];
}
return tokenOrComment;
}
/**
* Gets the token after the given node or token, skipping any comments unless includeComments is true.
* @param {AnyNode|Token} nodeOrToken The node or token to get the next token for.
* @param {Object} [options] Options object.
* @param {boolean} [options.includeComments=false] If true, return comments when they are present.
* @returns {Token|null} The next token or comment, or null if there is none.
*/
getTokenAfter(nodeOrToken, { includeComments = false } = {}) {
const index = this.#tokenEnds.get(nodeOrToken.range[1]);
if (index === undefined) {
return null;
}
let nextIndex = index + 1;
const tokens = this.ast.tokens;
if (nextIndex >= tokens.length) {
return null;
}
let tokenOrComment = tokens[nextIndex];
if (includeComments) {
return tokenOrComment;
}
// skip comments
while (tokenOrComment?.type.endsWith("Comment")) {
nextIndex++;
if (nextIndex >= tokens.length) {
return null;
}
tokenOrComment = tokens[nextIndex];
}
return tokenOrComment;
}
}