@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
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @angular-modernizer/api - Template Usage Scanner
|
|
3
|
+
*
|
|
4
|
+
* Parses a single Angular template with `@angular/compiler` and reports
|
|
5
|
+
* where a selector, pipe, class member, or input/output binding is used.
|
|
6
|
+
* Positions are returned as offsets into the template text so callers can
|
|
7
|
+
* map them to external `.html` files or to inline `template:` literals.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* Pure function over template text; it knows nothing about ts-morph. The
|
|
11
|
+
* project-level lookup (which templates belong to which class, selector and
|
|
12
|
+
* pipe resolution) lives in `TemplateUsageFinder`.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* const hits = scanTemplate('<app-user (saved)="onSave()"></app-user>', {
|
|
17
|
+
* selector: 'app-user',
|
|
18
|
+
* memberName: 'onSave',
|
|
19
|
+
* });
|
|
20
|
+
* // [{ kind: 'element', ... }, { kind: 'method-call', implicitReceiver: true, ... }]
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
CssSelector,
|
|
26
|
+
ImplicitReceiver,
|
|
27
|
+
PropertyRead,
|
|
28
|
+
RecursiveAstVisitor,
|
|
29
|
+
SelectorMatcher,
|
|
30
|
+
TmplAstBoundAttribute,
|
|
31
|
+
TmplAstBoundDeferredTrigger,
|
|
32
|
+
TmplAstElement,
|
|
33
|
+
TmplAstRecursiveVisitor,
|
|
34
|
+
createCssSelectorFromNode,
|
|
35
|
+
parseTemplate,
|
|
36
|
+
tmplAstVisitAll,
|
|
37
|
+
type AST,
|
|
38
|
+
type BindingPipe,
|
|
39
|
+
type Call,
|
|
40
|
+
type PropertyWrite,
|
|
41
|
+
type SafeCall,
|
|
42
|
+
type SafePropertyRead,
|
|
43
|
+
type TmplAstBoundEvent,
|
|
44
|
+
type TmplAstBoundText,
|
|
45
|
+
type TmplAstDeferredTrigger,
|
|
46
|
+
type TmplAstForLoopBlock,
|
|
47
|
+
type TmplAstIfBlockBranch,
|
|
48
|
+
type TmplAstSwitchBlock,
|
|
49
|
+
type TmplAstSwitchBlockCase,
|
|
50
|
+
type TmplAstTemplate,
|
|
51
|
+
} from '@angular/compiler';
|
|
52
|
+
|
|
53
|
+
/** How a template refers to the searched symbol. */
|
|
54
|
+
export type TemplateUsageKind =
|
|
55
|
+
| 'element'
|
|
56
|
+
| 'attribute'
|
|
57
|
+
| 'pipe'
|
|
58
|
+
| 'property-read'
|
|
59
|
+
| 'property-write'
|
|
60
|
+
| 'method-call'
|
|
61
|
+
| 'input-binding'
|
|
62
|
+
| 'output-binding';
|
|
63
|
+
|
|
64
|
+
/** What to look for in a template. All fields are optional and combinable. */
|
|
65
|
+
export interface TemplateQuery {
|
|
66
|
+
/** Component/directive CSS selector (may be a selector list). */
|
|
67
|
+
selector?: string;
|
|
68
|
+
|
|
69
|
+
/** Pipe name as used in templates (`{{ x | name }}`). */
|
|
70
|
+
pipeName?: string;
|
|
71
|
+
|
|
72
|
+
/** Class member name read, written, or called in expressions. */
|
|
73
|
+
memberName?: string;
|
|
74
|
+
|
|
75
|
+
/** Input/output name bound as `[name]`, `name="..."` or `(name)`. */
|
|
76
|
+
bindingName?: string;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Restricts `bindingName` matches to elements matching this selector.
|
|
80
|
+
* When omitted, bindings on any element match.
|
|
81
|
+
*/
|
|
82
|
+
bindingSelector?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A single match inside a template. */
|
|
86
|
+
export interface TemplateHit {
|
|
87
|
+
/** 0-based offset into the template text. */
|
|
88
|
+
offset: number;
|
|
89
|
+
|
|
90
|
+
/** Kind of usage. */
|
|
91
|
+
kind: TemplateUsageKind;
|
|
92
|
+
|
|
93
|
+
/** Matched name (selector, pipe, member, or binding name). */
|
|
94
|
+
name: string;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* For member hits: `true` when the expression reads the member from the
|
|
98
|
+
* component context (`foo`, `this.foo`), `false` for `x.foo`.
|
|
99
|
+
*/
|
|
100
|
+
implicitReceiver?: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface SpanLike {
|
|
104
|
+
start: number;
|
|
105
|
+
end: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isImplicit(receiver: AST): boolean {
|
|
109
|
+
return receiver instanceof ImplicitReceiver;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function createMatcher(selector: string | undefined): {
|
|
113
|
+
matches: (node: TmplAstElement | TmplAstTemplate) => boolean;
|
|
114
|
+
elementNames: Set<string>;
|
|
115
|
+
} | null {
|
|
116
|
+
if (selector === undefined || selector.trim() === '') {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let parsed: CssSelector[];
|
|
121
|
+
try {
|
|
122
|
+
parsed = CssSelector.parse(selector);
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const matcher = new SelectorMatcher<string>();
|
|
128
|
+
matcher.addSelectables(parsed, selector);
|
|
129
|
+
const elementNames = new Set(
|
|
130
|
+
parsed.map((s) => s.element).filter((e): e is string => e !== null),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
elementNames,
|
|
135
|
+
matches: (node) => {
|
|
136
|
+
let matched = false;
|
|
137
|
+
matcher.match(createCssSelectorFromNode(node), () => {
|
|
138
|
+
matched = true;
|
|
139
|
+
});
|
|
140
|
+
return matched;
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Constructor of the template walker (created lazily, see below). */
|
|
146
|
+
type HitCollectorClass = new (
|
|
147
|
+
query: TemplateQuery,
|
|
148
|
+
content: string,
|
|
149
|
+
hits: TemplateHit[],
|
|
150
|
+
) => TmplAstRecursiveVisitor;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Builds the visitor classes on first use instead of at module load.
|
|
154
|
+
*
|
|
155
|
+
* Several packages replace `@angular/compiler` with a minimal Jest mock that
|
|
156
|
+
* lacks the visitor base classes; extending them at module scope would make
|
|
157
|
+
* every import of `@angular-modernizer/api` throw. When the base classes are
|
|
158
|
+
* missing, template scanning is disabled (returns no hits).
|
|
159
|
+
*/
|
|
160
|
+
function createHitCollectorClass(): HitCollectorClass | null {
|
|
161
|
+
const compilerAvailable =
|
|
162
|
+
typeof (RecursiveAstVisitor as unknown) === 'function' &&
|
|
163
|
+
typeof (TmplAstRecursiveVisitor as unknown) === 'function';
|
|
164
|
+
if (!compilerAvailable) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Walks template expressions (bindings, interpolations, block expressions).
|
|
170
|
+
*/
|
|
171
|
+
class ExpressionHitCollector extends RecursiveAstVisitor {
|
|
172
|
+
private readonly callReceivers = new Set<AST>();
|
|
173
|
+
|
|
174
|
+
constructor(
|
|
175
|
+
private readonly query: TemplateQuery,
|
|
176
|
+
private readonly content: string,
|
|
177
|
+
private readonly hits: TemplateHit[],
|
|
178
|
+
) {
|
|
179
|
+
super();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private nameOffset(span: SpanLike, name: string): number {
|
|
183
|
+
const found = this.content.indexOf(name, span.start);
|
|
184
|
+
return found !== -1 && found < span.end ? found : span.start;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private recordMember(
|
|
188
|
+
kind: TemplateUsageKind,
|
|
189
|
+
name: string,
|
|
190
|
+
span: SpanLike,
|
|
191
|
+
implicitReceiver: boolean,
|
|
192
|
+
): void {
|
|
193
|
+
this.hits.push({
|
|
194
|
+
offset: this.nameOffset(span, name),
|
|
195
|
+
kind,
|
|
196
|
+
name,
|
|
197
|
+
implicitReceiver,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private checkCallee(receiver: AST): void {
|
|
202
|
+
const member = this.query.memberName;
|
|
203
|
+
if (member === undefined) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const isRead = receiver instanceof PropertyRead;
|
|
207
|
+
const readName = isRead ? receiver.name : undefined;
|
|
208
|
+
if (!isRead || readName !== member) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
this.callReceivers.add(receiver);
|
|
212
|
+
this.recordMember(
|
|
213
|
+
'method-call',
|
|
214
|
+
member,
|
|
215
|
+
receiver.nameSpan,
|
|
216
|
+
isImplicit(receiver.receiver),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
override visitCall(ast: Call, context: unknown): void {
|
|
221
|
+
this.checkCallee(ast.receiver);
|
|
222
|
+
super.visitCall(ast, context);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
override visitSafeCall(ast: SafeCall, context: unknown): void {
|
|
226
|
+
this.checkCallee(ast.receiver);
|
|
227
|
+
super.visitSafeCall(ast, context);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
override visitPropertyRead(ast: PropertyRead, context: unknown): void {
|
|
231
|
+
const isMember =
|
|
232
|
+
ast.name === this.query.memberName && !this.callReceivers.has(ast);
|
|
233
|
+
if (isMember) {
|
|
234
|
+
this.recordMember(
|
|
235
|
+
'property-read',
|
|
236
|
+
ast.name,
|
|
237
|
+
ast.nameSpan,
|
|
238
|
+
isImplicit(ast.receiver),
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
super.visitPropertyRead(ast, context);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
override visitSafePropertyRead(ast: SafePropertyRead, context: unknown): void {
|
|
245
|
+
if (ast.name === this.query.memberName) {
|
|
246
|
+
this.recordMember('property-read', ast.name, ast.nameSpan, false);
|
|
247
|
+
}
|
|
248
|
+
super.visitSafePropertyRead(ast, context);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
override visitPropertyWrite(ast: PropertyWrite, context: unknown): void {
|
|
252
|
+
if (ast.name === this.query.memberName) {
|
|
253
|
+
this.recordMember(
|
|
254
|
+
'property-write',
|
|
255
|
+
ast.name,
|
|
256
|
+
ast.nameSpan,
|
|
257
|
+
isImplicit(ast.receiver),
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
super.visitPropertyWrite(ast, context);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
override visitPipe(ast: BindingPipe, context: unknown): void {
|
|
264
|
+
if (ast.name === this.query.pipeName) {
|
|
265
|
+
this.hits.push({
|
|
266
|
+
offset: this.nameOffset(ast.nameSpan, ast.name),
|
|
267
|
+
kind: 'pipe',
|
|
268
|
+
name: ast.name,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
super.visitPipe(ast, context);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
scan(ast: AST | null | undefined): void {
|
|
275
|
+
if (ast === null || ast === undefined) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
ast.visit(this);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Walks the template node tree: selector matches, bindings, and every
|
|
284
|
+
* embedded expression (delegated to {@link ExpressionHitCollector}).
|
|
285
|
+
*/
|
|
286
|
+
class TemplateHitCollector extends TmplAstRecursiveVisitor {
|
|
287
|
+
private readonly selectorMatcher: ReturnType<typeof createMatcher>;
|
|
288
|
+
private readonly bindingMatcher: ReturnType<typeof createMatcher>;
|
|
289
|
+
private readonly expressions: ExpressionHitCollector;
|
|
290
|
+
|
|
291
|
+
constructor(
|
|
292
|
+
private readonly query: TemplateQuery,
|
|
293
|
+
content: string,
|
|
294
|
+
private readonly hits: TemplateHit[],
|
|
295
|
+
) {
|
|
296
|
+
super();
|
|
297
|
+
this.selectorMatcher = createMatcher(query.selector);
|
|
298
|
+
this.bindingMatcher = createMatcher(query.bindingSelector);
|
|
299
|
+
this.expressions = new ExpressionHitCollector(query, content, hits);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private selectorHit(node: TmplAstElement | TmplAstTemplate): void {
|
|
303
|
+
const matcher = this.selectorMatcher;
|
|
304
|
+
if (!matcher?.matches(node)) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const isElement =
|
|
308
|
+
node instanceof TmplAstElement && matcher.elementNames.has(node.name);
|
|
309
|
+
const selector = this.query.selector ?? '';
|
|
310
|
+
this.hits.push({
|
|
311
|
+
offset: isElement
|
|
312
|
+
? node.startSourceSpan.start.offset + 1
|
|
313
|
+
: this.attributeOffset(node),
|
|
314
|
+
kind: isElement ? 'element' : 'attribute',
|
|
315
|
+
name: selector,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Offset of the first attribute/binding named in the selector, else the tag. */
|
|
320
|
+
private attributeOffset(node: TmplAstElement | TmplAstTemplate): number {
|
|
321
|
+
const attrNames = new Set<string>();
|
|
322
|
+
try {
|
|
323
|
+
for (const s of CssSelector.parse(this.query.selector ?? '')) {
|
|
324
|
+
for (let i = 0; i < s.attrs.length; i += 2) {
|
|
325
|
+
const attr = s.attrs[i];
|
|
326
|
+
if (attr !== undefined) {
|
|
327
|
+
attrNames.add(attr);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
332
|
+
// fall through to the tag offset
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const candidates = [
|
|
336
|
+
...node.attributes,
|
|
337
|
+
...node.inputs,
|
|
338
|
+
...node.outputs,
|
|
339
|
+
...('templateAttrs' in node ? node.templateAttrs : []),
|
|
340
|
+
];
|
|
341
|
+
const hit = candidates.find((a) => attrNames.has(a.name));
|
|
342
|
+
return hit?.keySpan?.start.offset ?? node.startSourceSpan.start.offset;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private bindingAllowed(node: TmplAstElement | TmplAstTemplate): boolean {
|
|
346
|
+
if (this.query.bindingName === undefined) {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
return this.bindingMatcher === null || this.bindingMatcher.matches(node);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private bindingHits(node: TmplAstElement | TmplAstTemplate): void {
|
|
353
|
+
const name = this.query.bindingName;
|
|
354
|
+
if (name === undefined || !this.bindingAllowed(node)) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
for (const input of node.inputs) {
|
|
358
|
+
if (input.name === name) {
|
|
359
|
+
this.hits.push({
|
|
360
|
+
offset: input.keySpan.start.offset,
|
|
361
|
+
kind: 'input-binding',
|
|
362
|
+
name,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
for (const attr of node.attributes) {
|
|
367
|
+
if (attr.name === name) {
|
|
368
|
+
this.hits.push({
|
|
369
|
+
offset: attr.keySpan?.start.offset ?? attr.sourceSpan.start.offset,
|
|
370
|
+
kind: 'input-binding',
|
|
371
|
+
name,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
for (const output of node.outputs) {
|
|
376
|
+
if (output.name === name) {
|
|
377
|
+
this.hits.push({
|
|
378
|
+
offset: output.keySpan.start.offset,
|
|
379
|
+
kind: 'output-binding',
|
|
380
|
+
name,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
override visitElement(element: TmplAstElement): void {
|
|
387
|
+
this.selectorHit(element);
|
|
388
|
+
this.bindingHits(element);
|
|
389
|
+
super.visitElement(element);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
override visitTemplate(template: TmplAstTemplate): void {
|
|
393
|
+
this.selectorHit(template);
|
|
394
|
+
this.bindingHits(template);
|
|
395
|
+
// The base visitor skips `*directive="expr"` attributes.
|
|
396
|
+
for (const attr of template.templateAttrs) {
|
|
397
|
+
if (attr instanceof TmplAstBoundAttribute) {
|
|
398
|
+
this.expressions.scan(attr.value);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
super.visitTemplate(template);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
override visitBoundAttribute(attribute: TmplAstBoundAttribute): void {
|
|
405
|
+
this.expressions.scan(attribute.value);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
override visitBoundEvent(event: TmplAstBoundEvent): void {
|
|
409
|
+
this.expressions.scan(event.handler);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
override visitBoundText(text: TmplAstBoundText): void {
|
|
413
|
+
this.expressions.scan(text.value);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
override visitIfBlockBranch(block: TmplAstIfBlockBranch): void {
|
|
417
|
+
this.expressions.scan(block.expression);
|
|
418
|
+
super.visitIfBlockBranch(block);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
override visitForLoopBlock(block: TmplAstForLoopBlock): void {
|
|
422
|
+
this.expressions.scan(block.expression);
|
|
423
|
+
this.expressions.scan(block.trackBy);
|
|
424
|
+
super.visitForLoopBlock(block);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
override visitSwitchBlock(block: TmplAstSwitchBlock): void {
|
|
428
|
+
this.expressions.scan(block.expression);
|
|
429
|
+
super.visitSwitchBlock(block);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
override visitSwitchBlockCase(block: TmplAstSwitchBlockCase): void {
|
|
433
|
+
this.expressions.scan(block.expression);
|
|
434
|
+
super.visitSwitchBlockCase(block);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
override visitDeferredTrigger(trigger: TmplAstDeferredTrigger): void {
|
|
438
|
+
if (trigger instanceof TmplAstBoundDeferredTrigger) {
|
|
439
|
+
this.expressions.scan(trigger.value);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return TemplateHitCollector;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
let hitCollectorClass: HitCollectorClass | null | undefined;
|
|
448
|
+
|
|
449
|
+
function getHitCollectorClass(): HitCollectorClass | null {
|
|
450
|
+
if (hitCollectorClass === undefined) {
|
|
451
|
+
hitCollectorClass = createHitCollectorClass();
|
|
452
|
+
}
|
|
453
|
+
return hitCollectorClass;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Cheap pre-check: can the template possibly contain a hit for `query`?
|
|
458
|
+
* Avoids parsing templates that do not mention any searched token.
|
|
459
|
+
*/
|
|
460
|
+
export function templateMayMatch(content: string, query: TemplateQuery): boolean {
|
|
461
|
+
const tokens: string[] = [];
|
|
462
|
+
if (query.pipeName !== undefined) {
|
|
463
|
+
tokens.push(query.pipeName);
|
|
464
|
+
}
|
|
465
|
+
if (query.memberName !== undefined) {
|
|
466
|
+
tokens.push(query.memberName);
|
|
467
|
+
}
|
|
468
|
+
if (query.bindingName !== undefined) {
|
|
469
|
+
tokens.push(query.bindingName);
|
|
470
|
+
}
|
|
471
|
+
if (query.selector !== undefined) {
|
|
472
|
+
try {
|
|
473
|
+
for (const s of CssSelector.parse(query.selector)) {
|
|
474
|
+
if (s.element !== null && s.element !== '*') {
|
|
475
|
+
tokens.push(s.element);
|
|
476
|
+
}
|
|
477
|
+
for (let i = 0; i < s.attrs.length; i += 2) {
|
|
478
|
+
const attr = s.attrs[i];
|
|
479
|
+
if (attr !== undefined && attr !== '') {
|
|
480
|
+
tokens.push(attr);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
tokens.push(...s.classNames);
|
|
484
|
+
}
|
|
485
|
+
} catch {
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return tokens.some((t) => content.includes(t));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Scans one template for usages described by `query`.
|
|
494
|
+
*
|
|
495
|
+
* @param content - Raw template text.
|
|
496
|
+
* @param query - What to look for.
|
|
497
|
+
* @returns Hits ordered by offset. Returns `[]` when the template cannot be
|
|
498
|
+
* parsed at all.
|
|
499
|
+
*/
|
|
500
|
+
export function scanTemplate(content: string, query: TemplateQuery): TemplateHit[] {
|
|
501
|
+
const Collector = getHitCollectorClass();
|
|
502
|
+
if (Collector === null || !templateMayMatch(content, query)) {
|
|
503
|
+
return [];
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
let nodes: ReturnType<typeof parseTemplate>['nodes'];
|
|
507
|
+
try {
|
|
508
|
+
nodes = parseTemplate(content, 'template.html', {
|
|
509
|
+
preserveWhitespaces: true,
|
|
510
|
+
}).nodes;
|
|
511
|
+
} catch {
|
|
512
|
+
return [];
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const hits: TemplateHit[] = [];
|
|
516
|
+
tmplAstVisitAll(new Collector(query, content, hits), nodes);
|
|
517
|
+
|
|
518
|
+
const seen = new Set<string>();
|
|
519
|
+
return hits
|
|
520
|
+
.filter((h) => {
|
|
521
|
+
const key = `${h.offset}:${h.kind}`;
|
|
522
|
+
if (seen.has(key)) {
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
seen.add(key);
|
|
526
|
+
return true;
|
|
527
|
+
})
|
|
528
|
+
.sort((a, b) => a.offset - b.offset);
|
|
529
|
+
}
|