From db97453ca0d4c4dd011eef8df18cf6a482f0b272 Mon Sep 17 00:00:00 2001 From: Zach Arend Date: Thu, 3 Dec 2020 11:42:46 -0800 Subject: [PATCH] refactor(compiler-cli): move template parse errors to TemplateData (#40026) Durring analysis we find template parse errors. This commit changes where the type checking context stores the parse errors. Previously, we stored them on the AnalysisOutput this commit changes the errors to be stored on the TemplateData (which is a property on the shim). That way, the template parse errors can be grouped by template. Previously, if a template had a parse error, we poisoned the module and would not procede to find typecheck errors. This change does not poison modules whose template have typecheck errors, so that ngtsc can emit typecheck errors for templates with parse errors. Additionally, all template diagnostics are produced in the same place. This allows requesting just the template template diagnostics or just other types of errors. PR Close #40026 --- .../src/ngtsc/annotations/src/component.ts | 35 +++----- .../ngtsc/annotations/test/component_spec.ts | 33 ++++++++ .../src/ngtsc/typecheck/api/context.ts | 6 +- .../src/ngtsc/typecheck/src/checker.ts | 22 +++-- .../src/ngtsc/typecheck/src/context.ts | 67 +++++++++++---- .../src/ngtsc/typecheck/test/test_utils.ts | 2 +- .../compiler-cli/test/ngtsc/ngtsc_spec.ts | 39 +++++++-- .../ivy/test/diagnostic_spec.ts | 81 +++++++++++++++++-- 8 files changed, 222 insertions(+), 63 deletions(-) diff --git a/packages/compiler-cli/src/ngtsc/annotations/src/component.ts b/packages/compiler-cli/src/ngtsc/annotations/src/component.ts index 7ec79625eb..e4d39317b6 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/src/component.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/src/component.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {compileComponentFromMetadata, compileDeclareComponentFromMetadata, ConstantPool, CssSelector, DEFAULT_INTERPOLATION_CONFIG, DomElementSchemaRegistry, Expression, ExternalExpr, Identifiers, InterpolationConfig, LexerRange, makeBindingParser, ParsedTemplate, ParseSourceFile, parseTemplate, R3ComponentDef, R3ComponentMetadata, R3FactoryTarget, R3TargetBinder, R3UsedDirectiveMetadata, SelectorMatcher, Statement, TmplAstNode, WrappedNodeExpr} from '@angular/compiler'; +import {compileComponentFromMetadata, compileDeclareComponentFromMetadata, ConstantPool, CssSelector, DEFAULT_INTERPOLATION_CONFIG, DomElementSchemaRegistry, Expression, ExternalExpr, Identifiers, InterpolationConfig, LexerRange, makeBindingParser, ParsedTemplate, ParseSourceFile, parseTemplate, R3ComponentDef, R3ComponentMetadata, R3FactoryTarget, R3TargetBinder, R3UsedDirectiveMetadata, SelectorMatcher, Statement, syntaxError, TmplAstNode, WrappedNodeExpr} from '@angular/compiler'; import * as ts from 'typescript'; import {CycleAnalyzer} from '../../cycles'; @@ -266,28 +266,6 @@ export class ComponentDecoratorHandler implements {path: null, expression: component.get('template')!} : {path: absoluteFrom(template.templateUrl), expression: template.sourceMapping.node}; - let diagnostics: ts.Diagnostic[]|undefined = undefined; - - if (template.errors !== null) { - // If there are any template parsing errors, convert them to `ts.Diagnostic`s for display. - const id = getTemplateId(node); - diagnostics = template.errors.map(error => { - const span = error.span; - - if (span.start.offset === span.end.offset) { - // Template errors can contain zero-length spans, if the error occurs at a single point. - // However, TypeScript does not handle displaying a zero-length diagnostic very well, so - // increase the ending offset by 1 for such errors, to ensure the position is shown in the - // diagnostic. - span.end.offset++; - } - - return makeTemplateDiagnostic( - id, template.sourceMapping, span, ts.DiagnosticCategory.Error, - ngErrorCode(ErrorCode.TEMPLATE_PARSE_ERROR), error.msg); - }); - } - // Figure out the set of styles. The ordering here is important: external resources (styleUrls) // precede inline styles, and styles defined in the template override styles defined in the // component. @@ -370,9 +348,8 @@ export class ComponentDecoratorHandler implements styles: styleResources, template: templateResource, }, - isPoisoned: diagnostics !== undefined && diagnostics.length > 0, + isPoisoned: false, }, - diagnostics, }; if (changeDetection !== null) { output.analysis!.meta.changeDetection = changeDetection; @@ -456,7 +433,7 @@ export class ComponentDecoratorHandler implements const binder = new R3TargetBinder(scope.matcher); ctx.addTemplate( new Reference(node), binder, meta.template.diagNodes, scope.pipes, scope.schemas, - meta.template.sourceMapping, meta.template.file); + meta.template.sourceMapping, meta.template.file, meta.template.errors); } resolve(node: ClassDeclaration, analysis: Readonly): @@ -616,6 +593,9 @@ export class ComponentDecoratorHandler implements compileFull( node: ClassDeclaration, analysis: Readonly, resolution: Readonly, pool: ConstantPool): CompileResult[] { + if (analysis.template.errors !== null && analysis.template.errors.length > 0) { + return []; + } const meta: R3ComponentMetadata = {...analysis.meta, ...resolution}; const def = compileComponentFromMetadata(meta, pool, makeBindingParser()); return this.compileComponent(analysis, def); @@ -624,6 +604,9 @@ export class ComponentDecoratorHandler implements compilePartial( node: ClassDeclaration, analysis: Readonly, resolution: Readonly): CompileResult[] { + if (analysis.template.errors !== null && analysis.template.errors.length > 0) { + return []; + } const meta: R3ComponentMetadata = {...analysis.meta, ...resolution}; const def = compileDeclareComponentFromMetadata(meta, analysis.template); return this.compileComponent(analysis, def); diff --git a/packages/compiler-cli/src/ngtsc/annotations/test/component_spec.ts b/packages/compiler-cli/src/ngtsc/annotations/test/component_spec.ts index 51d139cee7..eff64819b0 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/test/component_spec.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/test/component_spec.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {ConstantPool} from '@angular/compiler'; import * as ts from 'typescript'; import {CycleAnalyzer, ImportGraph} from '../../cycles'; @@ -219,6 +220,38 @@ runInEachFileSystem(() => { const {analysis} = handler.analyze(TestCmp, detected.metadata); expect(analysis?.resources.styles.size).toBe(3); }); + + it('does not emit a program with template parse errors', () => { + const template = '{{x ? y }}'; + const {program, options, host} = makeProgram([ + { + name: _('/node_modules/@angular/core/index.d.ts'), + contents: 'export const Component: any;', + }, + { + name: _('/entry.ts'), + contents: ` + import {Component} from '@angular/core'; + @Component({ + template: '${template}', + }) class TestCmp {} + ` + }, + ]); + + const {reflectionHost, handler} = setup(program, options, host); + const TestCmp = getDeclaration(program, _('/entry.ts'), 'TestCmp', isNamedClassDeclaration); + const detected = handler.detect(TestCmp, reflectionHost.getDecoratorsOfDeclaration(TestCmp)); + if (detected === undefined) { + return fail('Failed to recognize @Component'); + } + const {analysis} = handler.analyze(TestCmp, detected.metadata); + const resolution = handler.resolve(TestCmp, analysis!); + + const compileResult = + handler.compileFull(TestCmp, analysis!, resolution.data!, new ConstantPool()); + expect(compileResult).toEqual([]); + }); }); function ivyCode(code: ErrorCode): number { diff --git a/packages/compiler-cli/src/ngtsc/typecheck/api/context.ts b/packages/compiler-cli/src/ngtsc/typecheck/api/context.ts index 9895075cd4..e738aa46b3 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/api/context.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/api/context.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {ParseSourceFile, R3TargetBinder, SchemaMetadata, TmplAstNode} from '@angular/compiler'; +import {ParseError, ParseSourceFile, R3TargetBinder, SchemaMetadata, TmplAstNode} from '@angular/compiler'; import * as ts from 'typescript'; import {Reference} from '../../imports'; @@ -35,12 +35,14 @@ export interface TypeCheckContext { * @param sourceMapping a `TemplateSourceMapping` instance which describes the origin of the * template text described by the AST. * @param file the `ParseSourceFile` associated with the template. + * @param parseErrors the `ParseError`'s associated with the template. */ addTemplate( ref: Reference>, binder: R3TargetBinder, template: TmplAstNode[], pipes: Map>>, - schemas: SchemaMetadata[], sourceMapping: TemplateSourceMapping, file: ParseSourceFile): void; + schemas: SchemaMetadata[], sourceMapping: TemplateSourceMapping, file: ParseSourceFile, + parseErrors: ParseError[]|null): void; } /** diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts index 0cd7b9de3b..2cd5cfffa4 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts @@ -20,7 +20,7 @@ import {DirectiveInScope, ElementSymbol, FullTemplateMapping, GlobalCompletion, import {TemplateDiagnostic} from '../diagnostics'; import {CompletionEngine} from './completion'; -import {InliningMode, ShimTypeCheckingData, TemplateData, TypeCheckContextImpl, TypeCheckingHost} from './context'; +import {InliningMode, ShimTypeCheckingData, TemplateData, TemplateOverride, TypeCheckContextImpl, TypeCheckingHost} from './context'; import {shouldReportDiagnostic, translateDiagnostic} from './diagnostics'; import {TemplateSourceManager} from './source'; import {findTypeCheckBlock, getTemplateMapping, TemplateSourceResolver} from './tcb_util'; @@ -167,7 +167,7 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker { fileRecord.templateOverrides = new Map(); } - fileRecord.templateOverrides.set(id, nodes); + fileRecord.templateOverrides.set(id, {nodes, errors}); // Clear data for the shim in question, so it'll be regenerated on the next request. const shimFile = this.typeCheckingStrategy.shimPathForComponent(component); @@ -217,8 +217,8 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker { } /** - * Retrieve type-checking diagnostics from the given `ts.SourceFile` using the most recent - * type-checking program. + * Retrieve type-checking and template parse diagnostics from the given `ts.SourceFile` using the + * most recent type-checking program. */ getDiagnosticsForFile(sf: ts.SourceFile, optimizeFor: OptimizeFor): ts.Diagnostic[] { switch (optimizeFor) { @@ -247,6 +247,10 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker { diagnostics.push(...typeCheckProgram.getSemanticDiagnostics(shimSf).map( diag => convertDiagnostic(diag, fileRecord.sourceManager))); diagnostics.push(...shimRecord.genesisDiagnostics); + + for (const templateData of shimRecord.templates.values()) { + diagnostics.push(...templateData.templateDiagnostics); + } } return diagnostics.filter((diag: ts.Diagnostic|null): diag is ts.Diagnostic => diag !== null); @@ -282,6 +286,10 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker { diag => convertDiagnostic(diag, fileRecord.sourceManager))); diagnostics.push(...shimRecord.genesisDiagnostics); + for (const templateData of shimRecord.templates.values()) { + diagnostics.push(...templateData.templateDiagnostics); + } + return diagnostics.filter( (diag: TemplateDiagnostic|null): diag is TemplateDiagnostic => diag !== null && diag.templateId === templateId); @@ -650,7 +658,7 @@ export interface FileTypeCheckingData { /** * Map of template overrides applied to any components in this input file. */ - templateOverrides: Map|null; + templateOverrides: Map|null; /** * Data for each shim generated from this input file. @@ -684,7 +692,7 @@ class WholeProgramTypeCheckingHost implements TypeCheckingHost { return !fileData.shimData.has(shimPath); } - getTemplateOverride(sfPath: AbsoluteFsPath, node: ts.ClassDeclaration): TmplAstNode[]|null { + getTemplateOverride(sfPath: AbsoluteFsPath, node: ts.ClassDeclaration): TemplateOverride|null { const fileData = this.impl.getFileData(sfPath); if (fileData.templateOverrides === null) { return null; @@ -742,7 +750,7 @@ class SingleFileTypeCheckingHost implements TypeCheckingHost { return !this.fileData.shimData.has(shimPath); } - getTemplateOverride(sfPath: AbsoluteFsPath, node: ts.ClassDeclaration): TmplAstNode[]|null { + getTemplateOverride(sfPath: AbsoluteFsPath, node: ts.ClassDeclaration): TemplateOverride|null { this.assertPath(sfPath); if (this.fileData.templateOverrides === null) { return null; diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/context.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/context.ts index c9c0cc7723..1bb8845a0f 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/context.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/context.ts @@ -6,7 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ -import {BoundTarget, ParseSourceFile, R3TargetBinder, SchemaMetadata, TmplAstNode} from '@angular/compiler'; +import {BoundTarget, ParseError, ParseSourceFile, R3TargetBinder, SchemaMetadata, TemplateParseError, TmplAstNode} from '@angular/compiler'; +import {ErrorCode, ngErrorCode} from '@angular/compiler-cli/src/ngtsc/diagnostics'; import * as ts from 'typescript'; import {absoluteFromSourceFile, AbsoluteFsPath} from '../../file_system'; @@ -14,7 +15,7 @@ import {NoopImportRewriter, Reference, ReferenceEmitter} from '../../imports'; import {ClassDeclaration, ReflectionHost} from '../../reflection'; import {ImportManager} from '../../translator'; import {ComponentToShimMappingStrategy, TemplateId, TemplateSourceMapping, TypeCheckableDirectiveMeta, TypeCheckBlockMetadata, TypeCheckContext, TypeCheckingConfig, TypeCtorMetadata} from '../api'; -import {TemplateDiagnostic} from '../diagnostics'; +import {makeTemplateDiagnostic, TemplateDiagnostic} from '../diagnostics'; import {DomSchemaChecker, RegistryDomSchemaChecker} from './dom'; import {Environment} from './environment'; @@ -50,6 +51,11 @@ export interface ShimTypeCheckingData { templates: Map; } +export interface TemplateOverride { + nodes: TmplAstNode[]; + errors: ParseError[]|null; +} + /** * Data tracked for each template processed by the template type-checking system. */ @@ -64,6 +70,11 @@ export interface TemplateData { * template nodes. */ boundTarget: BoundTarget; + + /** + * Errors found while parsing them template, which have been converted to diagnostics. + */ + templateDiagnostics: TemplateDiagnostic[]; } /** @@ -136,7 +147,7 @@ export interface TypeCheckingHost { * Check if the given component has had its template overridden, and retrieve the new template * nodes if so. */ - getTemplateOverride(sfPath: AbsoluteFsPath, node: ts.ClassDeclaration): TmplAstNode[]|null; + getTemplateOverride(sfPath: AbsoluteFsPath, node: ts.ClassDeclaration): TemplateOverride|null; /** * Report data from a shim generated from the given input file path. @@ -194,35 +205,42 @@ export class TypeCheckContextImpl implements TypeCheckContext { private typeCtorPending = new Set(); /** - * Record a template for the given component `node`, with a `SelectorMatcher` for directive - * matching. + * Register a template to potentially be type-checked. * - * @param node class of the node being recorded. - * @param template AST nodes of the template being recorded. - * @param matcher `SelectorMatcher` which tracks directives that are in scope for this template. + * Implements `TypeCheckContext.addTemplate`. */ addTemplate( ref: Reference>, binder: R3TargetBinder, template: TmplAstNode[], pipes: Map>>, - schemas: SchemaMetadata[], sourceMapping: TemplateSourceMapping, - file: ParseSourceFile): void { + schemas: SchemaMetadata[], sourceMapping: TemplateSourceMapping, file: ParseSourceFile, + parseErrors: ParseError[]|null): void { if (!this.host.shouldCheckComponent(ref.node)) { return; } + const fileData = this.dataForFile(ref.node.getSourceFile()); + const shimData = this.pendingShimForComponent(ref.node); + const templateId = fileData.sourceManager.getTemplateId(ref.node); + + const templateDiagnostics: TemplateDiagnostic[] = []; + const sfPath = absoluteFromSourceFile(ref.node.getSourceFile()); const overrideTemplate = this.host.getTemplateOverride(sfPath, ref.node); if (overrideTemplate !== null) { - template = overrideTemplate; + template = overrideTemplate.nodes; + parseErrors = overrideTemplate.errors; + } + + if (parseErrors !== null) { + templateDiagnostics.push( + ...this.getTemplateDiagnostics(parseErrors, templateId, sourceMapping)); } // Accumulate a list of any directives which could not have type constructors generated due to // unsupported inlining operations. let missingInlines: ClassDeclaration[] = []; - const fileData = this.dataForFile(ref.node.getSourceFile()); - const shimData = this.pendingShimForComponent(ref.node); const boundTarget = binder.bind({template}); // Get all of the directives used in the template and record type constructors for all of them. @@ -251,10 +269,11 @@ export class TypeCheckContextImpl implements TypeCheckContext { }); } } - const templateId = fileData.sourceManager.getTemplateId(ref.node); + shimData.templates.set(templateId, { template, boundTarget, + templateDiagnostics, }); const tcbRequiresInline = requiresInlineTypeCheckBlock(ref.node, pipes); @@ -435,6 +454,26 @@ export class TypeCheckContextImpl implements TypeCheckContext { return this.fileMap.get(sfPath)!; } + + private getTemplateDiagnostics( + parseErrors: ParseError[], templateId: TemplateId, + sourceMapping: TemplateSourceMapping): TemplateDiagnostic[] { + return parseErrors.map(error => { + const span = error.span; + + if (span.start.offset === span.end.offset) { + // Template errors can contain zero-length spans, if the error occurs at a single point. + // However, TypeScript does not handle displaying a zero-length diagnostic very well, so + // increase the ending offset by 1 for such errors, to ensure the position is shown in the + // diagnostic. + span.end.offset++; + } + + return makeTemplateDiagnostic( + templateId, sourceMapping, span, ts.DiagnosticCategory.Error, + ngErrorCode(ErrorCode.TEMPLATE_PARSE_ERROR), error.msg); + }); + } } /** diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts index 862c8c2a0c..bc55caad92 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts @@ -407,7 +407,7 @@ export function setup(targets: TypeCheckingTarget[], overrides: { node: classRef.node.name, }; - ctx.addTemplate(classRef, binder, nodes, pipes, [], sourceMapping, templateFile); + ctx.addTemplate(classRef, binder, nodes, pipes, [], sourceMapping, templateFile, errors); } } }); diff --git a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts index b44016d070..cb2a939196 100644 --- a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts +++ b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts @@ -7577,10 +7577,13 @@ export const Foo = Foo__PRE_R3__; env.write('test.ts', ` import {Component} from '@angular/core'; @Component({ - template: '', + template: '', selector: 'test-cmp', }) - export class TestCmp {} + export class TestCmp { + x = null; + y = null; + } `); const diags = env.driveDiagnostics(); expect(diags.length).toBe(1); @@ -7591,19 +7594,41 @@ export const Foo = Foo__PRE_R3__; env.write('test.ts', ` import {Component} from '@angular/core'; @Component({ - template: '', selector: 'test-cmp', }) - export class TestCmp {} + export class TestCmp { + x = null; + } `); const diags = env.driveDiagnostics(); expect(diags.length).toBe(1); expect(getDiagnosticSourceCode(diags[0])).toBe('\''); }); + + it('should emit both type-check diagnostics and parse error diagnostics', () => { + env.write('test.ts', ` + import {Component} from '@angular/core'; + @Component({ + template: \` {{x = 2}}\`, + selector: 'test-cmp', + }) + export class TestCmp { + x: number = 1; + } + `); + const diags = env.driveDiagnostics(); + + expect(diags.length).toBe(2); + expect(diags[0].messageText).toEqual(`Type 'string' is not assignable to type 'number'.`); + expect(diags[1].messageText) + .toContain( + 'Parser Error: Bindings cannot contain assignments at column 5 in [ {{x = 2}}]'); + }); }); describe('i18n errors', () => { - it('should report helpful error message on nested i18n sections', () => { + it('reports a diagnostics on nested i18n sections', () => { env.write('test.ts', ` import {Component} from '@angular/core'; @Component({ @@ -7625,7 +7650,7 @@ export const Foo = Foo__PRE_R3__; .toEqual('
Content
'); }); - it('report a diagnostic on nested i18n sections with tags in between', () => { + it('reports a diagnostic on nested i18n sections with tags in between', () => { env.write('test.ts', ` import {Component} from '@angular/core'; @Component({ @@ -7647,7 +7672,7 @@ export const Foo = Foo__PRE_R3__; .toEqual('
Content
'); }); - it('report a diagnostic on nested i18n sections represented with s', () => { + it('reports a diagnostic on nested i18n sections represented with s', () => { env.write('test.ts', ` import {Component} from '@angular/core'; @Component({ diff --git a/packages/language-service/ivy/test/diagnostic_spec.ts b/packages/language-service/ivy/test/diagnostic_spec.ts index 0031673450..b837c18ce7 100644 --- a/packages/language-service/ivy/test/diagnostic_spec.ts +++ b/packages/language-service/ivy/test/diagnostic_spec.ts @@ -13,8 +13,6 @@ import * as ts from 'typescript'; import {createModuleWithDeclarations} from './test_utils'; describe('getSemanticDiagnostics', () => { - let env: LanguageServiceTestEnvironment; - beforeEach(() => { initMockFileSystem('Native'); }); @@ -31,7 +29,7 @@ describe('getSemanticDiagnostics', () => { export class AppComponent {} ` }; - env = createModuleWithDeclarations([appFile]); + const env = createModuleWithDeclarations([appFile]); const diags = env.ngLS.getSemanticDiagnostics(absoluteFrom('/app.ts')); expect(diags.length).toEqual(0); @@ -49,7 +47,7 @@ describe('getSemanticDiagnostics', () => { export class AppComponent {} ` }; - env = createModuleWithDeclarations([appFile]); + const env = createModuleWithDeclarations([appFile]); const diags = env.ngLS.getSemanticDiagnostics(absoluteFrom('/app.ts')); expect(diags.length).toBe(1); @@ -78,7 +76,7 @@ describe('getSemanticDiagnostics', () => { ` }; - env = createModuleWithDeclarations([appFile], [templateFile]); + const env = createModuleWithDeclarations([appFile], [templateFile]); const diags = env.ngLS.getSemanticDiagnostics(absoluteFrom('/app.html')); expect(diags).toEqual([]); }); @@ -97,7 +95,7 @@ describe('getSemanticDiagnostics', () => { }; const templateFile = {name: absoluteFrom('/app.html'), contents: `{{nope}}`}; - env = createModuleWithDeclarations([appFile], [templateFile]); + const env = createModuleWithDeclarations([appFile], [templateFile]); const diags = env.ngLS.getSemanticDiagnostics(absoluteFrom('/app.html')); expect(diags.length).toBe(1); const {category, file, start, length, messageText} = diags[0]; @@ -105,4 +103,75 @@ describe('getSemanticDiagnostics', () => { expect(file?.fileName).toBe('/app.html'); expect(messageText).toBe(`Property 'nope' does not exist on type 'AppComponent'.`); }); + + it('should report a parse error in external template', () => { + const appFile = { + name: absoluteFrom('/app.ts'), + contents: ` + import {Component, NgModule} from '@angular/core'; + + @Component({ + templateUrl: './app.html' + }) + export class AppComponent { + nope = false; + } + ` + }; + const templateFile = {name: absoluteFrom('/app.html'), contents: `{{nope = true}}`}; + + const env = createModuleWithDeclarations([appFile], [templateFile]); + const diags = env.ngLS.getSemanticDiagnostics(absoluteFrom('/app.html')); + expect(diags.length).toBe(1); + + const {category, file, messageText} = diags[0]; + expect(category).toBe(ts.DiagnosticCategory.Error); + expect(file?.fileName).toBe('/app.html'); + expect(messageText) + .toContain( + `Parser Error: Bindings cannot contain assignments at column 8 in [{{nope = true}}]`); + }); + + it('should report parse errors of components defined in the same ts file', () => { + const appFile = { + name: absoluteFrom('/app.ts'), + contents: ` + import {Component, NgModule} from '@angular/core'; + + @Component({ templateUrl: './app1.html' }) + export class AppComponent1 { nope = false; } + + @Component({ templateUrl: './app2.html' }) + export class AppComponent2 { nope = false; } + ` + }; + const templateFile1 = {name: absoluteFrom('/app1.html'), contents: `{{nope = false}}`}; + const templateFile2 = {name: absoluteFrom('/app2.html'), contents: `{{nope = true}}`}; + + const moduleFile = { + name: absoluteFrom('/app-module.ts'), + contents: ` + import {NgModule} from '@angular/core'; + import {CommonModule} from '@angular/common'; + import {AppComponent, AppComponent2} from './app'; + + @NgModule({ + declarations: [AppComponent, AppComponent2], + imports: [CommonModule], + }) + export class AppModule {} + `, + isRoot: true + }; + + const env = + LanguageServiceTestEnvironment.setup([moduleFile, appFile, templateFile1, templateFile2]); + + const diags = env.ngLS.getSemanticDiagnostics(absoluteFrom('/app.ts')); + + expect(diags.map(x => x.messageText).sort()).toEqual([ + 'Parser Error: Bindings cannot contain assignments at column 8 in [{{nope = false}}] in /app1.html@0:0', + 'Parser Error: Bindings cannot contain assignments at column 8 in [{{nope = true}}] in /app2.html@0:0' + ]); + }); });