271 lines
11 KiB
JavaScript
271 lines
11 KiB
JavaScript
/**
|
|
* @fileoverview The CSSLanguage class.
|
|
* @author Nicholas C. Zakas
|
|
*/
|
|
//------------------------------------------------------------------------------
|
|
// Imports
|
|
//------------------------------------------------------------------------------
|
|
import { parse as originalParse, lexer as originalLexer, fork, toPlainObject, tokenTypes, } from "@eslint/css-tree";
|
|
import defaultSyntax from "@eslint/css-tree/definition-syntax-data";
|
|
import { CSSSourceCode } from "./css-source-code.js";
|
|
import { visitorKeys } from "./css-visitor-keys.js";
|
|
//-----------------------------------------------------------------------------
|
|
// Types
|
|
//-----------------------------------------------------------------------------
|
|
/**
|
|
* @import { CssNodePlain, Comment, Lexer, StyleSheetPlain, SyntaxConfig } from "@eslint/css-tree"
|
|
* @import { Language, OkParseResult, ParseResult, File, FileError } from "@eslint/core";
|
|
*/
|
|
/** @typedef {OkParseResult<StyleSheetPlain> & { comments: Comment[], lexer: Lexer }} CSSOkParseResult */
|
|
/** @typedef {ParseResult<StyleSheetPlain>} CSSParseResult */
|
|
/**
|
|
* DefaultSyntaxConfig type representing the structure returned by `@eslint/css-tree/definition-syntax-data`.
|
|
* This type is defined inline because it's not exported from the main `@eslint/css-tree` package.
|
|
* @typedef {Pick<SyntaxConfig, "atrules" | "types" | "properties">} DefaultSyntaxConfig
|
|
*/
|
|
/**
|
|
* @typedef {(defaultSyntax: DefaultSyntaxConfig) => Partial<SyntaxConfig>} SyntaxExtensionCallback
|
|
*/
|
|
/**
|
|
* @typedef {Object} CSSLanguageOptions
|
|
* @property {boolean} [tolerant] Whether to be tolerant of recoverable parsing errors.
|
|
* @property {Partial<SyntaxConfig> | SyntaxExtensionCallback} [customSyntax] Custom syntax to use for parsing.
|
|
*/
|
|
//-----------------------------------------------------------------------------
|
|
// Helpers
|
|
//-----------------------------------------------------------------------------
|
|
const blockOpenerTokenTypes = new Map([
|
|
[tokenTypes.Function, ")"],
|
|
[tokenTypes.LeftCurlyBracket, "}"],
|
|
[tokenTypes.LeftParenthesis, ")"],
|
|
[tokenTypes.LeftSquareBracket, "]"],
|
|
]);
|
|
const blockCloserTokenTypes = new Map([
|
|
[tokenTypes.RightCurlyBracket, "{"],
|
|
[tokenTypes.RightParenthesis, "("],
|
|
[tokenTypes.RightSquareBracket, "["],
|
|
]);
|
|
/**
|
|
* Recursively replaces all function values in an object with boolean true.
|
|
* Used to make objects serializable for JSON output.
|
|
* @param {Record<string,any>|unknown[]|unknown} object The object to process.
|
|
* @returns {Record<string,any>|unknown[]|unknown} A copy of the object with all functions replaced by true.
|
|
*/
|
|
function replaceFunctions(object) {
|
|
if (typeof object !== "object" || object === null) {
|
|
return object;
|
|
}
|
|
if (Array.isArray(object)) {
|
|
return object.map(replaceFunctions);
|
|
}
|
|
const result = {};
|
|
for (const key of Object.keys(object)) {
|
|
const value = object[key];
|
|
if (typeof value === "function") {
|
|
result[key] = true;
|
|
}
|
|
else if (typeof value === "object" && value !== null) {
|
|
result[key] = replaceFunctions(value);
|
|
}
|
|
else {
|
|
result[key] = value;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
//-----------------------------------------------------------------------------
|
|
// Exports
|
|
//-----------------------------------------------------------------------------
|
|
/**
|
|
* CSS Language Object
|
|
* @implements {Language<{ LangOptions: CSSLanguageOptions; Code: CSSSourceCode; RootNode: StyleSheetPlain; Node: CssNodePlain}>}
|
|
*/
|
|
export class CSSLanguage {
|
|
/**
|
|
* 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 visitor keys for the CSSTree AST.
|
|
* @type {Record<string, string[]>}
|
|
*/
|
|
visitorKeys = visitorKeys;
|
|
/**
|
|
* The default language options.
|
|
* @type {CSSLanguageOptions}
|
|
*/
|
|
defaultLanguageOptions = {
|
|
tolerant: false,
|
|
};
|
|
/**
|
|
* Validates the language options.
|
|
* @param {CSSLanguageOptions} languageOptions The language options to validate.
|
|
* @returns {void}
|
|
* @throws {TypeError} When the language options are invalid.
|
|
*/
|
|
validateLanguageOptions(languageOptions) {
|
|
if ("tolerant" in languageOptions &&
|
|
typeof languageOptions.tolerant !== "boolean") {
|
|
throw new TypeError("Expected a boolean value for 'tolerant' option.");
|
|
}
|
|
if ("customSyntax" in languageOptions) {
|
|
if (typeof languageOptions.customSyntax !== "object" &&
|
|
typeof languageOptions.customSyntax !== "function") {
|
|
throw new TypeError("Expected an object or function value for 'customSyntax' option.");
|
|
}
|
|
if (typeof languageOptions.customSyntax === "object" &&
|
|
languageOptions.customSyntax === null) {
|
|
throw new TypeError("Expected an object or function value for 'customSyntax' option.");
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Normalizes the language options so they can be serialized.
|
|
* @param {CSSLanguageOptions} languageOptions The language options to normalize.
|
|
* @returns {CSSLanguageOptions} The normalized language options.
|
|
*/
|
|
normalizeLanguageOptions(languageOptions) {
|
|
// if there's no custom syntax then no changes are necessary
|
|
if (!languageOptions?.customSyntax) {
|
|
return languageOptions;
|
|
}
|
|
// Shallow copy
|
|
const clone = { ...languageOptions };
|
|
// If customSyntax is a function, call it with the default syntax to get the config object
|
|
if (typeof languageOptions.customSyntax === "function") {
|
|
clone.customSyntax = languageOptions.customSyntax(defaultSyntax);
|
|
}
|
|
Object.defineProperty(clone, "toJSON", {
|
|
value() {
|
|
// another shallow copy
|
|
const result = { ...this };
|
|
result.customSyntax = replaceFunctions(result.customSyntax);
|
|
return result;
|
|
},
|
|
enumerable: false,
|
|
configurable: true,
|
|
});
|
|
return clone;
|
|
}
|
|
/**
|
|
* Parses the given file into an AST.
|
|
* @param {File} file The virtual file to parse.
|
|
* @param {Object} [context] The parsing context.
|
|
* @param {CSSLanguageOptions} [context.languageOptions] The language options to use for parsing.
|
|
* @returns {CSSParseResult} The result of parsing.
|
|
*/
|
|
parse(file, { languageOptions = {} } = {}) {
|
|
// Note: BOM already removed
|
|
const text = /** @type {string} */ (file.body);
|
|
/** @type {Comment[]} */
|
|
const comments = [];
|
|
/** @type {FileError[]} */
|
|
const errors = [];
|
|
const { tolerant } = languageOptions;
|
|
const { parse, lexer } = languageOptions.customSyntax
|
|
? fork(
|
|
/** @type {Partial<SyntaxConfig>} */ (languageOptions.customSyntax))
|
|
: { parse: originalParse, lexer: originalLexer };
|
|
/*
|
|
* 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 = toPlainObject(parse(text, {
|
|
filename: file.path,
|
|
positions: true,
|
|
onComment(value, loc) {
|
|
comments.push({
|
|
type: "Comment",
|
|
value,
|
|
loc,
|
|
});
|
|
},
|
|
onParseError(error) {
|
|
if (!tolerant) {
|
|
errors.push(error);
|
|
}
|
|
},
|
|
onToken(type, start, end, index) {
|
|
if (tolerant) {
|
|
return;
|
|
}
|
|
switch (type) {
|
|
// these already generate errors
|
|
case tokenTypes.BadString:
|
|
case tokenTypes.BadUrl:
|
|
break;
|
|
default:
|
|
/* eslint-disable new-cap -- This is a valid call */
|
|
if (this.isBlockOpenerTokenType(type)) {
|
|
if (this.getBlockTokenPairIndex(index) ===
|
|
-1) {
|
|
const loc = this.getRangeLocation(start, end);
|
|
errors.push(parse.SyntaxError(`Missing closing ${blockOpenerTokenTypes.get(type)}`, text, start, loc.start.line, loc.start.column));
|
|
}
|
|
}
|
|
else if (this.isBlockCloserTokenType(type)) {
|
|
if (this.getBlockTokenPairIndex(index) ===
|
|
-1) {
|
|
const loc = this.getRangeLocation(start, end);
|
|
errors.push(parse.SyntaxError(`Missing opening ${blockCloserTokenTypes.get(type)}`, text, start, loc.start.line, loc.start.column));
|
|
}
|
|
}
|
|
/* eslint-enable new-cap -- This is a valid call */
|
|
}
|
|
},
|
|
}));
|
|
if (errors.length) {
|
|
return {
|
|
ok: false,
|
|
errors,
|
|
};
|
|
}
|
|
return {
|
|
ok: true,
|
|
ast: /** @type {StyleSheetPlain} */ (root),
|
|
comments,
|
|
lexer,
|
|
};
|
|
}
|
|
catch (ex) {
|
|
return {
|
|
ok: false,
|
|
errors: [ex],
|
|
};
|
|
}
|
|
}
|
|
/**
|
|
* Creates a new `CSSSourceCode` object from the given information.
|
|
* @param {File} file The virtual file to create a `CSSSourceCode` object from.
|
|
* @param {CSSOkParseResult} parseResult The result returned from `parse()`.
|
|
* @returns {CSSSourceCode} The new `CSSSourceCode` object.
|
|
*/
|
|
createSourceCode(file, parseResult) {
|
|
return new CSSSourceCode({
|
|
text: /** @type {string} */ (file.body),
|
|
ast: parseResult.ast,
|
|
comments: parseResult.comments,
|
|
lexer: parseResult.lexer,
|
|
});
|
|
}
|
|
}
|