@angular-modernizer/api 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/investigation/call-graph-builder.d.ts +95 -8
- package/dist/investigation/call-graph-builder.d.ts.map +1 -1
- package/dist/investigation/call-graph-builder.js +424 -81
- package/dist/investigation/call-graph-builder.js.map +1 -1
- package/dist/investigation/codebase-searcher.d.ts +68 -0
- package/dist/investigation/codebase-searcher.d.ts.map +1 -1
- package/dist/investigation/codebase-searcher.js +154 -10
- package/dist/investigation/codebase-searcher.js.map +1 -1
- package/dist/investigation/template-usage-finder.d.ts +64 -0
- package/dist/investigation/template-usage-finder.d.ts.map +1 -0
- package/dist/investigation/template-usage-finder.js +279 -0
- package/dist/investigation/template-usage-finder.js.map +1 -0
- package/dist/investigation/template-usage-scanner.d.ts +69 -0
- package/dist/investigation/template-usage-scanner.d.ts.map +1 -0
- package/dist/investigation/template-usage-scanner.js +375 -0
- package/dist/investigation/template-usage-scanner.js.map +1 -0
- package/dist/investigation/usage-finder.d.ts +51 -5
- package/dist/investigation/usage-finder.d.ts.map +1 -1
- package/dist/investigation/usage-finder.js +129 -36
- package/dist/investigation/usage-finder.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +16 -0
- package/src/investigation/call-graph-builder.ts +528 -103
- package/src/investigation/codebase-searcher.ts +240 -11
- package/src/investigation/template-usage-finder.ts +389 -0
- package/src/investigation/template-usage-scanner.ts +529 -0
- package/src/investigation/usage-finder.ts +194 -54
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
* `search`. For `symbol` mode only top-level named declarations are indexed;
|
|
13
13
|
* local variables and anonymous expressions are not returned.
|
|
14
14
|
*
|
|
15
|
+
* When `rootDir` is given, `text` and `pattern` modes additionally scan files
|
|
16
|
+
* under that directory which are NOT part of the ts-morph program: Angular
|
|
17
|
+
* templates (`.html`) and `.ts` files outside the tsconfig (for example
|
|
18
|
+
* `src/i18n/*.ts`). `node_modules`, `dist`, `.angular`, `coverage`, `out-tsc`
|
|
19
|
+
* and hidden directories are skipped. Use `searchWithCoverage` to learn how
|
|
20
|
+
* many files were actually searched.
|
|
21
|
+
*
|
|
15
22
|
* @example
|
|
16
23
|
* ```typescript
|
|
17
24
|
* const searcher = new CodebaseSearcher(project);
|
|
@@ -27,7 +34,7 @@
|
|
|
27
34
|
* ```
|
|
28
35
|
*/
|
|
29
36
|
|
|
30
|
-
import type { Project } from 'ts-morph';
|
|
37
|
+
import type { Project, SourceFile } from 'ts-morph';
|
|
31
38
|
|
|
32
39
|
/** The kind of named declaration returned in symbol-mode results. */
|
|
33
40
|
export type SymbolKind =
|
|
@@ -57,6 +64,36 @@ export interface SearchResult {
|
|
|
57
64
|
* Absent in `text` and `pattern` modes.
|
|
58
65
|
*/
|
|
59
66
|
symbolKind?: SymbolKind;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `program` when the file is part of the ts-morph program, `non-program`
|
|
70
|
+
* when it was found by the file-system scan (templates, non-program `.ts`).
|
|
71
|
+
*/
|
|
72
|
+
source?: 'program' | 'non-program';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Describes which files a search actually covered. */
|
|
76
|
+
export interface SearchCoverage {
|
|
77
|
+
/** Number of program source files searched (after the `include` filter). */
|
|
78
|
+
programFileCount: number;
|
|
79
|
+
|
|
80
|
+
/** Number of non-program files searched (templates, non-program `.ts`). */
|
|
81
|
+
extraFileCount: number;
|
|
82
|
+
|
|
83
|
+
/** Whether `.html` templates under `rootDir` were scanned. */
|
|
84
|
+
templatesScanned: boolean;
|
|
85
|
+
|
|
86
|
+
/** Whether `.ts` files outside the program were scanned. */
|
|
87
|
+
nonProgramFilesScanned: boolean;
|
|
88
|
+
|
|
89
|
+
/** Directory names that are never scanned. */
|
|
90
|
+
excludedDirectories: string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Result of `CodebaseSearcher.searchWithCoverage()`. */
|
|
94
|
+
export interface SearchWithCoverageResult {
|
|
95
|
+
results: SearchResult[];
|
|
96
|
+
coverage: SearchCoverage;
|
|
60
97
|
}
|
|
61
98
|
|
|
62
99
|
/** Options for `CodebaseSearcher.search()`. */
|
|
@@ -80,6 +117,65 @@ export interface SearchOptions {
|
|
|
80
117
|
* returned.
|
|
81
118
|
*/
|
|
82
119
|
maxResults?: number;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Project root used to discover files that are not part of the ts-morph
|
|
123
|
+
* program. When omitted only program files are searched.
|
|
124
|
+
*/
|
|
125
|
+
rootDir?: string;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Scan `.html` files under `rootDir` (text/pattern modes only).
|
|
129
|
+
* Defaults to `true` when `rootDir` is set.
|
|
130
|
+
*/
|
|
131
|
+
includeTemplates?: boolean;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Scan `.ts` files under `rootDir` that are not part of the program
|
|
135
|
+
* (text/pattern modes only). Defaults to `true` when `rootDir` is set.
|
|
136
|
+
*/
|
|
137
|
+
includeNonProgramFiles?: boolean;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Directory names never descended into by the non-program file scan. */
|
|
141
|
+
export const EXCLUDED_SEARCH_DIRECTORIES = [
|
|
142
|
+
'node_modules',
|
|
143
|
+
'dist',
|
|
144
|
+
'.angular',
|
|
145
|
+
'coverage',
|
|
146
|
+
'out-tsc',
|
|
147
|
+
] as const;
|
|
148
|
+
|
|
149
|
+
/** A searchable file: path plus lazily loaded text. */
|
|
150
|
+
interface SearchableFile {
|
|
151
|
+
path: string;
|
|
152
|
+
text: () => string;
|
|
153
|
+
source: 'program' | 'non-program';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function normalizePath(path: string): string {
|
|
157
|
+
return path.replaceAll('\\', '/');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function joinPath(dir: string, name: string): string {
|
|
161
|
+
const normalizedName = normalizePath(name);
|
|
162
|
+
if (normalizedName.startsWith('/') || /^[A-Za-z]:\//.test(normalizedName)) {
|
|
163
|
+
return normalizedName;
|
|
164
|
+
}
|
|
165
|
+
const base = normalizePath(dir).replace(/\/+$/, '');
|
|
166
|
+
return `${base}/${normalizedName}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function baseName(path: string): string {
|
|
170
|
+
const parts = normalizePath(path).split('/');
|
|
171
|
+
return parts[parts.length - 1] ?? path;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isExcludedDirectory(name: string): boolean {
|
|
175
|
+
return (
|
|
176
|
+
name.startsWith('.') ||
|
|
177
|
+
(EXCLUDED_SEARCH_DIRECTORIES as readonly string[]).includes(name)
|
|
178
|
+
);
|
|
83
179
|
}
|
|
84
180
|
|
|
85
181
|
/**
|
|
@@ -116,7 +212,28 @@ export class CodebaseSearcher {
|
|
|
116
212
|
* ```
|
|
117
213
|
*/
|
|
118
214
|
search(query: string, options: SearchOptions): SearchResult[] {
|
|
119
|
-
|
|
215
|
+
return this.searchWithCoverage(query, options).results;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Like `search`, but also reports which files were covered. Callers should
|
|
220
|
+
* surface `coverage` whenever a search returns 0 hits: an empty result only
|
|
221
|
+
* proves absence within the covered files.
|
|
222
|
+
*
|
|
223
|
+
* @example
|
|
224
|
+
* ```typescript
|
|
225
|
+
* const { results, coverage } = searcher.searchWithCoverage('i18n.key', {
|
|
226
|
+
* mode: 'text',
|
|
227
|
+
* rootDir: '/project',
|
|
228
|
+
* });
|
|
229
|
+
* // coverage.programFileCount, coverage.extraFileCount
|
|
230
|
+
* ```
|
|
231
|
+
*/
|
|
232
|
+
searchWithCoverage(
|
|
233
|
+
query: string,
|
|
234
|
+
options: SearchOptions,
|
|
235
|
+
): SearchWithCoverageResult {
|
|
236
|
+
const programFiles = this.project
|
|
120
237
|
.getSourceFiles()
|
|
121
238
|
.filter(
|
|
122
239
|
(sf) =>
|
|
@@ -124,15 +241,126 @@ export class CodebaseSearcher {
|
|
|
124
241
|
matchesGlob(sf.getFilePath(), options.include),
|
|
125
242
|
);
|
|
126
243
|
|
|
244
|
+
const scanExtras = options.mode !== 'symbol' && options.rootDir !== undefined;
|
|
245
|
+
const templatesScanned = scanExtras && options.includeTemplates !== false;
|
|
246
|
+
const nonProgramFilesScanned =
|
|
247
|
+
scanExtras && options.includeNonProgramFiles !== false;
|
|
248
|
+
|
|
249
|
+
const extraFiles =
|
|
250
|
+
options.rootDir === undefined
|
|
251
|
+
? []
|
|
252
|
+
: this.collectNonProgramFiles(options.rootDir, {
|
|
253
|
+
html: templatesScanned,
|
|
254
|
+
ts: nonProgramFilesScanned,
|
|
255
|
+
include: options.include,
|
|
256
|
+
});
|
|
257
|
+
|
|
127
258
|
const results: SearchResult[] =
|
|
128
259
|
options.mode === 'symbol'
|
|
129
|
-
? this.searchSymbols(query,
|
|
130
|
-
: this.searchText(query, options.mode,
|
|
260
|
+
? this.searchSymbols(query, programFiles)
|
|
261
|
+
: this.searchText(query, options.mode, [
|
|
262
|
+
...programFiles.map((sf) => this.toSearchable(sf)),
|
|
263
|
+
...extraFiles,
|
|
264
|
+
]);
|
|
131
265
|
|
|
132
|
-
|
|
133
|
-
|
|
266
|
+
const limited =
|
|
267
|
+
options.maxResults === undefined
|
|
268
|
+
? results
|
|
269
|
+
: results.slice(0, options.maxResults);
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
results: limited,
|
|
273
|
+
coverage: {
|
|
274
|
+
programFileCount: programFiles.length,
|
|
275
|
+
extraFileCount: extraFiles.length,
|
|
276
|
+
templatesScanned,
|
|
277
|
+
nonProgramFilesScanned,
|
|
278
|
+
excludedDirectories: [...EXCLUDED_SEARCH_DIRECTORIES, '.*'],
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private toSearchable(sf: SourceFile): SearchableFile {
|
|
284
|
+
return {
|
|
285
|
+
path: sf.getFilePath(),
|
|
286
|
+
text: () => sf.getFullText(),
|
|
287
|
+
source: 'program',
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Walks `rootDir` via the project's file system host (works for real and
|
|
293
|
+
* in-memory file systems) and returns files outside the program.
|
|
294
|
+
*/
|
|
295
|
+
private collectNonProgramFiles(
|
|
296
|
+
rootDir: string,
|
|
297
|
+
filter: { html: boolean; ts: boolean; include: string | undefined },
|
|
298
|
+
): SearchableFile[] {
|
|
299
|
+
if (!filter.html && !filter.ts) {
|
|
300
|
+
return [];
|
|
134
301
|
}
|
|
135
|
-
|
|
302
|
+
|
|
303
|
+
const fs = this.project.getFileSystem();
|
|
304
|
+
const programPaths = new Set(
|
|
305
|
+
this.project.getSourceFiles().map((sf) => normalizePath(sf.getFilePath())),
|
|
306
|
+
);
|
|
307
|
+
const files: SearchableFile[] = [];
|
|
308
|
+
const pending: string[] = [normalizePath(rootDir)];
|
|
309
|
+
const visited = new Set<string>();
|
|
310
|
+
|
|
311
|
+
const wanted = (path: string): boolean => {
|
|
312
|
+
if (filter.html && path.endsWith('.html')) {
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
return filter.ts && path.endsWith('.ts') && !programPaths.has(path);
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
while (pending.length > 0) {
|
|
319
|
+
const dir = pending.pop();
|
|
320
|
+
if (dir === undefined || visited.has(dir)) {
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
visited.add(dir);
|
|
324
|
+
|
|
325
|
+
let entries: ReturnType<typeof fs.readDirSync>;
|
|
326
|
+
try {
|
|
327
|
+
entries = fs.readDirSync(dir);
|
|
328
|
+
} catch {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
for (const entry of entries) {
|
|
333
|
+
const fullPath = joinPath(dir, entry.name);
|
|
334
|
+
if (entry.isDirectory) {
|
|
335
|
+
if (!entry.isSymlink && !isExcludedDirectory(baseName(fullPath))) {
|
|
336
|
+
pending.push(fullPath);
|
|
337
|
+
}
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (!entry.isFile || !wanted(fullPath)) {
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (
|
|
344
|
+
filter.include !== undefined &&
|
|
345
|
+
!matchesGlob(fullPath, filter.include)
|
|
346
|
+
) {
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
files.push({
|
|
350
|
+
path: fullPath,
|
|
351
|
+
text: () => {
|
|
352
|
+
try {
|
|
353
|
+
return fs.readFileSync(fullPath);
|
|
354
|
+
} catch {
|
|
355
|
+
return '';
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
source: 'non-program',
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return files.sort((a, b) => a.path.localeCompare(b.path));
|
|
136
364
|
}
|
|
137
365
|
|
|
138
366
|
/**
|
|
@@ -141,14 +369,14 @@ export class CodebaseSearcher {
|
|
|
141
369
|
private searchText(
|
|
142
370
|
query: string,
|
|
143
371
|
mode: 'text' | 'pattern',
|
|
144
|
-
|
|
372
|
+
files: SearchableFile[],
|
|
145
373
|
): SearchResult[] {
|
|
146
374
|
const results: SearchResult[] = [];
|
|
147
375
|
const regex = mode === 'pattern' ? new RegExp(query) : null;
|
|
148
376
|
|
|
149
|
-
for (const
|
|
150
|
-
const filePath =
|
|
151
|
-
const lines =
|
|
377
|
+
for (const file of files) {
|
|
378
|
+
const filePath = file.path;
|
|
379
|
+
const lines = file.text().split('\n');
|
|
152
380
|
|
|
153
381
|
lines.forEach((lineText, index) => {
|
|
154
382
|
const matched =
|
|
@@ -168,6 +396,7 @@ export class CodebaseSearcher {
|
|
|
168
396
|
line: index + 1,
|
|
169
397
|
col,
|
|
170
398
|
snippet: lineText.trim(),
|
|
399
|
+
source: file.source,
|
|
171
400
|
});
|
|
172
401
|
});
|
|
173
402
|
}
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @angular-modernizer/api - Template Usage Finder
|
|
3
|
+
*
|
|
4
|
+
* Project-level companion to `scanTemplate`: resolves what a symbol means
|
|
5
|
+
* in Angular templates (component/directive selector, pipe name, class
|
|
6
|
+
* member, input/output binding) and scans every component template
|
|
7
|
+
* (external `templateUrl` files and inline `template:` literals).
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* Member hits are `verified` when they read the member from the component
|
|
11
|
+
* context (`foo`, `this.foo`) inside the template of the declaring class or
|
|
12
|
+
* one of its subclasses. Same-name reads elsewhere (other components, or
|
|
13
|
+
* `x.foo`) are reported as `template-unverified`: they may or may not refer
|
|
14
|
+
* to the searched member.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* const finder = new TemplateUsageFinder(project);
|
|
19
|
+
* const usages = finder.findTemplateUsages('UserCardComponent', declarations);
|
|
20
|
+
* // [{ file: '/src/app/list.component.html', usageType: 'template', templateKind: 'element', ... }]
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
Node,
|
|
26
|
+
type ClassDeclaration,
|
|
27
|
+
type Decorator,
|
|
28
|
+
type Project,
|
|
29
|
+
type SourceFile,
|
|
30
|
+
} from 'ts-morph';
|
|
31
|
+
import { dirname, resolve } from 'node:path';
|
|
32
|
+
import {
|
|
33
|
+
scanTemplate,
|
|
34
|
+
type TemplateHit,
|
|
35
|
+
type TemplateQuery,
|
|
36
|
+
type TemplateUsageKind,
|
|
37
|
+
} from './template-usage-scanner.js';
|
|
38
|
+
|
|
39
|
+
/** Whether a template hit is known to refer to the searched symbol. */
|
|
40
|
+
export type TemplateMatch = 'verified' | 'template-unverified';
|
|
41
|
+
|
|
42
|
+
/** A template usage mapped back to a file position. */
|
|
43
|
+
export interface TemplateSymbolUsage {
|
|
44
|
+
/** Absolute path of the `.html` file, or of the `.ts` file for inline templates. */
|
|
45
|
+
file: string;
|
|
46
|
+
|
|
47
|
+
/** 1-based line. */
|
|
48
|
+
line: number;
|
|
49
|
+
|
|
50
|
+
/** 1-based column. */
|
|
51
|
+
col: number;
|
|
52
|
+
|
|
53
|
+
/** Trimmed source line. */
|
|
54
|
+
snippet: string;
|
|
55
|
+
|
|
56
|
+
/** Kind of template usage. */
|
|
57
|
+
templateKind: TemplateUsageKind;
|
|
58
|
+
|
|
59
|
+
/** Verification level (see class remarks). */
|
|
60
|
+
templateMatch: TemplateMatch;
|
|
61
|
+
|
|
62
|
+
/** Class name of the component that owns the template. */
|
|
63
|
+
component: string;
|
|
64
|
+
|
|
65
|
+
/** Explanation for unverified hits. */
|
|
66
|
+
note?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const COMPONENT_DECORATORS = new Set(['Component', 'ExportComponent']);
|
|
70
|
+
const DIRECTIVE_DECORATORS = new Set([
|
|
71
|
+
'Component',
|
|
72
|
+
'ExportComponent',
|
|
73
|
+
'Directive',
|
|
74
|
+
]);
|
|
75
|
+
const SIGNAL_BINDING_FACTORIES = new Set(['input', 'output', 'model']);
|
|
76
|
+
|
|
77
|
+
interface ComponentTemplate {
|
|
78
|
+
owner: ClassDeclaration;
|
|
79
|
+
/** File the positions refer to. */
|
|
80
|
+
file: string;
|
|
81
|
+
content: string;
|
|
82
|
+
/** Maps an offset inside `content` to a 1-based line/column in `file`. */
|
|
83
|
+
locate: (offset: number) => { line: number; col: number; snippet: string };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface MemberTarget {
|
|
87
|
+
owner: ClassDeclaration;
|
|
88
|
+
bindingNames: string[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function findDecorator(
|
|
92
|
+
cls: ClassDeclaration,
|
|
93
|
+
names: Set<string>,
|
|
94
|
+
): Decorator | undefined {
|
|
95
|
+
return cls.getDecorators().find((d) => names.has(d.getName()));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Reads a string-valued property (`selector`, `name`, `templateUrl`) from decorator metadata. */
|
|
99
|
+
function readDecoratorString(
|
|
100
|
+
decorator: Decorator,
|
|
101
|
+
property: string,
|
|
102
|
+
): { value: string; node: Node } | undefined {
|
|
103
|
+
const arg = decorator.getArguments()[0];
|
|
104
|
+
if (arg === undefined || !Node.isObjectLiteralExpression(arg)) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
const prop = arg.getProperty(property);
|
|
108
|
+
if (prop === undefined || !Node.isPropertyAssignment(prop)) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
const init = prop.getInitializer();
|
|
112
|
+
if (
|
|
113
|
+
init !== undefined &&
|
|
114
|
+
(Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))
|
|
115
|
+
) {
|
|
116
|
+
return { value: init.getLiteralText(), node: init };
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function lineInfo(
|
|
122
|
+
text: string,
|
|
123
|
+
offset: number,
|
|
124
|
+
): { line: number; col: number; snippet: string } {
|
|
125
|
+
const before = text.slice(0, offset);
|
|
126
|
+
const line = before.split('\n').length;
|
|
127
|
+
const lineStart = before.lastIndexOf('\n') + 1;
|
|
128
|
+
const lineEnd = text.indexOf('\n', offset);
|
|
129
|
+
const snippet = text.slice(lineStart, lineEnd === -1 ? undefined : lineEnd);
|
|
130
|
+
return { line, col: offset - lineStart + 1, snippet: snippet.trim() };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Input/output names under which a member can be bound from a parent template. */
|
|
134
|
+
function bindingNamesOf(member: Node): string[] {
|
|
135
|
+
const names: string[] = [];
|
|
136
|
+
const name = Node.hasName(member) ? member.getName() : undefined;
|
|
137
|
+
|
|
138
|
+
if (Node.isDecoratable(member)) {
|
|
139
|
+
for (const decorator of member.getDecorators()) {
|
|
140
|
+
const decoratorName = decorator.getName();
|
|
141
|
+
if (decoratorName !== 'Input' && decoratorName !== 'Output') {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const alias = decorator.getArguments()[0];
|
|
145
|
+
const aliasText =
|
|
146
|
+
alias !== undefined && Node.isStringLiteral(alias)
|
|
147
|
+
? alias.getLiteralText()
|
|
148
|
+
: undefined;
|
|
149
|
+
names.push(aliasText ?? name ?? '');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (Node.isPropertyDeclaration(member)) {
|
|
154
|
+
const init = member.getInitializer();
|
|
155
|
+
const callee =
|
|
156
|
+
init !== undefined && Node.isCallExpression(init)
|
|
157
|
+
? init.getExpression().getText().split('.')[0]
|
|
158
|
+
: undefined;
|
|
159
|
+
if (callee !== undefined && SIGNAL_BINDING_FACTORIES.has(callee)) {
|
|
160
|
+
names.push(name ?? '');
|
|
161
|
+
if (callee === 'model' && name !== undefined) {
|
|
162
|
+
names.push(`${name}Change`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return names.filter((n) => n !== '');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function inheritsFrom(cls: ClassDeclaration, owners: Set<ClassDeclaration>): boolean {
|
|
171
|
+
const seen = new Set<ClassDeclaration>();
|
|
172
|
+
let current: ClassDeclaration | undefined = cls;
|
|
173
|
+
while (current !== undefined && !seen.has(current)) {
|
|
174
|
+
if (owners.has(current)) {
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
seen.add(current);
|
|
178
|
+
current = current.getBaseClass();
|
|
179
|
+
}
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Finds usages of a symbol inside Angular component templates.
|
|
185
|
+
*/
|
|
186
|
+
export class TemplateUsageFinder {
|
|
187
|
+
constructor(private readonly project: Project) {}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @param symbol - Searched name (class, member, or pipe class name).
|
|
191
|
+
* @param declarations - Declaration nodes named `symbol` (as found by `UsageFinder`).
|
|
192
|
+
* @returns Template usages, unsorted.
|
|
193
|
+
*/
|
|
194
|
+
findTemplateUsages(symbol: string, declarations: Node[]): TemplateSymbolUsage[] {
|
|
195
|
+
const selectorTargets: string[] = [];
|
|
196
|
+
const pipeNames: string[] = [];
|
|
197
|
+
const members: MemberTarget[] = [];
|
|
198
|
+
|
|
199
|
+
for (const decl of declarations) {
|
|
200
|
+
if (Node.isClassDeclaration(decl)) {
|
|
201
|
+
const directive = findDecorator(decl, DIRECTIVE_DECORATORS);
|
|
202
|
+
const selector =
|
|
203
|
+
directive === undefined
|
|
204
|
+
? undefined
|
|
205
|
+
: readDecoratorString(directive, 'selector')?.value;
|
|
206
|
+
if (selector !== undefined) {
|
|
207
|
+
selectorTargets.push(selector);
|
|
208
|
+
}
|
|
209
|
+
const pipe = findDecorator(decl, new Set(['Pipe']));
|
|
210
|
+
const pipeName =
|
|
211
|
+
pipe === undefined ? undefined : readDecoratorString(pipe, 'name')?.value;
|
|
212
|
+
if (pipeName !== undefined) {
|
|
213
|
+
pipeNames.push(pipeName);
|
|
214
|
+
}
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Parameter properties live in the constructor; other members directly in the class.
|
|
219
|
+
const ownerClass = Node.isParameterDeclaration(decl)
|
|
220
|
+
? decl.getParent().getParent()
|
|
221
|
+
: decl.getParent();
|
|
222
|
+
const isMember =
|
|
223
|
+
ownerClass !== undefined &&
|
|
224
|
+
Node.isClassDeclaration(ownerClass) &&
|
|
225
|
+
(Node.isMethodDeclaration(decl) ||
|
|
226
|
+
Node.isPropertyDeclaration(decl) ||
|
|
227
|
+
Node.isGetAccessorDeclaration(decl) ||
|
|
228
|
+
Node.isSetAccessorDeclaration(decl) ||
|
|
229
|
+
(Node.isParameterDeclaration(decl) && decl.isParameterProperty()));
|
|
230
|
+
if (isMember && Node.isClassDeclaration(ownerClass)) {
|
|
231
|
+
members.push({ owner: ownerClass, bindingNames: bindingNamesOf(decl) });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const hasClassTarget = selectorTargets.length > 0 || pipeNames.length > 0;
|
|
236
|
+
const scanMembers = members.length > 0 || declarations.length === 0;
|
|
237
|
+
if (!hasClassTarget && !scanMembers) {
|
|
238
|
+
return [];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const queries = this.buildQueries(symbol, selectorTargets, pipeNames, members, scanMembers);
|
|
242
|
+
const memberOwners = new Set(members.map((m) => m.owner));
|
|
243
|
+
const usages: TemplateSymbolUsage[] = [];
|
|
244
|
+
|
|
245
|
+
for (const template of this.collectTemplates()) {
|
|
246
|
+
for (const query of queries) {
|
|
247
|
+
for (const hit of scanTemplate(template.content, query)) {
|
|
248
|
+
usages.push(this.toUsage(template, hit, memberOwners));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return usages;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private buildQueries(
|
|
257
|
+
symbol: string,
|
|
258
|
+
selectors: string[],
|
|
259
|
+
pipeNames: string[],
|
|
260
|
+
members: MemberTarget[],
|
|
261
|
+
scanMembers: boolean,
|
|
262
|
+
): TemplateQuery[] {
|
|
263
|
+
const queries: TemplateQuery[] = [];
|
|
264
|
+
for (const selector of selectors) {
|
|
265
|
+
queries.push({ selector });
|
|
266
|
+
}
|
|
267
|
+
for (const pipeName of pipeNames) {
|
|
268
|
+
queries.push({ pipeName });
|
|
269
|
+
}
|
|
270
|
+
if (scanMembers) {
|
|
271
|
+
queries.push({ memberName: symbol });
|
|
272
|
+
}
|
|
273
|
+
for (const member of members) {
|
|
274
|
+
const directive = findDecorator(member.owner, DIRECTIVE_DECORATORS);
|
|
275
|
+
const bindingSelector =
|
|
276
|
+
directive === undefined
|
|
277
|
+
? undefined
|
|
278
|
+
: readDecoratorString(directive, 'selector')?.value;
|
|
279
|
+
if (bindingSelector === undefined) {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
for (const bindingName of member.bindingNames) {
|
|
283
|
+
queries.push({ bindingName, bindingSelector });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return queries;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private toUsage(
|
|
290
|
+
template: ComponentTemplate,
|
|
291
|
+
hit: TemplateHit,
|
|
292
|
+
memberOwners: Set<ClassDeclaration>,
|
|
293
|
+
): TemplateSymbolUsage {
|
|
294
|
+
const position = template.locate(hit.offset);
|
|
295
|
+
const isMemberHit = hit.implicitReceiver !== undefined;
|
|
296
|
+
const verified =
|
|
297
|
+
!isMemberHit ||
|
|
298
|
+
(hit.implicitReceiver === true && inheritsFrom(template.owner, memberOwners));
|
|
299
|
+
const component = template.owner.getName() ?? '<anonymous>';
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
file: template.file,
|
|
303
|
+
line: position.line,
|
|
304
|
+
col: position.col,
|
|
305
|
+
snippet: position.snippet,
|
|
306
|
+
templateKind: hit.kind,
|
|
307
|
+
templateMatch: verified ? 'verified' : 'template-unverified',
|
|
308
|
+
component,
|
|
309
|
+
...(verified
|
|
310
|
+
? {}
|
|
311
|
+
: {
|
|
312
|
+
note:
|
|
313
|
+
hit.implicitReceiver === true
|
|
314
|
+
? `Same-name member used in the template of ${component}, which does not declare or inherit the searched member.`
|
|
315
|
+
: `Same-name property accessed on another object ('x.${hit.name}') in the template of ${component}; receiver type not checked.`,
|
|
316
|
+
}),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** All component templates in the project (inline and external). */
|
|
321
|
+
private collectTemplates(): ComponentTemplate[] {
|
|
322
|
+
const templates: ComponentTemplate[] = [];
|
|
323
|
+
for (const sf of this.project.getSourceFiles()) {
|
|
324
|
+
if (sf.isDeclarationFile() || sf.isInNodeModules()) {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
for (const cls of sf.getClasses()) {
|
|
328
|
+
const component = findDecorator(cls, COMPONENT_DECORATORS);
|
|
329
|
+
if (component === undefined) {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const template = this.loadTemplate(sf, cls, component);
|
|
333
|
+
if (template !== undefined) {
|
|
334
|
+
templates.push(template);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return templates;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private loadTemplate(
|
|
342
|
+
sf: SourceFile,
|
|
343
|
+
owner: ClassDeclaration,
|
|
344
|
+
decorator: Decorator,
|
|
345
|
+
): ComponentTemplate | undefined {
|
|
346
|
+
const inline = readDecoratorString(decorator, 'template');
|
|
347
|
+
if (inline !== undefined) {
|
|
348
|
+
// Raw text between the delimiters keeps offsets aligned with the .ts file.
|
|
349
|
+
const literalStart = inline.node.getStart() + 1;
|
|
350
|
+
const content = inline.node.getText().slice(1, -1);
|
|
351
|
+
return {
|
|
352
|
+
owner,
|
|
353
|
+
file: sf.getFilePath(),
|
|
354
|
+
content,
|
|
355
|
+
locate: (offset) => {
|
|
356
|
+
const pos = literalStart + offset;
|
|
357
|
+
const { line, column } = sf.getLineAndColumnAtPos(pos);
|
|
358
|
+
const snippet = sf.getFullText().split('\n')[line - 1] ?? '';
|
|
359
|
+
return { line, col: column, snippet: snippet.trim() };
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const templateUrl = readDecoratorString(decorator, 'templateUrl');
|
|
365
|
+
if (templateUrl === undefined) {
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
const file = resolve(dirname(sf.getFilePath()), templateUrl.value).replaceAll(
|
|
369
|
+
'\\',
|
|
370
|
+
'/',
|
|
371
|
+
);
|
|
372
|
+
const fs = this.project.getFileSystem();
|
|
373
|
+
let content: string;
|
|
374
|
+
try {
|
|
375
|
+
if (!fs.fileExistsSync(file)) {
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
content = fs.readFileSync(file);
|
|
379
|
+
} catch {
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
return {
|
|
383
|
+
owner,
|
|
384
|
+
file,
|
|
385
|
+
content,
|
|
386
|
+
locate: (offset) => lineInfo(content, offset),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
}
|