diff --git a/packages/language-service/src/language_service.ts b/packages/language-service/src/language_service.ts index d451209f13..f3c5d2b02e 100644 --- a/packages/language-service/src/language_service.ts +++ b/packages/language-service/src/language_service.ts @@ -36,7 +36,7 @@ class LanguageServiceImpl implements LanguageService { const results: Diagnostic[] = []; const templates = this.host.getTemplates(fileName); for (const template of templates) { - const ast = this.host.getTemplateAst(template, fileName); + const ast = this.host.getTemplateAst(template); results.push(...getTemplateDiagnostics(template, ast)); } const declarations = this.host.getDeclarations(fileName); diff --git a/packages/language-service/src/template.ts b/packages/language-service/src/template.ts new file mode 100644 index 0000000000..241746bcc4 --- /dev/null +++ b/packages/language-service/src/template.ts @@ -0,0 +1,168 @@ +/** + * @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 {getClassMembersFromDeclaration, getPipesTable, getSymbolQuery} from '@angular/compiler-cli'; +import * as ts from 'typescript'; +import * as ng from './types'; +import {TypeScriptServiceHost} from './typescript_host'; + +/** + * A base class to represent a template and which component class it is + * associated with. A template source could answer basic questions about + * top-level declarations of its class through the members() and query() + * methods. + */ +abstract class BaseTemplate implements ng.TemplateSource { + private readonly program: ts.Program; + private membersTable: ng.SymbolTable|undefined; + private queryCache: ng.SymbolQuery|undefined; + + constructor( + private readonly host: TypeScriptServiceHost, + private readonly classDeclNode: ts.ClassDeclaration, + private readonly classSymbol: ng.StaticSymbol) { + this.program = host.program; + } + + abstract get span(): ng.Span; + abstract get fileName(): string; + abstract get source(): string; + + /** + * Return the Angular StaticSymbol for the class that contains this template. + */ + get type() { return this.classSymbol; } + + /** + * Return a Map-like data structure that allows users to retrieve some or all + * top-level declarations in the associated component class. + */ + get members() { + if (!this.membersTable) { + const typeChecker = this.program.getTypeChecker(); + const sourceFile = this.classDeclNode.getSourceFile(); + this.membersTable = + getClassMembersFromDeclaration(this.program, typeChecker, sourceFile, this.classDeclNode); + } + return this.membersTable; + } + + /** + * Return an engine that provides more information about symbols in the + * template. + */ + get query() { + if (!this.queryCache) { + const program = this.program; + const typeChecker = program.getTypeChecker(); + const sourceFile = this.classDeclNode.getSourceFile(); + this.queryCache = getSymbolQuery(program, typeChecker, sourceFile, () => { + // Computing the ast is relatively expensive. Do it only when absolutely + // necessary. + // TODO: There is circular dependency here between TemplateSource and + // TypeScriptHost. Consider refactoring the code to break this cycle. + const ast = this.host.getTemplateAst(this); + return getPipesTable(sourceFile, program, typeChecker, ast.pipes || []); + }); + } + return this.queryCache; + } +} + +/** + * An InlineTemplate represents template defined in a TS file through the + * `template` attribute in the decorator. + */ +export class InlineTemplate extends BaseTemplate { + public readonly fileName: string; + public readonly source: string; + public readonly span: ng.Span; + + constructor( + templateNode: ts.StringLiteralLike, classDeclNode: ts.ClassDeclaration, + classSymbol: ng.StaticSymbol, host: TypeScriptServiceHost) { + super(host, classDeclNode, classSymbol); + const sourceFile = templateNode.getSourceFile(); + if (sourceFile !== classDeclNode.getSourceFile()) { + throw new Error(`Inline template and component class should belong to the same source file`); + } + this.fileName = sourceFile.fileName; + this.source = templateNode.text; + this.span = { + // TS string literal includes surrounding quotes in the start/end offsets. + start: templateNode.getStart() + 1, + end: templateNode.getEnd() - 1, + }; + } +} + +/** + * An ExternalTemplate represents template defined in an external (most likely + * HTML, but not necessarily) file through the `templateUrl` attribute in the + * decorator. + * Note that there is no ts.Node associated with the template because it's not + * a TS file. + */ +export class ExternalTemplate extends BaseTemplate { + public readonly span: ng.Span; + + constructor( + public readonly source: string, public readonly fileName: string, + classDeclNode: ts.ClassDeclaration, classSymbol: ng.StaticSymbol, + host: TypeScriptServiceHost) { + super(host, classDeclNode, classSymbol); + this.span = { + start: 0, + end: source.length, + }; + } +} + +/** + * Given a template node, return the ClassDeclaration node that corresponds to + * the component class for the template. + * + * For example, + * + * @Component({ + * template: '
' <-- template node + * }) + * class AppComponent {} + * ^---- class declaration node + * + * @param node template node + */ +export function getClassDeclFromTemplateNode(node: ts.Node): ts.ClassDeclaration|undefined { + if (!ts.isStringLiteralLike(node)) { + return; + } + if (!node.parent || !ts.isPropertyAssignment(node.parent)) { + return; + } + const propAsgnNode = node.parent; + if (propAsgnNode.name.getText() !== 'template') { + return; + } + if (!propAsgnNode.parent || !ts.isObjectLiteralExpression(propAsgnNode.parent)) { + return; + } + const objLitExprNode = propAsgnNode.parent; + if (!objLitExprNode.parent || !ts.isCallExpression(objLitExprNode.parent)) { + return; + } + const callExprNode = objLitExprNode.parent; + if (!callExprNode.parent || !ts.isDecorator(callExprNode.parent)) { + return; + } + const decorator = callExprNode.parent; + if (!decorator.parent || !ts.isClassDeclaration(decorator.parent)) { + return; + } + const classDeclNode = decorator.parent; + return classDeclNode; +} diff --git a/packages/language-service/src/types.ts b/packages/language-service/src/types.ts index 56ef99a3f2..864492f888 100644 --- a/packages/language-service/src/types.ts +++ b/packages/language-service/src/types.ts @@ -6,9 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ -import {CompileDirectiveMetadata, CompileMetadataResolver, CompilePipeSummary, NgAnalyzedModules, StaticSymbol} from '@angular/compiler'; +import {CompileDirectiveMetadata, NgAnalyzedModules, StaticSymbol} from '@angular/compiler'; import {BuiltinType, DeclarationKind, Definition, PipeInfo, Pipes, Signature, Span, Symbol, SymbolDeclaration, SymbolQuery, SymbolTable} from '@angular/compiler-cli/src/language_services'; -import * as tss from 'typescript/lib/tsserverlibrary'; import {AstResult, TemplateInfo} from './common'; @@ -20,6 +19,7 @@ export { Pipes, Signature, Span, + StaticSymbol, Symbol, SymbolDeclaration, SymbolQuery, @@ -40,16 +40,6 @@ export interface TemplateSource { */ readonly source: string; - /** - * The version of the source. As files are modified the version should change. That is, if the - * `LanguageService` requesting template information for a source file and that file has changed - * since the last time the host was asked for the file then this version string should be - * different. No assumptions are made about the format of this string. - * - * The version can change more often than the source but should not change less often. - */ - readonly version: string; - /** * The span of the template within the source file. */ @@ -69,6 +59,11 @@ export interface TemplateSource { * A `SymbolQuery` for the context of the template. */ readonly query: SymbolQuery; + + /** + * Name of the file that contains the template. Could be `.html` or `.ts`. + */ + readonly fileName: string; } /** @@ -80,7 +75,6 @@ export interface TemplateSource { */ export type TemplateSources = TemplateSource[] | undefined; - /** * Error information found getting declaration information * @@ -174,13 +168,6 @@ export type Declarations = Declaration[]; * @publicApi */ export interface LanguageServiceHost { - /** - * Returns the template information for templates in `fileName` at the given location. If - * `fileName` refers to a template file then the `position` should be ignored. If the `position` - * is not in a template literal string then this method should return `undefined`. - */ - getTemplateAt(fileName: string, position: number): TemplateSource|undefined; - /** * Return the template source information for all templates in `fileName` or for `fileName` if * it is a template file. @@ -205,7 +192,7 @@ export interface LanguageServiceHost { /** * Return the AST for both HTML and template for the contextFile. */ - getTemplateAst(template: TemplateSource, contextFile: string): AstResult; + getTemplateAst(template: TemplateSource): AstResult; /** * Return the template AST for the node that corresponds to the position. @@ -381,20 +368,20 @@ export interface LanguageService { /** * Returns a list of all error for all templates in the given file. */ - getDiagnostics(fileName: string): tss.Diagnostic[]; + getDiagnostics(fileName: string): ts.Diagnostic[]; /** * Return the completions at the given position. */ - getCompletionsAt(fileName: string, position: number): tss.CompletionInfo|undefined; + getCompletionsAt(fileName: string, position: number): ts.CompletionInfo|undefined; /** * Return the definition location for the symbol at position. */ - getDefinitionAt(fileName: string, position: number): tss.DefinitionInfoAndBoundSpan|undefined; + getDefinitionAt(fileName: string, position: number): ts.DefinitionInfoAndBoundSpan|undefined; /** * Return the hover information for the symbol at position. */ - getHoverAt(fileName: string, position: number): tss.QuickInfo|undefined; + getHoverAt(fileName: string, position: number): ts.QuickInfo|undefined; } diff --git a/packages/language-service/src/typescript_host.ts b/packages/language-service/src/typescript_host.ts index 3235964c21..679c26ba2f 100644 --- a/packages/language-service/src/typescript_host.ts +++ b/packages/language-service/src/typescript_host.ts @@ -7,14 +7,15 @@ */ import {AotSummaryResolver, CompileMetadataResolver, CompileNgModuleMetadata, CompilerConfig, DirectiveNormalizer, DirectiveResolver, DomElementSchemaRegistry, FormattedError, FormattedMessageChain, HtmlParser, I18NHtmlParser, JitSummaryResolver, Lexer, NgAnalyzedModules, NgModuleResolver, ParseTreeResult, Parser, PipeResolver, ResourceLoader, StaticReflector, StaticSymbol, StaticSymbolCache, StaticSymbolResolver, TemplateParser, analyzeNgModules, createOfflineCompileUrlResolver, isFormattedError} from '@angular/compiler'; -import {getClassMembersFromDeclaration, getPipesTable, getSymbolQuery} from '@angular/compiler-cli/src/language_services'; import {ViewEncapsulation, ɵConsole as Console} from '@angular/core'; import * as ts from 'typescript'; import {AstResult, TemplateInfo} from './common'; import {createLanguageService} from './language_service'; import {ReflectorHost} from './reflector_host'; -import {Declaration, DeclarationError, Declarations, Diagnostic, DiagnosticKind, DiagnosticMessageChain, LanguageService, LanguageServiceHost, Span, SymbolQuery, TemplateSource} from './types'; +import {ExternalTemplate, InlineTemplate, getClassDeclFromTemplateNode} from './template'; +import {Declaration, DeclarationError, Declarations, Diagnostic, DiagnosticKind, DiagnosticMessageChain, LanguageService, LanguageServiceHost, Span, TemplateSource} from './types'; +import {findTighestNode} from './utils'; /** * Create a `LanguageServiceHost` @@ -119,30 +120,6 @@ export class TypeScriptServiceHost implements LanguageServiceHost { getTemplateReferences(): string[] { return [...this.templateReferences]; } - /** - * Get the Angular template in the file, if any. If TS file is provided then - * return the inline template, otherwise return the external template. - * @param fileName Either TS or HTML file - * @param position Only used if file is TS - */ - getTemplateAt(fileName: string, position: number): TemplateSource|undefined { - if (fileName.endsWith('.ts')) { - const sourceFile = this.getSourceFile(fileName); - if (sourceFile) { - const node = this.findNode(sourceFile, position); - if (node) { - return this.getSourceFromNode(fileName, node); - } - } - } else { - const componentSymbol = this.fileToComponent.get(fileName); - if (componentSymbol) { - return this.getSourceFromType(fileName, componentSymbol); - } - } - return undefined; - } - /** * Checks whether the program has changed and returns all analyzed modules. * If program has changed, invalidate all caches and update fileToComponent @@ -183,30 +160,30 @@ export class TypeScriptServiceHost implements LanguageServiceHost { return this.analyzedModules; } + /** + * Find all templates in the specified `file`. + * @param fileName TS or HTML file + */ getTemplates(fileName: string): TemplateSource[] { const results: TemplateSource[] = []; if (fileName.endsWith('.ts')) { // Find every template string in the file const visit = (child: ts.Node) => { - const templateSource = this.getSourceFromNode(fileName, child); - if (templateSource) { - results.push(templateSource); + const template = this.getInternalTemplate(child); + if (template) { + results.push(template); } else { ts.forEachChild(child, visit); } }; - const sourceFile = this.getSourceFile(fileName); if (sourceFile) { ts.forEachChild(sourceFile, visit); } } else { - const componentSymbol = this.fileToComponent.get(fileName); - if (componentSymbol) { - const templateSource = this.getTemplateAt(fileName, 0); - if (templateSource) { - results.push(templateSource); - } + const template = this.getExternalTemplate(fileName); + if (template) { + results.push(template); } } return results; @@ -239,7 +216,7 @@ export class TypeScriptServiceHost implements LanguageServiceHost { return this.program.getSourceFile(fileName); } - private get program() { + get program() { const program = this.tsLS.getProgram(); if (!program) { // Program is very very unlikely to be undefined. @@ -288,87 +265,64 @@ export class TypeScriptServiceHost implements LanguageServiceHost { } /** - * Return the template source given the Class declaration node for the template. - * @param fileName Name of the file that contains the template. Could be TS or HTML. - * @param source Source text of the template. - * @param span Source span of the template. - * @param classSymbol Angular symbol for the class declaration. - * @param declaration TypeScript symbol for the class declaration. - * @param node If file is TS this is the template node, otherwise it's the class declaration node. - * @param sourceFile Source file of the class declaration. - */ - private getSourceFromDeclaration( - fileName: string, source: string, span: Span, classSymbol: StaticSymbol, - declaration: ts.ClassDeclaration, node: ts.Node, sourceFile: ts.SourceFile): TemplateSource - |undefined { - let queryCache: SymbolQuery|undefined = undefined; - const self = this; - const program = this.program; - const typeChecker = program.getTypeChecker(); - if (declaration) { - return { - version: this.host.getScriptVersion(fileName), - source, - span, - type: classSymbol, - get members() { - return getClassMembersFromDeclaration(program, typeChecker, sourceFile, declaration); - }, - get query() { - if (!queryCache) { - const templateInfo = self.getTemplateAst(this, fileName); - const pipes = templateInfo && templateInfo.pipes || []; - queryCache = getSymbolQuery( - program, typeChecker, sourceFile, - () => getPipesTable(sourceFile, program, typeChecker, pipes)); - } - return queryCache; - } - }; - } - } - - /** - * Return the TemplateSource for the inline template. - * @param fileName TS file that contains the template + * Return the TemplateSource if `node` is a template node. + * + * For example, + * + * @Component({ + * template: '' <-- template node + * }) + * class AppComponent {} + * ^---- class declaration node + * + * * @param node Potential template node */ - private getSourceFromNode(fileName: string, node: ts.Node): TemplateSource|undefined { - switch (node.kind) { - case ts.SyntaxKind.NoSubstitutionTemplateLiteral: - case ts.SyntaxKind.StringLiteral: - const [declaration] = this.getTemplateClassDeclFromNode(node); - if (declaration && declaration.name) { - const sourceFile = this.getSourceFile(fileName); - if (sourceFile) { - return this.getSourceFromDeclaration( - fileName, this.stringOf(node) || '', shrink(spanOf(node)), - this.reflector.getStaticSymbol(sourceFile.fileName, declaration.name.text), - declaration, node, sourceFile); - } - } - break; + private getInternalTemplate(node: ts.Node): TemplateSource|undefined { + if (!ts.isStringLiteralLike(node)) { + return; } - return; + const classDecl = getClassDeclFromTemplateNode(node); + if (!classDecl || !classDecl.name) { // Does not handle anonymous class + return; + } + const fileName = node.getSourceFile().fileName; + const classSymbol = this.reflector.getStaticSymbol(fileName, classDecl.name.text); + return new InlineTemplate(node, classDecl, classSymbol, this); } /** - * Return the TemplateSource for the template associated with the classSymbol. - * @param fileName Template file (HTML) - * @param classSymbol + * Return the external template for `fileName`. + * @param fileName HTML file */ - private getSourceFromType(fileName: string, classSymbol: StaticSymbol): TemplateSource|undefined { - const declaration = this.getTemplateClassFromStaticSymbol(classSymbol); - if (declaration) { - const snapshot = this.host.getScriptSnapshot(fileName); - if (snapshot) { - const source = snapshot.getText(0, snapshot.getLength()); - return this.getSourceFromDeclaration( - fileName, source, {start: 0, end: source.length}, classSymbol, declaration, declaration, - declaration.getSourceFile()); - } + private getExternalTemplate(fileName: string): TemplateSource|undefined { + // First get the text for the template + const snapshot = this.host.getScriptSnapshot(fileName); + if (!snapshot) { + return; } - return; + const source = snapshot.getText(0, snapshot.getLength()); + // Next find the component class symbol + const classSymbol = this.fileToComponent.get(fileName); + if (!classSymbol) { + return; + } + // Then use the class symbol to find the actual ts.ClassDeclaration node + const sourceFile = this.getSourceFile(classSymbol.filePath); + if (!sourceFile) { + return; + } + // TODO: This only considers top-level class declarations in a source file. + // This would not find a class declaration in a namespace, for example. + const classDecl = sourceFile.forEachChild((child) => { + if (ts.isClassDeclaration(child) && child.name && child.name.text === classSymbol.name) { + return child; + } + }); + if (!classDecl) { + return; + } + return new ExternalTemplate(source, fileName, classDecl, classSymbol, this); } private collectError(error: any, filePath: string|null) { @@ -393,68 +347,6 @@ export class TypeScriptServiceHost implements LanguageServiceHost { return this._reflector; } - private getTemplateClassFromStaticSymbol(type: StaticSymbol): ts.ClassDeclaration|undefined { - const source = this.getSourceFile(type.filePath); - if (!source) { - return; - } - const declarationNode = ts.forEachChild(source, child => { - if (child.kind === ts.SyntaxKind.ClassDeclaration) { - const classDeclaration = child as ts.ClassDeclaration; - if (classDeclaration.name && classDeclaration.name.text === type.name) { - return classDeclaration; - } - } - }); - return declarationNode as ts.ClassDeclaration; - } - - private static missingTemplate: [ts.ClassDeclaration | undefined, ts.Expression|undefined] = - [undefined, undefined]; - - /** - * Given a template string node, see if it is an Angular template string, and if so return the - * containing class. - */ - private getTemplateClassDeclFromNode(currentToken: ts.Node): - [ts.ClassDeclaration | undefined, ts.Expression|undefined] { - // Verify we are in a 'template' property assignment, in an object literal, which is an call - // arg, in a decorator - let parentNode = currentToken.parent; // PropertyAssignment - if (!parentNode) { - return TypeScriptServiceHost.missingTemplate; - } - if (parentNode.kind !== ts.SyntaxKind.PropertyAssignment) { - return TypeScriptServiceHost.missingTemplate; - } else { - // TODO: Is this different for a literal, i.e. a quoted property name like "template"? - if ((parentNode as any).name.text !== 'template') { - return TypeScriptServiceHost.missingTemplate; - } - } - parentNode = parentNode.parent; // ObjectLiteralExpression - if (!parentNode || parentNode.kind !== ts.SyntaxKind.ObjectLiteralExpression) { - return TypeScriptServiceHost.missingTemplate; - } - - parentNode = parentNode.parent; // CallExpression - if (!parentNode || parentNode.kind !== ts.SyntaxKind.CallExpression) { - return TypeScriptServiceHost.missingTemplate; - } - const callTarget = (