Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unknown pipe & local ref diagnostics #33454

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/compiler-cli/src/ngtsc/diagnostics/src/code.ts
Expand Up @@ -84,6 +84,16 @@ export enum ErrorCode {
* An element's attribute name failed validation against the DOM schema.
*/
SCHEMA_INVALID_ATTRIBUTE = 8002,

/**
* No matching directive was found for a `#ref="target"` expression.
*/
MISSING_REFERENCE_TARGET = 8003,

/**
* No matching pipe was found for a
*/
MISSING_PIPE = 8004,
}

export function ngErrorCode(code: ErrorCode): number {
Expand Down
15 changes: 11 additions & 4 deletions packages/compiler-cli/src/ngtsc/typecheck/src/context.ts
Expand Up @@ -19,6 +19,7 @@ import {shouldReportDiagnostic, translateDiagnostic} from './diagnostics';
import {DomSchemaChecker, RegistryDomSchemaChecker} from './dom';
import {Environment} from './environment';
import {TypeCheckProgramHost} from './host';
import {OutOfBandDiagnosticRecorder, OutOfBandDiagnosticRecorderImpl} from './oob';
import {TcbSourceManager} from './source';
import {generateTypeCheckBlock, requiresInlineTypeCheckBlock} from './type_check_block';
import {TypeCheckFile} from './type_check_file';
Expand Down Expand Up @@ -58,6 +59,8 @@ export class TypeCheckContext {

private domSchemaChecker = new RegistryDomSchemaChecker(this.sourceManager);

private oobRecorder = new OutOfBandDiagnosticRecorderImpl(this.sourceManager);

/**
* Record a template for the given component `node`, with a `SelectorMatcher` for directive
* matching.
Expand Down Expand Up @@ -102,7 +105,8 @@ export class TypeCheckContext {
this.addInlineTypeCheckBlock(ref, tcbMetadata);
} else {
// The class can be type-checked externally as normal.
this.typeCheckFile.addTypeCheckBlock(ref, tcbMetadata, this.domSchemaChecker);
this.typeCheckFile.addTypeCheckBlock(
ref, tcbMetadata, this.domSchemaChecker, this.oobRecorder);
}
}

Expand Down Expand Up @@ -219,6 +223,7 @@ export class TypeCheckContext {
}

diagnostics.push(...this.domSchemaChecker.diagnostics);
diagnostics.push(...this.oobRecorder.diagnostics);

return {
diagnostics,
Expand All @@ -234,7 +239,7 @@ export class TypeCheckContext {
this.opMap.set(sf, []);
}
const ops = this.opMap.get(sf) !;
ops.push(new TcbOp(ref, tcbMeta, this.config, this.domSchemaChecker));
ops.push(new TcbOp(ref, tcbMeta, this.config, this.domSchemaChecker, this.oobRecorder));
}
}

Expand Down Expand Up @@ -266,7 +271,8 @@ class TcbOp implements Op {
constructor(
readonly ref: Reference<ClassDeclaration<ts.ClassDeclaration>>,
readonly meta: TypeCheckBlockMetadata, readonly config: TypeCheckingConfig,
readonly domSchemaChecker: DomSchemaChecker) {}
readonly domSchemaChecker: DomSchemaChecker,
readonly oobRecorder: OutOfBandDiagnosticRecorder) {}

/**
* Type check blocks are inserted immediately after the end of the component class.
Expand All @@ -277,7 +283,8 @@ class TcbOp implements Op {
string {
const env = new Environment(this.config, im, refEmitter, sf);
const fnName = ts.createIdentifier(`_tcb_${this.ref.node.pos}`);
const fn = generateTypeCheckBlock(env, this.ref, fnName, this.meta, this.domSchemaChecker);
const fn = generateTypeCheckBlock(
env, this.ref, fnName, this.meta, this.domSchemaChecker, this.oobRecorder);
return printer.printNode(ts.EmitHint.Unspecified, fn, sf);
}
}
Expand Down
7 changes: 6 additions & 1 deletion packages/compiler-cli/src/ngtsc/typecheck/src/diagnostics.ts
Expand Up @@ -5,7 +5,7 @@
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {ParseSourceSpan, ParseSpan, Position} from '@angular/compiler';
import {AbsoluteSourceSpan, ParseSourceSpan, ParseSpan, Position} from '@angular/compiler';
import * as ts from 'typescript';

import {getTokenAtPosition} from '../../util/src/typescript';
Expand Down Expand Up @@ -55,6 +55,11 @@ export function toAbsoluteSpan(span: ParseSpan, sourceSpan: ParseSourceSpan): Ab
return <AbsoluteSpan>{start: span.start + offset, end: span.end + offset};
}

export function absoluteSourceSpanToSourceLocation(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, maybe we should just decompose TcbSourceResolver.sourceLocationToSpan to receive two arguments instead of the SourceLocation bundle.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm all for refactoring this to clean it up :)

id: string, span: AbsoluteSourceSpan): SourceLocation {
return {id, ...span};
}

/**
* Wraps the node in parenthesis such that inserted span comments become attached to the proper
* node. This is an alias for `ts.createParen` with the benefit that it signifies that the
Expand Down
85 changes: 85 additions & 0 deletions packages/compiler-cli/src/ngtsc/typecheck/src/oob.ts
@@ -0,0 +1,85 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {AbsoluteSourceSpan, BindingPipe, TmplAstReference} from '@angular/compiler';
import * as ts from 'typescript';

import {ErrorCode, ngErrorCode} from '../../diagnostics';

import {TcbSourceResolver, absoluteSourceSpanToSourceLocation, makeTemplateDiagnostic} from './diagnostics';



/**
* Collects `ts.Diagnostic`s on problems which occur in the template which aren't directly sourced
* from Type Check Blocks.
*
* During the creation of a Type Check Block, the template is traversed and the
* `OutOfBandDiagnosticRecorder` is called to record cases when a correct interpretation for the
* template cannot be found. These operations create `ts.Diagnostic`s which are stored by the
* recorder for later display.
*/
export interface OutOfBandDiagnosticRecorder {
readonly diagnostics: ReadonlyArray<ts.Diagnostic>;

/**
* Reports a `#ref="target"` expression in the template for which a target directive could not be
* found.
*
* @param templateId the template type-checking ID of the template which contains the broken
* reference.
* @param ref the `TmplAstReference` which could not be matched to a directive.
*/
missingReferenceTarget(templateId: string, ref: TmplAstReference): void;

/**
* Reports usage of a `| pipe` expression in the template for which the named pipe could not be
* found.
*
* @param templateId the template type-checking ID of the template which contains the unknown
* pipe.
* @param ast the `BindingPipe` invocation of the pipe which could not be found.
* @param sourceSpan the `AbsoluteSourceSpan` of the pipe invocation (ideally, this should be the
* source span of the pipe's name). This depends on the source span of the `BindingPipe` itself
* plus span of the larger expression context.
*/
missingPipe(templateId: string, ast: BindingPipe, sourceSpan: AbsoluteSourceSpan): void;
}

export class OutOfBandDiagnosticRecorderImpl implements OutOfBandDiagnosticRecorder {
private _diagnostics: ts.Diagnostic[] = [];

constructor(private resolver: TcbSourceResolver) {}

get diagnostics(): ReadonlyArray<ts.Diagnostic> { return this._diagnostics; }

missingReferenceTarget(templateId: string, ref: TmplAstReference): void {
const mapping = this.resolver.getSourceMapping(templateId);
const value = ref.value.trim();

const errorMsg = `No directive found with exportAs '${value}'.`;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For future consideration: have an intuitive way of visualising the module scope/available directive exports to better indicate what needs to happen to resolve this diagnostic. Because it's a non-local issue, how to fix it is not straightforward (and one of the areas I would like to see Angular become better is in error reporting!)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I completely agree. One of the goals of having the centralized ErrorCode for Angular errors was to keep track of the different classes of errors and make it easier for us to set goals of improving them.

this._diagnostics.push(makeTemplateDiagnostic(
mapping, ref.valueSpan || ref.sourceSpan, ts.DiagnosticCategory.Error,
ngErrorCode(ErrorCode.MISSING_REFERENCE_TARGET), errorMsg));
}

missingPipe(templateId: string, ast: BindingPipe, absSpan: AbsoluteSourceSpan): void {
const mapping = this.resolver.getSourceMapping(templateId);
const errorMsg = `No pipe found with name '${ast.name}'.`;

const location = absoluteSourceSpanToSourceLocation(templateId, absSpan);
const sourceSpan = this.resolver.sourceLocationToSpan(location);
if (sourceSpan === null) {
throw new Error(
`Assertion failure: no SourceLocation found for usage of pipe '${ast.name}'.`);
}
this._diagnostics.push(makeTemplateDiagnostic(
mapping, sourceSpan, ts.DiagnosticCategory.Error, ngErrorCode(ErrorCode.MISSING_PIPE),
errorMsg));
}
}
55 changes: 45 additions & 10 deletions packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts
Expand Up @@ -17,6 +17,7 @@ import {addParseSpanInfo, addSourceId, toAbsoluteSpan, wrapForDiagnostics} from
import {DomSchemaChecker} from './dom';
import {Environment} from './environment';
import {NULL_AS_ANY, astToTypescript} from './expression';
import {OutOfBandDiagnosticRecorder} from './oob';
import {checkIfClassIsExported, checkIfGenericTypesAreUnbound, tsCallMethod, tsCastToAny, tsCreateElement, tsCreateVariable, tsDeclareVariable} from './ts_util';


Expand All @@ -28,15 +29,27 @@ import {checkIfClassIsExported, checkIfGenericTypesAreUnbound, tsCallMethod, tsC
* When passed through TypeScript's TypeChecker, type errors that arise within the type check block
* function indicate issues in the template itself.
*
* @param node the TypeScript node for the component class.
* As a side effect of generating a TCB for the component, `ts.Diagnostic`s may also be produced
* directly for issues within the template which are identified during generation. These issues are
* recorded in either the `domSchemaChecker` (which checks usage of DOM elements and bindings) as
* well as the `oobRecorder` (which records errors when the type-checking code generator is unable
* to sufficiently understand a template).
*
* @param env an `Environment` into which type-checking code will be generated.
* @param ref a `Reference` to the component class which should be type-checked.
* @param name a `ts.Identifier` to use for the generated `ts.FunctionDeclaration`.
* @param meta metadata about the component's template and the function being generated.
* @param importManager an `ImportManager` for the file into which the TCB will be written.
* @param domSchemaChecker used to check and record errors regarding improper usage of DOM elements
* and bindings.
* @param oobRecorder used to record errors regarding template elements which could not be correctly
* translated into types during TCB generation.
*/
export function generateTypeCheckBlock(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should make clear that this function has a side-effect when it comes to producing diagnostics. Having the out of bands parameter does indicate this somewhat, but it could be made more explicit. Come to think of it, it would be a good idea for future reference to document OutOfBandDiagnosticRecorder on why it exists and how it's used.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. I updated and expanded the comment to talk about the verifications which happen during generation.

As mentioned above, I think the "right way" to do this is to refactor the template traversal to be independent of everything, and hang various operations off of this central traversal, including both the verification diagnostics and TCB generation.

env: Environment, ref: Reference<ClassDeclaration<ts.ClassDeclaration>>, name: ts.Identifier,
meta: TypeCheckBlockMetadata, domSchemaChecker: DomSchemaChecker): ts.FunctionDeclaration {
const tcb =
new Context(env, domSchemaChecker, meta.id, meta.boundTarget, meta.pipes, meta.schemas);
meta: TypeCheckBlockMetadata, domSchemaChecker: DomSchemaChecker,
oobRecorder: OutOfBandDiagnosticRecorder): ts.FunctionDeclaration {
const tcb = new Context(
env, domSchemaChecker, oobRecorder, meta.id, meta.boundTarget, meta.pipes, meta.schemas);
const scope = Scope.forNodes(tcb, null, tcb.boundTarget.target.template !);
const ctxRawType = env.referenceType(ref);
if (!ts.isTypeReferenceNode(ctxRawType)) {
Expand Down Expand Up @@ -562,7 +575,8 @@ export class Context {
private nextId = 1;

constructor(
readonly env: Environment, readonly domSchemaChecker: DomSchemaChecker, readonly id: string,
readonly env: Environment, readonly domSchemaChecker: DomSchemaChecker,
readonly oobRecorder: OutOfBandDiagnosticRecorder, readonly id: string,
readonly boundTarget: BoundTarget<TypeCheckableDirectiveMeta>,
private pipes: Map<string, Reference<ClassDeclaration<ts.ClassDeclaration>>>,
readonly schemas: SchemaMetadata[]) {}
Expand All @@ -575,9 +589,9 @@ export class Context {
*/
allocateId(): ts.Identifier { return ts.createIdentifier(`_t${this.nextId++}`); }

getPipeByName(name: string): ts.Expression {
getPipeByName(name: string): ts.Expression|null {
if (!this.pipes.has(name)) {
throw new Error(`Missing pipe: ${name}`);
return null;
}
return this.env.pipeInst(this.pipes.get(name) !);
}
Expand Down Expand Up @@ -797,6 +811,7 @@ class Scope {
for (const child of node.children) {
this.appendNode(child);
}
this.checkReferencesOfNode(node);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am surprised you took this approach, intertwining the reporting of missing refs with the generation of TCBs, instead of adding some diagnostic reporting facility to R3TargetBinder itself.

Copy link
Member Author

@alxhub alxhub Oct 30, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered this, actually. Part of the problem is that the way we map expressions back into the original template source depends a lot on the way we traverse the template. This doesn't matter for References which are a top level concept and have proper source spans, but for BindingPipes which are an AST node, the source mapping depends on the context and the R3TargetBinder doesn't currently deal with those concerns.

Additionally, it's easier within the TCB generator to conform to the checkTemplateBodies config option.

Ultimately we might refactor in this direction. Especially because the HTML-level template parsing errors are not currently surfaced via our "nice" diagnostics mechanism. We'll eventually want to port that over too.

Maybe it would make more sense to refactor the template traversal out of the TCB code, and make TCB generation a side effect as well. So the core template typechecking engine would walk through the template, and call into each "plugin" to do things like check reference validity, check the DOM schema, and cause TCBs to be emitted into the type check file.

} else if (node instanceof TmplAstTemplate) {
// Template children are rendered in a child scope.
this.appendDirectivesAndInputsOfNode(node);
Expand All @@ -806,11 +821,20 @@ class Scope {
this.templateCtxOpMap.set(node, ctxIndex);
this.opQueue.push(new TcbTemplateBodyOp(this.tcb, this, node));
}
this.checkReferencesOfNode(node);
} else if (node instanceof TmplAstBoundText) {
this.opQueue.push(new TcbTextInterpolationOp(this.tcb, this, node));
}
}

private checkReferencesOfNode(node: TmplAstElement|TmplAstTemplate): void {
for (const ref of node.references) {
if (this.tcb.boundTarget.getReferenceTarget(ref) === null) {
this.tcb.oobRecorder.missingReferenceTarget(this.tcb.id, ref);
}
}
}

private appendDirectivesAndInputsOfNode(node: TmplAstElement|TmplAstTemplate): void {
// Collect all the inputs on the element.
const claimedInputs = new Set<string>();
Expand Down Expand Up @@ -964,9 +988,17 @@ class TcbExpressionTranslator {
return ts.createIdentifier('ctx');
} else if (ast instanceof BindingPipe) {
const expr = this.translate(ast.exp);
let pipe: ts.Expression;
let pipe: ts.Expression|null;
if (this.tcb.env.config.checkTypeOfPipes) {
pipe = this.tcb.getPipeByName(ast.name);
if (pipe === null) {
// No pipe by that name exists in scope. Record this as an error.
const nameAbsoluteSpan = toAbsoluteSpan(ast.nameSpan, this.sourceSpan);
this.tcb.oobRecorder.missingPipe(this.tcb.id, ast, nameAbsoluteSpan);

// Return an 'any' value to at least allow the rest of the expression to be checked.
pipe = NULL_AS_ANY;
}
} else {
pipe = ts.createParen(ts.createAsExpression(
ts.createNull(), ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword)));
Expand Down Expand Up @@ -1025,7 +1057,10 @@ class TcbExpressionTranslator {
} else if (binding instanceof TmplAstReference) {
const target = this.tcb.boundTarget.getReferenceTarget(binding);
if (target === null) {
throw new Error(`Unbound reference? ${binding.name}`);
// This reference is unbound. Traversal of the `TmplAstReference` itself should have
// recorded the error in the `OutOfBandDiagnosticRecorder`.
// Still check the rest of the expression if possible by using an `any` value.
return NULL_AS_ANY;
}

// The reference is either to an element, an <ng-template> node, or to a directive on an
Expand Down
Expand Up @@ -15,9 +15,11 @@ import {ImportManager} from '../../translator';
import {TypeCheckBlockMetadata, TypeCheckingConfig} from './api';
import {DomSchemaChecker} from './dom';
import {Environment} from './environment';
import {OutOfBandDiagnosticRecorder} from './oob';
import {generateTypeCheckBlock} from './type_check_block';



/**
* An `Environment` representing the single type-checking file into which most (if not all) Type
* Check Blocks (TCBs) will be generated.
Expand All @@ -38,9 +40,9 @@ export class TypeCheckFile extends Environment {

addTypeCheckBlock(
ref: Reference<ClassDeclaration<ts.ClassDeclaration>>, meta: TypeCheckBlockMetadata,
domSchemaChecker: DomSchemaChecker): void {
domSchemaChecker: DomSchemaChecker, oobRecorder: OutOfBandDiagnosticRecorder): void {
const fnId = ts.createIdentifier(`_tcb${this.nextTcbId++}`);
const fn = generateTypeCheckBlock(this, ref, fnId, meta, domSchemaChecker);
const fn = generateTypeCheckBlock(this, ref, fnId, meta, domSchemaChecker, oobRecorder);
this.tcbStatements.push(fn);
}

Expand Down
11 changes: 9 additions & 2 deletions packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {CssSelector, ParseSourceFile, ParseSourceSpan, R3TargetBinder, SchemaMetadata, SelectorMatcher, TmplAstElement, Type, parseTemplate} from '@angular/compiler';
import {CssSelector, ParseSourceFile, ParseSourceSpan, R3TargetBinder, SchemaMetadata, SelectorMatcher, TmplAstElement, TmplAstReference, Type, parseTemplate} from '@angular/compiler';
import * as ts from 'typescript';

import {AbsoluteFsPath, LogicalFileSystem, absoluteFrom} from '../../file_system';
Expand All @@ -19,6 +19,7 @@ import {TemplateSourceMapping, TypeCheckBlockMetadata, TypeCheckableDirectiveMet
import {TypeCheckContext} from '../src/context';
import {DomSchemaChecker} from '../src/dom';
import {Environment} from '../src/environment';
import {OutOfBandDiagnosticRecorder} from '../src/oob';
import {generateTypeCheckBlock} from '../src/type_check_block';

export function typescriptLibDts(): TestFile {
Expand Down Expand Up @@ -220,7 +221,7 @@ export function tcb(

const tcb = generateTypeCheckBlock(
FakeEnvironment.newFake(config), new Reference(clazz), ts.createIdentifier('Test_TCB'), meta,
new NoopSchemaChecker());
new NoopSchemaChecker(), new NoopOobRecorder());

const removeComments = !options.emitSpans;
const res = ts.createPrinter({removeComments}).printNode(ts.EmitHint.Unspecified, tcb, sf);
Expand Down Expand Up @@ -382,3 +383,9 @@ export class NoopSchemaChecker implements DomSchemaChecker {
id: string, element: TmplAstElement, name: string, span: ParseSourceSpan,
schemas: SchemaMetadata[]): void {}
}

export class NoopOobRecorder implements OutOfBandDiagnosticRecorder {
get diagnostics(): ReadonlyArray<ts.Diagnostic> { return []; }
missingReferenceTarget(): void {}
missingPipe(): void {}
}
18 changes: 18 additions & 0 deletions packages/compiler-cli/test/ngtsc/ngtsc_spec.ts
Expand Up @@ -3102,6 +3102,24 @@ runInEachFileSystem(os => {
});
});

describe('local refs', () => {
it('should not generate an error when a local ref is unresolved' +
' (outside of template type-checking)',
() => {

env.write('test.ts', `
import {Component} from '@angular/core';

@Component({
template: '<div #ref="unknownTarget"></div>',
})
export class TestCmp {}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(0);
});
});

describe('multiple local refs', () => {
const getComponentScript = (template: string): string => `
import {Component, Directive, NgModule} from '@angular/core';
Expand Down