diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f9272e..d416066 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,13 @@ ## Unreleased -- Deprecate `context.rawChecker`. Types overrides from `context.checker` have been updated so it can be passed to other libraries. Thanks @JoshuaKGoldberg for challenging this! +### Add `context.languageService` + +This allows to create a first kind of 'multi-file' rules. It's used in the new core rule `core/unusedExport`. + +### Deprecate `context.rawChecker` + +Types overrides of `context.checker` have been updated so it can be passed to other libraries. `context.rawChecker` is therefore not needed anymore and has been deprecated. Thanks @JoshuaKGoldberg for challenging this! ## 1.0.28 diff --git a/README.md b/README.md index 86b5f10..62f25b3 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,13 @@ export default defineConfig({ ## Core rules -Currently, the list of core rules are the type-aware lint rules I use from typescript-eslint. If you think more rules should be added, please open an issue, but to reduce the surface, only non-styling type-aware rules will be accepted. Here is the list of [typescript-eslint type aware rules](https://typescript-eslint.io/rules/?=typeInformation) with their status: +### Exclusive rules + +- unusedExport: Detect unused exports + +### From typescript-eslint + +Currently, the ported rules are the type-aware lint rules I use from typescript-eslint. If you think more rules should be added, please open an issue, but to reduce the surface, only non-styling type-aware rules will be accepted. Here is the list of [typescript-eslint type aware rules](https://typescript-eslint.io/rules/?=typeInformation) with their status: - await-thenable: ✅ Implemented - consistent-return: 🛑 Implementation not planned, you can use `noImplicitReturns` compilerOption diff --git a/package.json b/package.json index 3ff04b5..1dcb71d 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "import": "bun scripts/import-rule.ts", "test": "bun test", "build": "bun scripts/bundle.ts && tsc -p tsconfig.dist.json && cd dist && publint", - "tsl": "bun src/cli.ts", + "tsl": "bun src/cli-entrypoint.ts", "prettier-all": "bun prettier-ci --write", "prettier-ci": "prettier --cache --check '**/*.{ts,json,md,yml}'", "check": "bun prettier-ci && bun run test && bun tsl && bun run build", diff --git a/src/cli.ts b/src/cli.ts index 561a293..d59e648 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ import { formatDiagnostics, type TSLDiagnostic, } from "./formatDiagnostic.ts"; +import { getLanguageService } from "./getLanguageService.ts"; import { core } from "./index.ts"; import { initRules } from "./initRules.ts"; import { loadConfig } from "./loadConfig.ts"; @@ -62,12 +63,14 @@ if (result.errors.length) { } const host = ts.createCompilerHost(result.options, true); -const program = ts.createProgram({ +const programForLanguageServiceHost = ts.createProgram({ rootNames: result.fileNames, options: result.options, projectReferences: result.projectReferences, - host, + host: host, }); +const languageService = getLanguageService(programForLanguageServiceHost); +const program = languageService.getProgram()!; if (values.timing) { console.log(`TS program created in ${displayTiming(programStart)}`); @@ -77,6 +80,7 @@ const configStart = performance.now(); const { config } = await loadConfig(program); const { lint, allRules, timingMaps } = await initRules( () => program, + () => languageService, config ?? { rules: core.all() }, values.timing, ); diff --git a/src/formatDiagnostic.ts b/src/formatDiagnostic.ts index a2dcc0c..d9b0bfa 100644 --- a/src/formatDiagnostic.ts +++ b/src/formatDiagnostic.ts @@ -179,7 +179,7 @@ function formatCodeSpan( return context; } -export function formatLocation(file: SourceFile, start: number): string { +function formatLocation(file: SourceFile, start: number): string { const { line, character } = getLineAndCharacterOfPosition(file, start); const relativeFileName = displayFilename(file.fileName); let output = ""; diff --git a/src/getLanguageService.ts b/src/getLanguageService.ts new file mode 100644 index 0000000..cda09e4 --- /dev/null +++ b/src/getLanguageService.ts @@ -0,0 +1,30 @@ +import fs from "node:fs"; +import ts from "typescript"; + +export const getLanguageService = ( + program: ts.Program, + overridesForTesting?: { + readFile: (path: string) => string | undefined; + fileExists: (path: string) => boolean; + directoryExists: (path: string) => boolean; + }, +) => { + const languageServiceHost: ts.LanguageServiceHost = { + getCompilationSettings: () => program.getCompilerOptions(), + getScriptFileNames: () => program.getSourceFiles().map((f) => f.fileName), + getScriptVersion: () => "", + getScriptSnapshot: (fileName) => { + const sourceFile = program.getSourceFile(fileName); + if (!sourceFile) return undefined; + return ts.ScriptSnapshot.fromString(sourceFile.text); + }, + getCurrentDirectory: () => process.cwd(), + getDefaultLibFileName: ts.getDefaultLibFileName, + readFile: (path, encoding) => + overridesForTesting?.readFile(path) + ?? fs.readFileSync(path, encoding as BufferEncoding), + fileExists: overridesForTesting?.fileExists ?? fs.existsSync, + directoryExists: overridesForTesting?.directoryExists, + }; + return ts.createLanguageService(languageServiceHost); +}; diff --git a/src/getPlugin.ts b/src/getPlugin.ts index 4ada04e..a1d687c 100644 --- a/src/getPlugin.ts +++ b/src/getPlugin.ts @@ -38,6 +38,7 @@ export const getPlugin = async ( } const result = await initRules( () => languageService.getProgram()!, + () => languageService, config ?? { rules: [] }, false, ); diff --git a/src/index.ts b/src/index.ts index 2392496..60a951f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,8 +32,10 @@ import { restrictTemplateExpressions } from "./rules/restrictTemplateExpressions import { returnAwait } from "./rules/returnAwait/returnAwait.ts"; import { strictBooleanExpressions } from "./rules/strictBooleanExpressions/strictBooleanExpressions.ts"; import { switchExhaustivenessCheck } from "./rules/switchExhaustivenessCheck/switchExhaustivenessCheck.ts"; +import { unusedExport } from "./rules/unusedExport/unusedExport.ts"; import type { Config, Rule } from "./types.ts"; +/* tsl-ignore core/unusedExport */ export type { AST, Checker, @@ -96,4 +98,5 @@ export const core = createRulesSet({ returnAwait, strictBooleanExpressions, switchExhaustivenessCheck, + unusedExport, }); diff --git a/src/initRules.ts b/src/initRules.ts index 3ad5f14..6c63fb1 100644 --- a/src/initRules.ts +++ b/src/initRules.ts @@ -29,6 +29,7 @@ const run = (cb: () => T) => cb(); export const initRules = async ( getProgram: () => ts.Program, + getLanguageService: () => ts.LanguageService, config: Config, showTiming: boolean, ) => { @@ -98,6 +99,9 @@ export const initRules = async ( get program() { return getProgram(); }, + get languageService() { + return getLanguageService(); + }, get checker() { return getProgram().getTypeChecker() as unknown as Checker; }, diff --git a/src/loadConfig.ts b/src/loadConfig.ts index ea36f80..249468a 100644 --- a/src/loadConfig.ts +++ b/src/loadConfig.ts @@ -74,7 +74,7 @@ export const loadConfig = async ( }; }; -export const findConfigPath = (program: ts.Program) => { +const findConfigPath = (program: ts.Program) => { let dir = program.getCurrentDirectory(); const { root } = path.parse(dir); let configPath: string | undefined = undefined; diff --git a/src/ruleTester.ts b/src/ruleTester.ts index 27237b3..da4930b 100644 --- a/src/ruleTester.ts +++ b/src/ruleTester.ts @@ -1,9 +1,11 @@ import ts, { ModuleResolutionKind, NodeFlags, SyntaxKind } from "typescript"; import type { SourceFile, Visitor } from "./ast.ts"; import { getContextUtils } from "./getContextUtils.ts"; +import { getLanguageService } from "./getLanguageService.ts"; import type { AST, Checker, Context, ReportDescriptor, Rule } from "./types.ts"; import { visitorEntries } from "./visitorEntries.ts"; +// tsl-ignore core/unusedExport export function print(...args: any[]) { console.log(...args.map((value) => transform(value, new Set()))); } @@ -67,18 +69,24 @@ type CaseProps Rule> = { options?: Parameters[0]; code: string; }; +type MultiFilesCaseProps Rule> = + { + compilerOptions?: ts.CompilerOptions; + options?: Parameters[0]; + files: { fileName: `${string}.${"ts" | "tsx"}`; code: string }[]; + }; type ErrorReport = { + fileName?: string; message: string; line?: number; column?: number; endLine?: number; endColumn?: number; - suggestions?: { message: string; output?: string }[]; + suggestions?: { message: string; output: string }[]; }; type SetupCase Rule> = { compilerOptionsKey: string; - fileName: string; - code: string; + files: { path: string; fileName: string; code: string }[]; options?: Parameters[0]; isValid: boolean; index: number; @@ -93,6 +101,7 @@ const defaultCompilerOptions = { moduleResolution: ModuleResolutionKind.Bundler, noEmit: true, isolatedModules: true, + allowImportingTsExtensions: true, skipLibCheck: true, strict: true, types: [], @@ -101,25 +110,26 @@ const defaultCompilerOptions = { const typeFocus = process.argv[3]; const indexFocus = process.argv[4]; -export type ValidTestCase Rule> = +export type ValidTestCase Rule> = + | MultiFilesCaseProps | CaseProps | string; export type InvalidTestCase< TRule extends (options?: unknown) => Rule, -> = CaseProps & { - error?: string; - errors?: ( - | { - message: string; - line?: number; - column?: number; - endColumn?: number; - endLine?: number; - suggestions?: { message: string; output: string }[]; - } - | [message: string, line?: number, column?: number] - )[]; -}; +> = + | (CaseProps + & ( + | { error: string } + | { + errors: ( + | Omit + | [message: string, line?: number, column?: number] + )[]; + } + )) + | (MultiFilesCaseProps & { + errors: ErrorReport[]; + }); /** * Output API is still a bit raw (it prints differences to the console and returns a 'hasError' boolean) @@ -143,16 +153,35 @@ export const ruleTester = Rule>({ const cases: SetupCase[] = []; const setupCase = ( - caseProps: CaseProps, + caseProps: CaseProps | MultiFilesCaseProps, isValid: boolean, index: number, errors: ErrorReport[] | null, ) => { - const useTSX = tsx ? caseProps.tsx !== false : caseProps.tsx === true; - const fileName = `${isValid ? "valid" : "invalid"}-${index}.${ - useTSX ? "tsx" : "ts" - }`; - filesMap.set(fileName, caseProps.code); + const useTSX = + "files" in caseProps + ? caseProps.files.some((file) => file.fileName.endsWith(".tsx")) + : tsx + ? caseProps.tsx !== false + : caseProps.tsx === true; + const caseFolder = `${isValid ? "valid" : "invalid"}-${index}`; + const files = + "files" in caseProps + ? caseProps.files.map((file) => ({ + path: `/${caseFolder}/${file.fileName}`, + fileName: file.fileName, + code: file.code, + })) + : [ + { + path: `/${caseFolder}/file.${useTSX ? "tsx" : "ts"}`, + fileName: `file.${useTSX ? "tsx" : "ts"}`, + code: caseProps.code, + }, + ]; + for (const file of files) { + filesMap.set(file.path, file.code); + } const compilerOptionsInput = caseProps.compilerOptions !== undefined || useTSX ? { @@ -168,10 +197,10 @@ export const ruleTester = Rule>({ const compilerOptionsKey = JSON.stringify(compilerOptionsInput); const current = compilerOptionsToFiles.get(compilerOptionsKey); if (current) { - current.push(fileName); + current.push(...files.map((f) => f.path)); } else { compilerOptionsToFiles.set(compilerOptionsKey, [ - fileName, + ...files.map((f) => f.path), ...compilerOptionsInput.lib.map( (lib) => `node_modules/typescript/lib/lib.${lib}.d.ts`, ), @@ -179,8 +208,7 @@ export const ruleTester = Rule>({ } cases.push({ compilerOptionsKey, - fileName, - code: caseProps.code, + files, options: caseProps.options, isValid, index, @@ -198,30 +226,44 @@ export const ruleTester = Rule>({ for (const [index, invalidCase] of invalid.entries()) { if (typeFocus && typeFocus !== "invalid") continue; if (indexFocus && indexFocus !== index.toString()) continue; - const errors = invalidCase.errors - ? invalidCase.errors.map((e) => - Array.isArray(e) - ? { message: e[0], line: e[1], column: e[2], suggestions: [] } - : e, - ) - : invalidCase.error + const errors = + "error" in invalidCase ? [{ message: invalidCase.error }] - : []; + : invalidCase.errors.map((e) => + Array.isArray(e) ? { message: e[0], line: e[1], column: e[2] } : e, + ); if (errors.length === 0) { throw new Error(`Invalid case ${index} has no errors`); } setupCase(invalidCase, false, index, errors); } - const compilerOptionsToProgram = new Map(); + const compilerOptionsToProgram = new Map< + string, + { program: ts.Program; languageService: ts.LanguageService } + >(); for (const [optionsKey, files] of compilerOptionsToFiles.entries()) { const compilerOptionsInput = JSON.parse(optionsKey); const host = ts.createCompilerHost(compilerOptionsInput, true); + host.useCaseSensitiveFileNames = () => true; const originalReadFile = host.readFile; - host.readFile = (file) => { - if (filesMap.has(file)) return filesMap.get(file); - return originalReadFile(file); + const originalDirectoryExists = host.directoryExists; + const originalFileExists = host.fileExists; + const fileExists = (file: string) => { + if (filesMap.has(file)) return true; + return originalFileExists(file); + }; + host.fileExists = fileExists; + const directoryExists = (dir: string) => { + if (dir.startsWith("/invalid-") || dir.startsWith("/valid-")) { + return true; + } + return originalDirectoryExists!(dir); }; + host.directoryExists = directoryExists; + const readFile = (file: string) => + filesMap.get(file) ?? originalReadFile(file); + host.readFile = readFile; const domLib = "node_modules/typescript/lib/lib.dom.d.ts"; const program = ts.createProgram( [ @@ -237,11 +279,13 @@ export const ruleTester = Rule>({ compilerOptionsInput, host, ); - const emitResult = program.emit(); + const languageService = getLanguageService(program, { + readFile, + fileExists, + directoryExists, + }); + const allDiagnostics = ts.getPreEmitDiagnostics(program); if (indexFocus) { - const allDiagnostics = ts - .getPreEmitDiagnostics(program) - .concat(emitResult.diagnostics); allDiagnostics.forEach((diagnostic) => { if (diagnostic.file) { let { line, character } = ts.getLineAndCharacterOfPosition( @@ -264,53 +308,62 @@ export const ruleTester = Rule>({ } }); } - compilerOptionsToProgram.set(optionsKey, program); + compilerOptionsToProgram.set(optionsKey, { program, languageService }); } for (const caseProps of cases) { const rule = ruleFn(caseProps.options); - const program = compilerOptionsToProgram.get(caseProps.compilerOptionsKey)!; + const { program, languageService } = compilerOptionsToProgram.get( + caseProps.compilerOptionsKey, + )!; const compilerOptions = program.getCompilerOptions(); const reports: ReportDescriptor[] = []; - const sourceFile = program.getSourceFile( - caseProps.fileName, - ) as unknown as SourceFile; - const context: Context = { - sourceFile, - program, - get checker() { - return program.getTypeChecker() as unknown as Checker; - }, - get rawChecker() { - return program.getTypeChecker(); - }, - compilerOptions, - utils: getContextUtils(() => program), - report(descriptor) { - reports.push(descriptor); - }, - data: undefined, - }; - if (rule.createData) context.data = rule.createData(context); - const visit = (node: AST.AnyNode) => { - const nodeType = visitorEntries.find((e) => e[0] === node.kind)?.[1]; - if (nodeType) { - rule.visitor[nodeType]?.(context, node as any); - } - // @ts-expect-error - node.forEachChild(visit); - if (nodeType) { - rule.visitor[`${nodeType}_exit` as keyof Visitor]?.( - context, - node as any, - ); - } - }; - visit(sourceFile); + for (const file of caseProps.files) { + const sourceFile = program.getSourceFile( + file.path, + ) as unknown as SourceFile; + const context: Context = { + sourceFile, + program, + languageService, + get checker() { + return program.getTypeChecker() as unknown as Checker; + }, + get rawChecker() { + return program.getTypeChecker(); + }, + compilerOptions, + utils: getContextUtils(() => program), + report(descriptor) { + reports.push(descriptor); + }, + data: undefined, + }; + if (rule.createData) context.data = rule.createData(context); + const visit = (node: AST.AnyNode) => { + const nodeType = visitorEntries.find((e) => e[0] === node.kind)?.[1]; + if (nodeType) { + rule.visitor[nodeType]?.(context, node as any); + } + // @ts-expect-error + node.forEachChild(visit); + if (nodeType) { + rule.visitor[`${nodeType}_exit` as keyof Visitor]?.( + context, + node as any, + ); + } + }; + visit(sourceFile); + } + const displayCode = + caseProps.files.length > 1 + ? caseProps.files.map((file) => file.fileName).join(", ") + : caseProps.files[0].code; if (caseProps.isValid) { if (reports.length !== 0) { console.error( - `Reports for valid case ${caseProps.index} (${caseProps.code})`, + `Reports for valid case ${caseProps.index} (${displayCode})`, ); hasError = true; for (const report of reports) { @@ -320,7 +373,7 @@ export const ruleTester = Rule>({ } else { if (reports.length === 0) { console.error( - `No reports for invalid case ${caseProps.index} (${caseProps.code})`, + `No reports for invalid case ${caseProps.index} (${displayCode})`, ); hasError = true; } else { @@ -337,7 +390,7 @@ export const ruleTester = Rule>({ ) => { if (!introLogged) { console.error( - `Report(s) mismatch for invalid case ${caseProps.index} (${caseProps.code})`, + `Report(s) mismatch for invalid case ${caseProps.index} (${displayCode})`, ); hasError = true; introLogged = true; @@ -363,6 +416,22 @@ export const ruleTester = Rule>({ continue; } if (!expected || !got) continue; + const fileWithError = expected.fileName + ? caseProps.files.find( + (file) => file.fileName === expected.fileName, + ) + : caseProps.files[0]; + if (fileWithError === undefined) { + log( + " filename", + `One of ${caseProps.files.map((file) => file.fileName).join(", ")}`, + expected.fileName, + ); + continue; + } + const sourceFile = program.getSourceFile( + fileWithError.path, + ) as unknown as SourceFile; if (expected.line !== undefined) { const gotStart = "node" in got ? got.node.getStart() : got.start; const gotLine = @@ -452,7 +521,7 @@ export const ruleTester = Rule>({ const gotOutput = gotChanges.reduceRight( (acc, it) => acc.slice(0, it.start) + it.newText + acc.slice(it.end), - caseProps.code, + fileWithError.code, ); if (expectedSuggestion.output !== gotOutput) { log( diff --git a/src/rules/_utils/getOperatorPrecedence.ts b/src/rules/_utils/getOperatorPrecedence.ts index 1317908..15610ac 100644 --- a/src/rules/_utils/getOperatorPrecedence.ts +++ b/src/rules/_utils/getOperatorPrecedence.ts @@ -392,7 +392,7 @@ export function getOperatorPrecedence( } } -export function getBinaryOperatorPrecedence(kind: SyntaxKind): number { +function getBinaryOperatorPrecedence(kind: SyntaxKind): number { switch (kind) { case SyntaxKind.MinusToken: case SyntaxKind.PlusToken: diff --git a/src/rules/noMisusedPromises/noMisusedPromises.ts b/src/rules/noMisusedPromises/noMisusedPromises.ts index a575074..0a95d34 100644 --- a/src/rules/noMisusedPromises/noMisusedPromises.ts +++ b/src/rules/noMisusedPromises/noMisusedPromises.ts @@ -312,7 +312,7 @@ function checkVariableDeclaration( } } -export function checkPropertyAssignment( +function checkPropertyAssignment( context: Context, node: AST.PropertyAssignment, ) { @@ -335,7 +335,7 @@ export function checkPropertyAssignment( } } -export function checkShorthandPropertyAssignment( +function checkShorthandPropertyAssignment( context: Context, node: AST.ShorthandPropertyAssignment, ) { @@ -349,7 +349,7 @@ export function checkShorthandPropertyAssignment( } } -export function checkMethodDeclaration( +function checkMethodDeclaration( context: Context, node: AST.MethodDeclaration, ) { diff --git a/src/rules/noUnnecessaryCondition/noUnnecessaryCondition.ts b/src/rules/noUnnecessaryCondition/noUnnecessaryCondition.ts index 8de043e..9abc750 100644 --- a/src/rules/noUnnecessaryCondition/noUnnecessaryCondition.ts +++ b/src/rules/noUnnecessaryCondition/noUnnecessaryCondition.ts @@ -618,7 +618,7 @@ function checkCallExpression( * Inspect a call expression to see if it's a call to an assertion function. * If it is, return the node of the argument that is asserted and other useful info. */ -export function findTypeGuardAssertedArgument( +function findTypeGuardAssertedArgument( context: Context, node: AST.CallExpression, ): { argument: AST.Expression; asserts: boolean; type: ts.Type } | undefined { @@ -870,7 +870,7 @@ function checkOptionalChain( } // Truthiness utilities -export function isPossiblyFalsy(type: ts.Type): boolean { +function isPossiblyFalsy(type: ts.Type): boolean { return isTypeRecurser(type, (t) => { return t.isLiteral() ? !Boolean(getValueOfLiteralType(t)) diff --git a/src/rules/unusedExport/unusedExport.test.ts b/src/rules/unusedExport/unusedExport.test.ts new file mode 100644 index 0000000..6fe8831 --- /dev/null +++ b/src/rules/unusedExport/unusedExport.test.ts @@ -0,0 +1,119 @@ +import { expect, test } from "bun:test"; +import { ruleTester, type ValidTestCase } from "../../ruleTester.ts"; +import { messages, unusedExport } from "./unusedExport.ts"; + +test("unusedExport", () => { + const hasError = ruleTester({ + ruleFn: unusedExport, + valid: [ + ...[ + "export const foo = 1;", + "export const foo = () => 1;", + "const foo = 1; export { foo };", + "export function foo() { return 1; }", + "export class foo { }", + ].map( + (code): ValidTestCase => ({ + files: [ + { fileName: "file.ts", code }, + { + fileName: "file2.ts", + code: `import { foo } from "./file.ts";`, + }, + ], + }), + ), + ...[ + "const foo = 1; export default foo;", + "export default function foo() { return 1; }", + "export default class foo { }", + ].map( + (code): ValidTestCase => ({ + files: [ + { fileName: "file.ts", code }, + { fileName: "file2.ts", code: `import bar from "./file.ts";` }, + ], + }), + ), + { + files: [ + { + fileName: "file.ts", + code: "export const foo = 1; export const bar = 2;", + }, + { + fileName: "file2.ts", + code: `import * as utils from "./file.ts"; console.log(utils.foo, utils.bar);`, + }, + ], + }, + ], + invalid: [ + { + files: [ + { + fileName: "file.ts", + code: `export const foo = 1;\nexport const bar = 2;`, + }, + { fileName: "file2.ts", code: `import { foo } from "./file.ts";` }, + ], + errors: [ + { + fileName: "file.ts", + message: messages.unusedExport, + line: 2, + }, + ], + }, + { + files: [ + { + fileName: "file.ts", + code: `export const foo = 1;\nconst bar = 2; export default bar;`, + }, + { fileName: "file2.ts", code: `import { foo } from "./file.ts";` }, + ], + errors: [ + { + fileName: "file.ts", + message: messages.unusedExport, + line: 2, + }, + ], + }, + { + files: [ + { + fileName: "file.ts", + code: `export const foo = 1;\nconst bar = 2; export { bar };`, + }, + { fileName: "file2.ts", code: `import { foo } from "./file.ts";` }, + ], + errors: [ + { + fileName: "file.ts", + message: messages.unusedExport, + line: 2, + }, + ], + }, + { + files: [ + { + fileName: "file.ts", + code: `export const foo = 1;\nexport function bar() { return 2; };`, + }, + { fileName: "file2.ts", code: `import { foo } from "./file.ts";` }, + ], + errors: [ + { + fileName: "file.ts", + message: messages.unusedExport, + line: 2, + }, + ], + }, + ], + }); + expect(hasError).toEqual(false); +}); diff --git a/src/rules/unusedExport/unusedExport.ts b/src/rules/unusedExport/unusedExport.ts new file mode 100644 index 0000000..b5cc55f --- /dev/null +++ b/src/rules/unusedExport/unusedExport.ts @@ -0,0 +1,80 @@ +import { type Node, SyntaxKind } from "typescript"; +import { defineRule } from "../_utils/index.ts"; +import type { AST, Context } from "../../types.ts"; + +export const messages = { + unusedExport: "This export is never used.", +}; + +export const unusedExport = defineRule(() => ({ + name: "core/unusedExport", + visitor: { + SourceFile(context) { + for (const el of context.sourceFile.statements) { + switch (el.kind) { + case SyntaxKind.ExportDeclaration: + if (el.exportClause?.kind === SyntaxKind.NamedExports) { + for (const e of el.exportClause.elements) { + checkExportedNode( + context, + e.name, + el.exportClause.elements.length > 1 ? e.name : el, + ); + } + } + break; + case SyntaxKind.ExportAssignment: + // export default foo; + checkExportedNode(context, el, el); + break; + case SyntaxKind.VariableStatement: { + const exportKeyword = el.modifiers?.find( + (m) => m.kind === SyntaxKind.ExportKeyword, + ); + if (!exportKeyword) continue; + for (const v of el.declarationList.declarations) { + if (v.name.kind !== SyntaxKind.Identifier) continue; // Should not be possible + checkExportedNode( + context, + v.name, + el.declarationList.declarations.length > 1 + ? v.name + : exportKeyword, + ); + } + break; + } + case SyntaxKind.ClassDeclaration: + case SyntaxKind.FunctionDeclaration: { + const exportKeyword = el.modifiers?.find( + (m) => m.kind === SyntaxKind.ExportKeyword, + ); + if (!exportKeyword) continue; + checkExportedNode(context, el, exportKeyword); + break; + } + default: + break; + } + } + }, + }, +})); + +function checkExportedNode( + context: Context, + node: AST.AnyNode, + reportNode: Node, +) { + const references = context.languageService.findReferences( + context.sourceFile.fileName, + node.getStart(), + ); + if (!references) return; // possible? + const hasOutsideReferences = references.some((ref) => + ref.references.some((r) => r.fileName !== context.sourceFile.fileName), + ); + if (!hasOutsideReferences) { + context.report({ node: reportNode, message: messages.unusedExport }); + } +} diff --git a/src/types.ts b/src/types.ts index de9bfed..afaef36 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ import type { CompilerOptions, + LanguageService, Node, Program, Signature, @@ -145,6 +146,7 @@ export type ReportDescriptor = ( export type Context = { sourceFile: AST.SourceFile; program: Program; + languageService: LanguageService; /** * TypeScript checker, with some types overrides * Can be used to get the type of a node