Skip to content

Commit

Permalink
Refactor issue counters & introduce (custom) reporters
Browse files Browse the repository at this point in the history
  • Loading branch information
webpro committed Oct 6, 2022
1 parent 65d93ca commit 5992620
Show file tree
Hide file tree
Showing 10 changed files with 276 additions and 115 deletions.
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -174,6 +175,8 @@ can be tweaked further to the project structure.

## Example Output

### Default reporter

```
$ exportman --config ./exportman.json
--- UNUSED FILES (2)
Expand All @@ -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:
Expand Down
55 changes: 32 additions & 23 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -30,7 +31,8 @@ const {
onlyTypes: { type: 'boolean' },
onlyDuplicates: { type: 'boolean' },
ignoreNamespaceImports: { type: 'boolean' },
noProgress: { type: 'boolean' }
noProgress: { type: 'boolean' },
reporter: { type: 'string' }
}
});

Expand All @@ -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();
1 change: 1 addition & 0 deletions src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
74 changes: 34 additions & 40 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -36,62 +36,60 @@ 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)}`);
}
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();

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 });
});
}

Expand Down Expand Up @@ -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());

Expand All @@ -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;
}
18 changes: 0 additions & 18 deletions src/log.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
81 changes: 81 additions & 0 deletions src/reporters/compact.ts
Original file line number Diff line number Diff line change
@@ -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');
}
};
Loading

0 comments on commit 5992620

Please sign in to comment.