diff --git a/$shared/settings.ts b/$shared/settings.ts index fcb2b169..3c5ac220 100644 --- a/$shared/settings.ts +++ b/$shared/settings.ts @@ -121,6 +121,8 @@ export type RuleCustomization = { severity: RuleSeverity; /** Only apply to autofixable rules */ fixable?: boolean; + /** Only apply when the file has unsaved changes */ + unsavedOnly?: boolean; }; export type RunValues = 'onType' | 'onSave'; diff --git a/README.md b/README.md index c75aabc7..2481a086 100644 --- a/README.md +++ b/README.md @@ -453,6 +453,7 @@ This extension contributes the following variables to the [settings](https://cod - Prefix the name with a `"!"` to target all rules that _don't_ match the name: `{ "rule": "!no-*", "severity": "info" }` - `"severity"`: Sets a new severity for matched rule(s), `"downgrade"`s them to a lower severity, `"upgrade"`s them to a higher severity, or `"default"`s them to their original severity - `"fixable"`: Select only autofixable rules: `{ "rule": "no-*", "fixable": true, "severity": "info" }` + - `"unsavedOnly"`: When `true`, only applies the customization while the file has unsaved changes (is dirty): `{ "rule": "...", "severity": "off", "unsavedOnly": true }` In this example, all rules are overridden to warnings: @@ -480,6 +481,14 @@ This extension contributes the following variables to the [settings](https://cod ] ``` + In this example, `foo` is silenced while editing but shown once the file is saved: + + ```json + "eslint.rules.customizations": [ + { "rule": "foo", "severity": "off", "unsavedOnly": true } + ] + ``` + - `eslint.format.enable` (@since 2.0.0) - uses ESlint as a formatter for files that are validated by ESLint. If enabled please ensure to disable other formatters if you want to make this the default. A good way to do so is to add the following setting `"[javascript]": { "editor.defaultFormatter": "dbaeumer.vscode-eslint" }` for JavaScript. For TypeScript you need to add `"[typescript]": { "editor.defaultFormatter": "dbaeumer.vscode-eslint" }`. - `eslint.onIgnoredFiles` (@since 2.0.10): used to control whether warnings should be generated when trying to lint ignored files. Default is `off`. Can be set to `warn`. - `editor.codeActionsOnSave` (@since 2.0.0): this setting now supports an entry `source.fixAll.eslint`. If set to true all auto-fixable ESLint errors from all plugins will be fixed on save. You can also selectively enable and disabled specific languages using VS Code's language scoped settings. To disable `codeActionsOnSave` for HTML files use the following setting: diff --git a/client/src/client.ts b/client/src/client.ts index 9cfe1d23..b03c2177 100644 --- a/client/src/client.ts +++ b/client/src/client.ts @@ -823,18 +823,17 @@ export namespace ESLintClient { return rawConfig.map(rawValue => { if (typeof rawValue.severity === 'string' && typeof rawValue.rule === 'string') { + const customization: RuleCustomization = { + severity: rawValue.severity, + rule: rawValue.rule, + }; if (typeof rawValue.fixable === 'boolean') { - return { - severity: rawValue.severity, - rule: rawValue.rule, - fixable: rawValue.fixable - }; - } else { - return { - severity: rawValue.severity, - rule: rawValue.rule, - }; + customization.fixable = rawValue.fixable; + } + if (typeof rawValue.unsavedOnly === 'boolean') { + customization.unsavedOnly = rawValue.unsavedOnly; } + return customization; } return undefined; diff --git a/package.json b/package.json index f75cba91..6428825a 100644 --- a/package.json +++ b/package.json @@ -504,6 +504,13 @@ }, "rule": { "type": "string" + }, + "fixable": { + "type": "boolean" + }, + "unsavedOnly": { + "type": "boolean", + "description": "Only apply this customization when the file has unsaved changes." } }, "type": "object" @@ -529,6 +536,13 @@ }, "rule": { "type": "string" + }, + "fixable": { + "type": "boolean" + }, + "unsavedOnly": { + "type": "boolean", + "description": "Only apply this customization when the file has unsaved changes." } }, "type": "object" diff --git a/server/src/eslint.ts b/server/src/eslint.ts index cb39833e..677507a7 100644 --- a/server/src/eslint.ts +++ b/server/src/eslint.ts @@ -552,8 +552,9 @@ export namespace RuleSeverities { const ruleSeverityCache = new LRUCache(1024); - export function getOverride(ruleId: string, customizations: RuleCustomization[], isFixable?: boolean): RuleSeverity | undefined { - let result: RuleSeverity | undefined | null = ruleSeverityCache.get(ruleId); + export function getOverride(ruleId: string, customizations: RuleCustomization[], isFixable?: boolean, isDirty?: boolean): RuleSeverity | undefined { + const cacheKey = `${ruleId}:${isDirty ? '1' : '0'}`; + let result: RuleSeverity | undefined | null = ruleSeverityCache.get(cacheKey); if (result === null) { return undefined; } @@ -565,17 +566,19 @@ export namespace RuleSeverities { // Rule name should match asteriskMatches(customization.rule, ruleId) && // Fixable flag should match the fixability of the rule if it's defined - (customization.fixable === undefined || customization.fixable === isFixable) + (customization.fixable === undefined || customization.fixable === isFixable) && + // unsavedOnly customizations only apply when the document has unsaved changes + (customization.unsavedOnly !== true || isDirty === true) ) { result = customization.severity; } } if (result === undefined) { - ruleSeverityCache.set(ruleId, null); + ruleSeverityCache.set(cacheKey, null); return undefined; } - ruleSeverityCache.set(ruleId, result); + ruleSeverityCache.set(cacheKey, result); return result; } @@ -625,7 +628,7 @@ export namespace Diagnostics { return `[${range.start.line},${range.start.character},${range.end.line},${range.end.character}]-${diagnostic.code}-${message ?? ''}`; } - export function create(settings: TextDocumentSettings, problem: ESLintProblem, document: TextDocument): [Diagnostic, RuleSeverity | undefined] { + export function create(settings: TextDocumentSettings, problem: ESLintProblem, document: TextDocument, isDirty?: boolean): [Diagnostic, RuleSeverity | undefined] { const message = problem.message; const startLine = typeof problem.line !== 'number' || Number.isNaN(problem.line) ? 0 : Math.max(0, problem.line - 1); const startChar = typeof problem.column !== 'number' || Number.isNaN(problem.column) ? 0 : Math.max(0, problem.column - 1); @@ -646,7 +649,7 @@ export namespace Diagnostics { endChar = startLineText.length; } - const override = RuleSeverities.getOverride(problem.ruleId, settings.rulesCustomizations, problem.fix !== undefined); + const override = RuleSeverities.getOverride(problem.ruleId, settings.rulesCustomizations, problem.fix !== undefined, isDirty); const result: Diagnostic = { message: message, severity: convertSeverityToDiagnosticWithOverride(problem.severity, override), @@ -1215,7 +1218,7 @@ export namespace ESLint { } const validFixTypes = new Set(['problem', 'suggestion', 'layout', 'directive']); - export async function validate(document: TextDocument, settings: TextDocumentSettings & { library: ESLintModule }): Promise { + export async function validate(document: TextDocument, settings: TextDocumentSettings & { library: ESLintModule }, isDirty?: boolean): 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) { @@ -1244,7 +1247,7 @@ export namespace ESLint { if (docReport.messages && Array.isArray(docReport.messages)) { docReport.messages.forEach((problem) => { if (problem) { - const [diagnostic, override] = Diagnostics.create(settings, problem, document); + const [diagnostic, override] = Diagnostics.create(settings, problem, document, isDirty); if (!(override === RuleSeverity.off || (settings.quiet && (diagnostic.severity === DiagnosticSeverity.Warning || diagnostic.severity === DiagnosticSeverity.Information)))) { diagnostics.push(diagnostic); } diff --git a/server/src/eslintServer.ts b/server/src/eslintServer.ts index d9cd31c9..d55e073b 100644 --- a/server/src/eslintServer.ts +++ b/server/src/eslintServer.ts @@ -152,9 +152,35 @@ function inferFilePath(documentOrUri: string | TextDocument | URI | undefined, u ESLint.initialize(connection, documents, inferFilePath, loadNodeModule); SaveRuleConfigs.inferFilePath = inferFilePath; +// Track the document version at the time it was last opened or saved. +// A document is considered dirty when its current version exceeds the saved version. +// Using versions avoids the false-dirty state caused by onDidChangeContent firing +// on document open (it fires for both open and change notifications). +const savedDocumentVersions = new Map(); + +documents.onDidOpen((event) => { + savedDocumentVersions.set(event.document.uri, event.document.version); +}); + +documents.onDidSave((event) => { + savedDocumentVersions.set(event.document.uri, event.document.version); + // Force a diagnostics re-pull so that unsavedOnly customizations are re-evaluated + // now that the document is no longer dirty. Without this, the pull diagnostic model + // won't re-request diagnostics after a save when the in-memory content is unchanged. + connection.languages.diagnostics.refresh().catch(() => { + connection.console.error('Failed to refresh diagnostics after save'); + }); +}); + +function isDocumentDirty(document: { uri: string; version: number }): boolean { + const savedVersion = savedDocumentVersions.get(document.uri); + return savedVersion !== undefined && document.version > savedVersion; +} + documents.onDidClose(async (event) => { const document = event.document; const uri = document.uri; + savedDocumentVersions.delete(uri); ESLint.removeSettings(uri); SaveRuleConfigs.remove(uri); CodeActions.remove(uri); @@ -260,7 +286,8 @@ connection.languages.diagnostics.on(async (params) => { } try { const start = Date.now(); - const diagnostics = await ESLint.validate(document, settings); + const isDirty = isDocumentDirty(document); + const diagnostics = await ESLint.validate(document, settings, isDirty); const timeTaken = Date.now() - start; void connection.sendNotification(StatusNotification.type, { uri: document.uri, state: Status.ok, validationTime: timeTaken }); return {