From 599262018f6f85567d8bc86296e1e6b53117f1b7 Mon Sep 17 00:00:00 2001 From: Lars Kappert Date: Thu, 6 Oct 2022 07:45:04 +0200 Subject: [PATCH] Refactor issue counters & introduce (custom) reporters --- README.md | 24 ++++++++++++ src/cli.ts | 55 +++++++++++++++------------ src/help.ts | 1 + src/index.ts | 74 +++++++++++++++++------------------- src/log.ts | 18 --------- src/reporters/compact.ts | 81 ++++++++++++++++++++++++++++++++++++++++ src/reporters/index.ts | 7 ++++ src/reporters/symbols.ts | 66 ++++++++++++++++++++++++++++++++ src/types.ts | 31 ++++++++------- test/index.spec.ts | 34 ++++++++--------- 10 files changed, 276 insertions(+), 115 deletions(-) create mode 100644 src/reporters/compact.ts create mode 100644 src/reporters/index.ts create mode 100644 src/reporters/symbols.ts diff --git a/README.md b/README.md index cf5499d13..9ca678be5 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ Options: --onlyDuplicates Report only unused duplicate exports --ignoreNamespaceImports Ignore namespace imports (affects onlyExports and onlyTypes) --noProgress Don't show dynamic progress updates + --reporter Select reporter: symbols, compact (default: symbols) Examples: @@ -174,6 +175,8 @@ can be tweaked further to the project structure. ## Example Output +### Default reporter + ``` $ exportman --config ./exportman.json --- UNUSED FILES (2) @@ -195,6 +198,27 @@ Registration, default src/components/Registration.tsx ProductsList, default src/components/Products.tsx ``` +### Compact + +``` +$ exportman --config ./exportman.json --reporter compact +--- UNUSED FILES (2) +src/chat/helpers.ts +src/components/SideBar.tsx +--- UNUSED EXPORTS (4) +src/common/src/string/index.ts: lowercaseFirstLetter +src/components/Registration.tsx: RegistrationBox +src/css.ts: clamp +src/services/authentication.ts: restoreSession, PREFIX +--- UNUSED TYPES (3) +src/components/Registration/registrationMachine.ts: RegistrationServices, RegistrationAction +src/components/Registration.tsx: ComponentProps +src/types/Product.ts: ProductDetail +--- DUPLICATE EXPORTS (2) +src/components/Registration.tsx: Registration, default +src/components/Products.tsx: ProductsList, default +``` + ## Why Yet Another unused file/export finder? There are some fine modules available in the same category: diff --git a/src/cli.ts b/src/cli.ts index 70e4153e4..1a54bf1f7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,21 +4,22 @@ import path from 'node:path'; import { parseArgs } from 'node:util'; import { printHelp } from './help'; import { resolveConfig } from './util'; -import { logIssueGroupResult } from './log'; +import reporters from './reporters'; import { run } from '.'; -import type { ImportedConfiguration } from './types'; +import type { ImportedConfiguration, Configuration } from './types'; const { values: { help, cwd: cwdArg, config, - onlyFiles, - onlyExports, - onlyTypes, - onlyDuplicates, + onlyFiles: isOnlyFiles = false, + onlyExports: isOnlyExports = false, + onlyTypes: isOnlyTypes = false, + onlyDuplicates: isOnlyDuplicates = false, ignoreNamespaceImports = false, - noProgress = false + noProgress = false, + reporter = 'symbols' } } = parseArgs({ options: { @@ -30,7 +31,8 @@ const { onlyTypes: { type: 'boolean' }, onlyDuplicates: { type: 'boolean' }, ignoreNamespaceImports: { type: 'boolean' }, - noProgress: { type: 'boolean' } + noProgress: { type: 'boolean' }, + reporter: { type: 'string' } } }); @@ -44,33 +46,40 @@ const cwd = cwdArg ? path.resolve(cwdArg) : process.cwd(); const configuration: ImportedConfiguration = require(path.resolve(config)); const isShowProgress = !noProgress || !process.stdout.isTTY; -const isFindAll = !onlyFiles && !onlyExports && !onlyTypes && !onlyDuplicates; -const isFindUnusedFiles = onlyFiles === true || isFindAll; -const isFindUnusedExports = onlyExports === true || isFindAll; -const isFindUnusedTypes = onlyTypes === true || isFindAll; -const isFindDuplicateExports = onlyDuplicates === true || isFindAll; +const isFindAll = !isOnlyFiles && !isOnlyExports && !isOnlyTypes && !isOnlyDuplicates; +const isFindUnusedFiles = isOnlyFiles === true || isFindAll; +const isFindUnusedExports = isOnlyExports === true || isFindAll; +const isFindUnusedTypes = isOnlyTypes === true || isFindAll; +const isFindDuplicateExports = isOnlyDuplicates === true || isFindAll; + +const report = + reporter in reporters ? reporters[reporter as keyof typeof reporters] : require(path.join(cwd, reporter)); const main = async () => { - const config = resolveConfig(configuration, cwdArg); - if (!config) { + const resolvedConfig = resolveConfig(configuration, cwdArg); + + if (!resolvedConfig) { printHelp(); process.exit(1); } - const issues = await run({ - ...config, + + const config: Configuration = Object.assign({}, resolvedConfig, { cwd, - isShowProgress, + isOnlyFiles, + isOnlyExports, + isOnlyTypes, + isOnlyDuplicates, isFindUnusedFiles, isFindUnusedExports, isFindUnusedTypes, isFindDuplicateExports, - isFollowSymbols: !ignoreNamespaceImports + isFollowSymbols: !ignoreNamespaceImports, + isShowProgress }); - if (isFindUnusedFiles) logIssueGroupResult(cwd, 'UNUSED FILES', issues.file); - if (isFindUnusedExports) logIssueGroupResult(cwd, 'UNUSED EXPORTS', issues.export); - if (isFindUnusedTypes) logIssueGroupResult(cwd, 'UNUSED TYPES', issues.type); - if (isFindDuplicateExports) logIssueGroupResult(cwd, 'DUPLICATE EXPORTS', issues.duplicate); + const issues = await run(config); + + report({ issues, cwd, config }); }; main(); diff --git a/src/help.ts b/src/help.ts index 420035276..3aee61723 100644 --- a/src/help.ts +++ b/src/help.ts @@ -11,6 +11,7 @@ Options: --onlyDuplicates Report only unused duplicate exports --ignoreNamespaceImports Ignore namespace imports (affects onlyExports and onlyTypes) --noProgress Don't show dynamic progress updates + --reporter Select reporter: symbols, compact (default: symbols) Examples: diff --git a/src/index.ts b/src/index.ts index 5d190c41a..51dc6b498 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { SourceFile, ts } from 'ts-morph'; +import { ts } from 'ts-morph'; import { findDuplicateExportedNames } from 'ts-morph-helpers'; import { hasSymbol } from 'ts-morph-helpers/dist/experimental'; import { createProject, partitionSourceFiles, getType } from './util'; @@ -36,40 +36,31 @@ export async function run(configuration: Configuration) { // Set up the results const issues: Issues = { - file: new Map(), - export: new Map(), - type: new Map(), - duplicate: new Map() + file: new Set(unusedProductionFiles.map(file => file.getFilePath())), + export: {}, + type: {}, + duplicate: {} }; - unusedProductionFiles.forEach(file => - issues.file.set(file.getFilePath(), { filePath: file.getFilePath(), symbol: '' }) - ); - - const processedNonEntryFiles: SourceFile[] = []; - - if (isShowProgress) { - // Create proxies to automatically update output when result arrays are updated - new Proxy(issues, { - get(target, prop, issue) { - let value = Reflect.get(target, prop, issue); - updateProcessingOutput(issue); - return typeof value == 'function' ? value.bind(target) : value; - } - }); - } + const counters = { + file: issues.file.size, + export: 0, + type: 0, + duplicate: 0, + processed: issues.file.size + }; // OK, this looks ugly const updateProcessingOutput = (item: Issue) => { if (!isShowProgress) return; - const counter = unusedProductionFiles.length + processedNonEntryFiles.length; + const counter = unusedProductionFiles.length + counters.processed; const total = unusedProductionFiles.length + usedNonEntryFiles.length; const percentage = Math.floor((counter / total) * 100); const messages = [getLine(`${percentage}%`, `of files processed (${counter} of ${total})`)]; isFindUnusedFiles && messages.push(getLine(unusedProductionFiles.length, 'unused files')); - isFindUnusedExports && messages.push(getLine(issues.export.size, 'unused exports')); - isFindUnusedTypes && messages.push(getLine(issues.type.size, 'unused types')); - isFindDuplicateExports && messages.push(getLine(issues.duplicate.size, 'duplicate exports')); + isFindUnusedExports && messages.push(getLine(counters.export, 'unused exports')); + isFindUnusedTypes && messages.push(getLine(counters.type, 'unused types')); + isFindDuplicateExports && messages.push(getLine(counters.duplicate, 'duplicate exports')); if (counter < total) { messages.push(''); messages.push(`Processing: ${path.relative(cwd, item.filePath)}`); @@ -77,12 +68,19 @@ export async function run(configuration: Configuration) { lineRewriter.update(messages); }; + const addIssue = (issueType: 'export' | 'type' | 'duplicate', issue: Issue) => { + const { filePath, symbol } = issue; + const key = path.relative(cwd, filePath); + issues[issueType][key] = issues[issueType][key] ?? {}; + issues[issueType][key][symbol] = issue; + counters[issueType]++; + if (isShowProgress) updateProcessingOutput(issue); + }; + // Skip when only interested in unused files if (isFindUnusedExports || isFindUnusedTypes || isFindDuplicateExports) { usedNonEntryFiles.forEach(sourceFile => { const filePath = sourceFile.getFilePath(); - processedNonEntryFiles.push(sourceFile); - updateProcessingOutput({ filePath: sourceFile.getFilePath(), symbol: '' }); // The file is used, let's visit all export declarations to see which of them are not used somewhere else const exportDeclarations = sourceFile.getExportedDeclarations(); @@ -90,8 +88,8 @@ export async function run(configuration: Configuration) { if (isFindDuplicateExports) { const duplicateExports = findDuplicateExportedNames(sourceFile); duplicateExports.forEach(symbols => { - const symbol = symbols.join(', '); - issues.duplicate.set(`${filePath}:${symbol}`, { filePath, symbols, symbol }); + const symbol = symbols.join(','); + addIssue('duplicate', { filePath, symbol, symbols }); }); } @@ -121,12 +119,12 @@ export async function run(configuration: Configuration) { if (identifier) { const identifierText = identifier.getText(); - if (isFindUnusedExports && issues.export.has(`${filePath}:${identifierText}`)) return; - if (isFindUnusedTypes && issues.type.has(`${filePath}:${identifierText}`)) return; + if (isFindUnusedExports && issues.export[filePath]?.[identifierText]) return; + if (isFindUnusedTypes && issues.type[filePath]?.[identifierText]) return; const refs = identifier.findReferences(); if (refs.length === 0) { - issues.export.set(`${filePath}:${identifierText}`, { filePath, symbol: identifierText }); + addIssue('export', { filePath, symbol: identifierText }); } else { const refFiles = new Set(refs.map(r => r.compilerObject.references.map(r => r.fileName)).flat()); @@ -144,24 +142,20 @@ export async function run(configuration: Configuration) { // No more reasons left to think this identifier is used somewhere else, report it as unused if (type) { - issues.type.set(`${filePath}:${identifierText}`, { filePath, type, symbol: identifierText }); + addIssue('type', { filePath, symbol: identifierText, symbolType: type }); } else { - issues.export.set(`${filePath}:${identifierText}`, { filePath, symbol: identifierText }); + addIssue('export', { filePath, symbol: identifierText }); } } } }); }); } + counters.processed++; }); } if (isShowProgress) lineRewriter.resetLines(); - return { - file: Array.from(issues.file.values()), - export: Array.from(issues.export.values()), - type: Array.from(issues.type.values()), - duplicate: Array.from(issues.duplicate.values()) - }; + return issues; } diff --git a/src/log.ts b/src/log.ts index 8f673e1db..8262946ce 100644 --- a/src/log.ts +++ b/src/log.ts @@ -1,23 +1,5 @@ -import path from 'node:path'; -import type { Issue } from './types'; - export const getLine = (value: number | string, text: string) => `${String(value).padStart(5)} ${text}`; -const logIssueLine = (cwd: string, filePath: string, description: string, pad: number) => { - console.log(`${description ? description.padEnd(pad + 2) : ''}${path.relative(cwd, filePath)}`); -}; - -export const logIssueGroupResult = (cwd: string, title: string, issues: Issue[]) => { - console.log(`--- ${title} (${issues.length})`); - if (issues.length) { - const sortedByFilePath = issues.sort((a, b) => (a.filePath > b.filePath ? 1 : -1)); - const padLength = [...issues].sort((a, b) => b.symbol.length - a.symbol.length); - sortedByFilePath.forEach(({ filePath, symbol }) => logIssueLine(cwd, filePath, symbol, padLength[0].symbol.length)); - } else { - console.log('N/A'); - } -}; - export class LineRewriter { private lines: number = 0; diff --git a/src/reporters/compact.ts b/src/reporters/compact.ts new file mode 100644 index 000000000..bb4bd9c69 --- /dev/null +++ b/src/reporters/compact.ts @@ -0,0 +1,81 @@ +import path from 'node:path'; +import type { Issues, Configuration } from '../types'; + +const logIssueLine = (cwd: string, filePath: string, symbols: string[]) => { + console.log(`${path.relative(cwd, filePath)}: ${symbols.join(', ')}`); +}; + +const logIssueGroupResult = (issues: string[], cwd: string, title: false | string) => { + title && console.log(`--- ${title} (${issues.length})`); + if (issues.length) { + const sortedByFilePath = issues.sort(); + sortedByFilePath.forEach(filePath => console.log(path.relative(cwd, filePath))); + } else { + console.log('N/A'); + } +}; + +const logIssueGroupResults = ( + issues: { filePath: string; symbols: string[] }[], + cwd: string, + title: false | string +) => { + title && console.log(`--- ${title} (${issues.length})`); + if (issues.length) { + const sortedByFilePath = issues.sort((a, b) => (a.filePath > b.filePath ? 1 : -1)); + sortedByFilePath.forEach(({ filePath, symbols }) => logIssueLine(cwd, filePath, symbols)); + } else { + console.log('N/A'); + } +}; + +export default ({ issues, config, cwd }: { issues: Issues; config: Configuration; cwd: string }) => { + const { + isOnlyFiles, + isOnlyExports, + isOnlyTypes, + isOnlyDuplicates, + isFindUnusedFiles, + isFindUnusedExports, + isFindUnusedTypes, + isFindDuplicateExports + } = config; + + if (isFindUnusedFiles) { + const unusedFiles = Array.from(issues.file); + logIssueGroupResult(unusedFiles, cwd, !isOnlyFiles && 'UNUSED FILES'); + } + + if (isFindUnusedExports) { + const unusedExports = Object.values(issues.export).map(issues => { + const items = Object.values(issues); + return { + filePath: items[0].filePath, + symbols: items.map(i => i.symbol) + }; + }); + logIssueGroupResults(unusedExports, cwd, !isOnlyExports && 'UNUSED EXPORTS'); + } + + if (isFindUnusedTypes) { + const unusedTypes = Object.values(issues.type).map(issues => { + const items = Object.values(issues); + return { + filePath: items[0].filePath, + symbols: items.map(i => i.symbol) + }; + }); + logIssueGroupResults(unusedTypes, cwd, !isOnlyTypes && 'UNUSED TYPES'); + } + + if (isFindDuplicateExports) { + const unusedDuplicates = Object.values(issues.duplicate).map(issues => { + const items = Object.values(issues); + return { + filePath: items[0].filePath, + symbols: items.map(i => i.symbols ?? []).flat() + }; + }); + logIssueGroupResults(unusedDuplicates, cwd, !isOnlyDuplicates && 'DUPLICATE EXPORTS'); + } +}; diff --git a/src/reporters/index.ts b/src/reporters/index.ts new file mode 100644 index 000000000..4b757b5b1 --- /dev/null +++ b/src/reporters/index.ts @@ -0,0 +1,7 @@ +import symbols from './symbols'; +import compact from './compact'; + +export default { + symbols, + compact +}; diff --git a/src/reporters/symbols.ts b/src/reporters/symbols.ts new file mode 100644 index 000000000..3b32ddd8a --- /dev/null +++ b/src/reporters/symbols.ts @@ -0,0 +1,66 @@ +import path from 'node:path'; +import type { Issue, Issues, Configuration } from '../types'; + +const logIssueLine = (cwd: string, filePath: string, symbol: string, padding: number) => { + console.log(`${symbol.padEnd(padding + 2)}${path.relative(cwd, filePath)}`); +}; + +const logIssueGroupResult = (issues: string[], cwd: string, title: false | string) => { + title && console.log(`--- ${title} (${issues.length})`); + if (issues.length) { + const sortedByFilePath = issues.sort(); + sortedByFilePath.forEach(filePath => console.log(path.relative(cwd, filePath))); + } else { + console.log('N/A'); + } +}; + +const logIssueGroupResults = (issues: Issue[], cwd: string, title: false | string) => { + title && console.log(`--- ${title} (${issues.length})`); + if (issues.length) { + const sortedByFilePath = issues.sort((a, b) => (a.filePath > b.filePath ? 1 : -1)); + const padding = [...issues].sort((a, b) => b.symbol.length - a.symbol.length)[0].symbol.length; + sortedByFilePath.forEach(({ filePath, symbol }) => logIssueLine(cwd, filePath, symbol, padding)); + } else { + console.log('N/A'); + } +}; + +export default ({ issues, config, cwd }: { issues: Issues; config: Configuration; cwd: string }) => { + const { + isOnlyFiles, + isOnlyExports, + isOnlyTypes, + isOnlyDuplicates, + isFindUnusedFiles, + isFindUnusedExports, + isFindUnusedTypes, + isFindDuplicateExports + } = config; + + if (isFindUnusedFiles) { + const unusedFiles = Array.from(issues.file); + logIssueGroupResult(unusedFiles, cwd, !isOnlyFiles && 'UNUSED FILES'); + } + + if (isFindUnusedExports) { + const unusedExports = Object.values(issues.export) + .map(issues => Object.values(issues)) + .flat(); + logIssueGroupResults(unusedExports, cwd, !isOnlyExports && 'UNUSED EXPORTS'); + } + + if (isFindUnusedTypes) { + const unusedTypes = Object.values(issues.type) + .map(issues => Object.values(issues)) + .flat(); + logIssueGroupResults(unusedTypes, cwd, !isOnlyTypes && 'UNUSED TYPES'); + } + + if (isFindDuplicateExports) { + const unusedDuplicates = Object.values(issues.duplicate) + .map(issues => Object.values(issues)) + .flat(); + logIssueGroupResults(unusedDuplicates, cwd, !isOnlyDuplicates && 'DUPLICATE EXPORTS'); + } +}; diff --git a/src/types.ts b/src/types.ts index 5becc2133..0884daa9f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,30 +5,29 @@ type LocalConfiguration = { export type Configuration = LocalConfiguration & { cwd: string; + isOnlyFiles: boolean; + isOnlyExports: boolean; + isOnlyTypes: boolean; + isOnlyDuplicates: boolean; + isFindUnusedFiles: boolean; + isFindUnusedExports: boolean; + isFindUnusedTypes: boolean; + isFindDuplicateExports: boolean; + isFollowSymbols: boolean; isShowProgress: boolean; - isFindUnusedFiles?: boolean; - isFindUnusedExports?: boolean; - isFindUnusedTypes?: boolean; - isFindDuplicateExports?: boolean; - isFollowSymbols?: boolean; }; export type ImportedConfiguration = LocalConfiguration | Record; type FilePath = string; -type Type = 'type' | 'interface' | 'enum'; +export type SymbolType = 'type' | 'interface' | 'enum'; -type UnusedFileIssue = { filePath: FilePath; symbol: string }; -type UnusedExportIssue = { filePath: FilePath; symbol: string }; -type UnusedTypeIssue = { filePath: FilePath; symbol: string; type: Type }; -type DuplicateExportIssue = { filePath: FilePath; symbol: string; symbols: string[] }; +type UnusedFileIssues = Set; +type UnusedExportIssues = Record>; +type UnusedTypeIssues = Record>; +type DuplicateExportIssues = Record>; -type UnusedFileIssues = Map; -type UnusedExportIssues = Map; -type UnusedTypeIssues = Map; -type DuplicateExportIssues = Map; - -export type Issue = UnusedFileIssue | UnusedExportIssue | UnusedTypeIssue | DuplicateExportIssue; +export type Issue = { filePath: FilePath; symbol: string; symbols?: string[]; symbolType?: SymbolType }; export type Issues = { file: UnusedFileIssues; diff --git a/test/index.spec.ts b/test/index.spec.ts index 416779d56..34d525e85 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -7,31 +7,29 @@ test('run', async () => { cwd: 'test/fixtures/basic', entryFiles: ['index.ts'], filePatterns: ['*.ts'], - isShowProgress: false, + isOnlyFiles: false, + isOnlyExports: false, + isOnlyTypes: false, + isOnlyDuplicates: false, isFindUnusedFiles: true, isFindUnusedExports: true, isFindUnusedTypes: true, isFindDuplicateExports: true, - isFollowSymbols: false + isFollowSymbols: false, + isShowProgress: false }); - assert(issues.file.length === 1); - assert(issues.file[0].filePath.endsWith('dangling.ts')); + assert(issues.file.size === 1); - assert(issues.export.length === 1); - assert(issues.export[0].symbol === 'z'); - assert(issues.export[0].filePath.endsWith('ns.ts')); + assert(Array.from(issues.file)[0].endsWith('dangling.ts')); - assert(issues.type.length === 2); - assert(issues.type[0].symbol === 'Dep'); - assert(issues.type[0].type === 'type'); - assert(issues.type[0].filePath.endsWith('dep.ts')); - assert(issues.type[1].symbol === 'NS'); - assert(issues.type[1].type === 'interface'); - assert(issues.type[1].filePath.endsWith('ns.ts')); + assert(Object.values(issues.export).length === 1); + assert(issues.export['ns.ts']['z'].symbol === 'z'); - assert(issues.duplicate.length === 1); - assert(issues.duplicate[0].symbols[0] === 'dep'); - assert(issues.duplicate[0].symbols[1] === 'default'); - assert(issues.duplicate[0].filePath.endsWith('dep.ts')); + assert(Object.values(issues.type).length === 2); + assert(issues.type['dep.ts']['Dep'].symbolType === 'type'); + assert(issues.type['ns.ts']['NS'].symbolType === 'interface'); + + assert(Object.values(issues.duplicate).length === 1); + assert(issues.duplicate['dep.ts']['dep,default'].symbols?.[0] === 'dep'); });