Skip to content

Commit 0287b23

Browse files
alxhubAndrewKushnir
authored andcommitted
feat(ivy): convert all ngtsc diagnostics to ts.Diagnostics (#31952)
Historically, the Angular Compiler has produced both native TypeScript diagnostics (called ts.Diagnostics) and its own internal Diagnostic format (called an api.Diagnostic). This was done because TypeScript ts.Diagnostics cannot be produced for files not in the ts.Program, and template type- checking diagnostics are naturally produced for external .html template files. This design isn't optimal for several reasons: 1) Downstream tooling (such as the CLI) must support multiple formats of diagnostics, adding to the maintenance burden. 2) ts.Diagnostics have gotten a lot better in recent releases, with support for suggested changes, highlighting of the code in question, etc. None of these changes have been of any benefit for api.Diagnostics, which have continued to be reported in a very primitive fashion. 3) A future plugin model will not support anything but ts.Diagnostics, so generating api.Diagnostics is a blocker for ngtsc-as-a-plugin. 4) The split complicates both the typings and the testing of ngtsc. To fix this issue, this commit changes template type-checking to produce ts.Diagnostics instead. Instead of reporting a special kind of diagnostic for external template files, errors in a template are always reported in a ts.Diagnostic that highlights the portion of the template which contains the error. When this template text is distinct from the source .ts file (for example, when the template is parsed from an external resource file), additional contextual information links the error back to the originating component. A template error can thus be reported in 3 separate ways, depending on how the template was configured: 1) For inline template strings which can be directly mapped to offsets in the TS code, ts.Diagnostics point to real ranges in the source. This is the case if an inline template is used with a string literal or a "no-substitution" string. For example: ```typescript @component({..., template: ` <p>Bar: {{baz}}</p> `}) export class TestCmp { bar: string; } ``` The above template contains an error (no 'baz' property of `TestCmp`). The error produced by TS will look like: ``` <p>Bar: {{baz}}</p> ~~~ test.ts:2:11 - error TS2339: Property 'baz' does not exist on type 'TestCmp'. Did you mean 'bar'? ``` 2) For template strings which cannot be directly mapped to offsets in the TS code, a logical offset into the template string will be included in the error message. For example: ```typescript const SOME_TEMPLATE = '<p>Bar: {{baz}}</p>'; @component({..., template: SOME_TEMPLATE}) export class TestCmp { bar: string; } ``` Because the template is a reference to another variable and is not an inline string constant, the compiler will not be able to use "absolute" positions when parsing the template. As a result, errors will report logical offsets into the template string: ``` <p>Bar: {{baz}}</p> ~~~ test.ts (TestCmp template):2:15 - error TS2339: Property 'baz' does not exist on type 'TestCmp'. test.ts:3:28 @component({..., template: TEMPLATE}) ~~~~~~~~ Error occurs in the template of component TestCmp. ``` This error message uses logical offsets into the template string, and also gives a reference to the `TEMPLATE` expression from which the template was parsed. This helps in locating the component which contains the error. 3) For external templates (templateUrl), the error message is delivered within the HTML template file (testcmp.html) instead, and additional information contextualizes the error on the templateUrl expression from which the template file was determined: ``` <p>Bar: {{baz}}</p> ~~~ testcmp.html:2:15 - error TS2339: Property 'baz' does not exist on type 'TestCmp'. test.ts:10:31 @component({..., templateUrl: './testcmp.html'}) ~~~~~~~~~~~~~~~~ Error occurs in the template of component TestCmp. ``` PR Close #31952
1 parent bfc26bc commit 0287b23

File tree

10 files changed

+410
-166
lines changed

10 files changed

+410
-166
lines changed

packages/compiler-cli/src/ngtsc/annotations/src/component.ts

Lines changed: 120 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {EnumValue, PartialEvaluator} from '../../partial_evaluator';
2020
import {ClassDeclaration, Decorator, ReflectionHost, reflectObjectLiteral} from '../../reflection';
2121
import {ComponentScopeReader, LocalModuleScopeRegistry} from '../../scope';
2222
import {AnalysisOutput, CompileResult, DecoratorHandler, DetectResult, HandlerPrecedence, ResolveResult} from '../../transform';
23-
import {TypeCheckContext} from '../../typecheck';
23+
import {TemplateSourceMapping, TypeCheckContext} from '../../typecheck';
2424
import {NoopResourceDependencyRecorder, ResourceDependencyRecorder} from '../../util/src/resource_recorder';
2525
import {tsSourceMapBug29300Fixed} from '../../util/src/ts_source_map_bug_29300';
2626

@@ -34,7 +34,8 @@ const EMPTY_ARRAY: any[] = [];
3434

3535
export interface ComponentHandlerData {
3636
meta: R3ComponentMetadata;
37-
parsedTemplate: {nodes: TmplAstNode[]; file: ParseSourceFile};
37+
parsedTemplate: ParsedTemplate;
38+
templateSourceMapping: TemplateSourceMapping;
3839
metadataStmt: Statement|null;
3940
parseTemplate: (options?: ParseTemplateOptions) => ParsedTemplate;
4041
}
@@ -63,7 +64,7 @@ export class ComponentDecoratorHandler implements
6364
* any potential <link> tags which might need to be loaded. This cache ensures that work is not
6465
* thrown away, and the parsed template is reused during the analyze phase.
6566
*/
66-
private preanalyzeTemplateCache = new Map<ts.Declaration, ParsedTemplate>();
67+
private preanalyzeTemplateCache = new Map<ts.Declaration, PreanalyzedTemplate>();
6768

6869
readonly precedence = HandlerPrecedence.PRIMARY;
6970

@@ -174,18 +175,22 @@ export class ComponentDecoratorHandler implements
174175
// Extract a closure of the template parsing code so that it can be reparsed with different
175176
// options if needed, like in the indexing pipeline.
176177
let parseTemplate: (options?: ParseTemplateOptions) => ParsedTemplate;
178+
// Track the origin of the template to determine how the ParseSourceSpans should be interpreted.
179+
let templateSourceMapping: TemplateSourceMapping;
177180
if (this.preanalyzeTemplateCache.has(node)) {
178181
// The template was parsed in preanalyze. Use it and delete it to save memory.
179182
const template = this.preanalyzeTemplateCache.get(node) !;
180183
this.preanalyzeTemplateCache.delete(node);
181184

182-
// A pre-analyzed template cannot be reparsed. Pre-analysis is never run with the indexing
183-
// pipeline.
184-
parseTemplate = (options?: ParseTemplateOptions) => {
185-
if (options !== undefined) {
186-
throw new Error(`Cannot reparse a pre-analyzed template with new options`);
187-
}
188-
return template;
185+
parseTemplate = template.parseTemplate;
186+
187+
// A pre-analyzed template is always an external mapping.
188+
templateSourceMapping = {
189+
type: 'external',
190+
componentClass: node,
191+
node: component.get('templateUrl') !,
192+
template: template.template,
193+
templateUrl: template.templateUrl,
189194
};
190195
} else {
191196
// The template was not already parsed. Either there's a templateUrl, or an inline template.
@@ -203,6 +208,13 @@ export class ComponentDecoratorHandler implements
203208
parseTemplate = (options?: ParseTemplateOptions) => this._parseTemplate(
204209
component, templateStr, sourceMapUrl(templateUrl), /* templateRange */ undefined,
205210
/* escapedString */ false, options);
211+
templateSourceMapping = {
212+
type: 'external',
213+
componentClass: node,
214+
node: templateUrlExpr,
215+
template: templateStr,
216+
templateUrl: templateUrl,
217+
};
206218
} else {
207219
// Expect an inline template to be present.
208220
const inlineTemplate = this._extractInlineTemplate(component, containingFile);
@@ -214,6 +226,20 @@ export class ComponentDecoratorHandler implements
214226
const {templateStr, templateUrl, templateRange, escapedString} = inlineTemplate;
215227
parseTemplate = (options?: ParseTemplateOptions) => this._parseTemplate(
216228
component, templateStr, templateUrl, templateRange, escapedString, options);
229+
if (escapedString) {
230+
templateSourceMapping = {
231+
type: 'direct',
232+
node:
233+
component.get('template') !as(ts.StringLiteral | ts.NoSubstitutionTemplateLiteral),
234+
};
235+
} else {
236+
templateSourceMapping = {
237+
type: 'indirect',
238+
node: component.get('template') !,
239+
componentClass: node,
240+
template: templateStr,
241+
};
242+
}
217243
}
218244
}
219245
const template = parseTemplate();
@@ -308,7 +334,7 @@ export class ComponentDecoratorHandler implements
308334
},
309335
metadataStmt: generateSetClassMetadataCall(
310336
node, this.reflector, this.defaultImportRecorder, this.isCore),
311-
parsedTemplate: template, parseTemplate,
337+
parsedTemplate: template, parseTemplate, templateSourceMapping,
312338
},
313339
typeCheck: true,
314340
};
@@ -354,8 +380,22 @@ export class ComponentDecoratorHandler implements
354380
return;
355381
}
356382

357-
const pipes = new Map<string, Reference<ClassDeclaration<ts.ClassDeclaration>>>();
383+
// There are issues with parsing the template under certain configurations (namely with
384+
// `preserveWhitespaces: false`) which cause inaccurate positional information within the
385+
// template AST, particularly within interpolation expressions.
386+
//
387+
// To work around this, the template is re-parsed with settings that guarantee the spans are as
388+
// accurate as possible. This is only a temporary solution until the whitespace removal step can
389+
// be rewritten as a transform against the expression AST instead of against the HTML AST.
390+
//
391+
// TODO(alxhub): remove this when whitespace removal no longer corrupts span information.
392+
const template = meta.parseTemplate({
393+
preserveWhitespaces: true,
394+
leadingTriviaChars: [],
395+
});
396+
358397
const matcher = new SelectorMatcher<DirectiveMeta>();
398+
const pipes = new Map<string, Reference<ClassDeclaration<ts.ClassDeclaration>>>();
359399

360400
const scope = this.scopeReader.getScopeForComponent(node);
361401
if (scope !== null) {
@@ -372,8 +412,8 @@ export class ComponentDecoratorHandler implements
372412
}
373413
}
374414

375-
const bound = new R3TargetBinder(matcher).bind({template: meta.parsedTemplate.nodes});
376-
ctx.addTemplate(new Reference(node), bound, pipes, meta.parsedTemplate.file);
415+
const bound = new R3TargetBinder(matcher).bind({template: template.nodes});
416+
ctx.addTemplate(new Reference(node), bound, pipes, meta.templateSourceMapping, template.file);
377417
}
378418

379419
resolve(node: ClassDeclaration, analysis: ComponentHandlerData): ResolveResult {
@@ -561,10 +601,12 @@ export class ComponentDecoratorHandler implements
561601
return templatePromise.then(() => {
562602
const templateStr = this.resourceLoader.load(resourceUrl);
563603
this.resourceDependencies.recordResourceDependency(node.getSourceFile(), resourceUrl);
564-
const template = this._parseTemplate(
565-
component, templateStr, sourceMapUrl(resourceUrl), /* templateRange */ undefined,
566-
/* escapedString */ false);
567-
this.preanalyzeTemplateCache.set(node, template);
604+
const parseTemplate = (options?: ParseTemplateOptions) => this._parseTemplate(
605+
component, templateStr, sourceMapUrl(resourceUrl),
606+
/* templateRange */ undefined,
607+
/* escapedString */ false, options);
608+
const template = parseTemplate();
609+
this.preanalyzeTemplateCache.set(node, {...template, parseTemplate});
568610
return template;
569611
});
570612
} else {
@@ -579,9 +621,10 @@ export class ComponentDecoratorHandler implements
579621
}
580622

581623
const {templateStr, templateUrl, escapedString, templateRange} = inlineTemplate;
582-
const template =
583-
this._parseTemplate(component, templateStr, templateUrl, templateRange, escapedString);
584-
this.preanalyzeTemplateCache.set(node, template);
624+
const parseTemplate = (options?: ParseTemplateOptions) => this._parseTemplate(
625+
component, templateStr, templateUrl, templateRange, escapedString, options);
626+
const template = parseTemplate();
627+
this.preanalyzeTemplateCache.set(node, {...template, parseTemplate});
585628
return Promise.resolve(template);
586629
}
587630
}
@@ -656,6 +699,7 @@ export class ComponentDecoratorHandler implements
656699
interpolationConfig: interpolation,
657700
range: templateRange, escapedString, ...options,
658701
}),
702+
template: templateStr, templateUrl,
659703
isInline: component.has('template'),
660704
file: new ParseSourceFile(templateStr, templateUrl),
661705
};
@@ -713,12 +757,66 @@ function sourceMapUrl(resourceUrl: string): string {
713757
}
714758
}
715759

716-
interface ParsedTemplate {
760+
761+
/**
762+
* Information about the template which was extracted during parsing.
763+
*
764+
* This contains the actual parsed template as well as any metadata collected during its parsing,
765+
* some of which might be useful for re-parsing the template with different options.
766+
*/
767+
export interface ParsedTemplate {
768+
/**
769+
* The `InterpolationConfig` specified by the user.
770+
*/
717771
interpolation: InterpolationConfig;
772+
773+
/**
774+
* A full path to the file which contains the template.
775+
*
776+
* This can be either the original .ts file if the template is inline, or the .html file if an
777+
* external file was used.
778+
*/
779+
templateUrl: string;
780+
781+
/**
782+
* The string contents of the template.
783+
*
784+
* This is the "logical" template string, after expansion of any escaped characters (for inline
785+
* templates). This may differ from the actual template bytes as they appear in the .ts file.
786+
*/
787+
template: string;
788+
789+
/**
790+
* Any errors from parsing the template the first time.
791+
*/
718792
errors?: ParseError[]|undefined;
793+
794+
/**
795+
* The actual parsed template nodes.
796+
*/
719797
nodes: TmplAstNode[];
798+
799+
/**
800+
* Any styleUrls extracted from the metadata.
801+
*/
720802
styleUrls: string[];
803+
804+
/**
805+
* Any inline styles extracted from the metadata.
806+
*/
721807
styles: string[];
808+
809+
/**
810+
* Whether the template was inline.
811+
*/
722812
isInline: boolean;
813+
814+
/**
815+
* The `ParseSourceFile` for the template.
816+
*/
723817
file: ParseSourceFile;
724818
}
819+
820+
interface PreanalyzedTemplate extends ParsedTemplate {
821+
parseTemplate: (options?: ParseTemplateOptions) => ParsedTemplate;
822+
}

packages/compiler-cli/src/ngtsc/program.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ export class NgtscProgram implements api.Program {
175175
}
176176

177177
getNgOptionDiagnostics(cancellationToken?: ts.CancellationToken|
178-
undefined): ReadonlyArray<ts.Diagnostic|api.Diagnostic> {
178+
undefined): ReadonlyArray<ts.Diagnostic> {
179179
return this.constructionDiagnostics;
180180
}
181181

@@ -197,8 +197,8 @@ export class NgtscProgram implements api.Program {
197197
}
198198

199199
getNgSemanticDiagnostics(
200-
fileName?: string|undefined, cancellationToken?: ts.CancellationToken|
201-
undefined): ReadonlyArray<ts.Diagnostic|api.Diagnostic> {
200+
fileName?: string|undefined,
201+
cancellationToken?: ts.CancellationToken|undefined): ReadonlyArray<ts.Diagnostic> {
202202
const compilation = this.ensureAnalyzed();
203203
const diagnostics = [...compilation.diagnostics, ...this.getTemplateDiagnostics()];
204204
if (this.entryPoint !== null && this.exportReferenceGraph !== null) {
@@ -381,7 +381,7 @@ export class NgtscProgram implements api.Program {
381381
return ((opts && opts.mergeEmitResultsCallback) || mergeEmitResults)(emitResults);
382382
}
383383

384-
private getTemplateDiagnostics(): ReadonlyArray<api.Diagnostic|ts.Diagnostic> {
384+
private getTemplateDiagnostics(): ReadonlyArray<ts.Diagnostic> {
385385
// Skip template type-checking if it's disabled.
386386
if (this.options.ivyTemplateTypeCheck === false &&
387387
this.options.fullTemplateTypeCheck !== true) {

packages/compiler-cli/src/ngtsc/typecheck/src/api.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ export interface TypeCheckableDirectiveMeta extends DirectiveMeta {
2929
* for that component.
3030
*/
3131
export interface TypeCheckBlockMetadata {
32+
/**
33+
* A unique identifier for the class which gave rise to this TCB.
34+
*
35+
* This can be used to map errors back to the `ts.ClassDeclaration` for the component.
36+
*/
37+
id: string;
38+
3239
/**
3340
* Semantic information about the template of the component.
3441
*/
@@ -104,3 +111,47 @@ export interface TypeCheckingConfig {
104111
*/
105112
checkQueries: false;
106113
}
114+
115+
116+
export type TemplateSourceMapping =
117+
DirectTemplateSourceMapping | IndirectTemplateSourceMapping | ExternalTemplateSourceMapping;
118+
119+
/**
120+
* A mapping to an inline template in a TS file.
121+
*
122+
* `ParseSourceSpan`s for this template should be accurate for direct reporting in a TS error
123+
* message.
124+
*/
125+
export interface DirectTemplateSourceMapping {
126+
type: 'direct';
127+
node: ts.StringLiteral|ts.NoSubstitutionTemplateLiteral;
128+
}
129+
130+
/**
131+
* A mapping to a template which is still in a TS file, but where the node positions in any
132+
* `ParseSourceSpan`s are not accurate for one reason or another.
133+
*
134+
* This can occur if the template expression was interpolated in a way where the compiler could not
135+
* construct a contiguous mapping for the template string. The `node` refers to the `template`
136+
* expression.
137+
*/
138+
export interface IndirectTemplateSourceMapping {
139+
type: 'indirect';
140+
componentClass: ClassDeclaration;
141+
node: ts.Expression;
142+
template: string;
143+
}
144+
145+
/**
146+
* A mapping to a template declared in an external HTML file, where node positions in
147+
* `ParseSourceSpan`s represent accurate offsets into the external file.
148+
*
149+
* In this case, the given `node` refers to the `templateUrl` expression.
150+
*/
151+
export interface ExternalTemplateSourceMapping {
152+
type: 'external';
153+
componentClass: ClassDeclaration;
154+
node: ts.Expression;
155+
template: string;
156+
templateUrl: string;
157+
}

0 commit comments

Comments
 (0)