From e8ef2a9f680775e15d1dcb19bf5d74b9fa3aeb5c Mon Sep 17 00:00:00 2001 From: Yosuke Ota Date: Mon, 23 Aug 2021 17:40:16 +0900 Subject: [PATCH] Supports ESLint v8. (#1317) * Supports ESLint v8. * fix rule meta * update message --- README.md | 6 +- package.json | 8 +- server/src/eslintServer.ts | 148 ++++++++++++++++++++++++++----------- 3 files changed, 113 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index e3ed8aa4..f259f014 100644 --- a/README.md +++ b/README.md @@ -130,11 +130,11 @@ This extension contributes the following variables to the [settings](https://cod } ``` - `eslint.packageManager`: controls the package manager to be used to resolve the ESLint library. This has only an influence if the ESLint library is resolved globally. Valid values are `"npm"` or `"yarn"` or `"pnpm"`. -- `eslint.options`: options to configure how ESLint is started using the [ESLint CLI Engine API](http://eslint.org/docs/developer-guide/nodejs-api#cliengine). Defaults to an empty option bag. +- `eslint.options`: options to configure how ESLint is started using the [ESLint class API](http://eslint.org/docs/developer-guide/nodejs-api#eslint-class). (If you use ESLint<=v7, it will be used as an option for [CLI Engine](http://eslint.org/docs/developer-guide/nodejs-api#cliengine).) Defaults to an empty option bag. An example to point to a custom `.eslintrc.json` file is: ```json { - "eslint.options": { "configFile": "C:/mydirectory/.eslintrc.json" } + "eslint.options": { "overrideConfigFile": "C:/mydirectory/.eslintrc.json" } } ``` - `eslint.run` - run the linter `onSave` or `onType`, default is `onType`. @@ -145,7 +145,7 @@ This extension contributes the following variables to the [settings](https://cod - `eslint.probe` = an array for language identifiers for which the ESLint extension should be activated and should try to validate the file. If validation fails for probed languages the extension says silent. Defaults to `["javascript", "javascriptreact", "typescript", "typescriptreact", "html", "vue", "markdown"]`. - `eslint.validate` - an array of language identifiers specifying the files for which validation is to be enforced. This is an old legacy setting and should in normal cases not be necessary anymore. Defaults to `["javascript", "javascriptreact"]`. - `eslint.format.enable`: enables ESLint as a formatter for validated files. Although you can also use the formatter on save using the setting `editor.formatOnSave` it is recommended to use the `editor.codeActionsOnSave` feature since it allows for better configurability. -- `eslint.workingDirectories` - specifies how the working directories ESLint is using are computed. ESLint resolves configuration files (e.g. `eslintrc`, `.eslintignore`) relative to a working directory so it is important to configure this correctly. If executing ESLint in the terminal requires you to change the working directory in the terminal into a sub folder then it is usually necessary to tweak this setting. (see also [CLIEngine options#cwd](https://eslint.org/docs/developer-guide/nodejs-api#cliengine)). Please also keep in mind that the `.eslintrc*` file is resolved considering the parent directories whereas the `.eslintignore` file is only honored in the current working directory. The following values can be used: +- `eslint.workingDirectories` - specifies how the working directories ESLint is using are computed. ESLint resolves configuration files (e.g. `eslintrc`, `.eslintignore`) relative to a working directory so it is important to configure this correctly. If executing ESLint in the terminal requires you to change the working directory in the terminal into a sub folder then it is usually necessary to tweak this setting. (see also [ESLint class options#cwd](https://eslint.org/docs/developer-guide/nodejs-api#eslint-class)). Please also keep in mind that the `.eslintrc*` file is resolved considering the parent directories whereas the `.eslintignore` file is only honored in the current working directory. The following values can be used: - `[{ "mode": "location" }]` (@since 2.0.0): instructs ESLint to uses the workspace folder location or the file location (if no workspace folder is open) as the working directory. This is the default and is the same strategy as used in older versions of the ESLint extension (1.9.x versions). - `[{ "mode": "auto" }]` (@since 2.0.0): instructs ESLint to infer a working directory based on the location of `package.json`, `.eslintignore` and `.eslintrc*` files. This might work in many cases but can lead to unexpected results as well. - `string[]`: an array of working directories to use. diff --git a/package.json b/package.json index 82c7212b..ca8263b6 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "scope": "resource", "type": "object", "default": {}, - "markdownDescription": "The eslint options object to provide args normally passed to eslint when executed from a command line (see https://eslint.org/docs/developer-guide/nodejs-api#cliengine)." + "markdownDescription": "The eslint options object to provide args normally passed to eslint when executed from a command line (see https://eslint.org/docs/developer-guide/nodejs-api#eslint-class)." }, "eslint.trace.server": { "scope": "window", @@ -250,7 +250,7 @@ } ] }, - "markdownDescription": "Specifies how the working directories ESLint is using are computed. ESLint resolves configuration files (e.g. `eslintrc`, `.eslintignore`) relative to a working directory so it is important to configure this correctly." + "markdownDescription": "Specifies how the working directories ESLint is using are computed. ESLint resolves configuration files (e.g. `eslintrc`, `.eslintignore`) relative to a working directory so it is important to configure this correctly." }, "eslint.validate": { "scope": "resource", @@ -353,7 +353,7 @@ } }, "additionalProperties": false, - "markdownDescription": "Show disable lint rule in the quick fix menu." + "markdownDescription": "Show disable lint rule in the quick fix menu." }, "eslint.codeAction.showDocumentation": { "scope": "resource", @@ -369,7 +369,7 @@ } }, "additionalProperties": false, - "markdownDescription": "Show open lint rule documentation web page in the quick fix menu." + "markdownDescription": "Show open lint rule documentation web page in the quick fix menu." }, "eslint.codeActionsOnSave.mode": { "scope": "resource", diff --git a/server/src/eslintServer.ts b/server/src/eslintServer.ts index c8645a14..6b8969ac 100644 --- a/server/src/eslintServer.ts +++ b/server/src/eslintServer.ts @@ -286,6 +286,12 @@ interface CLIOptions { fix?: boolean; } +interface ESLintClassOptions { + cwd?: string; + fixTypes?: string[]; + fix?: boolean; +} + // { meta: { docs: [Object], schema: [Array] }, create: [Function: create] } interface RuleData { meta?: { @@ -297,8 +303,8 @@ interface RuleData { } namespace RuleData { - export function hasMetaType(value: RuleData | undefined): value is RuleData & { meta: { type: string; }; } { - return value !== undefined && value.meta !== undefined && value.meta.type !== undefined; + export function hasMetaType(value: RuleData['meta'] | undefined): value is RuleData['meta'] & { type: string; } { + return value !== undefined && value.type !== undefined; } } @@ -323,6 +329,28 @@ interface ESLintConfig { settings: object; } +namespace ESLintClass { + export function newESLintClass(library: ESLintModule, newOptions: ESLintClassOptions | CLIOptions): ESLintClass { + if (library.CLIEngine === undefined) { + return new library.ESLint(newOptions); + } else { + const cli = new library.CLIEngine(newOptions); + return new ESLintClassEmulator(cli); + } + } +} + +interface ESLintClass { + // https://eslint.org/docs/developer-guide/nodejs-api#-eslintlinttextcode-options + lintText(content: string, options: {filePath?: string, warnIgnored?: boolean}): Promise; + // https://eslint.org/docs/developer-guide/nodejs-api#-eslintispathignoredfilepath + isPathIgnored(path: string): Promise; + // https://eslint.org/docs/developer-guide/nodejs-api#-eslintgetrulesmetaforresultsresults + getRulesMetaForResults(results: ESLintDocumentReport[]): Record | undefined /* for ESLintClassEmulator */; + // https://eslint.org/docs/developer-guide/nodejs-api#-eslintcalculateconfigforfilefilepath + calculateConfigForFile(path: string): Promise; +} + interface CLIEngine { executeOnText(content: string, file?: string, warn?: boolean): ESLintReport; isPathIgnored(path: string): boolean; @@ -337,13 +365,21 @@ namespace CLIEngine { } } +interface ESLintClassConstructor { + new(options: ESLintClassOptions): ESLintClass; +} + interface CLIEngineConstructor { new(options: CLIOptions): CLIEngine; } -interface ESLintModule { +type ESLintModule = { CLIEngine: CLIEngineConstructor; -} +} | { + // for ESLint >= v8 + ESLint: ESLintClassConstructor; + CLIEngine: undefined; +}; declare const __webpack_require__: typeof require; declare const __non_webpack_require__: typeof require; @@ -890,7 +926,7 @@ function resolveSettings(document: TextDocument): Promise } settings.silent = settings.validate === Validate.probe; - return promise.then((libraryPath) => { + return promise.then(async (libraryPath) => { let library = path2Library.get(libraryPath); if (library === undefined) { library = loadNodeModule(libraryPath); @@ -899,9 +935,9 @@ function resolveSettings(document: TextDocument): Promise if (!settings.silent) { connection.console.error(`Failed to load eslint library from ${libraryPath}. See output panel for more information.`); } - } else if (library.CLIEngine === undefined) { + } else if (library.CLIEngine === undefined && library.ESLint === undefined) { settings.validate = Validate.off; - connection.console.error(`The eslint library loaded from ${libraryPath} doesn\'t export a CLIEngine. You need at least eslint@1.0.0`); + connection.console.error(`The eslint library loaded from ${libraryPath} doesn\'t neither exports a CLIEngine nor an ESLint class. You need at least eslint@1.0.0`); } else { connection.console.info(`ESLint library loaded from: ${libraryPath}`); settings.library = library; @@ -928,13 +964,9 @@ function resolveSettings(document: TextDocument): Promise if (defaultLanguageIds.has(document.languageId)) { settings.validate = Validate.on; } else if (parserRegExps !== undefined || pluginName !== undefined || parserOptions !== undefined) { - const eslintConfig: ESLintConfig | undefined = withCLIEngine((cli) => { + const eslintConfig: ESLintConfig | undefined = await withESLintClass((eslintClass) => { try { - if (typeof cli.getConfigForFile === 'function') { - return cli.getConfigForFile(filePath!); - } else { - return undefined; - } + return eslintClass.calculateConfigForFile(filePath!); } catch (err) { return undefined; } @@ -995,8 +1027,8 @@ function resolveSettings(document: TextDocument): Promise formatterRegistrations.set(uri, connection.client.register(DocumentFormattingRequest.type, options)); } else { const filePath = getFilePath(uri)!; - withCLIEngine((cli) => { - if (!cli.isPathIgnored(filePath)) { + await withESLintClass(async (eslintClass) => { + if (!await eslintClass.isPathIgnored(filePath)) { formatterRegistrations.set(uri, connection.client.register(DocumentFormattingRequest.type, options)); } }, settings); @@ -1304,12 +1336,12 @@ function validateSingle(document: TextDocument, publishDiagnostics: boolean = tr if (!documents.get(document.uri)) { return Promise.resolve(undefined); } - return resolveSettings(document).then((settings) => { + return resolveSettings(document).then(async (settings) => { if (settings.validate !== Validate.on || !TextDocumentSettings.hasLibrary(settings)) { return; } try { - validate(document, settings, publishDiagnostics); + await validate(document, settings, publishDiagnostics); connection.sendNotification(StatusNotification.type, { uri: document.uri, state: Status.ok }); } catch (err) { // if an exception has occurred while validating clear all errors to ensure @@ -1362,7 +1394,7 @@ const ruleDocData: { }; const validFixTypes = new Set(['problem', 'suggestion', 'layout']); -function validate(document: TextDocument, settings: TextDocumentSettings & { library: ESLintModule }, publishDiagnostics: boolean = true): void { +async function validate(document: TextDocument, settings: TextDocumentSettings & { library: ESLintModule }, publishDiagnostics: boolean = true): Promise { const newOptions: CLIOptions = Object.assign(Object.create(null), settings.options); let fixTypes: Set | undefined = undefined; if (Array.isArray(newOptions.fixTypes) && newOptions.fixTypes.length > 0) { @@ -1387,20 +1419,21 @@ function validate(document: TextDocument, settings: TextDocumentSettings & { lib const uri = document.uri; const file = getFilePath(document); - withCLIEngine((cli) => { + await withESLintClass(async (eslintClass) => { codeActions.delete(uri); - const report: ESLintReport = cli.executeOnText(content, file, settings.onIgnoredFiles !== ESLintSeverity.off); - if (CLIEngine.hasRule(cli) && !ruleDocData.handled.has(uri)) { + const reportResults: ESLintDocumentReport[] = await eslintClass.lintText(content, { filePath: file, warnIgnored: settings.onIgnoredFiles !== ESLintSeverity.off }); + const rulesMeta = eslintClass.getRulesMetaForResults(reportResults); + if (rulesMeta && !ruleDocData.handled.has(uri)) { ruleDocData.handled.add(uri); - cli.getRules().forEach((rule, key) => { - if (rule.meta && rule.meta.docs && Is.string(rule.meta.docs.url)) { - ruleDocData.urls.set(key, rule.meta.docs.url); + Object.entries(rulesMeta).forEach(([key, meta]) => { + if (meta && meta.docs && Is.string(meta.docs.url)) { + ruleDocData.urls.set(key, meta.docs.url); } }); } const diagnostics: Diagnostic[] = []; - if (report && report.results && Array.isArray(report.results) && report.results.length > 0) { - const docReport = report.results[0]; + if (reportResults && Array.isArray(reportResults) && reportResults.length > 0) { + const docReport = reportResults[0]; if (docReport.messages && Array.isArray(docReport.messages)) { docReport.messages.forEach((problem) => { if (problem) { @@ -1416,9 +1449,9 @@ function validate(document: TextDocument, settings: TextDocumentSettings & { lib } const diagnostic = makeDiagnostic(settings, problem); diagnostics.push(diagnostic); - if (fixTypes !== undefined && CLIEngine.hasRule(cli) && problem.ruleId !== undefined && problem.fix !== undefined) { - const rule = cli.getRules().get(problem.ruleId); - if (RuleData.hasMetaType(rule) && fixTypes.has(rule.meta.type)) { + if (fixTypes !== undefined && rulesMeta && problem.ruleId !== undefined && problem.fix !== undefined) { + const meta = rulesMeta[problem.ruleId]; + if (RuleData.hasMetaType(meta) && fixTypes.has(meta.type)) { recordCodeAction(document, diagnostic, problem); } } else { @@ -1434,8 +1467,8 @@ function validate(document: TextDocument, settings: TextDocumentSettings & { lib }, settings); } -function withCLIEngine(func: (cli: CLIEngine) => T, settings: TextDocumentSettings & { library: ESLintModule }, options?: CLIOptions): T { - const newOptions: CLIOptions = options === undefined +function withESLintClass(func: (eslintClass: ESLintClass) => T, settings: TextDocumentSettings & { library: ESLintModule }, options?: ESLintClassOptions | CLIOptions): T { + const newOptions: ESLintClassOptions | CLIOptions = options === undefined ? Object.assign(Object.create(null), settings.options) : Object.assign(Object.create(null), settings.options, options); @@ -1447,8 +1480,9 @@ function withCLIEngine(func: (cli: CLIEngine) => T, settings: TextDocumentSet process.chdir(settings.workingDirectory.directory); } } - const cli = new settings.library.CLIEngine(newOptions); - return func(cli); + + const eslintClass = ESLintClass.newESLintClass(settings.library, newOptions); + return func(eslintClass); } finally { if (cwd !== process.cwd()) { process.chdir(cwd); @@ -1456,6 +1490,36 @@ function withCLIEngine(func: (cli: CLIEngine) => T, settings: TextDocumentSet } } +/** + * ESLint class emulator using CLI Engine. + */ +class ESLintClassEmulator implements ESLintClass { + private cli: CLIEngine; + + constructor(cli: CLIEngine) { + this.cli = cli; + } + async lintText(content: string, options: { filePath?: string | undefined; warnIgnored?: boolean | undefined; }): Promise { + return this.cli.executeOnText(content, options.filePath, options.warnIgnored).results; + } + async isPathIgnored(path: string): Promise { + return this.cli.isPathIgnored(path); + } + getRulesMetaForResults(_results: ESLintDocumentReport[]): Record | undefined { + if (!CLIEngine.hasRule(this.cli)) { + return undefined; + } + const rules: Record = {}; + for (const [name, rule] of this.cli.getRules()) { + rules[name] = rule.meta; + } + return rules; + } + async calculateConfigForFile(path: string): Promise { + return typeof this.cli.getConfigForFile === 'function' ? this.cli.getConfigForFile(path) : undefined; + } +} + const noConfigReported: Map = new Map(); function isNoConfigFoundError(error: any): boolean { @@ -1572,7 +1636,7 @@ function showErrorMessage(error: any, document: TextDocument): Status { return Status.error; } -messageQueue.registerNotification(DidChangeWatchedFilesNotification.type, (params) => { +messageQueue.registerNotification(DidChangeWatchedFilesNotification.type, async (params) => { // A .eslintrc has change. No smartness here. // Simply revalidate all file. ruleDocData.handled.clear(); @@ -1580,7 +1644,7 @@ messageQueue.registerNotification(DidChangeWatchedFilesNotification.type, (param noConfigReported.clear(); missingModuleReported.clear(); document2Settings.clear(); // config files can change plugins and parser. - params.changes.forEach((change) => { + await Promise.all(params.changes.map(async (change) => { const fsPath = getFilePath(change.uri); if (fsPath === undefined || fsPath.length === 0 || isUNC(fsPath)) { return; @@ -1589,15 +1653,15 @@ messageQueue.registerNotification(DidChangeWatchedFilesNotification.type, (param if (dirname) { const library = configErrorReported.get(fsPath); if (library !== undefined) { - const cli = new library.CLIEngine({}); + const eslintClass = ESLintClass.newESLintClass(library, {}); try { - cli.executeOnText('', path.join(dirname, '___test___.js')); + await eslintClass.lintText('', { filePath: path.join(dirname, '___test___.js') }); configErrorReported.delete(fsPath); } catch (error) { } } } - }); + })); validateMany(documents.all()); }); @@ -2043,7 +2107,7 @@ function computeAllFixes(identifier: VersionedTextDocumentIdentifier, mode: AllF return []; } const filePath = getFilePath(textDocument); - return withCLIEngine((cli) => { + return withESLintClass(async (eslintClass) => { const problems = codeActions.get(uri); const originalContent = textDocument.getText(); let problemFixes: TextEdit[] | undefined; @@ -2069,10 +2133,10 @@ function computeAllFixes(identifier: VersionedTextDocumentIdentifier, mode: AllF } else { content = originalContent; } - const report = cli.executeOnText(content, filePath); + const reportResults = await eslintClass.lintText(content, { filePath }); connection.tracer.log(`Computing all fixes took: ${Date.now() - start} ms.`); - if (Array.isArray(report.results) && report.results.length === 1 && report.results[0].output !== undefined) { - const fixedContent = report.results[0].output; + if (Array.isArray(reportResults) && reportResults.length === 1 && reportResults[0].output !== undefined) { + const fixedContent = reportResults[0].output; start = Date.now(); const diffs = stringDiff(originalContent, fixedContent, false); connection.tracer.log(`Computing minimal edits took: ${Date.now() - start} ms.`);