Skip to content

Commit

Permalink
refactor(rewrite): rewrite code - fix #11
Browse files Browse the repository at this point in the history
- use tsmorph to work on classes directly
- filter ignored files early
- improve classes usage search
- handle all supported component selectors, including multiple (fix #9)
- improve cli (fix #7)
- make it pipeable (fix #8)
- limit output generated for non-tty
- use different exit codes depending on result (fix #10)
  • Loading branch information
wgrabowski committed Jan 3, 2024
1 parent d3312d4 commit 48634bc
Show file tree
Hide file tree
Showing 15 changed files with 346 additions and 65 deletions.
1 change: 1 addition & 0 deletions .npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
.*
tsconfig.json
commitlint.config.cjs
README.md
87 changes: 73 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,88 @@
# Version 2.0.0 is in works

# Docmentation for version 1.0.0

# ngx-unused

Find declared but unused Angular code.
Find declared but unused Angular classes in your codebase.

This tool recognizes components, directives, pipes and services in Angular code and cheks if they are used in provided project.
This tool recognizes components, directives, pipes and services in Angular code and checks if they are used in provided
project.

# Usage

Simplest way to use is via npx:

`npx ngx-unused <tsconfig-path>`
`npx ngx-unused <source-root> -p <tsconfig-path>`

```shell
ngx-unused - find unused classes in Angular codebase


Usage: ngx-unused <directory> [-p | --project] <tsconfig-file>

<directory> - directory to be scanned
to scan multiple directories pass names separated by space
(usages of classes from source roots will be also searched in source roots)

<tsconfig-file> - main tsconfig file
should be one containing @paths definitions
for NX projects its usually tsconfig.base.json


Options:
-p | --project <tsconfigfile> - tsconfig file path (required)
-h | --help - print this help
Source root directories and tsconfig file must be under the same root directory.

Examples:
ngx-unused . -p tsconfig.base.json
ngx-unused libs apps/my-app -p tsconfig.base.json
```

# How does it work?

Code from provided source root directory (or directories) is analyzed to find [relevant classes](#relevant-classes).
Relevant class is class with on of following Angular decorators: `@Component`,`@Directory`,`@Pipe`,`@Injectable`.
Each class is checked for [relevant usages](#relevant-usages) in codebase. When it has no relevant usages it is
considered unused.

## Relevant classes

Class decorated with one of Angular decorators

- `@Component`
- `@Directive`
- `@Pipe`
- `@Injectable`

Classes declared in [ignored files](#ignored-files) will be ignored.

## Relevant usages

Relevant usage is any usage that is not one of following:

- import
- export
- usage in `@NgModule` decorator (in `imports`,`exports`, `declarations`, `providers` properties)
- with exception for `useClass` and `useExisting` in provider object
- usage in any of [ignored files](#ignored-files)

## Ignored files

Files matching `*.spec.ts` glob will be ignored.
Future version may have option to configure that.

# Output

`<tsconfig-path>` - path to tsconfig file _(note: files excluded in given config will not be analyzed)_
Output is printed to standard output. If `process.stdout.isTTY` is false no decorative texts and no progress will be
printed, so it can be safely piped.

# Important notes
## Output formatting

Component - is a class with `Component` decorator.
Output contains progress information and formatted results.
Formatted results is a list of unused classes, grouped by files.

Directive - is a class with `Directive` decorator.
## Exit codes

Pipe - is a class with `Pipe` decorator.
`0` No unused classes detected.

Service - is a class with `Injectable` decorator.
`1` Detected unused component, directive, pipe, or service.

Only classes with default Angular decorators will be recognized.
`2`: Invalid configuration
13 changes: 0 additions & 13 deletions TODO.md

This file was deleted.

3 changes: 2 additions & 1 deletion lib/cli.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { existsSync } from 'fs';
import { stdout } from 'process';
import { CliArgs, RuntimeConfig } from './types';

Expand All @@ -17,7 +18,7 @@ export function getRuntimeConfig(cliArgs: CliArgs): RuntimeConfig {

export function validate(cliArgs: CliArgs): InputValidation {
return {
tsConfigFilePath: !!cliArgs.project,
tsConfigFilePath: !!cliArgs.project && existsSync(cliArgs.project),
sourceRoots: !!cliArgs._.length,
valid: !!cliArgs.project && !!cliArgs._.length,
};
Expand Down
2 changes: 2 additions & 0 deletions lib/createProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export function createProject({
const project: Project = new Project({
tsConfigFilePath,
skipAddingFilesFromTsConfig: true,
skipFileDependencyResolution: true,
skipLoadingLibFiles: true,
});
project.addSourceFilesAtPaths(getSourceFiles(sourceRoots));
return project;
Expand Down
46 changes: 38 additions & 8 deletions lib/findUsages/findUnusedClasses.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,61 @@
import { ClassDeclaration, SourceFile } from 'ts-morph';
import { stdout } from 'process';
import { ClassDeclaration, Decorator, SourceFile } from 'ts-morph';
import { RELEVANT_DECORATOR_NAMES } from '../constants.js';
import { print } from '../output.js';
import { ClassType, Result } from '../types';
import { hasUsagesByPipeName } from './hasUsagesByPipeName.js';
import { hasUsagesBySelectors } from './hasUsagesBySelectors.js';
import { hasUsagesByPipeName } from './hasUsagesInTemplatesByPipeName.js';
import { hasUsagesInTs } from './hasUsagesInTs.js';
import { TemplateService } from './templateService.js';

export function findUnusedClasses(sourceFiles: SourceFile[]): Result[] {
const classes = sourceFiles.flatMap(file => file.getClasses());
const classes = sourceFiles
.filter(file => !file.getBaseName().includes('.spec.ts'))
.flatMap(file => file.getClasses())
.filter(declaration => getRelevantDecorator(declaration) !== undefined);

const componentClasses = classes
.filter(
declaration =>
getRelevantDecorator(declaration)?.getFullName() === 'Component'
)
.map(getRelevantDecorator)
.filter(decorator => decorator !== undefined);
const templateService = new TemplateService(componentClasses as Decorator[]);

if (stdout.isTTY) {
print(
`Found ${classes.length} classes from ${sourceFiles.length} files.\n`
);
}
return classes
.filter(declaration => getRelevantDecorator(declaration) !== undefined)
.filter(declaration => !isUsed(declaration))
.filter((declaration, index, { length }) => {
const percentage = Math.round(((index + 1) / length) * 100);
if (stdout.isTTY) {
print(`Analyzing ${index + 1}/${length} (${percentage}%)`, true);
if (index === length - 1) stdout.write('\n');
}
return !isUsed(declaration, templateService);
})
.map(asResult);
}

function isUsed(declaration: ClassDeclaration): boolean {
function isUsed(
declaration: ClassDeclaration,
templateService: TemplateService
): boolean {
const relevantDecorator = getRelevantDecorator(declaration)!;
const classType = relevantDecorator.getFullName();
const hasTsUsages = hasUsagesInTs(declaration);
if (hasTsUsages) {
return true;
}

if (classType === 'Component' || classType === 'Directive') {
return hasUsagesBySelectors(relevantDecorator);
return hasUsagesBySelectors(relevantDecorator, templateService);
}
if (classType === 'Pipe') {
return hasUsagesByPipeName(relevantDecorator);
return hasUsagesByPipeName(relevantDecorator, templateService);
}

return false;
Expand Down
20 changes: 20 additions & 0 deletions lib/findUsages/getPropertyFromDecorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Decorator, ObjectLiteralExpression, SyntaxKind } from 'ts-morph';

export function getPropertyFromDecoratorCall(
decorator: Decorator,
propertyName: 'selector' | 'name' | 'template' | 'templateUrl'
) {
const decoratorCallArguments = decorator.getArguments();
const matchedProperty = decoratorCallArguments
.flatMap(argument =>
(argument as ObjectLiteralExpression)
.getProperties()
.map(prop => prop.asKind(SyntaxKind.PropertyAssignment))
.filter(value => value !== undefined)
)
.find(structure => structure!.getName() === propertyName);

return matchedProperty
?.getInitializerIfKind(SyntaxKind.StringLiteral)
?.getLiteralValue();
}
11 changes: 11 additions & 0 deletions lib/findUsages/hasUsagesByPipeName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Decorator } from 'ts-morph';
import { getPropertyFromDecoratorCall } from './getPropertyFromDecorator.js';
import { TemplateService } from './templateService.js';

export function hasUsagesByPipeName(
decorator: Decorator,
templateService: TemplateService
): boolean {
const name = getPropertyFromDecoratorCall(decorator, 'name');
return templateService.matchesPipeName(name ?? '');
}
11 changes: 8 additions & 3 deletions lib/findUsages/hasUsagesBySelectors.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { Decorator } from 'ts-morph';
import { getPropertyFromDecoratorCall } from './getPropertyFromDecorator.js';
import { TemplateService } from './templateService.js';

// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function hasUsagesBySelectors(decorator: Decorator): boolean {
return false;
export function hasUsagesBySelectors(
decorator: Decorator,
templateService: TemplateService
): boolean {
const selector = getPropertyFromDecoratorCall(decorator, 'selector');
return templateService.matchesSelectors(selector?.split(',') ?? []);
}
6 changes: 0 additions & 6 deletions lib/findUsages/hasUsagesInTemplatesByPipeName.ts

This file was deleted.

32 changes: 25 additions & 7 deletions lib/findUsages/hasUsagesInTs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,14 @@
import { ClassDeclaration, Node, SyntaxKind } from 'ts-morph';

export function hasUsagesInTs(declaration: ClassDeclaration): boolean {
const referencingNodes = declaration.findReferencesAsNodes();
return referencingNodes.some(isReferecingNodeRelevant);
const referencingNodes = declaration.findReferencesAsNodes().filter(node => {
const sourceFile = node.getSourceFile();
return (
!sourceFile.isDeclarationFile() &&
!sourceFile.getBaseName().includes('.spec.ts')
);
});
return referencingNodes.some(node => isReferecingNodeRelevant(node));
}

function isReferecingNodeRelevant(node: Node): boolean {
Expand All @@ -24,9 +30,10 @@ function isReferecingNodeRelevant(node: Node): boolean {
irrelevantParentNodeKinds.includes(node.getParent()!.getKind());

return (
!isOfIrrelevantKind &&
!hasParentOfIrrelevantKind &&
!isInNgModuleDecoratorCall(node)
(!isOfIrrelevantKind &&
!hasParentOfIrrelevantKind &&
!isInNgModuleDecoratorCall(node)) ||
node.getParent()!.isKind(SyntaxKind.PropertyAssignment)
);
}

Expand All @@ -38,11 +45,22 @@ function isInNgModuleDecoratorCall(node: Node): boolean {
if (node.getParent()!.isKind(SyntaxKind.PropertyAssignment)) {
return false;
}
// bootstrap
if (
node.getFirstAncestor(
ancestor =>
ancestor.isKind(SyntaxKind.PropertyAssignment) &&
ancestor.getName() === 'bootstrap'
) !== undefined
) {
return false;
}

return (
node.getFirstAncestor(
node =>
node.isKind(SyntaxKind.Decorator) && node.getFullName() === 'NgModule'
ancestor =>
ancestor.isKind(SyntaxKind.Decorator) &&
ancestor.getFullName() === 'NgModule'
) !== undefined
);
}
Loading

0 comments on commit 48634bc

Please sign in to comment.