From 2da9c7c90414ad3dcbdae0540f1f5b175122b3f7 Mon Sep 17 00:00:00 2001 From: Jake Scott Date: Sat, 7 Feb 2026 10:12:15 -0800 Subject: [PATCH] Add ESLint cache support via new settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce three new settings — `eslint.cache`, `eslint.cacheLocation`, and `eslint.cacheStrategy` — that enable ESLint's built-in result caching when linting on save. When cache is active the server uses `lintFiles` (disk read) instead of `lintText` (in-memory) so the `.eslintcache` file is consulted and updated, which also benefits command-line ESLint and pre-commit hooks. Changes across the four touchpoints required by AGENTS.md: - package.json: setting schemas under contributes.configuration - $shared/settings.ts: ConfigurationSettings type - client/src/client.ts: readConfiguration() mapping - server/src/eslint.ts: option wiring and validate() integration Also includes targeted refactors for the new code: - Extract `applyCacheOptions()` helper from `withClass()` - Extract `shouldUseLintFiles()` type-guard from `validate()` - Narrow `ESLintClassOptions.cacheStrategy` to `'metadata' | 'content'` - Add JSDoc to all new public/shared cache properties Co-authored-by: Cursor --- $shared/settings.ts | 6 ++++++ client/src/client.ts | 3 +++ package.json | 20 +++++++++++++++++++ server/src/eslint.ts | 46 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 74 insertions(+), 1 deletion(-) diff --git a/$shared/settings.ts b/$shared/settings.ts index fcb2b169..07823cea 100644 --- a/$shared/settings.ts +++ b/$shared/settings.ts @@ -186,4 +186,10 @@ export type ConfigurationSettings = { nodePath: string | null; workspaceFolder: WorkspaceFolder | undefined; workingDirectory: ModeItem | DirectoryItem | undefined; + /** Enables ESLint's result caching. Only effective when {@link run} is `onSave`. */ + cache: boolean; + /** Path to the cache file. Defaults to `.eslintcache` in the working directory. Only used when {@link cache} is `true`. */ + cacheLocation?: string; + /** Strategy for detecting changed files: `metadata` (faster) or `content`. Only used when {@link cache} is `true`. */ + cacheStrategy?: 'metadata' | 'content'; }; diff --git a/client/src/client.ts b/client/src/client.ts index 9cfe1d23..782612c7 100644 --- a/client/src/client.ts +++ b/client/src/client.ts @@ -713,6 +713,9 @@ export namespace ESLintClient { nodePath: config.get('nodePath', undefined) ?? null, workingDirectory: undefined, workspaceFolder: undefined, + cache: config.get('cache', false), + cacheLocation: config.get('cacheLocation', undefined), + cacheStrategy: config.get<'metadata' | 'content' | undefined>('cacheStrategy', undefined), codeAction: { disableRuleComment: config.get('codeAction.disableRuleComment', { enable: true, location: 'separateLine' as const, commentStyle: 'line' as const }), showDocumentation: config.get('codeAction.showDocumentation', { enable: true }) diff --git a/package.json b/package.json index 44783b00..aa49c95e 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,26 @@ "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#eslint-class)." }, + "eslint.cache": { + "scope": "resource", + "type": "boolean", + "default": false, + "markdownDescription": "Enables ESLint's cache when linting on save (`eslint.run` must be `onSave`). The cache file (`.eslintcache`) can be reused by command-line ESLint and pre-commit hooks, improving their performance." + }, + "eslint.cacheLocation": { + "scope": "resource", + "type": "string", + "markdownDescription": "Path to the ESLint cache file. If not specified, defaults to `.eslintcache` in the working directory. Only used when `eslint.cache` is enabled." + }, + "eslint.cacheStrategy": { + "scope": "resource", + "type": "string", + "enum": [ + "metadata", + "content" + ], + "markdownDescription": "Strategy for the ESLint cache to detect changed files. `metadata` uses file metadata (faster) while `content` uses file content (for environments where metadata is unreliable). Only used when `eslint.cache` is enabled." + }, "eslint.trace.server": { "scope": "window", "anyOf": [ diff --git a/server/src/eslint.ts b/server/src/eslint.ts index 84ae2d2b..42a62fac 100644 --- a/server/src/eslint.ts +++ b/server/src/eslint.ts @@ -99,6 +99,10 @@ export type CLIOptions = { cwd?: string; fixTypes?: string[]; fix?: boolean; + /** Enables ESLint's result caching. Only effective when `eslint.run` is `onSave`. */ + cache?: boolean; + /** Path to the cache file. Only used when {@link cache} is `true`. */ + cacheLocation?: string; }; export type SeverityConf = 0 | 1 | 2 | 'off' | 'warn' | 'error'; @@ -115,6 +119,12 @@ export type ESLintClassOptions = { fix?: boolean; overrideConfig?: ConfigData; overrideConfigFile?: string | null; + /** Enables ESLint's result caching. Only effective when `eslint.run` is `onSave`. */ + cache?: boolean; + /** Path to the cache file. Only used when {@link cache} is `true`. */ + cacheLocation?: string; + /** Strategy for detecting changed files: `metadata` (default, faster) or `content`. Only used when {@link cache} is `true`. */ + cacheStrategy?: 'metadata' | 'content'; }; export type RuleMetaData = { @@ -252,6 +262,8 @@ export namespace SuggestionsProblem { interface ESLintClass extends Object { // 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#-eslintlintfilespatterns + lintFiles(patterns: string | string[]): Promise; // https://eslint.org/docs/developer-guide/nodejs-api#-eslintispathignoredfilepath isPathIgnored(path: string): Promise; // https://eslint.org/docs/developer-guide/nodejs-api#-eslintgetrulesmetaforresultsresults @@ -333,6 +345,7 @@ namespace RuleData { interface CLIEngine { executeOnText(content: string, file?: string, warn?: boolean): ESLintReport; + executeOnFiles(patterns: string[]): ESLintReport; isPathIgnored(path: string): boolean; // This is only available from v4.15.0 forward getRules?(): Map; @@ -361,6 +374,9 @@ class ESLintClassEmulator implements ESLintClass { async lintText(content: string, options: { filePath?: string | undefined; warnIgnored?: boolean | undefined }): Promise { return this.cli.executeOnText(content, options.filePath, options.warnIgnored).results; } + async lintFiles(patterns: string | string[]): Promise { + return this.cli.executeOnFiles(Array.isArray(patterns) ? patterns : [patterns]).results; + } async isPathIgnored(path: string): Promise { return this.cli.isPathIgnored(path); } @@ -1138,11 +1154,27 @@ export namespace ESLint { return new library.ESLint(newOptions); } + /** Applies cache-related settings to ESLint options. Cache properties are only set when `settings.cache` is `true`. */ + function applyCacheOptions(options: ESLintClassOptions, settings: TextDocumentSettings): void { + if (!settings.cache) { + return; + } + options.cache = true; + if (settings.cacheLocation) { + options.cacheLocation = settings.cacheLocation; + } + if (settings.cacheStrategy) { + options.cacheStrategy = settings.cacheStrategy; + } + } + export async function withClass(func: (eslintClass: ESLintClass) => Promise, settings: TextDocumentSettings & { library: ESLintModule }, options?: ESLintClassOptions | CLIOptions): Promise { const newOptions: ESLintClassOptions | CLIOptions = options === undefined ? Object.assign(Object.create(null), settings.options) : Object.assign(Object.create(null), settings.options, options); + applyCacheOptions(newOptions as ESLintClassOptions, settings); + const cwd = process.cwd(); try { if (settings.workingDirectory) { @@ -1196,6 +1228,16 @@ export namespace ESLint { } const validFixTypes = new Set(['problem', 'suggestion', 'layout', 'directive']); + + /** + * Returns `true` when ESLint should lint from disk (via `lintFiles`) instead of + * from the in-memory document (via `lintText`). Caching requires disk access + * because ESLint's cache tracks files on disk; in-memory content would bypass it. + */ + function shouldUseLintFiles(settings: TextDocumentSettings, file: string | undefined): file is string { + return settings.cache && settings.run === 'onSave' && file !== undefined; + } + export async function validate(document: TextDocument, settings: TextDocumentSettings & { library: ESLintModule }): Promise { const newOptions: CLIOptions = Object.assign(Object.create(null), settings.options); let fixTypes: Set | undefined = undefined; @@ -1217,7 +1259,9 @@ export namespace ESLint { return withClass(async (eslintClass) => { CodeActions.remove(uri); - const reportResults: ESLintDocumentReport[] = await eslintClass.lintText(content, { filePath: file, warnIgnored: settings.onIgnoredFiles !== ESLintSeverity.off }); + const reportResults: ESLintDocumentReport[] = shouldUseLintFiles(settings, file) + ? await eslintClass.lintFiles(file) + : await eslintClass.lintText(content, { filePath: file, warnIgnored: settings.onIgnoredFiles !== ESLintSeverity.off }); RuleMetaData.capture(eslintClass, reportResults); const diagnostics: Diagnostic[] = []; if (reportResults && Array.isArray(reportResults) && reportResults.length > 0) {