diff --git a/packages/eslint-plugin/src/rules/no-unused-vars.ts b/packages/eslint-plugin/src/rules/no-unused-vars.ts index f2bfa26bb1a8..8a8a538b851d 100644 --- a/packages/eslint-plugin/src/rules/no-unused-vars.ts +++ b/packages/eslint-plugin/src/rules/no-unused-vars.ts @@ -15,6 +15,7 @@ import type { MakeRequired } from '../util'; import { collectVariables, createRule, + getFixOrSuggest, getNameLocationInGlobalDirectiveComment, isDefinitionFile, isFunction, @@ -23,7 +24,11 @@ import { } from '../util'; import { referenceContainsTypeQuery } from '../util/referenceContainsTypeQuery'; -export type MessageIds = 'unusedVar' | 'usedIgnoredVar' | 'usedOnlyAsType'; +export type MessageIds = + | 'unusedVar' + | 'unusedVarSuggestion' + | 'usedIgnoredVar' + | 'usedOnlyAsType'; export type Options = [ | 'all' | 'local' @@ -38,6 +43,9 @@ export type Options = [ reportUsedIgnorePattern?: boolean; vars?: 'all' | 'local'; varsIgnorePattern?: string; + enableAutofixRemoval?: { + imports: boolean; + }; }, ]; @@ -52,6 +60,9 @@ interface TranslatedOptions { reportUsedIgnorePattern: boolean; vars: 'all' | 'local'; varsIgnorePattern?: RegExp; + enableAutofixRemoval?: { + imports: boolean; + }; } type VariableType = @@ -74,8 +85,13 @@ export default createRule({ extendsBaseRule: true, recommended: 'recommended', }, + fixable: 'code', + // If generate suggest dynamically, disable the eslint rule. + // eslint-disable-next-line eslint-plugin/require-meta-has-suggestions + hasSuggestions: true, messages: { unusedVar: "'{{varName}}' is {{action}} but never used{{additional}}.", + unusedVarSuggestion: 'Remove unused variable.', usedIgnoredVar: "'{{varName}}' is marked as ignored but is used{{additional}}.", usedOnlyAsType: @@ -117,6 +133,16 @@ export default createRule({ description: 'Regular expressions of destructured array variable names to not check for usage.', }, + enableAutofixRemoval: { + type: 'object', + properties: { + imports: { + type: 'boolean', + description: + 'Whether to enable autofix for removing unused imports.', + }, + }, + }, ignoreClassWithStaticInitBlock: { type: 'boolean', description: @@ -208,6 +234,10 @@ export default createRule({ 'u', ); } + + if (firstOption.enableAutofixRemoval) { + options.enableAutofixRemoval = firstOption.enableAutofixRemoval; + } } return options; @@ -681,12 +711,84 @@ export default createRule({ }, }; + const fixer: TSESLint.ReportFixFunction = fixer => { + // Find the import statement + const def = unusedVar.defs.find( + d => d.type === DefinitionType.ImportBinding, + ); + if (!def) { + return null; + } + + const source = context.sourceCode; + const node = def.node; + const decl = node.parent; + if (decl.type !== AST_NODE_TYPES.ImportDeclaration) { + // decl.type is Program, import foo = require('bar'); + return fixer.remove(node); + } + + const afterNodeToken = source.getTokenAfter(node); + const beforeNodeToken = source.getTokenBefore(node); + const prevBeforeNodeToken = beforeNodeToken + ? source.getTokenBefore(beforeNodeToken) + : null; + + // Remove import declaration line if no specifiers are left, import unused from 'a'; + if (decl.specifiers.length === 1) { + return fixer.removeRange([decl.range[0], decl.range[1]]); + } + + // case: remove braces, import used, { unused } from 'a'; + const restNamed = decl.specifiers.filter( + s => s === node && s.type === AST_NODE_TYPES.ImportSpecifier, + ); + if ( + restNamed.length === 1 && + afterNodeToken?.value === '}' && + beforeNodeToken?.value === '{' && + prevBeforeNodeToken?.value === ',' + ) { + return fixer.removeRange([ + prevBeforeNodeToken.range[0], + afterNodeToken.range[1], + ]); + } + + // case: Remove comma after node, import { unused, used } from 'a'; + if (afterNodeToken?.value === ',') { + return fixer.removeRange([ + node.range[0], + afterNodeToken.range[1], + ]); + } + + // case: Remove comma before node, import { used, unused } from 'a'; + if (beforeNodeToken?.value === ',') { + return fixer.removeRange([ + beforeNodeToken.range[0], + node.range[1], + ]); + } + + return null; + }; + context.report({ loc, messageId, data: unusedVar.references.some(ref => ref.isWrite()) ? getAssignedMessageData(unusedVar) : getDefinedMessageData(unusedVar), + ...getFixOrSuggest({ + fixOrSuggest: options.enableAutofixRemoval?.imports + ? 'fix' + : 'suggest', + suggestion: { + messageId: 'unusedVarSuggestion', + fix: fixer, + }, + }), }); // If there are no regular declaration, report the first `/*globals*/` comment directive. diff --git a/packages/eslint-plugin/tests/rules/no-unused-vars/no-unused-vars-eslint.test.ts b/packages/eslint-plugin/tests/rules/no-unused-vars/no-unused-vars-eslint.test.ts index c9048412d8c0..a68c0f7abee4 100644 --- a/packages/eslint-plugin/tests/rules/no-unused-vars/no-unused-vars-eslint.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unused-vars/no-unused-vars-eslint.test.ts @@ -385,7 +385,17 @@ function f() { }, { code: "import x from 'y';", - errors: [definedError('x')], + errors: [ + { + ...definedError('x'), + suggestions: [ + { + messageId: 'unusedVarSuggestion', + output: '', + }, + ], + }, + ], languageOptions: { parserOptions: { ecmaVersion: 6, sourceType: 'module' }, }, diff --git a/packages/eslint-plugin/tests/rules/no-unused-vars/no-unused-vars.test.ts b/packages/eslint-plugin/tests/rules/no-unused-vars/no-unused-vars.test.ts index 5ebc8d9a406b..a9e9b7874950 100644 --- a/packages/eslint-plugin/tests/rules/no-unused-vars/no-unused-vars.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unused-vars/no-unused-vars.test.ts @@ -53,6 +53,16 @@ export class Foo {} endLine: 2, line: 2, messageId: 'unusedVar', + suggestions: [ + { + data: { varName: 'ClassDecoratorFactory' }, + messageId: 'unusedVarSuggestion', + output: ` + +export class Foo {} + `, + }, + ], }, ], }, @@ -72,6 +82,17 @@ baz(); }, line: 2, messageId: 'unusedVar', + suggestions: [ + { + data: { varName: 'Foo' }, + messageId: 'unusedVarSuggestion', + output: ` +import { Bar } from 'foo'; +function baz(): Foo {} +baz(); + `, + }, + ], }, ], }, @@ -91,6 +112,17 @@ console.log(a); }, line: 2, messageId: 'unusedVar', + suggestions: [ + { + data: { varName: 'Nullable' }, + messageId: 'unusedVarSuggestion', + output: ` + +const a: string = 'hello'; +console.log(a); + `, + }, + ], }, ], }, @@ -111,6 +143,18 @@ console.log(a); }, line: 3, messageId: 'unusedVar', + suggestions: [ + { + data: { varName: 'SomeOther' }, + messageId: 'unusedVarSuggestion', + output: ` +import { Nullable } from 'nullable'; + +const a: Nullable = 'hello'; +console.log(a); + `, + }, + ], }, ], }, @@ -136,6 +180,22 @@ new A(); }, line: 3, messageId: 'unusedVar', + suggestions: [ + { + data: { varName: 'Another' }, + messageId: 'unusedVarSuggestion', + output: ` +import { Nullable } from 'nullable'; + +class A { + do = (a: Nullable) => { + console.log(a); + }; +} +new A(); + `, + }, + ], }, ], }, @@ -160,6 +220,22 @@ new A(); }, line: 3, messageId: 'unusedVar', + suggestions: [ + { + data: { varName: 'Another' }, + messageId: 'unusedVarSuggestion', + output: ` +import { Nullable } from 'nullable'; + +class A { + do(a: Nullable) { + console.log(a); + } +} +new A(); + `, + }, + ], }, ], }, @@ -184,6 +260,22 @@ new A(); }, line: 3, messageId: 'unusedVar', + suggestions: [ + { + data: { varName: 'Another' }, + messageId: 'unusedVarSuggestion', + output: ` +import { Nullable } from 'nullable'; + +class A { + do(): Nullable { + return null; + } +} +new A(); + `, + }, + ], }, ], }, @@ -205,6 +297,19 @@ export interface A { }, line: 3, messageId: 'unusedVar', + suggestions: [ + { + data: { varName: 'Another' }, + messageId: 'unusedVarSuggestion', + output: ` +import { Nullable } from 'nullable'; + +export interface A { + do(a: Nullable); +} + `, + }, + ], }, ], }, @@ -226,6 +331,19 @@ export interface A { }, line: 3, messageId: 'unusedVar', + suggestions: [ + { + data: { varName: 'Nullable' }, + messageId: 'unusedVarSuggestion', + output: ` +import { Nullable } from 'nullable'; + +export interface A { + other: Nullable; +} + `, + }, + ], }, ], }, @@ -247,6 +365,19 @@ foo(); }, line: 2, messageId: 'unusedVar', + suggestions: [ + { + data: { varName: 'Nullable' }, + messageId: 'unusedVarSuggestion', + output: ` + +function foo(a: string) { + console.log(a); +} +foo(); + `, + }, + ], }, ], }, @@ -268,6 +399,19 @@ foo(); }, line: 2, messageId: 'unusedVar', + suggestions: [ + { + data: { varName: 'Nullable' }, + messageId: 'unusedVarSuggestion', + output: ` + +function foo(): string | null { + return null; +} +foo(); + `, + }, + ], }, ], }, @@ -291,6 +435,21 @@ new A(); }, line: 3, messageId: 'unusedVar', + suggestions: [ + { + data: { varName: 'SomeOther' }, + messageId: 'unusedVarSuggestion', + output: ` +import { Nullable } from 'nullable'; + +import { Another } from 'some'; +class A extends Nullable { + other: Nullable; +} +new A(); + `, + }, + ], }, ], }, @@ -314,6 +473,21 @@ new A(); }, line: 3, messageId: 'unusedVar', + suggestions: [ + { + data: { varName: 'SomeOther' }, + messageId: 'unusedVarSuggestion', + output: ` +import { Nullable } from 'nullable'; + +import { Another } from 'some'; +abstract class A extends Nullable { + other: Nullable; +} +new A(); + `, + }, + ], }, ], }, @@ -353,6 +527,17 @@ export interface Bar extends baz.test {} }, line: 2, messageId: 'unusedVar', + suggestions: [ + { + data: { varName: 'test' }, + messageId: 'unusedVarSuggestion', + output: ` + +import baz from 'baz'; +export interface Bar extends baz.test {} + `, + }, + ], }, ], }, @@ -372,6 +557,17 @@ export interface Bar extends baz().test {} }, line: 2, messageId: 'unusedVar', + suggestions: [ + { + data: { varName: 'test' }, + messageId: 'unusedVarSuggestion', + output: ` + +import baz from 'baz'; +export interface Bar extends baz().test {} + `, + }, + ], }, ], }, @@ -391,6 +587,17 @@ export class Bar implements baz.test {} }, line: 2, messageId: 'unusedVar', + suggestions: [ + { + data: { varName: 'test' }, + messageId: 'unusedVarSuggestion', + output: ` + +import baz from 'baz'; +export class Bar implements baz.test {} + `, + }, + ], }, ], }, @@ -410,6 +617,17 @@ export class Bar implements baz().test {} }, line: 2, messageId: 'unusedVar', + suggestions: [ + { + data: { varName: 'test' }, + messageId: 'unusedVarSuggestion', + output: ` + +import baz from 'baz'; +export class Bar implements baz().test {} + `, + }, + ], }, ], }, @@ -579,6 +797,20 @@ export const ComponentFoo = () => { }, line: 3, messageId: 'unusedVar', + suggestions: [ + { + data: { varName: 'Fragment' }, + messageId: 'unusedVarSuggestion', + output: ` +import React from 'react'; + + +export const ComponentFoo = () => { + return
Foo Foo
; +}; + `, + }, + ], }, ], languageOptions: { @@ -608,6 +840,20 @@ export const ComponentFoo = () => { }, line: 2, messageId: 'unusedVar', + suggestions: [ + { + data: { varName: 'React' }, + messageId: 'unusedVarSuggestion', + output: ` + +import { h } from 'some-other-jsx-lib'; + +export const ComponentFoo = () => { + return
Foo Foo
; +}; + `, + }, + ], }, ], languageOptions: { @@ -638,6 +884,19 @@ export const ComponentFoo = () => { }, line: 2, messageId: 'unusedVar', + suggestions: [ + { + data: { varName: 'React' }, + messageId: 'unusedVarSuggestion', + output: ` + + +export const ComponentFoo = () => { + return
Foo Foo
; +}; + `, + }, + ], }, ], languageOptions: { @@ -851,17 +1110,17 @@ export = Foo; ], }, { - code: ` + code: noFormat` namespace Foo { export const foo = 1; } export namespace Bar { - import TheFoo = Foo; +import TheFoo = Foo; } `, errors: [ { - column: 10, + column: 8, data: { action: 'defined', additional: '', @@ -869,6 +1128,20 @@ export namespace Bar { }, line: 6, messageId: 'unusedVar', + suggestions: [ + { + data: { varName: 'TheFoo' }, + messageId: 'unusedVarSuggestion', + output: ` +namespace Foo { + export const foo = 1; +} +export namespace Bar { + +} + `, + }, + ], }, ], }, @@ -1734,6 +2007,206 @@ export {}; ], filename: 'foo.d.ts', }, + { + code: noFormat` +import * as Unused from 'foo';import * as Used from 'bar'; +export { Used }; + `, + errors: [ + { + messageId: 'unusedVar', + }, + ], + options: [{ enableAutofixRemoval: { imports: true } }], + output: ` +import * as Used from 'bar'; +export { Used }; + `, + }, + { + code: noFormat` +import Unused1 from 'foo'; +import Unused2,{ Used } from 'bar'; +export { Used }; + `, + errors: [ + { + messageId: 'unusedVar', + }, + { + messageId: 'unusedVar', + }, + ], + options: [{ enableAutofixRemoval: { imports: true } }], + output: ` + +import { Used } from 'bar'; +export { Used }; + `, + }, + { + code: noFormat` +import { Unused1 } from 'foo'; +import Used1, { Unused2 } from 'bar'; +import { Used2, Unused3 } from 'baz'; +import Used3, { Unused4,Used4 } from 'foobar'; +export { Used1, Used2, Used3, Used4 }; + `, + errors: [ + { + messageId: 'unusedVar', + }, + { + messageId: 'unusedVar', + }, + { + messageId: 'unusedVar', + }, + { + messageId: 'unusedVar', + }, + ], + options: [{ enableAutofixRemoval: { imports: true } }], + output: ` + +import Used1 from 'bar'; +import { Used2 } from 'baz'; +import Used3, { Used4 } from 'foobar'; +export { Used1, Used2, Used3, Used4 }; + `, + }, + { + code: ` +let unused; + `, + errors: [ + { + column: 5, + data: { + action: 'defined', + additional: '', + varName: 'unused', + }, + line: 2, + messageId: 'unusedVar', + }, + ], + options: [{ enableAutofixRemoval: { imports: true } }], + output: null, + }, + { + code: ` +import { /* cmt */ Unused1, Used1 } from 'foo'; +export { Used1 }; + `, + errors: [{ messageId: 'unusedVar' }], + options: [{ enableAutofixRemoval: { imports: true } }], + output: ` +import { /* cmt */ Used1 } from 'foo'; +export { Used1 }; + `, + }, + { + code: noFormat` +import type { UnusedType } from 'foo';import { Used1, Unused1 } from 'foo'; +export { Used1 }; + `, + errors: [{ messageId: 'unusedVar' }, { messageId: 'unusedVar' }], + options: [{ enableAutofixRemoval: { imports: true } }], + output: ` +import { Used1 } from 'foo'; +export { Used1 }; + `, + }, + { + code: ` +import { Unused1 as u1, Used1 as u2 } from 'foo'; +export { u2 }; + `, + errors: [{ messageId: 'unusedVar' }], + options: [{ enableAutofixRemoval: { imports: true } }], + output: ` +import { Used1 as u2 } from 'foo'; +export { u2 }; + `, + }, + { + code: ` +import x = require('foo'); +import y = require('bar'); +export { y }; + `, + errors: [{ messageId: 'unusedVar' }], + options: [{ enableAutofixRemoval: { imports: true } }], + output: ` + +import y = require('bar'); +export { y }; + `, + }, + // TODO: Logic to remove multiple unused vars in one-line + // { + // code: ` + // import { Unused1, Unused2, Used1 } from 'foo'; + // import { Unused3, Unused4 } from 'bar'; + // export { Used1, Used2 }; + // `, + // errors: [ + // { messageId: 'unusedVar' }, + // { messageId: 'unusedVar' }, + // { messageId: 'unusedVar' }, + // { messageId: 'unusedVar' }, + // { messageId: 'unusedVar' }, + // { messageId: 'unusedVar' }, + // { messageId: 'unusedVar' }, + // { messageId: 'unusedVar' }, + // ], + // options: [{ enableAutofixRemoval: { imports: true } }], + // output: ` + // import { Used1,Used2 } from 'foo'; + + // export { Used1, Used2 }; + // `, + // }, + { + code: noFormat` +import { +Unused1, +Unused2, +Unused3, +Unused4, +Used1, +/* cmt */ +Unused5, +Unused6, +Used2, +} from 'foo'; +export { Used1, Used2 }; + `, + errors: [ + { messageId: 'unusedVar' }, + { messageId: 'unusedVar' }, + { messageId: 'unusedVar' }, + { messageId: 'unusedVar' }, + { messageId: 'unusedVar' }, + { messageId: 'unusedVar' }, + ], + options: [{ enableAutofixRemoval: { imports: true } }], + output: noFormat` +import { + + + + +Used1, +/* cmt */ + + +Used2, +} from 'foo'; +export { Used1, Used2 }; + `, + }, ], valid: [ diff --git a/packages/eslint-plugin/tests/schema-snapshots/no-unused-vars.shot b/packages/eslint-plugin/tests/schema-snapshots/no-unused-vars.shot index 0d9872aa6a11..afe9fbe0945b 100644 --- a/packages/eslint-plugin/tests/schema-snapshots/no-unused-vars.shot +++ b/packages/eslint-plugin/tests/schema-snapshots/no-unused-vars.shot @@ -33,6 +33,15 @@ "description": "Regular expressions of destructured array variable names to not check for usage.", "type": "string" }, + "enableAutofixRemoval": { + "properties": { + "imports": { + "description": "Whether to enable autofix for removing unused imports.", + "type": "boolean" + } + }, + "type": "object" + }, "ignoreClassWithStaticInitBlock": { "description": "Whether to ignore classes with at least one static initialization block.", "type": "boolean" @@ -85,6 +94,11 @@ type Options = [ caughtErrorsIgnorePattern?: string; /** Regular expressions of destructured array variable names to not check for usage. */ destructuredArrayIgnorePattern?: string; + enableAutofixRemoval?: { + /** Whether to enable autofix for removing unused imports. */ + imports?: boolean; + [k: string]: unknown; + }; /** Whether to ignore classes with at least one static initialization block. */ ignoreClassWithStaticInitBlock?: boolean; /** Whether to ignore sibling properties in `...` destructurings. */