Skip to content

Commit

Permalink
refactor: Move JS parsing logic into JS language (#18448)
Browse files Browse the repository at this point in the history
* feat: Implement language plugins

Refs eslint/rfcs#99

* Add JS language index file stub

* Refactor to use language parser

* Add language to flat config

* Fix up linter errors

* Add VFile class

* Hook up validation and esquery

* Update flat config schema to account for new languageOptions

* Fix linting errors

* Add visitorKeys property per RFC

* Remove unused eslint-utils

* Fix test failures

* Ensure columnStart and lineStart are honored

* Update lib/languages/js/validate-language-options.js

Co-authored-by: Milos Djermanovic <[email protected]>

* Update lib/languages/js/validate-language-options.js

Co-authored-by: Milos Djermanovic <[email protected]>

* Update lib/languages/js/validate-language-options.js

Co-authored-by: Milos Djermanovic <[email protected]>

* Update lib/languages/js/validate-language-options.js

Co-authored-by: Milos Djermanovic <[email protected]>

* Clean up logic and comments; update docs

* Fix passing hasBOM to constructor

* Update location info with language settings

* Fix line/column errors in linter

* Revert changes to tests for endLine/endColumn

* Revert fuzzer tests

* Fix global merge behavior

* Fix tests

* Remove restriction on ESTree for scope analysis

* Fix RuleTester conflict

* Fix RuleTester tests

* Remove check for global scope

* Remove ESTree check

* Ensure globals is serialized properly

---------

Co-authored-by: Milos Djermanovic <[email protected]>
  • Loading branch information
nzakas and mdjermanovic committed Jun 14, 2024
1 parent 6880286 commit 4b23ffd
Show file tree
Hide file tree
Showing 38 changed files with 1,220 additions and 257 deletions.
4 changes: 2 additions & 2 deletions docs/src/extend/custom-parsers.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ ESLint custom parsers let you extend ESLint to support linting new non-standard

### Methods in Custom Parsers

A custom parser is a JavaScript object with either a `parse` or `parseForESLint` method. The `parse` method only returns the AST, whereas `parseForESLint` also returns additional values that let the parser customize the behavior of ESLint even more.
A custom parser is a JavaScript object with either a `parse()` or `parseForESLint()` method. The `parse` method only returns the AST, whereas `parseForESLint()` also returns additional values that let the parser customize the behavior of ESLint even more.

Both methods should take in the source code as the first argument, and an optional configuration object as the second argument, which is provided as [`parserOptions`](../use/configure/language-options#specifying-parser-options) in a configuration file.
Both methods should be instance (own) properties and take in the source code as the first argument, and an optional configuration object as the second argument, which is provided as [`parserOptions`](../use/configure/language-options#specifying-parser-options) in a configuration file.

```javascript
// customParser.js
Expand Down
2 changes: 1 addition & 1 deletion lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const { ESLint, shouldUseFlatConfig } = require("./eslint/eslint");
const { LegacyESLint } = require("./eslint/legacy-eslint");
const { Linter } = require("./linter");
const { RuleTester } = require("./rule-tester");
const { SourceCode } = require("./source-code");
const { SourceCode } = require("./languages/js/source-code");

//-----------------------------------------------------------------------------
// Functions
Expand Down
5 changes: 5 additions & 0 deletions lib/config/default-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ exports.defaultConfig = [
plugins: {
"@": {

languages: {
js: require("../languages/js")
},

/*
* Because we try to delay loading rules until absolutely
* necessary, a proxy allows us to hook into the lazy-loading
Expand All @@ -37,6 +41,7 @@ exports.defaultConfig = [
})
}
},
language: "@/js",
languageOptions: {
sourceType: "module",
ecmaVersion: "latest",
Expand Down
79 changes: 71 additions & 8 deletions lib/config/flat-config-array.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
//-----------------------------------------------------------------------------

const { ConfigArray, ConfigArraySymbol } = require("@eslint/config-array");
const { flatConfigSchema } = require("./flat-config-schema");
const { flatConfigSchema, hasMethod } = require("./flat-config-schema");
const { RuleValidator } = require("./rule-validator");
const { defaultConfig } = require("./default-config");

Expand Down Expand Up @@ -123,6 +123,43 @@ function wrapConfigErrorWithDetails(error, originalLength, baseLength) {
);
}

/**
* Converts a languageOptions object to a JSON representation.
* @param {Record<string, any>} languageOptions The options to create a JSON
* representation of.
* @param {string} objectKey The key of the object being converted.
* @returns {Record<string, any>} The JSON representation of the languageOptions.
* @throws {TypeError} If a function is found in the languageOptions.
*/
function languageOptionsToJSON(languageOptions, objectKey = "languageOptions") {

const result = {};

for (const [key, value] of Object.entries(languageOptions)) {
if (value) {
if (typeof value === "object") {
const name = getObjectId(value);

if (name && hasMethod(value)) {
result[key] = name;
} else {
result[key] = languageOptionsToJSON(value, key);
}
continue;
}

if (typeof value === "function") {
throw new TypeError(`Cannot serialize key "${key}" in ${objectKey}: Function values are not supported.`);
}

}

result[key] = value;
}

return result;
}

const originalBaseConfig = Symbol("originalBaseConfig");
const originalLength = Symbol("originalLength");
const baseLength = Symbol("baseLength");
Expand Down Expand Up @@ -269,10 +306,11 @@ class FlatConfigArray extends ConfigArray {
*/
[ConfigArraySymbol.finalizeConfig](config) {

const { plugins, languageOptions, processor } = config;
let parserName, processorName;
const { plugins, language, languageOptions, processor } = config;
let parserName, processorName, languageName;
let invalidParser = false,
invalidProcessor = false;
invalidProcessor = false,
invalidLanguage = false;

// Check parser value
if (languageOptions && languageOptions.parser) {
Expand All @@ -290,6 +328,29 @@ class FlatConfigArray extends ConfigArray {
}
}

// Check language value
if (language) {
if (typeof language === "string") {
const { pluginName, objectName: localLanguageName } = splitPluginIdentifier(language);

languageName = language;

if (!plugins || !plugins[pluginName] || !plugins[pluginName].languages || !plugins[pluginName].languages[localLanguageName]) {
throw new TypeError(`Key "language": Could not find "${localLanguageName}" in plugin "${pluginName}".`);
}

config.language = plugins[pluginName].languages[localLanguageName];
} else {
invalidLanguage = true;
}

try {
config.language.validateLanguageOptions(config.languageOptions);
} catch (error) {
throw new TypeError(`Key "languageOptions": ${error.message}`, { cause: error });
}
}

// Check processor value
if (processor) {
if (typeof processor === "string") {
Expand Down Expand Up @@ -329,6 +390,10 @@ class FlatConfigArray extends ConfigArray {
throw new Error("Could not serialize processor object (missing 'meta' object).");
}

if (invalidLanguage) {
throw new Error("Caching is not supported when language is an object.");
}

return {
...this,
plugins: Object.entries(plugins).map(([namespace, plugin]) => {
Expand All @@ -341,10 +406,8 @@ class FlatConfigArray extends ConfigArray {

return `${namespace}:${pluginId}`;
}),
languageOptions: {
...languageOptions,
parser: parserName
},
language: languageName,
languageOptions: languageOptionsToJSON(languageOptions),
processor: processorName
};
}
Expand Down
108 changes: 46 additions & 62 deletions lib/config/flat-config-schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,6 @@ const ruleSeverities = new Map([
[2, 2], ["error", 2]
]);

const globalVariablesValues = new Set([
true, "true", "writable", "writeable",
false, "false", "readonly", "readable", null,
"off"
]);

/**
* Check if a value is a non-null object.
* @param {any} value The value to check.
Expand Down Expand Up @@ -143,6 +137,23 @@ function normalizeRuleOptions(ruleOptions) {
return structuredClone(finalOptions);
}

/**
* Determines if an object has any methods.
* @param {Object} object The object to check.
* @returns {boolean} `true` if the object has any methods.
*/
function hasMethod(object) {

for (const key of Object.keys(object)) {

if (typeof object[key] === "function") {
return true;
}
}

return false;
}

//-----------------------------------------------------------------------------
// Assertions
//-----------------------------------------------------------------------------
Expand Down Expand Up @@ -301,47 +312,48 @@ const deepObjectAssignSchema = {
validate: "object"
};


//-----------------------------------------------------------------------------
// High-Level Schemas
//-----------------------------------------------------------------------------

/** @type {ObjectPropertySchema} */
const globalsSchema = {
merge: "assign",
validate(value) {
const languageOptionsSchema = {
merge(first = {}, second = {}) {

assertIsObject(value);
const result = deepMerge(first, second);

for (const key of Object.keys(value)) {
for (const [key, value] of Object.entries(result)) {

// avoid hairy edge case
if (key === "__proto__") {
continue;
}
/*
* Special case: Because the `parser` property is an object, it should
* not be deep merged. Instead, it should be replaced if it exists in
* the second object. To make this more generic, we just check for
* objects with methods and replace them if they exist in the second
* object.
*/
if (isNonArrayObject(value)) {
if (hasMethod(value)) {
result[key] = second[key] ?? first[key];
continue;
}

if (key !== key.trim()) {
throw new TypeError(`Global "${key}" has leading or trailing whitespace.`);
// for other objects, make sure we aren't reusing the same object
result[key] = { ...result[key] };
continue;
}

if (!globalVariablesValues.has(value[key])) {
throw new TypeError(`Key "${key}": Expected "readonly", "writable", or "off".`);
}
}
}

return result;
},
validate: "object"
};

/** @type {ObjectPropertySchema} */
const parserSchema = {
const languageSchema = {
merge: "replace",
validate(value) {

if (!value || typeof value !== "object" ||
(typeof value.parse !== "function" && typeof value.parseForESLint !== "function")
) {
throw new TypeError("Expected object with parse() or parseForESLint() method.");
}

}
validate: assertIsPluginMemberName
};

/** @type {ObjectPropertySchema} */
Expand Down Expand Up @@ -501,28 +513,6 @@ const rulesSchema = {
}
};

/** @type {ObjectPropertySchema} */
const ecmaVersionSchema = {
merge: "replace",
validate(value) {
if (typeof value === "number" || value === "latest") {
return;
}

throw new TypeError("Expected a number or \"latest\".");
}
};

/** @type {ObjectPropertySchema} */
const sourceTypeSchema = {
merge: "replace",
validate(value) {
if (typeof value !== "string" || !/^(?:script|module|commonjs)$/u.test(value)) {
throw new TypeError("Expected \"script\", \"module\", or \"commonjs\".");
}
}
};

/**
* Creates a schema that always throws an error. Useful for warning
* about eslintrc-style keys.
Expand Down Expand Up @@ -568,15 +558,8 @@ const flatConfigSchema = {
reportUnusedDisableDirectives: disableDirectiveSeveritySchema
}
},
languageOptions: {
schema: {
ecmaVersion: ecmaVersionSchema,
sourceType: sourceTypeSchema,
globals: globalsSchema,
parser: parserSchema,
parserOptions: deepObjectAssignSchema
}
},
language: languageSchema,
languageOptions: languageOptionsSchema,
processor: processorSchema,
plugins: pluginsSchema,
rules: rulesSchema
Expand All @@ -588,5 +571,6 @@ const flatConfigSchema = {

module.exports = {
flatConfigSchema,
hasMethod,
assertIsRuleSeverity
};
Loading

0 comments on commit 4b23ffd

Please sign in to comment.