feat(ivy): translate type-check diagnostics to their original source (#30181)
PR Close #30181
This commit is contained in:
parent
489cef6ea2
commit
3a2b195a58
|
@ -34,7 +34,7 @@ const EMPTY_ARRAY: any[] = [];
|
||||||
|
|
||||||
export interface ComponentHandlerData {
|
export interface ComponentHandlerData {
|
||||||
meta: R3ComponentMetadata;
|
meta: R3ComponentMetadata;
|
||||||
parsedTemplate: TmplAstNode[];
|
parsedTemplate: {nodes: TmplAstNode[]; file: ParseSourceFile};
|
||||||
metadataStmt: Statement|null;
|
metadataStmt: Statement|null;
|
||||||
parseTemplate: (options?: ParseTemplateOptions) => ParsedTemplate;
|
parseTemplate: (options?: ParseTemplateOptions) => ParsedTemplate;
|
||||||
}
|
}
|
||||||
|
@ -308,7 +308,7 @@ export class ComponentDecoratorHandler implements
|
||||||
},
|
},
|
||||||
metadataStmt: generateSetClassMetadataCall(
|
metadataStmt: generateSetClassMetadataCall(
|
||||||
node, this.reflector, this.defaultImportRecorder, this.isCore),
|
node, this.reflector, this.defaultImportRecorder, this.isCore),
|
||||||
parsedTemplate: template.nodes, parseTemplate,
|
parsedTemplate: template, parseTemplate,
|
||||||
},
|
},
|
||||||
typeCheck: true,
|
typeCheck: true,
|
||||||
};
|
};
|
||||||
|
@ -360,7 +360,7 @@ export class ComponentDecoratorHandler implements
|
||||||
const extMeta = flattenInheritedDirectiveMetadata(this.metaReader, meta.ref);
|
const extMeta = flattenInheritedDirectiveMetadata(this.metaReader, meta.ref);
|
||||||
matcher.addSelectables(CssSelector.parse(meta.selector), extMeta);
|
matcher.addSelectables(CssSelector.parse(meta.selector), extMeta);
|
||||||
}
|
}
|
||||||
const bound = new R3TargetBinder(matcher).bind({template: meta.parsedTemplate});
|
const bound = new R3TargetBinder(matcher).bind({template: meta.parsedTemplate.nodes});
|
||||||
const pipes = new Map<string, Reference<ClassDeclaration<ts.ClassDeclaration>>>();
|
const pipes = new Map<string, Reference<ClassDeclaration<ts.ClassDeclaration>>>();
|
||||||
for (const {name, ref} of scope.compilation.pipes) {
|
for (const {name, ref} of scope.compilation.pipes) {
|
||||||
if (!ts.isClassDeclaration(ref.node)) {
|
if (!ts.isClassDeclaration(ref.node)) {
|
||||||
|
@ -369,7 +369,7 @@ export class ComponentDecoratorHandler implements
|
||||||
}
|
}
|
||||||
pipes.set(name, ref as Reference<ClassDeclaration<ts.ClassDeclaration>>);
|
pipes.set(name, ref as Reference<ClassDeclaration<ts.ClassDeclaration>>);
|
||||||
}
|
}
|
||||||
ctx.addTemplate(new Reference(node), bound, pipes);
|
ctx.addTemplate(new Reference(node), bound, pipes, meta.parsedTemplate.file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -381,7 +381,7 @@ export class NgtscProgram implements api.Program {
|
||||||
return ((opts && opts.mergeEmitResultsCallback) || mergeEmitResults)(emitResults);
|
return ((opts && opts.mergeEmitResultsCallback) || mergeEmitResults)(emitResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getTemplateDiagnostics(): ReadonlyArray<ts.Diagnostic> {
|
private getTemplateDiagnostics(): ReadonlyArray<api.Diagnostic|ts.Diagnostic> {
|
||||||
// Skip template type-checking if it's disabled.
|
// Skip template type-checking if it's disabled.
|
||||||
if (this.options.ivyTemplateTypeCheck === false &&
|
if (this.options.ivyTemplateTypeCheck === false &&
|
||||||
this.options.fullTemplateTypeCheck !== true) {
|
this.options.fullTemplateTypeCheck !== true) {
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {BoundTarget} from '@angular/compiler';
|
import {BoundTarget, ParseLocation, ParseSourceFile, ParseSourceSpan} from '@angular/compiler';
|
||||||
import * as ts from 'typescript';
|
import * as ts from 'typescript';
|
||||||
|
|
||||||
import {AbsoluteFsPath} from '../../file_system';
|
import {AbsoluteFsPath} from '../../file_system';
|
||||||
|
@ -15,8 +15,10 @@ import {ClassDeclaration} from '../../reflection';
|
||||||
import {ImportManager} from '../../translator';
|
import {ImportManager} from '../../translator';
|
||||||
|
|
||||||
import {TypeCheckBlockMetadata, TypeCheckableDirectiveMeta, TypeCheckingConfig, TypeCtorMetadata} from './api';
|
import {TypeCheckBlockMetadata, TypeCheckableDirectiveMeta, TypeCheckingConfig, TypeCtorMetadata} from './api';
|
||||||
|
import {Diagnostic, SourceLocation, getSourceReferenceName, shouldReportDiagnostic, translateDiagnostic} from './diagnostics';
|
||||||
import {Environment} from './environment';
|
import {Environment} from './environment';
|
||||||
import {TypeCheckProgramHost} from './host';
|
import {TypeCheckProgramHost} from './host';
|
||||||
|
import {computeLineStartsMap, getLineAndCharacterFromPosition} from './line_mappings';
|
||||||
import {generateTypeCheckBlock, requiresInlineTypeCheckBlock} from './type_check_block';
|
import {generateTypeCheckBlock, requiresInlineTypeCheckBlock} from './type_check_block';
|
||||||
import {TypeCheckFile, typeCheckFilePath} from './type_check_file';
|
import {TypeCheckFile, typeCheckFilePath} from './type_check_file';
|
||||||
import {generateInlineTypeCtor, requiresInlineTypeCtor} from './type_constructor';
|
import {generateInlineTypeCtor, requiresInlineTypeCtor} from './type_constructor';
|
||||||
|
@ -51,6 +53,13 @@ export class TypeCheckContext {
|
||||||
*/
|
*/
|
||||||
private typeCtorPending = new Set<ts.ClassDeclaration>();
|
private typeCtorPending = new Set<ts.ClassDeclaration>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This map keeps track of all template sources that have been type-checked by the reference name
|
||||||
|
* that is attached to a TCB's function declaration as leading trivia. This enables translation
|
||||||
|
* of diagnostics produced for TCB code to their source location in the template.
|
||||||
|
*/
|
||||||
|
private templateSources = new Map<string, TemplateSource>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record a template for the given component `node`, with a `SelectorMatcher` for directive
|
* Record a template for the given component `node`, with a `SelectorMatcher` for directive
|
||||||
* matching.
|
* matching.
|
||||||
|
@ -62,7 +71,10 @@ export class TypeCheckContext {
|
||||||
addTemplate(
|
addTemplate(
|
||||||
ref: Reference<ClassDeclaration<ts.ClassDeclaration>>,
|
ref: Reference<ClassDeclaration<ts.ClassDeclaration>>,
|
||||||
boundTarget: BoundTarget<TypeCheckableDirectiveMeta>,
|
boundTarget: BoundTarget<TypeCheckableDirectiveMeta>,
|
||||||
pipes: Map<string, Reference<ClassDeclaration<ts.ClassDeclaration>>>): void {
|
pipes: Map<string, Reference<ClassDeclaration<ts.ClassDeclaration>>>,
|
||||||
|
file: ParseSourceFile): void {
|
||||||
|
this.templateSources.set(getSourceReferenceName(ref.node), new TemplateSource(file));
|
||||||
|
|
||||||
// Get all of the directives used in the template and record type constructors for all of them.
|
// Get all of the directives used in the template and record type constructors for all of them.
|
||||||
for (const dir of boundTarget.getUsedDirectives()) {
|
for (const dir of boundTarget.getUsedDirectives()) {
|
||||||
const dirRef = dir.ref as Reference<ClassDeclaration<ts.ClassDeclaration>>;
|
const dirRef = dir.ref as Reference<ClassDeclaration<ts.ClassDeclaration>>;
|
||||||
|
@ -149,7 +161,7 @@ export class TypeCheckContext {
|
||||||
// the source code in between the original chunks.
|
// the source code in between the original chunks.
|
||||||
ops.forEach((op, idx) => {
|
ops.forEach((op, idx) => {
|
||||||
const text = op.execute(importManager, sf, this.refEmitter, printer);
|
const text = op.execute(importManager, sf, this.refEmitter, printer);
|
||||||
code += text + textParts[idx + 1];
|
code += '\n\n' + text + textParts[idx + 1];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Write out the imports that need to be added to the beginning of the file.
|
// Write out the imports that need to be added to the beginning of the file.
|
||||||
|
@ -165,7 +177,7 @@ export class TypeCheckContext {
|
||||||
calculateTemplateDiagnostics(
|
calculateTemplateDiagnostics(
|
||||||
originalProgram: ts.Program, originalHost: ts.CompilerHost,
|
originalProgram: ts.Program, originalHost: ts.CompilerHost,
|
||||||
originalOptions: ts.CompilerOptions): {
|
originalOptions: ts.CompilerOptions): {
|
||||||
diagnostics: ts.Diagnostic[],
|
diagnostics: Diagnostic[],
|
||||||
program: ts.Program,
|
program: ts.Program,
|
||||||
} {
|
} {
|
||||||
const typeCheckSf = this.typeCheckFile.render();
|
const typeCheckSf = this.typeCheckFile.render();
|
||||||
|
@ -189,26 +201,32 @@ export class TypeCheckContext {
|
||||||
rootNames: originalProgram.getRootFileNames(),
|
rootNames: originalProgram.getRootFileNames(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const diagnostics: ts.Diagnostic[] = [];
|
const diagnostics: Diagnostic[] = [];
|
||||||
|
const resolveSpan = (sourceLocation: SourceLocation): ParseSourceSpan | null => {
|
||||||
|
if (!this.templateSources.has(sourceLocation.sourceReference)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const templateSource = this.templateSources.get(sourceLocation.sourceReference) !;
|
||||||
|
return templateSource.toParseSourceSpan(sourceLocation.start, sourceLocation.end);
|
||||||
|
};
|
||||||
|
const collectDiagnostics = (diags: readonly ts.Diagnostic[]): void => {
|
||||||
|
for (const diagnostic of diags) {
|
||||||
|
if (shouldReportDiagnostic(diagnostic)) {
|
||||||
|
const translated = translateDiagnostic(diagnostic, resolveSpan);
|
||||||
|
|
||||||
|
if (translated !== null) {
|
||||||
|
diagnostics.push(translated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
for (const sf of interestingFiles) {
|
for (const sf of interestingFiles) {
|
||||||
diagnostics.push(...typeCheckProgram.getSemanticDiagnostics(sf));
|
collectDiagnostics(typeCheckProgram.getSemanticDiagnostics(sf));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
diagnostics: diagnostics.filter(
|
diagnostics,
|
||||||
(diag: ts.Diagnostic):
|
|
||||||
boolean => {
|
|
||||||
if (diag.code === 6133 /* $var is declared but its value is never read. */) {
|
|
||||||
return false;
|
|
||||||
} else if (diag.code === 6199 /* All variables are unused. */) {
|
|
||||||
return false;
|
|
||||||
} else if (
|
|
||||||
diag.code ===
|
|
||||||
2695 /* Left side of comma operator is unused and has no side effects. */) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}),
|
|
||||||
program: typeCheckProgram,
|
program: typeCheckProgram,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -225,6 +243,35 @@ export class TypeCheckContext {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the source of a template that was processed during type-checking. This information is
|
||||||
|
* used when translating parse offsets in diagnostics back to their original line/column location.
|
||||||
|
*/
|
||||||
|
class TemplateSource {
|
||||||
|
private lineStarts: number[]|null = null;
|
||||||
|
|
||||||
|
constructor(private file: ParseSourceFile) {}
|
||||||
|
|
||||||
|
toParseSourceSpan(start: number, end: number): ParseSourceSpan {
|
||||||
|
const startLoc = this.toParseLocation(start);
|
||||||
|
const endLoc = this.toParseLocation(end);
|
||||||
|
return new ParseSourceSpan(startLoc, endLoc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toParseLocation(position: number) {
|
||||||
|
const lineStarts = this.acquireLineStarts();
|
||||||
|
const {line, character} = getLineAndCharacterFromPosition(lineStarts, position);
|
||||||
|
return new ParseLocation(this.file, position, line, character);
|
||||||
|
}
|
||||||
|
|
||||||
|
private acquireLineStarts(): number[] {
|
||||||
|
if (this.lineStarts === null) {
|
||||||
|
this.lineStarts = computeLineStartsMap(this.file.content);
|
||||||
|
}
|
||||||
|
return this.lineStarts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A code generation operation that needs to happen within a given source file.
|
* A code generation operation that needs to happen within a given source file.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -5,11 +5,37 @@
|
||||||
* Use of this source code is governed by an MIT-style license that can be
|
* 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
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
import {ParseSourceSpan, ParseSpan} from '@angular/compiler';
|
import {ParseSourceSpan, ParseSpan, Position} from '@angular/compiler';
|
||||||
import * as ts from 'typescript';
|
import * as ts from 'typescript';
|
||||||
|
|
||||||
import {ClassDeclaration} from '../../reflection';
|
import {ClassDeclaration} from '../../reflection';
|
||||||
import {getSourceFile} from '../../util/src/typescript';
|
import {getSourceFile, getTokenAtPosition} from '../../util/src/typescript';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FIXME: Taken from packages/compiler-cli/src/transformers/api.ts to prevent circular dep,
|
||||||
|
* modified to account for new span notation.
|
||||||
|
*/
|
||||||
|
export interface DiagnosticMessageChain {
|
||||||
|
messageText: string;
|
||||||
|
position?: Position;
|
||||||
|
next?: DiagnosticMessageChain;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Diagnostic {
|
||||||
|
messageText: string;
|
||||||
|
span?: ParseSourceSpan;
|
||||||
|
position?: Position;
|
||||||
|
chain?: DiagnosticMessageChain;
|
||||||
|
category: ts.DiagnosticCategory;
|
||||||
|
code: number;
|
||||||
|
source: 'angular';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceLocation {
|
||||||
|
sourceReference: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An `AbsoluteSpan` is the result of translating the `ParseSpan` of `AST` template expression nodes
|
* An `AbsoluteSpan` is the result of translating the `ParseSpan` of `AST` template expression nodes
|
||||||
|
@ -51,24 +77,156 @@ export function wrapForDiagnostics(expr: ts.Expression): ts.Expression {
|
||||||
*/
|
*/
|
||||||
export function addParseSpanInfo(node: ts.Node, span: AbsoluteSpan | ParseSourceSpan): void {
|
export function addParseSpanInfo(node: ts.Node, span: AbsoluteSpan | ParseSourceSpan): void {
|
||||||
let commentText: string;
|
let commentText: string;
|
||||||
if (typeof span.start === 'number') {
|
if (isAbsoluteSpan(span)) {
|
||||||
commentText = `${span.start},${span.end}`;
|
commentText = `${span.start},${span.end}`;
|
||||||
} else {
|
} else {
|
||||||
const {start, end} = span as ParseSourceSpan;
|
commentText = `${span.start.offset},${span.end.offset}`;
|
||||||
commentText = `${start.offset},${end.offset}`;
|
|
||||||
}
|
}
|
||||||
ts.addSyntheticTrailingComment(
|
ts.addSyntheticTrailingComment(
|
||||||
node, ts.SyntaxKind.MultiLineCommentTrivia, commentText,
|
node, ts.SyntaxKind.MultiLineCommentTrivia, commentText,
|
||||||
/* hasTrailingNewLine */ false);
|
/* hasTrailingNewLine */ false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAbsoluteSpan(span: AbsoluteSpan | ParseSourceSpan): span is AbsoluteSpan {
|
||||||
|
return typeof span.start === 'number';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a synthetic comment to the function declaration that contains the source location
|
* Adds a synthetic comment to the function declaration that contains the source location
|
||||||
* of the class declaration.
|
* of the class declaration.
|
||||||
*/
|
*/
|
||||||
export function addSourceInfo(
|
export function addSourceReferenceName(
|
||||||
tcb: ts.FunctionDeclaration, source: ClassDeclaration<ts.ClassDeclaration>): void {
|
tcb: ts.FunctionDeclaration, source: ClassDeclaration): void {
|
||||||
const fileName = getSourceFile(source).fileName;
|
const commentText = getSourceReferenceName(source);
|
||||||
const commentText = `${fileName}#${source.name.text}`;
|
|
||||||
ts.addSyntheticLeadingComment(tcb, ts.SyntaxKind.MultiLineCommentTrivia, commentText, true);
|
ts.addSyntheticLeadingComment(tcb, ts.SyntaxKind.MultiLineCommentTrivia, commentText, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSourceReferenceName(source: ClassDeclaration): string {
|
||||||
|
const fileName = getSourceFile(source).fileName;
|
||||||
|
return `${fileName}#${source.name.text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if the diagnostic should be reported. Some diagnostics are produced because of the
|
||||||
|
* way TCBs are generated; those diagnostics should not be reported as type check errors of the
|
||||||
|
* template.
|
||||||
|
*/
|
||||||
|
export function shouldReportDiagnostic(diagnostic: ts.Diagnostic): boolean {
|
||||||
|
const {code} = diagnostic;
|
||||||
|
if (code === 6133 /* $var is declared but its value is never read. */) {
|
||||||
|
return false;
|
||||||
|
} else if (code === 6199 /* All variables are unused. */) {
|
||||||
|
return false;
|
||||||
|
} else if (code === 2695 /* Left side of comma operator is unused and has no side effects. */) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to translate a TypeScript diagnostic produced during template type-checking to their
|
||||||
|
* location of origin, based on the comments that are emitted in the TCB code.
|
||||||
|
*
|
||||||
|
* If the diagnostic could not be translated, `null` is returned to indicate that the diagnostic
|
||||||
|
* should not be reported at all. This prevents diagnostics from non-TCB code in a user's source
|
||||||
|
* file from being reported as type-check errors.
|
||||||
|
*/
|
||||||
|
export function translateDiagnostic(
|
||||||
|
diagnostic: ts.Diagnostic, resolveParseSource: (sourceLocation: SourceLocation) =>
|
||||||
|
ParseSourceSpan | null): Diagnostic|null {
|
||||||
|
if (diagnostic.file === undefined || diagnostic.start === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate the node that the diagnostic is reported on and determine its location in the source.
|
||||||
|
const node = getTokenAtPosition(diagnostic.file, diagnostic.start);
|
||||||
|
const sourceLocation = findSourceLocation(node, diagnostic.file);
|
||||||
|
if (sourceLocation === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now use the external resolver to obtain the full `ParseSourceFile` of the template.
|
||||||
|
const span = resolveParseSource(sourceLocation);
|
||||||
|
if (span === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let messageText: string;
|
||||||
|
if (typeof diagnostic.messageText === 'string') {
|
||||||
|
messageText = diagnostic.messageText;
|
||||||
|
} else {
|
||||||
|
messageText = diagnostic.messageText.messageText;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'angular',
|
||||||
|
code: diagnostic.code,
|
||||||
|
category: diagnostic.category, messageText, span,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSourceLocation(node: ts.Node, sourceFile: ts.SourceFile): SourceLocation|null {
|
||||||
|
// Search for comments until the TCB's function declaration is encountered.
|
||||||
|
while (node !== undefined && !ts.isFunctionDeclaration(node)) {
|
||||||
|
const parseSpan =
|
||||||
|
ts.forEachTrailingCommentRange(sourceFile.text, node.getEnd(), (pos, end, kind) => {
|
||||||
|
if (kind !== ts.SyntaxKind.MultiLineCommentTrivia) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const commentText = sourceFile.text.substring(pos, end);
|
||||||
|
return parseParseSpanComment(commentText);
|
||||||
|
}) || null;
|
||||||
|
if (parseSpan !== null) {
|
||||||
|
// Once the positional information has been extracted, search further up the TCB to extract
|
||||||
|
// the file information that is attached with the TCB's function declaration.
|
||||||
|
return toSourceLocation(parseSpan, node, sourceFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
node = node.parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSourceLocation(
|
||||||
|
parseSpan: ParseSpan, node: ts.Node, sourceFile: ts.SourceFile): SourceLocation|null {
|
||||||
|
// Walk up to the function declaration of the TCB, the file information is attached there.
|
||||||
|
let tcb = node;
|
||||||
|
while (!ts.isFunctionDeclaration(tcb)) {
|
||||||
|
tcb = tcb.parent;
|
||||||
|
|
||||||
|
// Bail once we have reached the root.
|
||||||
|
if (tcb === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceReference =
|
||||||
|
ts.forEachLeadingCommentRange(sourceFile.text, tcb.getFullStart(), (pos, end, kind) => {
|
||||||
|
if (kind !== ts.SyntaxKind.MultiLineCommentTrivia) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const commentText = sourceFile.text.substring(pos, end);
|
||||||
|
return commentText.substring(2, commentText.length - 2);
|
||||||
|
}) || null;
|
||||||
|
if (sourceReference === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceReference,
|
||||||
|
start: parseSpan.start,
|
||||||
|
end: parseSpan.end,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseSpanComment = /^\/\*(\d+),(\d+)\*\/$/;
|
||||||
|
|
||||||
|
function parseParseSpanComment(commentText: string): ParseSpan|null {
|
||||||
|
const match = commentText.match(parseSpanComment);
|
||||||
|
if (match === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {start: +match[1], end: +match[2]};
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,63 @@
|
||||||
|
/**
|
||||||
|
* @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
|
||||||
|
*/
|
||||||
|
|
||||||
|
const LF_CHAR = 10;
|
||||||
|
const CR_CHAR = 13;
|
||||||
|
const LINE_SEP_CHAR = 8232;
|
||||||
|
const PARAGRAPH_CHAR = 8233;
|
||||||
|
|
||||||
|
/** Gets the line and character for the given position from the line starts map. */
|
||||||
|
export function getLineAndCharacterFromPosition(lineStartsMap: number[], position: number) {
|
||||||
|
const lineIndex = findClosestLineStartPosition(lineStartsMap, position);
|
||||||
|
return {character: position - lineStartsMap[lineIndex], line: lineIndex};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the line start map of the given text. This can be used in order to
|
||||||
|
* retrieve the line and character of a given text position index.
|
||||||
|
*/
|
||||||
|
export function computeLineStartsMap(text: string): number[] {
|
||||||
|
const result: number[] = [0];
|
||||||
|
let pos = 0;
|
||||||
|
while (pos < text.length) {
|
||||||
|
const char = text.charCodeAt(pos++);
|
||||||
|
// Handles the "CRLF" line break. In that case we peek the character
|
||||||
|
// after the "CR" and check if it is a line feed.
|
||||||
|
if (char === CR_CHAR) {
|
||||||
|
if (text.charCodeAt(pos) === LF_CHAR) {
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
result.push(pos);
|
||||||
|
} else if (char === LF_CHAR || char === LINE_SEP_CHAR || char === PARAGRAPH_CHAR) {
|
||||||
|
result.push(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push(pos);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finds the closest line start for the given position. */
|
||||||
|
function findClosestLineStartPosition<T>(
|
||||||
|
linesMap: T[], position: T, low = 0, high = linesMap.length - 1) {
|
||||||
|
while (low <= high) {
|
||||||
|
const pivotIdx = Math.floor((low + high) / 2);
|
||||||
|
const pivotEl = linesMap[pivotIdx];
|
||||||
|
|
||||||
|
if (pivotEl === position) {
|
||||||
|
return pivotIdx;
|
||||||
|
} else if (position > pivotEl) {
|
||||||
|
low = pivotIdx + 1;
|
||||||
|
} else {
|
||||||
|
high = pivotIdx - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In case there was no exact match, return the closest "lower" line index. We also
|
||||||
|
// subtract the index by one because want the index of the previous line start.
|
||||||
|
return low - 1;
|
||||||
|
}
|
|
@ -13,7 +13,7 @@ import {Reference} from '../../imports';
|
||||||
import {ClassDeclaration} from '../../reflection';
|
import {ClassDeclaration} from '../../reflection';
|
||||||
|
|
||||||
import {TypeCheckBlockMetadata, TypeCheckableDirectiveMeta} from './api';
|
import {TypeCheckBlockMetadata, TypeCheckableDirectiveMeta} from './api';
|
||||||
import {addParseSpanInfo, addSourceInfo, toAbsoluteSpan, wrapForDiagnostics} from './diagnostics';
|
import {addParseSpanInfo, addSourceReferenceName, toAbsoluteSpan, wrapForDiagnostics} from './diagnostics';
|
||||||
import {Environment} from './environment';
|
import {Environment} from './environment';
|
||||||
import {astToTypescript} from './expression';
|
import {astToTypescript} from './expression';
|
||||||
import {checkIfClassIsExported, checkIfGenericTypesAreUnbound, tsCallMethod, tsCastToAny, tsCreateElement, tsCreateVariable, tsDeclareVariable} from './ts_util';
|
import {checkIfClassIsExported, checkIfGenericTypesAreUnbound, tsCallMethod, tsCastToAny, tsCreateElement, tsCreateVariable, tsDeclareVariable} from './ts_util';
|
||||||
|
@ -60,7 +60,7 @@ export function generateTypeCheckBlock(
|
||||||
/* parameters */ paramList,
|
/* parameters */ paramList,
|
||||||
/* type */ undefined,
|
/* type */ undefined,
|
||||||
/* body */ body);
|
/* body */ body);
|
||||||
addSourceInfo(fnDecl, ref.node);
|
addSourceReferenceName(fnDecl, ref.node);
|
||||||
return fnDecl;
|
return fnDecl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,257 @@
|
||||||
|
/**
|
||||||
|
* @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 {TestFile, runInEachFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing';
|
||||||
|
import * as ts from 'typescript';
|
||||||
|
|
||||||
|
import {Diagnostic} from '../src/diagnostics';
|
||||||
|
|
||||||
|
import {NGFOR_DECLARATION, TestDeclaration, ngForDts, typecheck} from './test_utils';
|
||||||
|
|
||||||
|
runInEachFileSystem(() => {
|
||||||
|
describe('template diagnostics', () => {
|
||||||
|
it('works for directive bindings', () => {
|
||||||
|
const messages = diagnose(
|
||||||
|
`<div dir [input]="person.name"></div>`, `
|
||||||
|
class Dir {
|
||||||
|
input: number;
|
||||||
|
}
|
||||||
|
class TestComponent {
|
||||||
|
person: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}`,
|
||||||
|
[{
|
||||||
|
type: 'directive',
|
||||||
|
name: 'Dir',
|
||||||
|
selector: '[dir]',
|
||||||
|
exportAs: ['dir'],
|
||||||
|
inputs: {input: 'input'},
|
||||||
|
}]);
|
||||||
|
|
||||||
|
expect(messages).toEqual(
|
||||||
|
[`synthetic.html(9, 30): Type 'string' is not assignable to type 'number | undefined'.`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('infers type of template variables', () => {
|
||||||
|
const messages = diagnose(
|
||||||
|
`<div *ngFor="let person of persons; let idx=index">{{ render(idx) }}</div>`, `
|
||||||
|
class TestComponent {
|
||||||
|
persons: {}[];
|
||||||
|
|
||||||
|
render(input: string): string { return input; }
|
||||||
|
}`,
|
||||||
|
[NGFOR_DECLARATION], [ngForDts()]);
|
||||||
|
|
||||||
|
expect(messages).toEqual([
|
||||||
|
`synthetic.html(61, 64): Argument of type 'number' is not assignable to parameter of type 'string'.`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('infers any type when generic type inference fails', () => {
|
||||||
|
const messages = diagnose(
|
||||||
|
`<div *ngFor="let person of persons;">{{ render(person.namme) }}</div>`, `
|
||||||
|
class TestComponent {
|
||||||
|
persons: any;
|
||||||
|
|
||||||
|
render(input: string): string { return input; }
|
||||||
|
}`,
|
||||||
|
[NGFOR_DECLARATION], [ngForDts()]);
|
||||||
|
|
||||||
|
expect(messages).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('infers type of element references', () => {
|
||||||
|
const messages = diagnose(
|
||||||
|
`<div dir #el>{{ render(el) }}</div>`, `
|
||||||
|
class Dir {
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
class TestComponent {
|
||||||
|
render(input: string): string { return input; }
|
||||||
|
}`,
|
||||||
|
[{
|
||||||
|
type: 'directive',
|
||||||
|
name: 'Dir',
|
||||||
|
selector: '[dir]',
|
||||||
|
exportAs: ['dir'],
|
||||||
|
}]);
|
||||||
|
|
||||||
|
expect(messages).toEqual([
|
||||||
|
`synthetic.html(23, 25): Argument of type 'HTMLDivElement' is not assignable to parameter of type 'string'.`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('infers type of directive references', () => {
|
||||||
|
const messages = diagnose(
|
||||||
|
`<div dir #dir="dir">{{ render(dir) }}</div>`, `
|
||||||
|
class Dir {
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
class TestComponent {
|
||||||
|
render(input: string): string { return input; }
|
||||||
|
}`,
|
||||||
|
[{
|
||||||
|
type: 'directive',
|
||||||
|
name: 'Dir',
|
||||||
|
selector: '[dir]',
|
||||||
|
exportAs: ['dir'],
|
||||||
|
}]);
|
||||||
|
|
||||||
|
expect(messages).toEqual([
|
||||||
|
`synthetic.html(30, 33): Argument of type 'Dir' is not assignable to parameter of type 'string'.`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('infers TemplateRef<any> for ng-template references', () => {
|
||||||
|
const messages = diagnose(`<ng-template #tmpl>{{ render(tmpl) }}</ng-template>`, `
|
||||||
|
class TestComponent {
|
||||||
|
render(input: string): string { return input; }
|
||||||
|
}`);
|
||||||
|
|
||||||
|
expect(messages).toEqual([
|
||||||
|
`synthetic.html(29, 33): Argument of type 'TemplateRef<any>' is not assignable to parameter of type 'string'.`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('infers type of template context', () => {
|
||||||
|
const messages = diagnose(
|
||||||
|
`<div *ngFor="let person of persons">{{ person.namme }}</div>`, `
|
||||||
|
class TestComponent {
|
||||||
|
persons: {
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
|
}`,
|
||||||
|
[NGFOR_DECLARATION], [ngForDts()]);
|
||||||
|
|
||||||
|
expect(messages).toEqual([
|
||||||
|
`synthetic.html(39, 52): Property 'namme' does not exist on type '{ name: string; }'. Did you mean 'name'?`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('interprets interpolation as strings', () => {
|
||||||
|
const messages = diagnose(`<blockquote cite="{{ person }}"></blockquote>`, `
|
||||||
|
class TestComponent {
|
||||||
|
person: {};
|
||||||
|
}`);
|
||||||
|
|
||||||
|
expect(messages).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks bindings to regular element', () => {
|
||||||
|
const messages = diagnose(`<img [srcc]="src" [height]="heihgt">`, `
|
||||||
|
class TestComponent {
|
||||||
|
src: string;
|
||||||
|
height: number;
|
||||||
|
}`);
|
||||||
|
|
||||||
|
expect(messages).toEqual([
|
||||||
|
`synthetic.html(5, 17): Property 'srcc' does not exist on type 'HTMLImageElement'. Did you mean 'src'?`,
|
||||||
|
`synthetic.html(28, 34): Property 'heihgt' does not exist on type 'TestComponent'. Did you mean 'height'?`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces diagnostics for pipes', () => {
|
||||||
|
const messages = diagnose(
|
||||||
|
`<div>{{ person.name | pipe:person.age:1 }}</div>`, `
|
||||||
|
class Pipe {
|
||||||
|
transform(value: string, a: string, b: string): string { return a + b; }
|
||||||
|
}
|
||||||
|
class TestComponent {
|
||||||
|
person: {
|
||||||
|
name: string;
|
||||||
|
age: number;
|
||||||
|
};
|
||||||
|
}`,
|
||||||
|
[{type: 'pipe', name: 'Pipe', pipeName: 'pipe'}]);
|
||||||
|
|
||||||
|
expect(messages).toEqual([
|
||||||
|
`synthetic.html(27, 37): Argument of type 'number' is not assignable to parameter of type 'string'.`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not produce diagnostics for user code', () => {
|
||||||
|
const messages = diagnose(`{{ person.name }}`, `
|
||||||
|
class TestComponent {
|
||||||
|
person: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
render(input: string): number { return input; } // <-- type error here should not be reported
|
||||||
|
}`);
|
||||||
|
|
||||||
|
expect(messages).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('strict null checks', () => {
|
||||||
|
it('produces diagnostic for unchecked property access', () => {
|
||||||
|
const messages =
|
||||||
|
diagnose(`<div [class.has-street]="person.address.street.length > 0"></div>`, `
|
||||||
|
export class TestComponent {
|
||||||
|
person: {
|
||||||
|
address?: {
|
||||||
|
street: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}`);
|
||||||
|
|
||||||
|
expect(messages).toEqual([`synthetic.html(25, 46): Object is possibly 'undefined'.`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not produce diagnostic for checked property access', () => {
|
||||||
|
const messages = diagnose(
|
||||||
|
`<div [class.has-street]="person.address && person.address.street.length > 0"></div>`, `
|
||||||
|
export class TestComponent {
|
||||||
|
person: {
|
||||||
|
address?: {
|
||||||
|
street: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}`);
|
||||||
|
|
||||||
|
expect(messages).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes line and column offsets', () => {
|
||||||
|
const diagnostics = typecheck(
|
||||||
|
`
|
||||||
|
<div>
|
||||||
|
<img [src]="srcc"
|
||||||
|
[height]="heihgt">
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
`
|
||||||
|
class TestComponent {
|
||||||
|
src: string;
|
||||||
|
height: number;
|
||||||
|
}`);
|
||||||
|
|
||||||
|
expect(diagnostics.length).toBe(2);
|
||||||
|
expect(formatSpan(diagnostics[0])).toBe('2:14, 2:18');
|
||||||
|
expect(formatSpan(diagnostics[1])).toBe('3:17, 3:23');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function diagnose(
|
||||||
|
template: string, source: string, declarations?: TestDeclaration[],
|
||||||
|
additionalSources: TestFile[] = []): string[] {
|
||||||
|
const diagnostics = typecheck(template, source, declarations, additionalSources);
|
||||||
|
return diagnostics.map(diagnostic => {
|
||||||
|
const span = diagnostic.span !;
|
||||||
|
return `${span.start.file.url}(${span.start.offset}, ${span.end.offset}): ${diagnostic.messageText}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSpan(diagostic: ts.Diagnostic | Diagnostic): string {
|
||||||
|
if (diagostic.source !== 'angular') {
|
||||||
|
return '<unexpected non-angular span>';
|
||||||
|
}
|
||||||
|
const diag = diagostic as Diagnostic;
|
||||||
|
return `${diag.span!.start.line}:${diag.span!.start.col}, ${diag.span!.end.line}:${diag.span!.end.col}`;
|
||||||
|
}
|
|
@ -6,21 +6,128 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {CssSelector, R3TargetBinder, SelectorMatcher, parseTemplate} from '@angular/compiler';
|
import {CssSelector, ParseSourceFile, R3TargetBinder, SelectorMatcher, parseTemplate} from '@angular/compiler';
|
||||||
import * as ts from 'typescript';
|
import * as ts from 'typescript';
|
||||||
|
|
||||||
import {Reference} from '../../imports';
|
import {AbsoluteFsPath, LogicalFileSystem, absoluteFrom} from '../../file_system';
|
||||||
import {ClassDeclaration, isNamedClassDeclaration} from '../../reflection';
|
import {TestFile} from '../../file_system/testing';
|
||||||
|
import {AbsoluteModuleStrategy, LocalIdentifierStrategy, LogicalProjectStrategy, Reference, ReferenceEmitter} from '../../imports';
|
||||||
|
import {ClassDeclaration, TypeScriptReflectionHost, isNamedClassDeclaration} from '../../reflection';
|
||||||
|
import {makeProgram} from '../../testing';
|
||||||
|
import {getRootDirs} from '../../util/src/typescript';
|
||||||
import {TypeCheckBlockMetadata, TypeCheckableDirectiveMeta, TypeCheckingConfig} from '../src/api';
|
import {TypeCheckBlockMetadata, TypeCheckableDirectiveMeta, TypeCheckingConfig} from '../src/api';
|
||||||
|
import {TypeCheckContext} from '../src/context';
|
||||||
|
import {Diagnostic} from '../src/diagnostics';
|
||||||
import {Environment} from '../src/environment';
|
import {Environment} from '../src/environment';
|
||||||
import {generateTypeCheckBlock} from '../src/type_check_block';
|
import {generateTypeCheckBlock} from '../src/type_check_block';
|
||||||
|
|
||||||
|
export function typescriptLibDts(): TestFile {
|
||||||
|
return {
|
||||||
|
name: absoluteFrom('/lib.d.ts'),
|
||||||
|
contents: `
|
||||||
|
type Partial<T> = { [P in keyof T]?: T[P]; };
|
||||||
|
type Pick<T, K extends keyof T> = { [P in K]: T[P]; };
|
||||||
|
type NonNullable<T> = T extends null | undefined ? never : T;
|
||||||
|
|
||||||
|
// The following native type declarations are required for proper type inference
|
||||||
|
declare interface Function {
|
||||||
|
call(...args: any[]): any;
|
||||||
|
}
|
||||||
|
declare interface Array<T> {
|
||||||
|
length: number;
|
||||||
|
}
|
||||||
|
declare interface String {
|
||||||
|
length: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare interface HTMLElement {}
|
||||||
|
declare interface HTMLDivElement extends HTMLElement {}
|
||||||
|
declare interface HTMLImageElement extends HTMLElement {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
declare interface HTMLQuoteElement extends HTMLElement {
|
||||||
|
cite: string;
|
||||||
|
}
|
||||||
|
declare interface HTMLElementTagNameMap {
|
||||||
|
"blockquote": HTMLQuoteElement;
|
||||||
|
"div": HTMLDivElement;
|
||||||
|
"img": HTMLImageElement;
|
||||||
|
}
|
||||||
|
declare interface Document {
|
||||||
|
createElement<K extends keyof HTMLElementTagNameMap>(tagName: K): HTMLElementTagNameMap[K];
|
||||||
|
createElement(tagName: string): HTMLElement;
|
||||||
|
}
|
||||||
|
declare const document: Document;
|
||||||
|
`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function angularCoreDts(): TestFile {
|
||||||
|
return {
|
||||||
|
name: absoluteFrom('/node_modules/@angular/core/index.d.ts'),
|
||||||
|
contents: `
|
||||||
|
export declare class TemplateRef<C> {
|
||||||
|
abstract readonly elementRef: unknown;
|
||||||
|
abstract createEmbeddedView(context: C): unknown;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NGFOR_DECLARATION: TestDeclaration = {
|
||||||
|
type: 'directive',
|
||||||
|
file: 'ngfor.d.ts',
|
||||||
|
selector: '[ngForOf]',
|
||||||
|
name: 'NgForOf',
|
||||||
|
inputs: {ngForOf: 'ngForOf'},
|
||||||
|
hasNgTemplateContextGuard: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ngForDts(): TestFile {
|
||||||
|
return {
|
||||||
|
name: absoluteFrom('/ngfor.d.ts'),
|
||||||
|
contents: `
|
||||||
|
export declare class NgForOf<T> {
|
||||||
|
ngForOf: T[];
|
||||||
|
ngForTrackBy: TrackByFunction<T>;
|
||||||
|
static ngTemplateContextGuard<T>(dir: NgForOf<T>, ctx: any): ctx is NgForOfContext<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackByFunction<T> {
|
||||||
|
(index: number, item: T): any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare class NgForOfContext<T> {
|
||||||
|
$implicit: T;
|
||||||
|
index: number;
|
||||||
|
count: number;
|
||||||
|
readonly odd: boolean;
|
||||||
|
readonly even: boolean;
|
||||||
|
readonly first: boolean;
|
||||||
|
readonly last: boolean;
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ALL_ENABLED_CONFIG: TypeCheckingConfig = {
|
||||||
|
applyTemplateContextGuards: true,
|
||||||
|
checkQueries: false,
|
||||||
|
checkTemplateBodies: true,
|
||||||
|
checkTypeOfBindings: true,
|
||||||
|
checkTypeOfPipes: true,
|
||||||
|
strictSafeNavigationTypes: true,
|
||||||
|
};
|
||||||
|
|
||||||
// Remove 'ref' from TypeCheckableDirectiveMeta and add a 'selector' instead.
|
// Remove 'ref' from TypeCheckableDirectiveMeta and add a 'selector' instead.
|
||||||
export type TestDirective =
|
export type TestDirective =
|
||||||
Partial<Pick<TypeCheckableDirectiveMeta, Exclude<keyof TypeCheckableDirectiveMeta, 'ref'>>>&
|
Partial<Pick<TypeCheckableDirectiveMeta, Exclude<keyof TypeCheckableDirectiveMeta, 'ref'>>>&
|
||||||
{selector: string, name: string, type: 'directive'};
|
{selector: string, name: string, file?: string, type: 'directive'};
|
||||||
export type TestPipe = {
|
export type TestPipe = {
|
||||||
name: string,
|
name: string,
|
||||||
|
file?: string,
|
||||||
pipeName: string,
|
pipeName: string,
|
||||||
type: 'pipe',
|
type: 'pipe',
|
||||||
};
|
};
|
||||||
|
@ -35,38 +142,13 @@ export function tcb(
|
||||||
|
|
||||||
const sf = ts.createSourceFile('synthetic.ts', code, ts.ScriptTarget.Latest, true);
|
const sf = ts.createSourceFile('synthetic.ts', code, ts.ScriptTarget.Latest, true);
|
||||||
const clazz = getClass(sf, 'Test');
|
const clazz = getClass(sf, 'Test');
|
||||||
const {nodes} = parseTemplate(template, 'synthetic.html');
|
const templateUrl = 'synthetic.html';
|
||||||
const matcher = new SelectorMatcher();
|
const {nodes} = parseTemplate(template, templateUrl);
|
||||||
|
|
||||||
for (const decl of declarations) {
|
|
||||||
if (decl.type !== 'directive') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const selector = CssSelector.parse(decl.selector);
|
|
||||||
const meta: TypeCheckableDirectiveMeta = {
|
|
||||||
name: decl.name,
|
|
||||||
ref: new Reference(getClass(sf, decl.name)),
|
|
||||||
exportAs: decl.exportAs || null,
|
|
||||||
hasNgTemplateContextGuard: decl.hasNgTemplateContextGuard || false,
|
|
||||||
inputs: decl.inputs || {},
|
|
||||||
isComponent: decl.isComponent || false,
|
|
||||||
ngTemplateGuards: decl.ngTemplateGuards || [],
|
|
||||||
outputs: decl.outputs || {},
|
|
||||||
queries: decl.queries || [],
|
|
||||||
};
|
|
||||||
matcher.addSelectables(selector, meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const {matcher, pipes} = prepareDeclarations(declarations, decl => getClass(sf, decl.name));
|
||||||
const binder = new R3TargetBinder(matcher);
|
const binder = new R3TargetBinder(matcher);
|
||||||
const boundTarget = binder.bind({template: nodes});
|
const boundTarget = binder.bind({template: nodes});
|
||||||
|
|
||||||
const pipes = new Map<string, Reference<ClassDeclaration<ts.ClassDeclaration>>>();
|
|
||||||
for (const decl of declarations) {
|
|
||||||
if (decl.type === 'pipe') {
|
|
||||||
pipes.set(decl.pipeName, new Reference(getClass(sf, decl.name)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta: TypeCheckBlockMetadata = {boundTarget, pipes};
|
const meta: TypeCheckBlockMetadata = {boundTarget, pipes};
|
||||||
|
|
||||||
config = config || {
|
config = config || {
|
||||||
|
@ -89,7 +171,91 @@ export function tcb(
|
||||||
return res.replace(/\s+/g, ' ');
|
return res.replace(/\s+/g, ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function getClass(sf: ts.SourceFile, name: string): ClassDeclaration<ts.ClassDeclaration> {
|
export function typecheck(
|
||||||
|
template: string, source: string, declarations: TestDeclaration[] = [],
|
||||||
|
additionalSources: {name: AbsoluteFsPath; contents: string}[] = []): Diagnostic[] {
|
||||||
|
const typeCheckFilePath = absoluteFrom('/_typecheck_.ts');
|
||||||
|
const files = [
|
||||||
|
typescriptLibDts(),
|
||||||
|
angularCoreDts(),
|
||||||
|
// Add the typecheck file to the program, as the typecheck program is created with the
|
||||||
|
// assumption that the typecheck file was already a root file in the original program.
|
||||||
|
{name: typeCheckFilePath, contents: 'export const TYPECHECK = true;'},
|
||||||
|
{name: absoluteFrom('/main.ts'), contents: source},
|
||||||
|
...additionalSources,
|
||||||
|
];
|
||||||
|
const {program, host, options} = makeProgram(files, {strictNullChecks: true}, undefined, false);
|
||||||
|
const sf = program.getSourceFile('main.ts') !;
|
||||||
|
const checker = program.getTypeChecker();
|
||||||
|
const logicalFs = new LogicalFileSystem(getRootDirs(host, options));
|
||||||
|
const emitter = new ReferenceEmitter([
|
||||||
|
new LocalIdentifierStrategy(),
|
||||||
|
new AbsoluteModuleStrategy(
|
||||||
|
program, checker, options, host, new TypeScriptReflectionHost(checker)),
|
||||||
|
new LogicalProjectStrategy(checker, logicalFs),
|
||||||
|
]);
|
||||||
|
const ctx = new TypeCheckContext(ALL_ENABLED_CONFIG, emitter, typeCheckFilePath);
|
||||||
|
|
||||||
|
const templateUrl = 'synthetic.html';
|
||||||
|
const templateFile = new ParseSourceFile(template, templateUrl);
|
||||||
|
const {nodes, errors} = parseTemplate(template, templateUrl);
|
||||||
|
if (errors !== undefined) {
|
||||||
|
throw new Error('Template parse errors: \n' + errors.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const {matcher, pipes} = prepareDeclarations(declarations, decl => {
|
||||||
|
let declFile = sf;
|
||||||
|
if (decl.file !== undefined) {
|
||||||
|
declFile = program.getSourceFile(decl.file) !;
|
||||||
|
if (declFile === undefined) {
|
||||||
|
throw new Error(`Unable to locate ${decl.file} for ${decl.type} ${decl.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getClass(declFile, decl.name);
|
||||||
|
});
|
||||||
|
const binder = new R3TargetBinder(matcher);
|
||||||
|
const boundTarget = binder.bind({template: nodes});
|
||||||
|
const clazz = new Reference(getClass(sf, 'TestComponent'));
|
||||||
|
|
||||||
|
ctx.addTemplate(clazz, boundTarget, pipes, templateFile);
|
||||||
|
return ctx.calculateTemplateDiagnostics(program, host, options).diagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareDeclarations(
|
||||||
|
declarations: TestDeclaration[],
|
||||||
|
resolveDeclaration: (decl: TestDeclaration) => ClassDeclaration<ts.ClassDeclaration>) {
|
||||||
|
const matcher = new SelectorMatcher();
|
||||||
|
for (const decl of declarations) {
|
||||||
|
if (decl.type !== 'directive') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selector = CssSelector.parse(decl.selector);
|
||||||
|
const meta: TypeCheckableDirectiveMeta = {
|
||||||
|
name: decl.name,
|
||||||
|
ref: new Reference(resolveDeclaration(decl)),
|
||||||
|
exportAs: decl.exportAs || null,
|
||||||
|
hasNgTemplateContextGuard: decl.hasNgTemplateContextGuard || false,
|
||||||
|
inputs: decl.inputs || {},
|
||||||
|
isComponent: decl.isComponent || false,
|
||||||
|
ngTemplateGuards: decl.ngTemplateGuards || [],
|
||||||
|
outputs: decl.outputs || {},
|
||||||
|
queries: decl.queries || [],
|
||||||
|
};
|
||||||
|
matcher.addSelectables(selector, meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pipes = new Map<string, Reference<ClassDeclaration<ts.ClassDeclaration>>>();
|
||||||
|
for (const decl of declarations) {
|
||||||
|
if (decl.type === 'pipe') {
|
||||||
|
pipes.set(decl.pipeName, new Reference(resolveDeclaration(decl)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {matcher, pipes};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClass(sf: ts.SourceFile, name: string): ClassDeclaration<ts.ClassDeclaration> {
|
||||||
for (const stmt of sf.statements) {
|
for (const stmt of sf.statements) {
|
||||||
if (isNamedClassDeclaration(stmt) && stmt.name.text === name) {
|
if (isNamedClassDeclaration(stmt) && stmt.name.text === name) {
|
||||||
return stmt;
|
return stmt;
|
||||||
|
|
|
@ -12,17 +12,8 @@ import {AbsoluteModuleStrategy, LocalIdentifierStrategy, LogicalProjectStrategy,
|
||||||
import {TypeScriptReflectionHost, isNamedClassDeclaration} from '../../reflection';
|
import {TypeScriptReflectionHost, isNamedClassDeclaration} from '../../reflection';
|
||||||
import {getDeclaration, makeProgram} from '../../testing';
|
import {getDeclaration, makeProgram} from '../../testing';
|
||||||
import {getRootDirs} from '../../util/src/typescript';
|
import {getRootDirs} from '../../util/src/typescript';
|
||||||
import {TypeCheckingConfig} from '../src/api';
|
|
||||||
import {TypeCheckContext} from '../src/context';
|
import {TypeCheckContext} from '../src/context';
|
||||||
|
import {ALL_ENABLED_CONFIG} from './test_utils';
|
||||||
const ALL_ENABLED_CONFIG: TypeCheckingConfig = {
|
|
||||||
applyTemplateContextGuards: true,
|
|
||||||
checkQueries: false,
|
|
||||||
checkTemplateBodies: true,
|
|
||||||
checkTypeOfBindings: true,
|
|
||||||
checkTypeOfPipes: true,
|
|
||||||
strictSafeNavigationTypes: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
runInEachFileSystem(() => {
|
runInEachFileSystem(() => {
|
||||||
describe('ngtsc typechecking', () => {
|
describe('ngtsc typechecking', () => {
|
||||||
|
|
|
@ -53,6 +53,11 @@ export function getSourceFileOrNull(program: ts.Program, fileName: AbsoluteFsPat
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function getTokenAtPosition(sf: ts.SourceFile, pos: number): ts.Node {
|
||||||
|
// getTokenAtPosition is part of TypeScript's private API.
|
||||||
|
return (ts as any).getTokenAtPosition(sf, pos);
|
||||||
|
}
|
||||||
|
|
||||||
export function identifierOfNode(decl: ts.Node & {name?: ts.Node}): ts.Identifier|null {
|
export function identifierOfNode(decl: ts.Node & {name?: ts.Node}): ts.Identifier|null {
|
||||||
if (decl.name !== undefined && ts.isIdentifier(decl.name)) {
|
if (decl.name !== undefined && ts.isIdentifier(decl.name)) {
|
||||||
return decl.name;
|
return decl.name;
|
||||||
|
|
|
@ -72,11 +72,11 @@ export function formatDiagnostic(
|
||||||
result += `${formatDiagnosticPosition(diagnostic.position, host)}: `;
|
result += `${formatDiagnosticPosition(diagnostic.position, host)}: `;
|
||||||
}
|
}
|
||||||
if (diagnostic.span && diagnostic.span.details) {
|
if (diagnostic.span && diagnostic.span.details) {
|
||||||
result += `: ${diagnostic.span.details}, ${diagnostic.messageText}${newLine}`;
|
result += `${diagnostic.span.details}, ${diagnostic.messageText}${newLine}`;
|
||||||
} else if (diagnostic.chain) {
|
} else if (diagnostic.chain) {
|
||||||
result += `${flattenDiagnosticMessageChain(diagnostic.chain, host)}.${newLine}`;
|
result += `${flattenDiagnosticMessageChain(diagnostic.chain, host)}.${newLine}`;
|
||||||
} else {
|
} else {
|
||||||
result += `: ${diagnostic.messageText}${newLine}`;
|
result += `${diagnostic.messageText}${newLine}`;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {CustomTransformers, Program} from '@angular/compiler-cli';
|
import {CustomTransformers, Program} from '@angular/compiler-cli';
|
||||||
|
import * as api from '@angular/compiler-cli/src/transformers/api';
|
||||||
import * as ts from 'typescript';
|
import * as ts from 'typescript';
|
||||||
|
|
||||||
import {createCompilerHost, createProgram} from '../../ngtools2';
|
import {createCompilerHost, createProgram} from '../../ngtools2';
|
||||||
|
@ -185,9 +186,8 @@ export class NgtscTestEnvironment {
|
||||||
/**
|
/**
|
||||||
* Run the compiler to completion, and return any `ts.Diagnostic` errors that may have occurred.
|
* Run the compiler to completion, and return any `ts.Diagnostic` errors that may have occurred.
|
||||||
*/
|
*/
|
||||||
driveDiagnostics(): ReadonlyArray<ts.Diagnostic> {
|
driveDiagnostics(): ReadonlyArray<ts.Diagnostic|api.Diagnostic> {
|
||||||
// Cast is safe as ngtsc mode only produces ts.Diagnostics.
|
return mainDiagnosticsForTest(['-p', this.basePath]);
|
||||||
return mainDiagnosticsForTest(['-p', this.basePath]) as ReadonlyArray<ts.Diagnostic>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
driveRoutes(entryPoint?: string): LazyRoute[] {
|
driveRoutes(entryPoint?: string): LazyRoute[] {
|
||||||
|
|
|
@ -2979,7 +2979,8 @@ runInEachFileSystem(os => {
|
||||||
'entrypoint.');
|
'entrypoint.');
|
||||||
|
|
||||||
// Verify that the error is for the correct class.
|
// Verify that the error is for the correct class.
|
||||||
const id = expectTokenAtPosition(errors[0].file !, errors[0].start !, ts.isIdentifier);
|
const error = errors[0] as ts.Diagnostic;
|
||||||
|
const id = expectTokenAtPosition(error.file !, error.start !, ts.isIdentifier);
|
||||||
expect(id.text).toBe('Dir');
|
expect(id.text).toBe('Dir');
|
||||||
expect(ts.isClassDeclaration(id.parent)).toBe(true);
|
expect(ts.isClassDeclaration(id.parent)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
|
@ -5,10 +5,13 @@
|
||||||
* Use of this source code is governed by an MIT-style license that can be
|
* 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
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {Diagnostic} from '@angular/compiler-cli';
|
||||||
import * as ts from 'typescript';
|
import * as ts from 'typescript';
|
||||||
|
|
||||||
import {ErrorCode, ngErrorCode} from '../../src/ngtsc/diagnostics';
|
import {ErrorCode, ngErrorCode} from '../../src/ngtsc/diagnostics';
|
||||||
import {runInEachFileSystem} from '../../src/ngtsc/file_system/testing';
|
import {runInEachFileSystem} from '../../src/ngtsc/file_system/testing';
|
||||||
|
import {getTokenAtPosition} from '../../src/ngtsc/util/src/typescript';
|
||||||
import {loadStandardTestFiles} from '../helpers/src/mock_file_loading';
|
import {loadStandardTestFiles} from '../helpers/src/mock_file_loading';
|
||||||
|
|
||||||
import {NgtscTestEnvironment} from './env';
|
import {NgtscTestEnvironment} from './env';
|
||||||
|
@ -179,11 +182,12 @@ runInEachFileSystem(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
function diagnosticToNode<T extends ts.Node>(
|
function diagnosticToNode<T extends ts.Node>(
|
||||||
diag: ts.Diagnostic, guard: (node: ts.Node) => node is T): T {
|
diagnostic: ts.Diagnostic | Diagnostic, guard: (node: ts.Node) => node is T): T {
|
||||||
|
const diag = diagnostic as ts.Diagnostic;
|
||||||
if (diag.file === undefined) {
|
if (diag.file === undefined) {
|
||||||
throw new Error(`Expected ts.Diagnostic to have a file source`);
|
throw new Error(`Expected ts.Diagnostic to have a file source`);
|
||||||
}
|
}
|
||||||
const node = (ts as any).getTokenAtPosition(diag.file, diag.start) as ts.Node;
|
const node = getTokenAtPosition(diag.file, diag.start !);
|
||||||
expect(guard(node)).toBe(true);
|
expect(guard(node)).toBe(true);
|
||||||
return node as T;
|
return node as T;
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {Diagnostic} from '@angular/compiler-cli';
|
||||||
import * as ts from 'typescript';
|
import * as ts from 'typescript';
|
||||||
|
|
||||||
import {runInEachFileSystem} from '../../src/ngtsc/file_system/testing';
|
import {runInEachFileSystem} from '../../src/ngtsc/file_system/testing';
|
||||||
|
@ -171,6 +172,7 @@ export declare class CommonModule {
|
||||||
const diags = env.driveDiagnostics();
|
const diags = env.driveDiagnostics();
|
||||||
expect(diags.length).toBe(1);
|
expect(diags.length).toBe(1);
|
||||||
expect(diags[0].messageText).toContain('does_not_exist');
|
expect(diags[0].messageText).toContain('does_not_exist');
|
||||||
|
expect(formatSpan(diags[0])).toEqual('/test.ts: 6:51, 6:70');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should accept an NgFor iteration over an any-typed value', () => {
|
it('should accept an NgFor iteration over an any-typed value', () => {
|
||||||
|
@ -271,6 +273,7 @@ export declare class CommonModule {
|
||||||
const diags = env.driveDiagnostics();
|
const diags = env.driveDiagnostics();
|
||||||
expect(diags.length).toBe(1);
|
expect(diags.length).toBe(1);
|
||||||
expect(diags[0].messageText).toContain('does_not_exist');
|
expect(diags[0].messageText).toContain('does_not_exist');
|
||||||
|
expect(formatSpan(diags[0])).toEqual('/test.ts: 6:51, 6:70');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should property type-check a microsyntax variable with the same name as the expression',
|
it('should property type-check a microsyntax variable with the same name as the expression',
|
||||||
|
@ -334,10 +337,48 @@ export declare class CommonModule {
|
||||||
// Error from the binding to [fromBase].
|
// Error from the binding to [fromBase].
|
||||||
expect(diags[0].messageText)
|
expect(diags[0].messageText)
|
||||||
.toBe(`Type 'number' is not assignable to type 'string | undefined'.`);
|
.toBe(`Type 'number' is not assignable to type 'string | undefined'.`);
|
||||||
|
expect(formatSpan(diags[0])).toEqual('/test.ts: 19:28, 19:42');
|
||||||
|
|
||||||
// Error from the binding to [fromChild].
|
// Error from the binding to [fromChild].
|
||||||
expect(diags[1].messageText)
|
expect(diags[1].messageText)
|
||||||
.toBe(`Type 'number' is not assignable to type 'boolean | undefined'.`);
|
.toBe(`Type 'number' is not assignable to type 'boolean | undefined'.`);
|
||||||
|
expect(formatSpan(diags[1])).toEqual('/test.ts: 19:43, 19:58');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report diagnostics for external template files', () => {
|
||||||
|
env.write('test.ts', `
|
||||||
|
import {Component, NgModule} from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'test',
|
||||||
|
templateUrl: './template.html',
|
||||||
|
})
|
||||||
|
export class TestCmp {
|
||||||
|
user: {name: string}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [TestCmp],
|
||||||
|
})
|
||||||
|
export class Module {}
|
||||||
|
`);
|
||||||
|
env.write('template.html', `<div>
|
||||||
|
<span>{{user.does_not_exist}}</span>
|
||||||
|
</div>`);
|
||||||
|
|
||||||
|
const diags = env.driveDiagnostics();
|
||||||
|
expect(diags.length).toBe(1);
|
||||||
|
expect(diags[0].messageText).toContain('does_not_exist');
|
||||||
|
expect(formatSpan(diags[0])).toEqual('/template.html: 1:14, 1:33');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function formatSpan(diagnostic: ts.Diagnostic | Diagnostic): string {
|
||||||
|
if (diagnostic.source !== 'angular') {
|
||||||
|
return '<unexpected non-angular span>';
|
||||||
|
}
|
||||||
|
const span = (diagnostic as Diagnostic).span !;
|
||||||
|
const fileName = span.start.file.url.replace(/^C:\//, '/');
|
||||||
|
return `${fileName}: ${span.start.line}:${span.start.col}, ${span.end.line}:${span.end.col}`;
|
||||||
|
}
|
||||||
|
|
|
@ -7,9 +7,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {AST} from '../../expression_parser/ast';
|
import {AST} from '../../expression_parser/ast';
|
||||||
|
|
||||||
import {BoundAttribute, BoundEvent, Element, Node, Reference, Template, TextAttribute, Variable} from '../r3_ast';
|
import {BoundAttribute, BoundEvent, Element, Node, Reference, Template, TextAttribute, Variable} from '../r3_ast';
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* t2 is the replacement for the `TemplateDefinitionBuilder`. It handles the operations of
|
* t2 is the replacement for the `TemplateDefinitionBuilder`. It handles the operations of
|
||||||
* analyzing Angular templates, extracting semantic info, and ultimately producing a template
|
* analyzing Angular templates, extracting semantic info, and ultimately producing a template
|
||||||
|
|
Loading…
Reference in New Issue