diff --git a/build.sh b/build.sh index d3386205a2..c498e5b4bd 100755 --- a/build.sh +++ b/build.sh @@ -17,6 +17,7 @@ PACKAGES=(core upgrade router compiler-cli + language-service benchpress) BUILD_ALL=true BUNDLE=true @@ -184,7 +185,6 @@ do mv ${UMD_ES5_PATH}.tmp ${UMD_ES5_PATH} $UGLIFYJS -c --screw-ie8 --comments -o ${UMD_ES5_MIN_PATH} ${UMD_ES5_PATH} - if [[ -e rollup-testing.config.js ]]; then echo "====== Rollup ${PACKAGE} testing" ../../../node_modules/.bin/rollup -c rollup-testing.config.js diff --git a/karma-js.conf.js b/karma-js.conf.js index 6394183d10..993c768037 100644 --- a/karma-js.conf.js +++ b/karma-js.conf.js @@ -52,6 +52,7 @@ module.exports = function(config) { 'dist/all/@angular/compiler-cli/**', 'dist/all/@angular/compiler/test/aot/**', 'dist/all/@angular/benchpress/**', + 'dist/all/@angular/language-service/**', 'dist/all/angular1_router.js', 'dist/all/@angular/platform-browser/testing/e2e_util.js', 'dist/examples/**/e2e_test/**', diff --git a/modules/@angular/language-service/index.ts b/modules/@angular/language-service/index.ts new file mode 100644 index 0000000000..100d4e13e6 --- /dev/null +++ b/modules/@angular/language-service/index.ts @@ -0,0 +1,22 @@ +/** + * @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 + */ + +/** + * @module + * @description + * Entry point for all public APIs of the language service package. + */ +import * as ts from 'typescript'; + +import {LanguageServicePlugin} from './src/ts_plugin'; + +export {createLanguageService} from './src/language_service'; +export {Completion, Completions, Declaration, Declarations, Definition, Diagnostic, Diagnostics, Hover, HoverTextSection, LanguageService, LanguageServiceHost, Location, Span, TemplateSource, TemplateSources} from './src/types'; +export {TypeScriptServiceHost, createLanguageServiceFromTypescript} from './src/typescript_host'; + +export default LanguageServicePlugin; diff --git a/modules/@angular/language-service/package.json b/modules/@angular/language-service/package.json new file mode 100644 index 0000000000..61f0eeaa02 --- /dev/null +++ b/modules/@angular/language-service/package.json @@ -0,0 +1,14 @@ +{ + "name": "@angular/language-service", + "version": "0.0.0-PLACEHOLDER", + "description": "Angular 2 - language services", + "main": "bundles/language-service.umd.js", + "module": "index.js", + "typings": "index.d.ts", + "author": "angular", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/angular/angular.git" + } +} diff --git a/modules/@angular/language-service/rollup.config.js b/modules/@angular/language-service/rollup.config.js new file mode 100644 index 0000000000..0f6fd64828 --- /dev/null +++ b/modules/@angular/language-service/rollup.config.js @@ -0,0 +1,81 @@ +/** + * @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 commonjs from 'rollup-plugin-commonjs'; +import * as path from 'path'; + +var m = /^\@angular\/((\w|\-)+)(\/(\w|\d|\/|\-)+)?$/; +var location = normalize('../../../dist/packages-dist') + '/'; +var rxjsLocation = normalize('../../../node_modules/rxjs'); +var esm = 'esm/'; + +var locations = { + 'tsc-wrapped': normalize('../../../dist/tools/@angular') + '/', +}; + +var esm_suffixes = {}; + +function normalize(fileName) { + return path.resolve(__dirname, fileName); +} + +function resolve(id, from) { + // console.log('Resolve id:', id, 'from', from) + if (id == '@angular/tsc-wrapped') { + // Hack to restrict the import to not include the index of @angular/tsc-wrapped so we don't + // rollup tsickle. + return locations['tsc-wrapped'] + 'tsc-wrapped/src/collector.js'; + } + var match = m.exec(id); + if (match) { + var packageName = match[1]; + var esm_suffix = esm_suffixes[packageName] || ''; + var loc = locations[packageName] || location; + var r = loc + esm_suffix + packageName + (match[3] || '/index') + '.js'; + // console.log('** ANGULAR MAPPED **: ', r); + return r; + } + if (id && id.startsWith('rxjs/')) { + const resolved = `${rxjsLocation}${id.replace('rxjs', '')}.js`; + return resolved; + } +} + +var banner = ` +var $deferred, $resolved, $provided; +function $getModule(name) { return $provided[name] || require(name); } +function define(modules, cb) { $deferred = { modules: modules, cb: cb }; } +module.exports = function(provided) { + if ($resolved) return $resolved; + var result = {}; + $provided = Object.assign({}, provided || {}, { exports: result }); + $deferred.cb.apply(this, $deferred.modules.map($getModule)); + $resolved = result; + return result; +} +`; + +export default { + entry: '../../../dist/packages-dist/language-service/index.js', + dest: '../../../dist/packages-dist/language-service/bundles/language-service.umd.js', + format: 'amd', + moduleName: 'ng.language_service', + exports: 'named', + external: [ + 'fs', + 'path', + 'typescript', + ], + globals: { + 'typescript': 'ts', + 'path': 'path', + 'fs': 'fs', + }, + banner: banner, + plugins: [{resolveId: resolve}, commonjs()] +} diff --git a/modules/@angular/language-service/src/ast_path.ts b/modules/@angular/language-service/src/ast_path.ts new file mode 100644 index 0000000000..bc570988bd --- /dev/null +++ b/modules/@angular/language-service/src/ast_path.ts @@ -0,0 +1,29 @@ +/** + * @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 + */ + +export class AstPath { + constructor(private path: T[]) {} + + get empty(): boolean { return !this.path || !this.path.length; } + get head(): T|undefined { return this.path[0]; } + get tail(): T|undefined { return this.path[this.path.length - 1]; } + + parentOf(node: T): T|undefined { return this.path[this.path.indexOf(node) - 1]; } + childOf(node: T): T|undefined { return this.path[this.path.indexOf(node) + 1]; } + + first(ctor: {new (...args: any[]): N}): N|undefined { + for (let i = this.path.length - 1; i >= 0; i--) { + let item = this.path[i]; + if (item instanceof ctor) return item; + } + } + + push(node: T) { this.path.push(node); } + + pop(): T { return this.path.pop(); } +} diff --git a/modules/@angular/language-service/src/common.ts b/modules/@angular/language-service/src/common.ts new file mode 100644 index 0000000000..457cfca2a4 --- /dev/null +++ b/modules/@angular/language-service/src/common.ts @@ -0,0 +1,53 @@ +/** + * @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 {CompileDirectiveMetadata, CompileDirectiveSummary, CompilePipeSummary} from '@angular/compiler'; + +import {Parser} from '@angular/compiler/src/expression_parser/parser'; +import {Node as HtmlAst} from '@angular/compiler/src/ml_parser/ast'; +import {ParseError} from '@angular/compiler/src/parse_util'; +import {CssSelector} from '@angular/compiler/src/selector'; +import {TemplateAst} from '@angular/compiler/src/template_parser/template_ast'; + +import {Diagnostic, TemplateSource} from './types'; + +export interface AstResult { + htmlAst?: HtmlAst[]; + templateAst?: TemplateAst[]; + directive?: CompileDirectiveMetadata; + directives?: CompileDirectiveSummary[]; + pipes?: CompilePipeSummary[]; + parseErrors?: ParseError[]; + expressionParser?: Parser; + errors?: Diagnostic[]; +} + +export interface TemplateInfo { + position?: number; + fileName?: string; + template: TemplateSource; + htmlAst: HtmlAst[]; + directive: CompileDirectiveMetadata; + directives: CompileDirectiveSummary[]; + pipes: CompilePipeSummary[]; + templateAst: TemplateAst[]; + expressionParser: Parser; +} + +export interface AttrInfo { + name: string; + input?: boolean; + output?: boolean; + template?: boolean; + fromHtml?: boolean; +} + +export type SelectorInfo = { + selectors: CssSelector[], + map: Map +}; diff --git a/modules/@angular/language-service/src/completions.ts b/modules/@angular/language-service/src/completions.ts new file mode 100644 index 0000000000..661d5ce629 --- /dev/null +++ b/modules/@angular/language-service/src/completions.ts @@ -0,0 +1,495 @@ +/** + * @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 {AST, ImplicitReceiver, ParseSpan, PropertyRead} from '@angular/compiler/src/expression_parser/ast'; +import {Attribute, Element, Node as HtmlAst, Text} from '@angular/compiler/src/ml_parser/ast'; +import {getHtmlTagDefinition} from '@angular/compiler/src/ml_parser/html_tags'; +import {NAMED_ENTITIES, TagContentType, splitNsName} from '@angular/compiler/src/ml_parser/tags'; +import {CssSelector, SelectorMatcher} from '@angular/compiler/src/selector'; +import {AttrAst, BoundDirectivePropertyAst, BoundElementPropertyAst, BoundEventAst, BoundTextAst, DirectiveAst, ElementAst, EmbeddedTemplateAst, NgContentAst, ReferenceAst, TemplateAst, TemplateAstVisitor, TextAst, VariableAst, templateVisitAll} from '@angular/compiler/src/template_parser/template_ast'; + +import {AstResult, AttrInfo, SelectorInfo, TemplateInfo} from './common'; +import {getExpressionCompletions, getExpressionScope} from './expressions'; +import {attributeNames, elementNames, eventNames, propertyNames} from './html_info'; +import {HtmlAstPath} from './html_path'; +import {NullTemplateVisitor, TemplateAstChildVisitor, TemplateAstPath} from './template_path'; +import {BuiltinType, Completion, Completions, Span, Symbol, SymbolDeclaration, SymbolTable, TemplateSource} from './types'; +import {flatten, getSelectors, hasTemplateReference, inSpan, removeSuffix, spanOf, uniqueByName} from './utils'; + +const TEMPLATE_ATTR_PREFIX = '*'; + +const hiddenHtmlElements = { + html: true, + script: true, + noscript: true, + base: true, + body: true, + title: true, + head: true, + link: true, +}; + +export function getTemplateCompletions(templateInfo: TemplateInfo): Completions { + let result: Completions = undefined; + let {htmlAst, templateAst, template} = templateInfo; + // The templateNode starts at the delimiter character so we add 1 to skip it. + let templatePosition = templateInfo.position - template.span.start; + let path = new HtmlAstPath(htmlAst, templatePosition); + let mostSpecific = path.tail; + if (path.empty) { + result = elementCompletions(templateInfo, path); + } else { + let astPosition = templatePosition - mostSpecific.sourceSpan.start.offset; + mostSpecific.visit( + { + visitElement(ast) { + let startTagSpan = spanOf(ast.sourceSpan); + let tagLen = ast.name.length; + if (templatePosition <= + startTagSpan.start + tagLen + 1 /* 1 for the opening angle bracked */) { + // If we are in the tag then return the element completions. + result = elementCompletions(templateInfo, path); + } else if (templatePosition < startTagSpan.end) { + // We are in the attribute section of the element (but not in an attribute). + // Return the attribute completions. + result = attributeCompletions(templateInfo, path); + } + }, + visitAttribute(ast) { + if (!ast.valueSpan || !inSpan(templatePosition, spanOf(ast.valueSpan))) { + // We are in the name of an attribute. Show attribute completions. + result = attributeCompletions(templateInfo, path); + } else if (ast.valueSpan && inSpan(templatePosition, spanOf(ast.valueSpan))) { + result = attributeValueCompletions(templateInfo, templatePosition, ast); + } + }, + visitText(ast) { + // Check if we are in a entity. + result = entityCompletions(getSourceText(template, spanOf(ast)), astPosition); + if (result) return result; + result = interpolationCompletions(templateInfo, templatePosition); + if (result) return result; + let element = path.first(Element); + if (element) { + let definition = getHtmlTagDefinition(element.name); + if (definition.contentType === TagContentType.PARSABLE_DATA) { + result = voidElementAttributeCompletions(templateInfo, path); + if (!result) { + // If the element can hold content Show element completions. + result = elementCompletions(templateInfo, path); + } + } + } else { + // If no element container, implies parsable data so show elements. + result = voidElementAttributeCompletions(templateInfo, path); + if (!result) { + result = elementCompletions(templateInfo, path); + } + } + }, + visitComment(ast) {}, + visitExpansion(ast) {}, + visitExpansionCase(ast) {} + }, + null); + } + return result; +} + +function attributeCompletions(info: TemplateInfo, path: HtmlAstPath): Completions { + let item = path.tail instanceof Element ? path.tail : path.parentOf(path.tail); + if (item instanceof Element) { + return attributeCompletionsForElement(info, item.name, item); + } + return undefined; +} + +function attributeCompletionsForElement( + info: TemplateInfo, elementName: string, element?: Element): Completions { + const attributes = getAttributeInfosForElement(info, elementName, element); + + // Map all the attributes to a completion + return attributes.map(attr => ({ + kind: attr.fromHtml ? 'html attribute' : 'attribute', + name: nameOfAttr(attr), + sort: attr.name + })); +} + +function getAttributeInfosForElement( + info: TemplateInfo, elementName: string, element?: Element): AttrInfo[] { + let attributes: AttrInfo[] = []; + + // Add html attributes + let htmlAttributes = attributeNames(elementName) || []; + if (htmlAttributes) { + attributes.push(...htmlAttributes.map(name => ({name, fromHtml: true}))); + } + + // Add html properties + let htmlProperties = propertyNames(elementName); + if (htmlProperties) { + attributes.push(...htmlProperties.map(name => ({name, input: true}))); + } + + // Add html events + let htmlEvents = eventNames(elementName); + if (htmlEvents) { + attributes.push(...htmlEvents.map(name => ({name, output: true}))); + } + + let {selectors, map: selectorMap} = getSelectors(info); + if (selectors && selectors.length) { + // All the attributes that are selectable should be shown. + const applicableSelectors = + selectors.filter(selector => !selector.element || selector.element == elementName); + const selectorAndAttributeNames = + applicableSelectors.map(selector => ({selector, attrs: selector.attrs.filter(a => !!a)})); + let attrs = flatten(selectorAndAttributeNames.map(selectorAndAttr => { + const directive = selectorMap.get(selectorAndAttr.selector); + const result = selectorAndAttr.attrs.map( + name => ({name, input: name in directive.inputs, output: name in directive.outputs})); + return result; + })); + + // Add template attribute if a directive contains a template reference + selectorAndAttributeNames.forEach(selectorAndAttr => { + const selector = selectorAndAttr.selector; + const directive = selectorMap.get(selector); + if (directive && hasTemplateReference(directive.type) && selector.attrs.length && + selector.attrs[0]) { + attrs.push({name: selector.attrs[0], template: true}); + } + }); + + // All input and output properties of the matching directives should be added. + let elementSelector = element ? + createElementCssSelector(element) : + createElementCssSelector(new Element(elementName, [], [], undefined, undefined, undefined)); + + let matcher = new SelectorMatcher(); + matcher.addSelectables(selectors); + matcher.match(elementSelector, selector => { + let directive = selectorMap.get(selector); + if (directive) { + attrs.push(...Object.keys(directive.inputs).map(name => ({name, input: true}))); + attrs.push(...Object.keys(directive.outputs).map(name => ({name, output: true}))); + } + }); + + // If a name shows up twice, fold it into a single value. + attrs = foldAttrs(attrs); + + // Now expand them back out to ensure that input/output shows up as well as input and + // output. + attributes.push(...flatten(attrs.map(expandedAttr))); + } + return attributes; +} + +function attributeValueCompletions( + info: TemplateInfo, position: number, attr: Attribute): Completions { + const path = new TemplateAstPath(info.templateAst, position); + const mostSpecific = path.tail; + if (mostSpecific) { + const visitor = + new ExpressionVisitor(info, position, attr, () => getExpressionScope(info, path, false)); + mostSpecific.visit(visitor, null); + if (!visitor.result || !visitor.result.length) { + // Try allwoing widening the path + const widerPath = new TemplateAstPath(info.templateAst, position, /* allowWidening */ true); + if (widerPath.tail) { + const widerVisitor = new ExpressionVisitor( + info, position, attr, () => getExpressionScope(info, widerPath, false)); + widerPath.tail.visit(widerVisitor, null); + return widerVisitor.result; + } + } + return visitor.result; + } +} + +function elementCompletions(info: TemplateInfo, path: HtmlAstPath): Completions { + let htmlNames = elementNames().filter(name => !(name in hiddenHtmlElements)); + + // Collect the elements referenced by the selectors + let directiveElements = + getSelectors(info).selectors.map(selector => selector.element).filter(name => !!name); + + let components = + directiveElements.map(name => ({kind: 'component', name: name, sort: name})); + let htmlElements = htmlNames.map(name => ({kind: 'element', name: name, sort: name})); + + // Return components and html elements + return uniqueByName(htmlElements.concat(components)); +} + +function entityCompletions(value: string, position: number): Completions { + // Look for entity completions + const re = /&[A-Za-z]*;?(?!\d)/g; + let found: RegExpExecArray|null; + let result: Completions; + while (found = re.exec(value)) { + let len = found[0].length; + if (position >= found.index && position < (found.index + len)) { + result = Object.keys(NAMED_ENTITIES) + .map(name => ({kind: 'entity', name: `&${name};`, sort: name})); + break; + } + } + return result; +} + +function interpolationCompletions(info: TemplateInfo, position: number): Completions { + // Look for an interpolation in at the position. + const templatePath = new TemplateAstPath(info.templateAst, position); + const mostSpecific = templatePath.tail; + if (mostSpecific) { + let visitor = new ExpressionVisitor( + info, position, undefined, () => getExpressionScope(info, templatePath, false)); + mostSpecific.visit(visitor, null); + return uniqueByName(visitor.result); + } +} + +// There is a special case of HTML where text that contains a unclosed tag is treated as +// text. For exaple '

Some ' produces a text nodes inside of the H1 +// element "Some = match.index + match[0].length + tail.sourceSpan.start.offset) { + return attributeCompletionsForElement(info, match[3]); + } + } +} + +class ExpressionVisitor extends NullTemplateVisitor { + result: Completions; + + constructor( + private info: TemplateInfo, private position: number, private attr?: Attribute, + private getExpressionScope?: () => SymbolTable) { + super(); + if (!getExpressionScope) { + this.getExpressionScope = () => info.template.members; + } + } + + visitDirectiveProperty(ast: BoundDirectivePropertyAst): void { + this.attributeValueCompletions(ast.value); + } + + visitElementProperty(ast: BoundElementPropertyAst): void { + this.attributeValueCompletions(ast.value); + } + + visitEvent(ast: BoundEventAst): void { this.attributeValueCompletions(ast.handler); } + + visitElement(ast: ElementAst): void { + if (this.attr && getSelectors(this.info) && this.attr.name.startsWith(TEMPLATE_ATTR_PREFIX)) { + // The value is a template expression but the expression AST was not produced when the + // TemplateAst was produce so + // do that now. + + const key = this.attr.name.substr(TEMPLATE_ATTR_PREFIX.length); + + // Find the selector + const selectorInfo = getSelectors(this.info); + const selectors = selectorInfo.selectors; + const selector = + selectors.filter(s => s.attrs.some((attr, i) => i % 2 == 0 && attr == key))[0]; + + const templateBindingResult = + this.info.expressionParser.parseTemplateBindings(key, this.attr.value, null); + + // find the template binding that contains the position + const valueRelativePosition = this.position - this.attr.valueSpan.start.offset - 1; + const bindings = templateBindingResult.templateBindings; + const binding = + bindings.find( + binding => inSpan(valueRelativePosition, binding.span, /* exclusive */ true)) || + bindings.find(binding => inSpan(valueRelativePosition, binding.span)); + + const keyCompletions = () => { + let keys: string[] = []; + if (selector) { + const attrNames = selector.attrs.filter((_, i) => i % 2 == 0); + keys = attrNames.filter(name => name.startsWith(key) && name != key) + .map(name => lowerName(name.substr(key.length))); + } + keys.push('let'); + this.result = keys.map(key => {kind: 'key', name: key, sort: key}); + }; + + if (!binding || (binding.key == key && !binding.expression)) { + // We are in the root binding. We should return `let` and keys that are left in the + // selector. + keyCompletions(); + } else if (binding.keyIsVar) { + const equalLocation = this.attr.value.indexOf('='); + this.result = []; + if (equalLocation >= 0 && valueRelativePosition >= equalLocation) { + // We are after the '=' in a let clause. The valid values here are the members of the + // template reference's type parameter. + const directiveMetadata = selectorInfo.map.get(selector); + const contextTable = + this.info.template.query.getTemplateContext(directiveMetadata.type.reference); + if (contextTable) { + this.result = this.symbolsToCompletions(contextTable.values()); + } + } else if (binding.key && valueRelativePosition <= (binding.key.length - key.length)) { + keyCompletions(); + } + } else { + // If the position is in the expression or after the key or there is no key, return the + // expression completions + if ((binding.expression && inSpan(valueRelativePosition, binding.expression.ast.span)) || + (binding.key && + valueRelativePosition > binding.span.start + (binding.key.length - key.length)) || + !binding.key) { + const span = new ParseSpan(0, this.attr.value.length); + this.attributeValueCompletions( + binding.expression ? binding.expression.ast : + new PropertyRead(span, new ImplicitReceiver(span), ''), + valueRelativePosition); + } else { + keyCompletions(); + } + } + } + } + + visitBoundText(ast: BoundTextAst) { + const expressionPosition = this.position - ast.sourceSpan.start.offset; + if (inSpan(expressionPosition, ast.value.span)) { + const completions = getExpressionCompletions( + this.getExpressionScope(), ast.value, expressionPosition, this.info.template.query); + if (completions) { + this.result = this.symbolsToCompletions(completions); + } + } + } + + private attributeValueCompletions(value: AST, position?: number) { + const symbols = getExpressionCompletions( + this.getExpressionScope(), value, position == null ? this.attributeValuePosition : position, + this.info.template.query); + if (symbols) { + this.result = this.symbolsToCompletions(symbols); + } + } + + private symbolsToCompletions(symbols: Symbol[]): Completions { + return symbols.filter(s => !s.name.startsWith('__') && s.public) + .map(symbol => {kind: symbol.kind, name: symbol.name, sort: symbol.name}); + } + + private get attributeValuePosition() { + return this.position - this.attr.valueSpan.start.offset - 1; + } +} + + + +function getSourceText(template: TemplateSource, span: Span): string { + return template.source.substring(span.start, span.end); +} + +function nameOfAttr(attr: AttrInfo): string { + let name = attr.name; + if (attr.output) { + name = removeSuffix(name, 'Events'); + name = removeSuffix(name, 'Changed'); + } + let result = [name]; + if (attr.input) { + result.unshift('['); + result.push(']'); + } + if (attr.output) { + result.unshift('('); + result.push(')'); + } + if (attr.template) { + result.unshift('*'); + } + return result.join(''); +} + +const templateAttr = /^(\w+:)?(template$|^\*)/; +function createElementCssSelector(element: Element): CssSelector { + const cssSelector = new CssSelector(); + let elNameNoNs = splitNsName(element.name)[1]; + + cssSelector.setElement(elNameNoNs); + + for (let attr of element.attrs) { + if (!attr.name.match(templateAttr)) { + let [_, attrNameNoNs] = splitNsName(attr.name); + cssSelector.addAttribute(attrNameNoNs, attr.value); + if (attr.name.toLowerCase() == 'class') { + const classes = attr.value.split(/s+/g); + classes.forEach(className => cssSelector.addClassName(className)); + } + } + } + return cssSelector; +} + +function foldAttrs(attrs: AttrInfo[]): AttrInfo[] { + let inputOutput = new Map(); + let templates = new Map(); + let result: AttrInfo[] = []; + attrs.forEach(attr => { + if (attr.fromHtml) { + return attr; + } + if (attr.template) { + let duplicate = templates.get(attr.name); + if (!duplicate) { + result.push({name: attr.name, template: true}); + templates.set(attr.name, attr); + } + } + if (attr.input || attr.output) { + let duplicate = inputOutput.get(attr.name); + if (duplicate) { + duplicate.input = duplicate.input || attr.input; + duplicate.output = duplicate.output || attr.output; + } else { + let cloneAttr: AttrInfo = {name: attr.name}; + if (attr.input) cloneAttr.input = true; + if (attr.output) cloneAttr.output = true; + result.push(cloneAttr); + inputOutput.set(attr.name, cloneAttr); + } + } + }); + return result; +} + +function expandedAttr(attr: AttrInfo): AttrInfo[] { + if (attr.input && attr.output) { + return [ + attr, {name: attr.name, input: true, output: false}, + {name: attr.name, input: false, output: true} + ]; + } + return [attr]; +} + +function lowerName(name: string): string { + return name && (name[0].toLowerCase() + name.substr(1)); +} diff --git a/modules/@angular/language-service/src/definitions.ts b/modules/@angular/language-service/src/definitions.ts new file mode 100644 index 0000000000..76a6e0a1be --- /dev/null +++ b/modules/@angular/language-service/src/definitions.ts @@ -0,0 +1,16 @@ +/** + * @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 {TemplateInfo} from './common'; +import {locateSymbol} from './locate_symbol'; +import {Definition} from './types'; + +export function getDefinition(info: TemplateInfo): Definition { + const result = locateSymbol(info); + return result && result.symbol.definition; +} diff --git a/modules/@angular/language-service/src/diagnostics.ts b/modules/@angular/language-service/src/diagnostics.ts new file mode 100644 index 0000000000..079e9e52a4 --- /dev/null +++ b/modules/@angular/language-service/src/diagnostics.ts @@ -0,0 +1,250 @@ +/** + * @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 {CompileDirectiveMetadata, CompileDirectiveSummary, StaticSymbol} from '@angular/compiler'; +import {NgAnalyzedModules} from '@angular/compiler/src/aot/compiler'; +import {AST} from '@angular/compiler/src/expression_parser/ast'; +import {Attribute} from '@angular/compiler/src/ml_parser/ast'; +import {AttrAst, BoundDirectivePropertyAst, BoundElementPropertyAst, BoundEventAst, BoundTextAst, DirectiveAst, ElementAst, EmbeddedTemplateAst, NgContentAst, ReferenceAst, TemplateAst, TemplateAstVisitor, TextAst, VariableAst, templateVisitAll} from '@angular/compiler/src/template_parser/template_ast'; + +import {AstResult, SelectorInfo, TemplateInfo} from './common'; +import {getExpressionDiagnostics, getExpressionScope} from './expressions'; +import {HtmlAstPath} from './html_path'; +import {NullTemplateVisitor, TemplateAstChildVisitor, TemplateAstPath} from './template_path'; +import {Declaration, Declarations, Diagnostic, DiagnosticKind, Diagnostics, Span, SymbolTable, TemplateSource} from './types'; +import {getSelectors, hasTemplateReference, offsetSpan, spanOf} from './utils'; + +export interface AstProvider { + getTemplateAst(template: TemplateSource, fileName: string): AstResult; +} + +export function getTemplateDiagnostics( + fileName: string, astProvider: AstProvider, templates: TemplateSource[]): Diagnostics { + const results: Diagnostics = []; + for (const template of templates) { + const ast = astProvider.getTemplateAst(template, fileName); + if (ast) { + if (ast.parseErrors && ast.parseErrors.length) { + results.push(...ast.parseErrors.map( + e => ({ + kind: DiagnosticKind.Error, + span: offsetSpan(spanOf(e.span), template.span.start), + message: e.msg + }))); + } else if (ast.templateAst) { + const expressionDiagnostics = getTemplateExpressionDiagnostics(template, ast); + results.push(...expressionDiagnostics); + } + if (ast.errors) { + results.push(...ast.errors.map( + e => ({kind: e.kind, span: e.span || template.span, message: e.message}))); + } + } + } + return results; +} + +export function getDeclarationDiagnostics( + declarations: Declarations, modules: NgAnalyzedModules): Diagnostics { + const results: Diagnostics = []; + + let directives: Set|undefined = undefined; + for (const declaration of declarations) { + let report = (message: string) => { + results.push( + {kind: DiagnosticKind.Error, span: declaration.declarationSpan, message}); + }; + if (declaration.error) { + report(declaration.error); + } + if (declaration.metadata) { + if (declaration.metadata.isComponent) { + if (!modules.ngModuleByPipeOrDirective.has(declaration.type)) { + report( + `Component '${declaration.type.name}' is not included in a module and will not be available inside a template. Consider adding it to a NgModule declaration`); + } + if (declaration.metadata.template.template == null && + !declaration.metadata.template.templateUrl) { + report(`Component ${declaration.type.name} must have a template or templateUrl`); + } + } else { + if (!directives) { + directives = new Set(); + modules.ngModules.forEach(module => { + module.declaredDirectives.forEach( + directive => { directives.add(directive.reference); }); + }); + } + if (!directives.has(declaration.type)) { + report( + `Directive '${declaration.type.name}' is not included in a module and will not be available inside a template. Consider adding it to a NgModule declaration`); + } + } + } + } + + return results; +} + +function getTemplateExpressionDiagnostics( + template: TemplateSource, astResult: AstResult): Diagnostics { + const info: TemplateInfo = { + template, + htmlAst: astResult.htmlAst, + directive: astResult.directive, + directives: astResult.directives, + pipes: astResult.pipes, + templateAst: astResult.templateAst, + expressionParser: astResult.expressionParser + }; + const visitor = new ExpressionDiagnosticsVisitor( + info, (path: TemplateAstPath, includeEvent: boolean) => + getExpressionScope(info, path, includeEvent)); + templateVisitAll(visitor, astResult.templateAst); + return visitor.diagnostics; +} + +class ExpressionDiagnosticsVisitor extends TemplateAstChildVisitor { + private path: TemplateAstPath; + private directiveSummary: CompileDirectiveSummary; + + diagnostics: Diagnostics = []; + + constructor( + private info: TemplateInfo, + private getExpressionScope: (path: TemplateAstPath, includeEvent: boolean) => SymbolTable) { + super(); + this.path = new TemplateAstPath([], 0); + } + + visitDirective(ast: DirectiveAst, context: any): any { + // Override the default child visitor to ignore the host properties of a directive. + if (ast.inputs && ast.inputs.length) { + templateVisitAll(this, ast.inputs, context); + } + } + + visitBoundText(ast: BoundTextAst): void { + this.push(ast); + this.diagnoseExpression(ast.value, ast.sourceSpan.start.offset, false); + this.pop(); + } + + visitDirectiveProperty(ast: BoundDirectivePropertyAst): void { + this.push(ast); + this.diagnoseExpression(ast.value, this.attributeValueLocation(ast), false); + this.pop(); + } + + visitElementProperty(ast: BoundElementPropertyAst): void { + this.push(ast); + this.diagnoseExpression(ast.value, this.attributeValueLocation(ast), false); + this.pop(); + } + + visitEvent(ast: BoundEventAst): void { + this.push(ast); + this.diagnoseExpression(ast.handler, this.attributeValueLocation(ast), true); + this.pop(); + } + + visitVariable(ast: VariableAst): void { + const directive = this.directiveSummary; + if (directive && ast.value) { + const context = this.info.template.query.getTemplateContext(directive.type.reference); + if (!context.has(ast.value)) { + if (ast.value === '$implicit') { + this.reportError( + 'The template context does not have an implicit value', spanOf(ast.sourceSpan)); + } else { + this.reportError( + `The template context does not defined a member called '${ast.value}'`, + spanOf(ast.sourceSpan)); + } + } + } + } + + visitElement(ast: ElementAst, context: any): void { + this.push(ast); + super.visitElement(ast, context); + this.pop(); + } + + visitEmbeddedTemplate(ast: EmbeddedTemplateAst, context: any): any { + const previousDirectiveSummary = this.directiveSummary; + + this.push(ast); + + // Find directive that refernces this template + this.directiveSummary = + ast.directives.map(d => d.directive).find(d => hasTemplateReference(d.type)); + + // Process children + super.visitEmbeddedTemplate(ast, context); + + this.pop(); + + this.directiveSummary = previousDirectiveSummary; + } + + private attributeValueLocation(ast: TemplateAst) { + const path = new HtmlAstPath(this.info.htmlAst, ast.sourceSpan.start.offset); + const last = path.tail; + if (last instanceof Attribute && last.valueSpan) { + // Add 1 for the quote. + return last.valueSpan.start.offset + 1; + } + return ast.sourceSpan.start.offset; + } + + private diagnoseExpression(ast: AST, offset: number, includeEvent: boolean) { + const scope = this.getExpressionScope(this.path, includeEvent); + this.diagnostics.push( + ...getExpressionDiagnostics(scope, ast, this.info.template.query) + .map(d => ({ + span: offsetSpan(d.ast.span, offset + this.info.template.span.start), + kind: d.kind, + message: d.message + }))); + } + + private push(ast: TemplateAst) { this.path.push(ast); } + + private pop() { this.path.pop(); } + + private _selectors: SelectorInfo; + private selectors(): SelectorInfo { + let result = this._selectors; + if (!result) { + this._selectors = result = getSelectors(this.info); + } + return result; + } + + private findElement(position: number): Element { + const htmlPath = new HtmlAstPath(this.info.htmlAst, position); + if (htmlPath.tail instanceof Element) { + return htmlPath.tail; + } + } + + private reportError(message: string, span: Span) { + this.diagnostics.push({ + span: offsetSpan(span, this.info.template.span.start), + kind: DiagnosticKind.Error, message + }); + } + + private reportWarning(message: string, span: Span) { + this.diagnostics.push({ + span: offsetSpan(span, this.info.template.span.start), + kind: DiagnosticKind.Warning, message + }); + } +} diff --git a/modules/@angular/language-service/src/expressions.ts b/modules/@angular/language-service/src/expressions.ts new file mode 100644 index 0000000000..b21f8f73fc --- /dev/null +++ b/modules/@angular/language-service/src/expressions.ts @@ -0,0 +1,770 @@ +/** + * @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 {StaticSymbol} from '@angular/compiler'; +import {AST, ASTWithSource, AstVisitor, Binary, BindingPipe, Chain, Conditional, FunctionCall, ImplicitReceiver, Interpolation, KeyedRead, KeyedWrite, LiteralArray, LiteralMap, LiteralPrimitive, MethodCall, PrefixNot, PropertyRead, PropertyWrite, Quote, SafeMethodCall, SafePropertyRead} from '@angular/compiler/src/expression_parser/ast'; +import {ElementAst, EmbeddedTemplateAst, ReferenceAst, TemplateAst, templateVisitAll} from '@angular/compiler/src/template_parser/template_ast'; + +import {AstPath as AstPathBase} from './ast_path'; +import {TemplateInfo} from './common'; +import {TemplateAstChildVisitor, TemplateAstPath} from './template_path'; +import {BuiltinType, CompletionKind, Definition, DiagnosticKind, Signature, Span, Symbol, SymbolDeclaration, SymbolQuery, SymbolTable} from './types'; +import {inSpan, spanOf} from './utils'; + +export function getExpressionDiagnostics( + scope: SymbolTable, ast: AST, query: SymbolQuery): TypeDiagnostic[] { + const analyzer = new AstType(scope, query); + analyzer.getDiagnostics(ast); + return analyzer.diagnostics; +} + +export function getExpressionCompletions( + scope: SymbolTable, ast: AST, position: number, query: SymbolQuery): Symbol[] { + const path = new AstPath(ast, position); + if (path.empty) return undefined; + const tail = path.tail; + let result: SymbolTable|undefined = scope; + + function getType(ast: AST): Symbol { return new AstType(scope, query).getType(ast); } + + // If the completion request is in a not in a pipe or property access then the global scope + // (that is the scope of the implicit receiver) is the right scope as the user is typing the + // beginning of an expression. + tail.visit({ + visitBinary(ast) {}, + visitChain(ast) {}, + visitConditional(ast) {}, + visitFunctionCall(ast) {}, + visitImplicitReceiver(ast) {}, + visitInterpolation(ast) { result = undefined; }, + visitKeyedRead(ast) {}, + visitKeyedWrite(ast) {}, + visitLiteralArray(ast) {}, + visitLiteralMap(ast) {}, + visitLiteralPrimitive(ast) {}, + visitMethodCall(ast) {}, + visitPipe(ast) { + if (position >= ast.exp.span.end && + (!ast.args || !ast.args.length || position < (ast.args[0]).span.start)) { + // We are in a position a pipe name is expected. + result = query.getPipes(); + } + }, + visitPrefixNot(ast) {}, + visitPropertyRead(ast) { + const receiverType = getType(ast.receiver); + result = receiverType ? receiverType.members() : scope; + }, + visitPropertyWrite(ast) { + const receiverType = getType(ast.receiver); + result = receiverType ? receiverType.members() : scope; + }, + visitQuote(ast) { + // For a quote, return the members of any (if there are any). + result = query.getBuiltinType(BuiltinType.Any).members(); + }, + visitSafeMethodCall(ast) { + const receiverType = getType(ast.receiver); + result = receiverType ? receiverType.members() : scope; + }, + visitSafePropertyRead(ast) { + const receiverType = getType(ast.receiver); + result = receiverType ? receiverType.members() : scope; + }, + }); + + return result && result.values(); +} + +export function getExpressionSymbol( + scope: SymbolTable, ast: AST, position: number, + query: SymbolQuery): {symbol: Symbol, span: Span} { + const path = new AstPath(ast, position, /* excludeEmpty */ true); + if (path.empty) return undefined; + const tail = path.tail; + + function getType(ast: AST): Symbol { return new AstType(scope, query).getType(ast); } + + let symbol: Symbol = undefined; + let span: Span = undefined; + + // If the completion request is in a not in a pipe or property access then the global scope + // (that is the scope of the implicit receiver) is the right scope as the user is typing the + // beginning of an expression. + tail.visit({ + visitBinary(ast) {}, + visitChain(ast) {}, + visitConditional(ast) {}, + visitFunctionCall(ast) {}, + visitImplicitReceiver(ast) {}, + visitInterpolation(ast) {}, + visitKeyedRead(ast) {}, + visitKeyedWrite(ast) {}, + visitLiteralArray(ast) {}, + visitLiteralMap(ast) {}, + visitLiteralPrimitive(ast) {}, + visitMethodCall(ast) { + const receiverType = getType(ast.receiver); + symbol = receiverType && receiverType.members().get(ast.name); + span = ast.span; + }, + visitPipe(ast) { + if (position >= ast.exp.span.end && + (!ast.args || !ast.args.length || position < (ast.args[0]).span.start)) { + // We are in a position a pipe name is expected. + const pipes = query.getPipes(); + if (pipes) { + symbol = pipes.get(ast.name); + span = ast.span; + } + } + }, + visitPrefixNot(ast) {}, + visitPropertyRead(ast) { + const receiverType = getType(ast.receiver); + symbol = receiverType && receiverType.members().get(ast.name); + span = ast.span; + }, + visitPropertyWrite(ast) { + const receiverType = getType(ast.receiver); + symbol = receiverType && receiverType.members().get(ast.name); + span = ast.span; + }, + visitQuote(ast) {}, + visitSafeMethodCall(ast) { + const receiverType = getType(ast.receiver); + symbol = receiverType && receiverType.members().get(ast.name); + span = ast.span; + }, + visitSafePropertyRead(ast) { + const receiverType = getType(ast.receiver); + symbol = receiverType && receiverType.members().get(ast.name); + span = ast.span; + }, + }); + + if (symbol && span) { + return {symbol, span}; + } +} + +interface ExpressionVisitor extends AstVisitor { + visit?(ast: AST, context?: any): any; +} + + +// Consider moving to expression_parser/ast +class NullVisitor implements ExpressionVisitor { + visitBinary(ast: Binary): void {} + visitChain(ast: Chain): void {} + visitConditional(ast: Conditional): void {} + visitFunctionCall(ast: FunctionCall): void {} + visitImplicitReceiver(ast: ImplicitReceiver): void {} + visitInterpolation(ast: Interpolation): void {} + visitKeyedRead(ast: KeyedRead): void {} + visitKeyedWrite(ast: KeyedWrite): void {} + visitLiteralArray(ast: LiteralArray): void {} + visitLiteralMap(ast: LiteralMap): void {} + visitLiteralPrimitive(ast: LiteralPrimitive): void {} + visitMethodCall(ast: MethodCall): void {} + visitPipe(ast: BindingPipe): void {} + visitPrefixNot(ast: PrefixNot): void {} + visitPropertyRead(ast: PropertyRead): void {} + visitPropertyWrite(ast: PropertyWrite): void {} + visitQuote(ast: Quote): void {} + visitSafeMethodCall(ast: SafeMethodCall): void {} + visitSafePropertyRead(ast: SafePropertyRead): void {} +} + +export class TypeDiagnostic { + constructor(public kind: DiagnosticKind, public message: string, public ast: AST) {} +} + +// AstType calculatetype of the ast given AST element. +class AstType implements ExpressionVisitor { + public diagnostics: TypeDiagnostic[]; + + constructor(private scope: SymbolTable, private query: SymbolQuery) {} + + getType(ast: AST): Symbol { return ast.visit(this); } + + getDiagnostics(ast: AST): TypeDiagnostic[] { + this.diagnostics = []; + ast.visit(this); + return this.diagnostics; + } + + visitBinary(ast: Binary): Symbol { + // Treat undefined and null as other. + function normalize(kind: BuiltinType): BuiltinType { + switch (kind) { + case BuiltinType.Undefined: + case BuiltinType.Null: + return BuiltinType.Other; + } + return kind; + } + + const leftType = this.getType(ast.left); + const rightType = this.getType(ast.right); + const leftKind = normalize(this.query.getTypeKind(leftType)); + const rightKind = normalize(this.query.getTypeKind(rightType)); + + // The following swtich implements operator typing similar to the + // type production tables in the TypeScript specification. + const operKind = leftKind << 8 | rightKind; + switch (ast.operation) { + case '*': + case '/': + case '%': + case '-': + case '<<': + case '>>': + case '>>>': + case '&': + case '^': + case '|': + switch (operKind) { + case BuiltinType.Any << 8 | BuiltinType.Any: + case BuiltinType.Number << 8 | BuiltinType.Any: + case BuiltinType.Any << 8 | BuiltinType.Number: + case BuiltinType.Number << 8 | BuiltinType.Number: + return this.query.getBuiltinType(BuiltinType.Number); + default: + let errorAst = ast.left; + switch (leftKind) { + case BuiltinType.Any: + case BuiltinType.Number: + errorAst = ast.right; + break; + } + return this.reportError('Expected a numeric type', errorAst); + } + case '+': + switch (operKind) { + case BuiltinType.Any << 8 | BuiltinType.Any: + case BuiltinType.Any << 8 | BuiltinType.Boolean: + case BuiltinType.Any << 8 | BuiltinType.Number: + case BuiltinType.Any << 8 | BuiltinType.Other: + case BuiltinType.Boolean << 8 | BuiltinType.Any: + case BuiltinType.Number << 8 | BuiltinType.Any: + case BuiltinType.Other << 8 | BuiltinType.Any: + return this.anyType; + case BuiltinType.Any << 8 | BuiltinType.String: + case BuiltinType.Boolean << 8 | BuiltinType.String: + case BuiltinType.Number << 8 | BuiltinType.String: + case BuiltinType.String << 8 | BuiltinType.Any: + case BuiltinType.String << 8 | BuiltinType.Boolean: + case BuiltinType.String << 8 | BuiltinType.Number: + case BuiltinType.String << 8 | BuiltinType.String: + case BuiltinType.String << 8 | BuiltinType.Other: + case BuiltinType.Other << 8 | BuiltinType.String: + return this.query.getBuiltinType(BuiltinType.String); + case BuiltinType.Number << 8 | BuiltinType.Number: + return this.query.getBuiltinType(BuiltinType.Number); + case BuiltinType.Boolean << 8 | BuiltinType.Number: + case BuiltinType.Other << 8 | BuiltinType.Number: + return this.reportError('Expected a number type', ast.left); + case BuiltinType.Number << 8 | BuiltinType.Boolean: + case BuiltinType.Number << 8 | BuiltinType.Other: + return this.reportError('Expected a number type', ast.right); + default: + return this.reportError('Expected operands to be a string or number type', ast); + } + case '>': + case '<': + case '<=': + case '>=': + case '==': + case '!=': + case '===': + case '!==': + switch (operKind) { + case BuiltinType.Any << 8 | BuiltinType.Any: + case BuiltinType.Any << 8 | BuiltinType.Boolean: + case BuiltinType.Any << 8 | BuiltinType.Number: + case BuiltinType.Any << 8 | BuiltinType.String: + case BuiltinType.Any << 8 | BuiltinType.Other: + case BuiltinType.Boolean << 8 | BuiltinType.Any: + case BuiltinType.Boolean << 8 | BuiltinType.Boolean: + case BuiltinType.Number << 8 | BuiltinType.Any: + case BuiltinType.Number << 8 | BuiltinType.Number: + case BuiltinType.String << 8 | BuiltinType.Any: + case BuiltinType.String << 8 | BuiltinType.String: + case BuiltinType.Other << 8 | BuiltinType.Any: + case BuiltinType.Other << 8 | BuiltinType.Other: + return this.query.getBuiltinType(BuiltinType.Boolean); + default: + return this.reportError('Expected the operants to be of similar type or any', ast); + } + case '&&': + return rightType; + case '||': + return this.query.getTypeUnion(leftType, rightType); + } + + return this.reportError(`Unrecognized operator ${ast.operation}`, ast); + } + + visitChain(ast: Chain) { + if (this.diagnostics) { + // If we are producing diagnostics, visit the children + visitChildren(ast, this); + } + // The type of a chain is always undefined. + return this.query.getBuiltinType(BuiltinType.Undefined); + } + + visitConditional(ast: Conditional) { + // The type of a conditional is the union of the true and false conditions. + return this.query.getTypeUnion(this.getType(ast.trueExp), this.getType(ast.falseExp)); + } + + visitFunctionCall(ast: FunctionCall) { + // The type of a function call is the return type of the selected signature. + // The signature is selected based on the types of the arguments. Angular doesn't + // support contextual typing of arguments so this is simpler than TypeScript's + // version. + const args = ast.args.map(arg => this.getType(arg)); + const target = this.getType(ast.target); + if (!target || !target.callable) return this.reportError('Call target is not callable', ast); + const signature = target.selectSignature(args); + if (signature) return signature.result; + // TODO: Consider a better error message here. + return this.reportError('Unable no compatible signature found for call', ast); + } + + visitImplicitReceiver(ast: ImplicitReceiver): Symbol { + const _this = this; + // Return a pseudo-symbol for the implicit receiver. + // The members of the implicit receiver are what is defined by the + // scope passed into this class. + return { + name: '$implict', + kind: 'component', + language: 'ng-template', + type: undefined, + container: undefined, + callable: false, + public: true, + definition: undefined, + members(): SymbolTable{return _this.scope;}, + signatures(): Signature[]{return [];}, + selectSignature(types): Signature | undefined{return undefined;}, + indexed(argument): Symbol | undefined{return undefined;} + }; + } + + visitInterpolation(ast: Interpolation): Symbol { + // If we are producing diagnostics, visit the children. + if (this.diagnostics) { + visitChildren(ast, this); + } + return this.undefinedType; + } + + visitKeyedRead(ast: KeyedRead): Symbol { + const targetType = this.getType(ast.obj); + const keyType = this.getType(ast.key); + const result = targetType.indexed(keyType); + return result || this.anyType; + } + + visitKeyedWrite(ast: KeyedWrite): Symbol { + // The write of a type is the type of the value being written. + return this.getType(ast.value); + } + + visitLiteralArray(ast: LiteralArray): Symbol { + // A type literal is an array type of the union of the elements + return this.query.getArrayType( + this.query.getTypeUnion(...ast.expressions.map(element => this.getType(element)))); + } + + visitLiteralMap(ast: LiteralMap): Symbol { + // If we are producing diagnostics, visit the children + if (this.diagnostics) { + visitChildren(ast, this); + } + // TODO: Return a composite type. + return this.anyType; + } + + visitLiteralPrimitive(ast: LiteralPrimitive) { + // The type of a literal primitive depends on the value of the literal. + switch (ast.value) { + case true: + case false: + return this.query.getBuiltinType(BuiltinType.Boolean); + case null: + return this.query.getBuiltinType(BuiltinType.Null); + default: + switch (typeof ast.value) { + case 'string': + return this.query.getBuiltinType(BuiltinType.String); + case 'number': + return this.query.getBuiltinType(BuiltinType.Number); + default: + return this.reportError('Unrecognized primitive', ast); + } + } + } + + visitMethodCall(ast: MethodCall) { + return this.resolveMethodCall(this.getType(ast.receiver), ast); + } + + visitPipe(ast: BindingPipe) { + // The type of a pipe node is the return type of the pipe's transform method. The table returned + // by getPipes() is expected to contain symbols with the corresponding transform method type. + const pipe = this.query.getPipes().get(ast.name); + if (!pipe) return this.reportError(`No pipe by the name ${pipe.name} found`, ast); + const expType = this.getType(ast.exp); + const signature = + pipe.selectSignature([expType].concat(ast.args.map(arg => this.getType(arg)))); + if (!signature) return this.reportError('Unable to resolve signature for pipe invocation', ast); + return signature.result; + } + + visitPrefixNot(ast: PrefixNot) { + // The type of a prefix ! is always boolean. + return this.query.getBuiltinType(BuiltinType.Boolean); + } + + visitPropertyRead(ast: PropertyRead) { + return this.resolvePropertyRead(this.getType(ast.receiver), ast); + } + + visitPropertyWrite(ast: PropertyWrite) { + // The type of a write is the type of the value being written. + return this.getType(ast.value); + } + + visitQuote(ast: Quote) { + // The type of a quoted expression is any. + return this.query.getBuiltinType(BuiltinType.Any); + } + + visitSafeMethodCall(ast: SafeMethodCall) { + return this.resolveMethodCall(this.query.getNonNullableType(this.getType(ast.receiver)), ast); + } + + visitSafePropertyRead(ast: SafePropertyRead) { + return this.resolvePropertyRead(this.query.getNonNullableType(this.getType(ast.receiver)), ast); + } + + private _anyType: Symbol; + private get anyType(): Symbol { + let result = this._anyType; + if (!result) { + result = this._anyType = this.query.getBuiltinType(BuiltinType.Any); + } + return result; + } + + private _undefinedType: Symbol; + private get undefinedType(): Symbol { + let result = this._undefinedType; + if (!result) { + result = this._undefinedType = this.query.getBuiltinType(BuiltinType.Undefined); + } + return result; + } + + private resolveMethodCall(receiverType: Symbol, ast: SafeMethodCall|MethodCall) { + if (this.isAny(receiverType)) { + return this.anyType; + } + + // The type of a method is the selected methods result type. + const method = receiverType.members().get(ast.name); + if (!method) return this.reportError(`Unknown method ${ast.name}`, ast); + if (!method.type.callable) return this.reportError(`Member ${ast.name} is not callable`, ast); + const signature = method.type.selectSignature(ast.args.map(arg => this.getType(arg))); + if (!signature) + return this.reportError(`Unable to resolve signature for call of method ${ast.name}`, ast); + return signature.result; + } + + private resolvePropertyRead(receiverType: Symbol, ast: SafePropertyRead|PropertyRead) { + if (this.isAny(receiverType)) { + return this.anyType; + } + + // The type of a property read is the seelcted member's type. + const member = receiverType.members().get(ast.name); + if (!member) { + let receiverInfo = receiverType.name; + if (receiverInfo == '$implict') { + receiverInfo = + 'The component declaration, template variable declarations, and element references do'; + } else { + receiverInfo = `'${receiverInfo}' does`; + } + return this.reportError( + `Identifier '${ast.name}' is not defined. ${receiverInfo} not contain such a member`, + ast); + } + if (!member.public) { + let receiverInfo = receiverType.name; + if (receiverInfo == '$implict') { + receiverInfo = 'the component'; + } else { + receiverInfo = `'${receiverInfo}'`; + } + this.reportWarning( + `Identifier '${ast.name}' refers to a private member of ${receiverInfo}`, ast); + } + return member.type; + } + + private reportError(message: string, ast: AST): Symbol { + if (this.diagnostics) { + this.diagnostics.push(new TypeDiagnostic(DiagnosticKind.Error, message, ast)); + } + return this.anyType; + } + + private reportWarning(message: string, ast: AST): Symbol { + if (this.diagnostics) { + this.diagnostics.push(new TypeDiagnostic(DiagnosticKind.Warning, message, ast)); + } + return this.anyType; + } + + private isAny(symbol: Symbol): boolean { + return !symbol || this.query.getTypeKind(symbol) == BuiltinType.Any || + (symbol.type && this.isAny(symbol.type)); + } +} + +class AstPath extends AstPathBase { + constructor(ast: AST, public position: number, excludeEmpty: boolean = false) { + super(new AstPathVisitor(position, excludeEmpty).buildPath(ast).path); + } +} + +class AstPathVisitor extends NullVisitor { + public path: AST[] = []; + + constructor(private position: number, private excludeEmpty: boolean) { super(); } + + visit(ast: AST) { + if ((!this.excludeEmpty || ast.span.start < ast.span.end) && inSpan(this.position, ast.span)) { + this.path.push(ast); + visitChildren(ast, this); + } + } + + buildPath(ast: AST): AstPathVisitor { + // We never care about the ASTWithSource node and its visit() method calls its ast's visit so + // the visit() method above would never see it. + if (ast instanceof ASTWithSource) { + ast = ast.ast; + } + this.visit(ast); + return this; + } +} + +// TODO: Consider moving to expression_parser/ast +function visitChildren(ast: AST, visitor: ExpressionVisitor) { + function visit(ast: AST) { visitor.visit && visitor.visit(ast) || ast.visit(visitor); } + + function visitAll(asts: T[]) { asts.forEach(visit); } + + ast.visit({ + visitBinary(ast) { + visit(ast.left); + visit(ast.right); + }, + visitChain(ast) { visitAll(ast.expressions); }, + visitConditional(ast) { + visit(ast.condition); + visit(ast.trueExp); + visit(ast.falseExp); + }, + visitFunctionCall(ast) { + visit(ast.target); + visitAll(ast.args); + }, + visitImplicitReceiver(ast) {}, + visitInterpolation(ast) { visitAll(ast.expressions); }, + visitKeyedRead(ast) { + visit(ast.obj); + visit(ast.key); + }, + visitKeyedWrite(ast) { + visit(ast.obj); + visit(ast.key); + visit(ast.obj); + }, + visitLiteralArray(ast) { visitAll(ast.expressions); }, + visitLiteralMap(ast) {}, + visitLiteralPrimitive(ast) {}, + visitMethodCall(ast) { + visit(ast.receiver); + visitAll(ast.args); + }, + visitPipe(ast) { + visit(ast.exp); + visitAll(ast.args); + }, + visitPrefixNot(ast) { visit(ast.expression); }, + visitPropertyRead(ast) { visit(ast.receiver); }, + visitPropertyWrite(ast) { + visit(ast.receiver); + visit(ast.value); + }, + visitQuote(ast) {}, + visitSafeMethodCall(ast) { + visit(ast.receiver); + visitAll(ast.args); + }, + visitSafePropertyRead(ast) { visit(ast.receiver); }, + }); +} + +export function getExpressionScope( + info: TemplateInfo, path: TemplateAstPath, includeEvent: boolean): SymbolTable { + let result = info.template.members; + const references = getReferences(info); + const variables = getVarDeclarations(info, path); + const events = getEventDeclaration(info, path, includeEvent); + if (references.length || variables.length || events.length) { + const referenceTable = info.template.query.createSymbolTable(references); + const variableTable = info.template.query.createSymbolTable(variables); + const eventsTable = info.template.query.createSymbolTable(events); + result = + info.template.query.mergeSymbolTable([result, referenceTable, variableTable, eventsTable]); + } + return result; +} + +function getEventDeclaration(info: TemplateInfo, path: TemplateAstPath, includeEvent?: boolean) { + let result: SymbolDeclaration[] = []; + if (includeEvent) { + // TODO: Determine the type of the event parameter based on the Observable or EventEmitter + // of the event. + result = [{ + name: '$event', + kind: 'variable', + type: info.template.query.getBuiltinType(BuiltinType.Any) + }]; + } + return result; +} + +function getReferences(info: TemplateInfo): SymbolDeclaration[] { + const result: SymbolDeclaration[] = []; + + function processReferences(references: ReferenceAst[]) { + for (const reference of references) { + let type: Symbol; + if (reference.value) { + type = info.template.query.getTypeSymbol(reference.value.reference); + } + result.push({ + name: reference.name, + kind: 'reference', + type: type || info.template.query.getBuiltinType(BuiltinType.Any), + get definition() { return getDefintionOf(info, reference); } + }); + } + } + + const visitor = new class extends TemplateAstChildVisitor { + visitEmbeddedTemplate(ast: EmbeddedTemplateAst, context: any): any { + super.visitEmbeddedTemplate(ast, context); + processReferences(ast.references); + } + visitElement(ast: ElementAst, context: any): any { + super.visitElement(ast, context); + processReferences(ast.references); + } + }; + + templateVisitAll(visitor, info.templateAst); + + return result; +} + +function getVarDeclarations(info: TemplateInfo, path: TemplateAstPath): SymbolDeclaration[] { + const result: SymbolDeclaration[] = []; + + let current = path.tail; + while (current) { + if (current instanceof EmbeddedTemplateAst) { + for (const variable of current.variables) { + const name = variable.name; + + // Find the first directive with a context. + const context = + current.directives + .map(d => info.template.query.getTemplateContext(d.directive.type.reference)) + .find(c => !!c); + + // Determine the type of the context field referenced by variable.value. + let type: Symbol; + if (context) { + const value = context.get(variable.value); + if (value) { + type = value.type; + if (info.template.query.getTypeKind(type) === BuiltinType.Any) { + // The any type is not very useful here. For special cases, such as ngFor, we can do + // better. + type = refinedVariableType(type, info, current); + } + } + } + if (!type) { + type = info.template.query.getBuiltinType(BuiltinType.Any); + } + result.push({ + name, + kind: 'variable', type, get definition() { return getDefintionOf(info, variable); } + }); + } + } + current = path.parentOf(current); + } + + return result; +} + +function refinedVariableType( + type: Symbol, info: TemplateInfo, templateElement: EmbeddedTemplateAst): Symbol { + // Special case the ngFor directive + const ngForDirective = templateElement.directives.find(d => d.directive.type.name == 'NgFor'); + if (ngForDirective) { + const ngForOfBinding = ngForDirective.inputs.find(i => i.directiveName == 'ngForOf'); + if (ngForOfBinding) { + const bindingType = + new AstType(info.template.members, info.template.query).getType(ngForOfBinding.value); + if (bindingType) { + return info.template.query.getElementType(bindingType); + } + } + } + + // We can't do better, just return the original type. + return type; +} + +function getDefintionOf(info: TemplateInfo, ast: TemplateAst): Definition { + if (info.fileName) { + const templateOffset = info.template.span.start; + return [{ + fileName: info.fileName, + span: { + start: ast.sourceSpan.start.offset + templateOffset, + end: ast.sourceSpan.end.offset + templateOffset + } + }]; + } +} \ No newline at end of file diff --git a/modules/@angular/language-service/src/hover.ts b/modules/@angular/language-service/src/hover.ts new file mode 100644 index 0000000000..9f62881bcc --- /dev/null +++ b/modules/@angular/language-service/src/hover.ts @@ -0,0 +1,28 @@ +/** + * @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 {TemplateInfo} from './common'; +import {locateSymbol} from './locate_symbol'; +import {Hover, HoverTextSection, Symbol} from './types'; + +export function getHover(info: TemplateInfo): Hover { + const result = locateSymbol(info); + if (result) { + return {text: hoverTextOf(result.symbol), span: result.span}; + } +} + +function hoverTextOf(symbol: Symbol): HoverTextSection[] { + const result: HoverTextSection[] = + [{text: symbol.kind}, {text: ' '}, {text: symbol.name, language: symbol.language}]; + const container = symbol.container; + if (container) { + result.push({text: ' of '}, {text: container.name, language: container.language}); + } + return result; +} \ No newline at end of file diff --git a/modules/@angular/language-service/src/html_info.ts b/modules/@angular/language-service/src/html_info.ts new file mode 100644 index 0000000000..8b13f6dbff --- /dev/null +++ b/modules/@angular/language-service/src/html_info.ts @@ -0,0 +1,462 @@ +/** + * @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 + */ + +// Information about the HTML DOM elements + +// This section defines the HTML elements and attribute surface of HTML 4 +// which is derived from https://www.w3.org/TR/html4/strict.dtd +type attrType = string | string[]; +type hash = { + [name: string]: T +}; + +const values: attrType[] = [ + 'ID', + 'CDATA', + 'NAME', + ['ltr', 'rtl'], + ['rect', 'circle', 'poly', 'default'], + 'NUMBER', + ['nohref'], + ['ismap'], + ['declare'], + ['DATA', 'REF', 'OBJECT'], + ['GET', 'POST'], + 'IDREF', + ['TEXT', 'PASSWORD', 'CHECKBOX', 'RADIO', 'SUBMIT', 'RESET', 'FILE', 'HIDDEN', 'IMAGE', 'BUTTON'], + ['checked'], + ['disabled'], + ['readonly'], + ['multiple'], + ['selected'], + ['button', 'submit', 'reset'], + ['void', 'above', 'below', 'hsides', 'lhs', 'rhs', 'vsides', 'box', 'border'], + ['none', 'groups', 'rows', 'cols', 'all'], + ['left', 'center', 'right', 'justify', 'char'], + ['top', 'middle', 'bottom', 'baseline'], + 'IDREFS', + ['row', 'col', 'rowgroup', 'colgroup'], + ['defer'] +]; + +const groups: hash[] = [ + {id: 0}, + { + onclick: 1, + ondblclick: 1, + onmousedown: 1, + onmouseup: 1, + onmouseover: 1, + onmousemove: 1, + onmouseout: 1, + onkeypress: 1, + onkeydown: 1, + onkeyup: 1 + }, + {lang: 2, dir: 3}, + {onload: 1, onunload: 1}, + {name: 1}, + {href: 1}, + {type: 1}, + {alt: 1}, + {tabindex: 5}, + {media: 1}, + {nohref: 6}, + {usemap: 1}, + {src: 1}, + {onfocus: 1, onblur: 1}, + {charset: 1}, + {declare: 8, classid: 1, codebase: 1, data: 1, codetype: 1, archive: 1, standby: 1}, + {title: 1}, + {value: 1}, + {cite: 1}, + {datetime: 1}, + {accept: 1}, + {shape: 4, coords: 1}, + { for: 11 + }, + {action: 1, method: 10, enctype: 1, onsubmit: 1, onreset: 1, 'accept-charset': 1}, + {valuetype: 9}, + {longdesc: 1}, + {width: 1}, + {disabled: 14}, + {readonly: 15, onselect: 1}, + {accesskey: 1}, + {size: 5, multiple: 16}, + {onchange: 1}, + {label: 1}, + {selected: 17}, + {type: 12, checked: 13, size: 1, maxlength: 5}, + {rows: 5, cols: 5}, + {type: 18}, + {height: 1}, + {summary: 1, border: 1, frame: 19, rules: 20, cellspacing: 1, cellpadding: 1, datapagesize: 1}, + {align: 21, char: 1, charoff: 1, valign: 22}, + {span: 5}, + {abbr: 1, axis: 1, headers: 23, scope: 24, rowspan: 5, colspan: 5}, + {profile: 1}, + {'http-equiv': 2, name: 2, content: 1, scheme: 1}, + {class: 1, style: 1}, + {hreflang: 2, rel: 1, rev: 1}, + {ismap: 7}, + { defer: 25, event: 1, for : 1 } +]; + +const elements: {[name: string]: number[]} = { + TT: [0, 1, 2, 16, 44], + I: [0, 1, 2, 16, 44], + B: [0, 1, 2, 16, 44], + BIG: [0, 1, 2, 16, 44], + SMALL: [0, 1, 2, 16, 44], + EM: [0, 1, 2, 16, 44], + STRONG: [0, 1, 2, 16, 44], + DFN: [0, 1, 2, 16, 44], + CODE: [0, 1, 2, 16, 44], + SAMP: [0, 1, 2, 16, 44], + KBD: [0, 1, 2, 16, 44], + VAR: [0, 1, 2, 16, 44], + CITE: [0, 1, 2, 16, 44], + ABBR: [0, 1, 2, 16, 44], + ACRONYM: [0, 1, 2, 16, 44], + SUB: [0, 1, 2, 16, 44], + SUP: [0, 1, 2, 16, 44], + SPAN: [0, 1, 2, 16, 44], + BDO: [0, 2, 16, 44], + BR: [0, 16, 44], + BODY: [0, 1, 2, 3, 16, 44], + ADDRESS: [0, 1, 2, 16, 44], + DIV: [0, 1, 2, 16, 44], + A: [0, 1, 2, 4, 5, 6, 8, 13, 14, 16, 21, 29, 44, 45], + MAP: [0, 1, 2, 4, 16, 44], + AREA: [0, 1, 2, 5, 7, 8, 10, 13, 16, 21, 29, 44], + LINK: [0, 1, 2, 5, 6, 9, 14, 16, 44, 45], + IMG: [0, 1, 2, 4, 7, 11, 12, 16, 25, 26, 37, 44, 46], + OBJECT: [0, 1, 2, 4, 6, 8, 11, 15, 16, 26, 37, 44], + PARAM: [0, 4, 6, 17, 24], + HR: [0, 1, 2, 16, 44], + P: [0, 1, 2, 16, 44], + H1: [0, 1, 2, 16, 44], + H2: [0, 1, 2, 16, 44], + H3: [0, 1, 2, 16, 44], + H4: [0, 1, 2, 16, 44], + H5: [0, 1, 2, 16, 44], + H6: [0, 1, 2, 16, 44], + PRE: [0, 1, 2, 16, 44], + Q: [0, 1, 2, 16, 18, 44], + BLOCKQUOTE: [0, 1, 2, 16, 18, 44], + INS: [0, 1, 2, 16, 18, 19, 44], + DEL: [0, 1, 2, 16, 18, 19, 44], + DL: [0, 1, 2, 16, 44], + DT: [0, 1, 2, 16, 44], + DD: [0, 1, 2, 16, 44], + OL: [0, 1, 2, 16, 44], + UL: [0, 1, 2, 16, 44], + LI: [0, 1, 2, 16, 44], + FORM: [0, 1, 2, 4, 16, 20, 23, 44], + LABEL: [0, 1, 2, 13, 16, 22, 29, 44], + INPUT: [0, 1, 2, 4, 7, 8, 11, 12, 13, 16, 17, 20, 27, 28, 29, 31, 34, 44, 46], + SELECT: [0, 1, 2, 4, 8, 13, 16, 27, 30, 31, 44], + OPTGROUP: [0, 1, 2, 16, 27, 32, 44], + OPTION: [0, 1, 2, 16, 17, 27, 32, 33, 44], + TEXTAREA: [0, 1, 2, 4, 8, 13, 16, 27, 28, 29, 31, 35, 44], + FIELDSET: [0, 1, 2, 16, 44], + LEGEND: [0, 1, 2, 16, 29, 44], + BUTTON: [0, 1, 2, 4, 8, 13, 16, 17, 27, 29, 36, 44], + TABLE: [0, 1, 2, 16, 26, 38, 44], + CAPTION: [0, 1, 2, 16, 44], + COLGROUP: [0, 1, 2, 16, 26, 39, 40, 44], + COL: [0, 1, 2, 16, 26, 39, 40, 44], + THEAD: [0, 1, 2, 16, 39, 44], + TBODY: [0, 1, 2, 16, 39, 44], + TFOOT: [0, 1, 2, 16, 39, 44], + TR: [0, 1, 2, 16, 39, 44], + TH: [0, 1, 2, 16, 39, 41, 44], + TD: [0, 1, 2, 16, 39, 41, 44], + HEAD: [2, 42], + TITLE: [2], + BASE: [5], + META: [2, 43], + STYLE: [2, 6, 9, 16], + SCRIPT: [6, 12, 14, 47], + NOSCRIPT: [0, 1, 2, 16, 44], + HTML: [2] +}; + +const defaultAttributes = [0, 1, 2, 4]; + +export function elementNames(): string[] { + return Object.keys(elements).sort().map(v => v.toLowerCase()); +} + +function compose(indexes: number[] | undefined): hash { + const result: hash = {}; + if (indexes) { + for (let index of indexes) { + const group = groups[index]; + for (let name in group) + if (group.hasOwnProperty(name)) result[name] = values[group[name]]; + } + } + return result; +} + +export function attributeNames(element: string): string[] { + return Object.keys(compose(elements[element.toUpperCase()] || defaultAttributes)).sort(); +} + +export function attributeType(element: string, attribute: string): string|string[]|undefined { + return compose(elements[element.toUpperCase()] || defaultAttributes)[attribute.toLowerCase()]; +} + +// This section is describes the DOM property surface of a DOM element and is dervided from +// from the SCHEMA strings from the security context information. SCHEMA is copied here because +// it would be an unnecessary risk to allow this array to be imported from the security context +// schema registry. + +const SCHEMA: + string[] = + [ + '[Element]|textContent,%classList,className,id,innerHTML,*beforecopy,*beforecut,*beforepaste,*copy,*cut,*paste,*search,*selectstart,*webkitfullscreenchange,*webkitfullscreenerror,*wheel,outerHTML,#scrollLeft,#scrollTop', + '[HTMLElement]^[Element]|accessKey,contentEditable,dir,!draggable,!hidden,innerText,lang,*abort,*beforecopy,*beforecut,*beforepaste,*blur,*cancel,*canplay,*canplaythrough,*change,*click,*close,*contextmenu,*copy,*cuechange,*cut,*dblclick,*drag,*dragend,*dragenter,*dragleave,*dragover,*dragstart,*drop,*durationchange,*emptied,*ended,*error,*focus,*input,*invalid,*keydown,*keypress,*keyup,*load,*loadeddata,*loadedmetadata,*loadstart,*message,*mousedown,*mouseenter,*mouseleave,*mousemove,*mouseout,*mouseover,*mouseup,*mousewheel,*mozfullscreenchange,*mozfullscreenerror,*mozpointerlockchange,*mozpointerlockerror,*paste,*pause,*play,*playing,*progress,*ratechange,*reset,*resize,*scroll,*search,*seeked,*seeking,*select,*selectstart,*show,*stalled,*submit,*suspend,*timeupdate,*toggle,*volumechange,*waiting,*webglcontextcreationerror,*webglcontextlost,*webglcontextrestored,*webkitfullscreenchange,*webkitfullscreenerror,*wheel,outerText,!spellcheck,%style,#tabIndex,title,!translate', + 'abbr,address,article,aside,b,bdi,bdo,cite,code,dd,dfn,dt,em,figcaption,figure,footer,header,i,kbd,main,mark,nav,noscript,rb,rp,rt,rtc,ruby,s,samp,section,small,strong,sub,sup,u,var,wbr^[HTMLElement]|accessKey,contentEditable,dir,!draggable,!hidden,innerText,lang,*abort,*beforecopy,*beforecut,*beforepaste,*blur,*cancel,*canplay,*canplaythrough,*change,*click,*close,*contextmenu,*copy,*cuechange,*cut,*dblclick,*drag,*dragend,*dragenter,*dragleave,*dragover,*dragstart,*drop,*durationchange,*emptied,*ended,*error,*focus,*input,*invalid,*keydown,*keypress,*keyup,*load,*loadeddata,*loadedmetadata,*loadstart,*message,*mousedown,*mouseenter,*mouseleave,*mousemove,*mouseout,*mouseover,*mouseup,*mousewheel,*mozfullscreenchange,*mozfullscreenerror,*mozpointerlockchange,*mozpointerlockerror,*paste,*pause,*play,*playing,*progress,*ratechange,*reset,*resize,*scroll,*search,*seeked,*seeking,*select,*selectstart,*show,*stalled,*submit,*suspend,*timeupdate,*toggle,*volumechange,*waiting,*webglcontextcreationerror,*webglcontextlost,*webglcontextrestored,*webkitfullscreenchange,*webkitfullscreenerror,*wheel,outerText,!spellcheck,%style,#tabIndex,title,!translate', + 'media^[HTMLElement]|!autoplay,!controls,%crossOrigin,#currentTime,!defaultMuted,#defaultPlaybackRate,!disableRemotePlayback,!loop,!muted,*encrypted,#playbackRate,preload,src,%srcObject,#volume', + ':svg:^[HTMLElement]|*abort,*blur,*cancel,*canplay,*canplaythrough,*change,*click,*close,*contextmenu,*cuechange,*dblclick,*drag,*dragend,*dragenter,*dragleave,*dragover,*dragstart,*drop,*durationchange,*emptied,*ended,*error,*focus,*input,*invalid,*keydown,*keypress,*keyup,*load,*loadeddata,*loadedmetadata,*loadstart,*mousedown,*mouseenter,*mouseleave,*mousemove,*mouseout,*mouseover,*mouseup,*mousewheel,*pause,*play,*playing,*progress,*ratechange,*reset,*resize,*scroll,*seeked,*seeking,*select,*show,*stalled,*submit,*suspend,*timeupdate,*toggle,*volumechange,*waiting,%style,#tabIndex', + ':svg:graphics^:svg:|', + ':svg:animation^:svg:|*begin,*end,*repeat', + ':svg:geometry^:svg:|', + ':svg:componentTransferFunction^:svg:|', + ':svg:gradient^:svg:|', + ':svg:textContent^:svg:graphics|', + ':svg:textPositioning^:svg:textContent|', + 'a^[HTMLElement]|charset,coords,download,hash,host,hostname,href,hreflang,name,password,pathname,ping,port,protocol,referrerPolicy,rel,rev,search,shape,target,text,type,username', + 'area^[HTMLElement]|alt,coords,hash,host,hostname,href,!noHref,password,pathname,ping,port,protocol,referrerPolicy,search,shape,target,username', + 'audio^media|', + 'br^[HTMLElement]|clear', + 'base^[HTMLElement]|href,target', + 'body^[HTMLElement]|aLink,background,bgColor,link,*beforeunload,*blur,*error,*focus,*hashchange,*languagechange,*load,*message,*offline,*online,*pagehide,*pageshow,*popstate,*rejectionhandled,*resize,*scroll,*storage,*unhandledrejection,*unload,text,vLink', + 'button^[HTMLElement]|!autofocus,!disabled,formAction,formEnctype,formMethod,!formNoValidate,formTarget,name,type,value', + 'canvas^[HTMLElement]|#height,#width', + 'content^[HTMLElement]|select', + 'dl^[HTMLElement]|!compact', + 'datalist^[HTMLElement]|', + 'details^[HTMLElement]|!open', + 'dialog^[HTMLElement]|!open,returnValue', + 'dir^[HTMLElement]|!compact', + 'div^[HTMLElement]|align', + 'embed^[HTMLElement]|align,height,name,src,type,width', + 'fieldset^[HTMLElement]|!disabled,name', + 'font^[HTMLElement]|color,face,size', + 'form^[HTMLElement]|acceptCharset,action,autocomplete,encoding,enctype,method,name,!noValidate,target', + 'frame^[HTMLElement]|frameBorder,longDesc,marginHeight,marginWidth,name,!noResize,scrolling,src', + 'frameset^[HTMLElement]|cols,*beforeunload,*blur,*error,*focus,*hashchange,*languagechange,*load,*message,*offline,*online,*pagehide,*pageshow,*popstate,*rejectionhandled,*resize,*scroll,*storage,*unhandledrejection,*unload,rows', + 'hr^[HTMLElement]|align,color,!noShade,size,width', + 'head^[HTMLElement]|', + 'h1,h2,h3,h4,h5,h6^[HTMLElement]|align', + 'html^[HTMLElement]|version', + 'iframe^[HTMLElement]|align,!allowFullscreen,frameBorder,height,longDesc,marginHeight,marginWidth,name,referrerPolicy,%sandbox,scrolling,src,srcdoc,width', + 'img^[HTMLElement]|align,alt,border,%crossOrigin,#height,#hspace,!isMap,longDesc,lowsrc,name,referrerPolicy,sizes,src,srcset,useMap,#vspace,#width', + 'input^[HTMLElement]|accept,align,alt,autocapitalize,autocomplete,!autofocus,!checked,!defaultChecked,defaultValue,dirName,!disabled,%files,formAction,formEnctype,formMethod,!formNoValidate,formTarget,#height,!incremental,!indeterminate,max,#maxLength,min,#minLength,!multiple,name,pattern,placeholder,!readOnly,!required,selectionDirection,#selectionEnd,#selectionStart,#size,src,step,type,useMap,value,%valueAsDate,#valueAsNumber,#width', + 'keygen^[HTMLElement]|!autofocus,challenge,!disabled,keytype,name', + 'li^[HTMLElement]|type,#value', + 'label^[HTMLElement]|htmlFor', + 'legend^[HTMLElement]|align', + 'link^[HTMLElement]|as,charset,%crossOrigin,!disabled,href,hreflang,integrity,media,rel,%relList,rev,%sizes,target,type', + 'map^[HTMLElement]|name', + 'marquee^[HTMLElement]|behavior,bgColor,direction,height,#hspace,#loop,#scrollAmount,#scrollDelay,!trueSpeed,#vspace,width', + 'menu^[HTMLElement]|!compact', + 'meta^[HTMLElement]|content,httpEquiv,name,scheme', + 'meter^[HTMLElement]|#high,#low,#max,#min,#optimum,#value', + 'ins,del^[HTMLElement]|cite,dateTime', + 'ol^[HTMLElement]|!compact,!reversed,#start,type', + 'object^[HTMLElement]|align,archive,border,code,codeBase,codeType,data,!declare,height,#hspace,name,standby,type,useMap,#vspace,width', + 'optgroup^[HTMLElement]|!disabled,label', + 'option^[HTMLElement]|!defaultSelected,!disabled,label,!selected,text,value', + 'output^[HTMLElement]|defaultValue,%htmlFor,name,value', + 'p^[HTMLElement]|align', + 'param^[HTMLElement]|name,type,value,valueType', + 'picture^[HTMLElement]|', + 'pre^[HTMLElement]|#width', + 'progress^[HTMLElement]|#max,#value', + 'q,blockquote,cite^[HTMLElement]|', + 'script^[HTMLElement]|!async,charset,%crossOrigin,!defer,event,htmlFor,integrity,src,text,type', + 'select^[HTMLElement]|!autofocus,!disabled,#length,!multiple,name,!required,#selectedIndex,#size,value', + 'shadow^[HTMLElement]|', + 'source^[HTMLElement]|media,sizes,src,srcset,type', + 'span^[HTMLElement]|', + 'style^[HTMLElement]|!disabled,media,type', + 'caption^[HTMLElement]|align', + 'th,td^[HTMLElement]|abbr,align,axis,bgColor,ch,chOff,#colSpan,headers,height,!noWrap,#rowSpan,scope,vAlign,width', + 'col,colgroup^[HTMLElement]|align,ch,chOff,#span,vAlign,width', + 'table^[HTMLElement]|align,bgColor,border,%caption,cellPadding,cellSpacing,frame,rules,summary,%tFoot,%tHead,width', + 'tr^[HTMLElement]|align,bgColor,ch,chOff,vAlign', + 'tfoot,thead,tbody^[HTMLElement]|align,ch,chOff,vAlign', + 'template^[HTMLElement]|', + 'textarea^[HTMLElement]|autocapitalize,!autofocus,#cols,defaultValue,dirName,!disabled,#maxLength,#minLength,name,placeholder,!readOnly,!required,#rows,selectionDirection,#selectionEnd,#selectionStart,value,wrap', + 'title^[HTMLElement]|text', + 'track^[HTMLElement]|!default,kind,label,src,srclang', + 'ul^[HTMLElement]|!compact,type', + 'unknown^[HTMLElement]|', + 'video^media|#height,poster,#width', + ':svg:a^:svg:graphics|', + ':svg:animate^:svg:animation|', + ':svg:animateMotion^:svg:animation|', + ':svg:animateTransform^:svg:animation|', + ':svg:circle^:svg:geometry|', + ':svg:clipPath^:svg:graphics|', + ':svg:cursor^:svg:|', + ':svg:defs^:svg:graphics|', + ':svg:desc^:svg:|', + ':svg:discard^:svg:|', + ':svg:ellipse^:svg:geometry|', + ':svg:feBlend^:svg:|', + ':svg:feColorMatrix^:svg:|', + ':svg:feComponentTransfer^:svg:|', + ':svg:feComposite^:svg:|', + ':svg:feConvolveMatrix^:svg:|', + ':svg:feDiffuseLighting^:svg:|', + ':svg:feDisplacementMap^:svg:|', + ':svg:feDistantLight^:svg:|', + ':svg:feDropShadow^:svg:|', + ':svg:feFlood^:svg:|', + ':svg:feFuncA^:svg:componentTransferFunction|', + ':svg:feFuncB^:svg:componentTransferFunction|', + ':svg:feFuncG^:svg:componentTransferFunction|', + ':svg:feFuncR^:svg:componentTransferFunction|', + ':svg:feGaussianBlur^:svg:|', + ':svg:feImage^:svg:|', + ':svg:feMerge^:svg:|', + ':svg:feMergeNode^:svg:|', + ':svg:feMorphology^:svg:|', + ':svg:feOffset^:svg:|', + ':svg:fePointLight^:svg:|', + ':svg:feSpecularLighting^:svg:|', + ':svg:feSpotLight^:svg:|', + ':svg:feTile^:svg:|', + ':svg:feTurbulence^:svg:|', + ':svg:filter^:svg:|', + ':svg:foreignObject^:svg:graphics|', + ':svg:g^:svg:graphics|', + ':svg:image^:svg:graphics|', + ':svg:line^:svg:geometry|', + ':svg:linearGradient^:svg:gradient|', + ':svg:mpath^:svg:|', + ':svg:marker^:svg:|', + ':svg:mask^:svg:|', + ':svg:metadata^:svg:|', + ':svg:path^:svg:geometry|', + ':svg:pattern^:svg:|', + ':svg:polygon^:svg:geometry|', + ':svg:polyline^:svg:geometry|', + ':svg:radialGradient^:svg:gradient|', + ':svg:rect^:svg:geometry|', + ':svg:svg^:svg:graphics|#currentScale,#zoomAndPan', + ':svg:script^:svg:|type', + ':svg:set^:svg:animation|', + ':svg:stop^:svg:|', + ':svg:style^:svg:|!disabled,media,title,type', + ':svg:switch^:svg:graphics|', + ':svg:symbol^:svg:|', + ':svg:tspan^:svg:textPositioning|', + ':svg:text^:svg:textPositioning|', + ':svg:textPath^:svg:textContent|', + ':svg:title^:svg:|', + ':svg:use^:svg:graphics|', + ':svg:view^:svg:|#zoomAndPan', + 'data^[HTMLElement]|value', + 'menuitem^[HTMLElement]|type,label,icon,!disabled,!checked,radiogroup,!default', + 'summary^[HTMLElement]|', + 'time^[HTMLElement]|dateTime', + ]; + +const attrToPropMap: {[name: string]: string} = { + 'class': 'className', + 'formaction': 'formAction', + 'innerHtml': 'innerHTML', + 'readonly': 'readOnly', + 'tabindex': 'tabIndex' +}; + +const EVENT = 'event'; +const BOOLEAN = 'boolean'; +const NUMBER = 'number'; +const STRING = 'string'; +const OBJECT = 'object'; + +export class SchemaInformation { + schema = <{[element: string]: {[property: string]: string}}>{}; + + constructor() { + SCHEMA.forEach(encodedType => { + const parts = encodedType.split('|'); + const properties = parts[1].split(','); + const typeParts = (parts[0] + '^').split('^'); + const typeName = typeParts[0]; + const type = <{[property: string]: string}>{}; + typeName.split(',').forEach(tag => this.schema[tag.toLowerCase()] = type); + const superName = typeParts[1]; + const superType = superName && this.schema[superName.toLowerCase()]; + if (superType) { + for (const key in superType) { + type[key] = superType[key]; + } + } + properties.forEach((property: string) => { + if (property == '') { + } else if (property.startsWith('*')) { + type[property.substring(1)] = EVENT; + } else if (property.startsWith('!')) { + type[property.substring(1)] = BOOLEAN; + } else if (property.startsWith('#')) { + type[property.substring(1)] = NUMBER; + } else if (property.startsWith('%')) { + type[property.substring(1)] = OBJECT; + } else { + type[property] = STRING; + } + }); + }); + } + + allKnownElements(): string[] { return Object.keys(this.schema); } + + eventsOf(elementName: string): string[] { + const elementType = this.schema[elementName.toLowerCase()] || {}; + return Object.keys(elementType).filter(property => elementType[property] === EVENT); + } + + propertiesOf(elementName: string): string[] { + const elementType = this.schema[elementName.toLowerCase()] || {}; + return Object.keys(elementType).filter(property => elementType[property] !== EVENT); + } + + typeOf(elementName: string, property: string): string { + return (this.schema[elementName.toLowerCase()] || {})[property]; + } + + private static _instance: SchemaInformation; + + static get instance(): SchemaInformation { + let result = SchemaInformation._instance; + if (!result) { + result = SchemaInformation._instance = new SchemaInformation(); + } + return result; + } +} + +export function eventNames(elementName: string): string[] { + return SchemaInformation.instance.eventsOf(elementName); +} + +export function propertyNames(elementName: string): string[] { + return SchemaInformation.instance.propertiesOf(elementName); +} + +export function propertyType(elementName: string, propertyName: string): string { + return SchemaInformation.instance.typeOf(elementName, propertyName); +} diff --git a/modules/@angular/language-service/src/html_path.ts b/modules/@angular/language-service/src/html_path.ts new file mode 100644 index 0000000000..6e4907e095 --- /dev/null +++ b/modules/@angular/language-service/src/html_path.ts @@ -0,0 +1,72 @@ +/** + * @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 {Attribute, Comment, Element, Expansion, ExpansionCase, Node, Text, Visitor, visitAll} from '@angular/compiler/src/ml_parser/ast'; + +import {AstPath} from './ast_path'; +import {inSpan, spanOf} from './utils'; + +export class HtmlAstPath extends AstPath { + constructor(ast: Node[], public position: number) { super(buildPath(ast, position)); } +} + +function buildPath(ast: Node[], position: number): Node[] { + let visitor = new HtmlAstPathBuilder(position); + visitAll(visitor, ast); + return visitor.getPath(); +} + +export class ChildVisitor implements Visitor { + constructor(private visitor?: Visitor) {} + + visitElement(ast: Element, context: any): any { + this.visitChildren(context, visit => { + visit(ast.attrs); + visit(ast.children); + }); + } + + visitAttribute(ast: Attribute, context: any): any {} + visitText(ast: Text, context: any): any {} + visitComment(ast: Comment, context: any): any {} + + visitExpansion(ast: Expansion, context: any): any { + return this.visitChildren(context, visit => { visit(ast.cases); }); + } + + visitExpansionCase(ast: ExpansionCase, context: any): any {} + + private visitChildren( + context: any, cb: (visit: ((children: V[]|undefined) => void)) => void) { + const visitor = this.visitor || this; + let results: any[][] = []; + function visit(children: T[] | undefined) { + if (children) results.push(visitAll(visitor, children, context)); + } + cb(visit); + return [].concat.apply([], results); + } +} + +class HtmlAstPathBuilder extends ChildVisitor { + private path: Node[] = []; + + constructor(private position: number) { super(); } + + visit(ast: Node, context: any): any { + let span = spanOf(ast); + if (inSpan(this.position, span)) { + this.path.push(ast); + } else { + // Returning a value here will result in the children being skipped. + return true; + } + } + + getPath(): Node[] { return this.path; } +} diff --git a/modules/@angular/language-service/src/language_service.ts b/modules/@angular/language-service/src/language_service.ts new file mode 100644 index 0000000000..2389dc8c47 --- /dev/null +++ b/modules/@angular/language-service/src/language_service.ts @@ -0,0 +1,186 @@ +/** + * @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 {NgAnalyzedModules} from '@angular/compiler/src/aot/compiler'; +import {CompileNgModuleMetadata} from '@angular/compiler/src/compile_metadata'; +import {Lexer} from '@angular/compiler/src/expression_parser/lexer'; +import {Parser} from '@angular/compiler/src/expression_parser/parser'; +import {I18NHtmlParser} from '@angular/compiler/src/i18n/i18n_html_parser'; +import {CompileMetadataResolver} from '@angular/compiler/src/metadata_resolver'; +import {HtmlParser} from '@angular/compiler/src/ml_parser/html_parser'; +import {DomElementSchemaRegistry} from '@angular/compiler/src/schema/dom_element_schema_registry'; +import {TemplateParser} from '@angular/compiler/src/template_parser/template_parser'; + +import {AstResult, AttrInfo, TemplateInfo} from './common'; +import {getTemplateCompletions} from './completions'; +import {getDefinition} from './definitions'; +import {getDeclarationDiagnostics, getTemplateDiagnostics} from './diagnostics'; +import {getHover} from './hover'; +import {Completion, CompletionKind, Completions, Declaration, Declarations, Definition, Diagnostic, DiagnosticKind, Diagnostics, Hover, LanguageService, LanguageServiceHost, Location, PipeInfo, Pipes, Signature, Span, Symbol, SymbolDeclaration, SymbolQuery, SymbolTable, TemplateSource, TemplateSources} from './types'; + +/** + * Create an instance of an Angular `LanguageService`. + * + * @experimental + */ +export function createLanguageService(host: LanguageServiceHost): LanguageService { + return new LanguageServiceImpl(host); +} + +class LanguageServiceImpl implements LanguageService { + constructor(private host: LanguageServiceHost) {} + + private get metadataResolver(): CompileMetadataResolver { return this.host.resolver; } + + getTemplateReferences(): string[] { return this.host.getTemplateReferences(); } + + getDiagnostics(fileName: string): Diagnostics { + let results: Diagnostics = []; + let templates = this.host.getTemplates(fileName); + if (templates && templates.length) { + results.push(...getTemplateDiagnostics(fileName, this, templates)); + } + + let declarations = this.host.getDeclarations(fileName); + if (declarations && declarations.length) { + const summary = this.host.getAnalyzedModules(); + results.push(...getDeclarationDiagnostics(declarations, summary)); + } + + return uniqueBySpan(results); + } + + getPipesAt(fileName: string, position: number): Pipes { + let templateInfo = this.getTemplateAstAtPosition(fileName, position); + if (templateInfo) { + return templateInfo.pipes.map( + pipeInfo => ({name: pipeInfo.name, symbol: pipeInfo.type.reference})); + } + } + + getCompletionsAt(fileName: string, position: number): Completions { + let templateInfo = this.getTemplateAstAtPosition(fileName, position); + if (templateInfo) { + return getTemplateCompletions(templateInfo); + } + } + + getDefinitionAt(fileName: string, position: number): Definition { + let templateInfo = this.getTemplateAstAtPosition(fileName, position); + if (templateInfo) { + return getDefinition(templateInfo); + } + } + + getHoverAt(fileName: string, position: number): Hover { + let templateInfo = this.getTemplateAstAtPosition(fileName, position); + if (templateInfo) { + return getHover(templateInfo); + } + } + + private getTemplateAstAtPosition(fileName: string, position: number): TemplateInfo { + let template = this.host.getTemplateAt(fileName, position); + if (template) { + let astResult = this.getTemplateAst(template, fileName); + if (astResult && astResult.htmlAst && astResult.templateAst) + return { + position, + fileName, + template, + htmlAst: astResult.htmlAst, + directive: astResult.directive, + directives: astResult.directives, + pipes: astResult.pipes, + templateAst: astResult.templateAst, + expressionParser: astResult.expressionParser + }; + } + return undefined; + } + + getTemplateAst(template: TemplateSource, contextFile: string): AstResult { + let result: AstResult; + try { + const directive = + this.metadataResolver.getNonNormalizedDirectiveMetadata(template.type as any); + if (directive) { + const rawHtmlParser = new HtmlParser(); + const htmlParser = new I18NHtmlParser(rawHtmlParser); + const expressionParser = new Parser(new Lexer()); + const parser = new TemplateParser( + expressionParser, new DomElementSchemaRegistry(), htmlParser, null, []); + const htmlResult = htmlParser.parse(template.source, ''); + const analyzedModules = this.host.getAnalyzedModules(); + let errors: Diagnostic[] = undefined; + let ngModule = analyzedModules.ngModuleByPipeOrDirective.get(template.type); + if (!ngModule) { + // Reported by the the declaration diagnostics. + ngModule = findSuitableDefaultModule(analyzedModules); + } + if (ngModule) { + const directives = ngModule.transitiveModule.directives.map( + d => this.host.resolver.getNonNormalizedDirectiveMetadata(d.reference).toSummary()); + const pipes = ngModule.transitiveModule.pipes.map( + p => this.host.resolver.getOrLoadPipeMetadata(p.reference).toSummary()); + const schemas = ngModule.schemas; + const parseResult = parser.tryParseHtml( + htmlResult, directive, template.source, directives, pipes, schemas, ''); + result = { + htmlAst: htmlResult.rootNodes, + templateAst: parseResult.templateAst, directive, directives, pipes, + parseErrors: parseResult.errors, expressionParser, errors + }; + } + } + } catch (e) { + let span = template.span; + if (e.fileName == contextFile) { + span = template.query.getSpanAt(e.line, e.column) || span; + } + result = {errors: [{kind: DiagnosticKind.Error, message: e.message, span}]}; + } + return result; + } +} + +function uniqueBySpan < T extends { + span: Span; +} +> (elements: T[] | undefined): T[]|undefined { + if (elements) { + const result: T[] = []; + const map = new Map>(); + for (const element of elements) { + let span = element.span; + let set = map.get(span.start); + if (!set) { + set = new Set(); + map.set(span.start, set); + } + if (!set.has(span.end)) { + set.add(span.end); + result.push(element); + } + } + return result; + } +} + +function findSuitableDefaultModule(modules: NgAnalyzedModules): CompileNgModuleMetadata { + let result: CompileNgModuleMetadata; + let resultSize = 0; + for (const module of modules.ngModules) { + const moduleSize = module.transitiveModule.directives.length; + if (moduleSize > resultSize) { + result = module; + resultSize = moduleSize; + } + } + return result; +} \ No newline at end of file diff --git a/modules/@angular/language-service/src/locate_symbol.ts b/modules/@angular/language-service/src/locate_symbol.ts new file mode 100644 index 0000000000..7bbc7c538b --- /dev/null +++ b/modules/@angular/language-service/src/locate_symbol.ts @@ -0,0 +1,192 @@ +/** + * @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 {AST} from '@angular/compiler/src/expression_parser/ast'; +import {Attribute} from '@angular/compiler/src/ml_parser/ast'; +import {BoundDirectivePropertyAst, BoundEventAst, ElementAst, TemplateAst} from '@angular/compiler/src/template_parser/template_ast'; + +import {TemplateInfo} from './common'; +import {getExpressionScope, getExpressionSymbol} from './expressions'; +import {HtmlAstPath} from './html_path'; +import {TemplateAstPath} from './template_path'; +import {Definition, Location, Span, Symbol, SymbolTable} from './types'; +import {inSpan, offsetSpan, spanOf} from './utils'; + +export interface SymbolInfo { + symbol: Symbol; + span: Span; +} + +export function locateSymbol(info: TemplateInfo): SymbolInfo { + const templatePosition = info.position - info.template.span.start; + const path = new TemplateAstPath(info.templateAst, templatePosition); + if (path.tail) { + let symbol: Symbol = undefined; + let span: Span = undefined; + const attributeValueSymbol = (ast: AST, inEvent: boolean = false): boolean => { + const attribute = findAttribute(info); + if (attribute) { + if (inSpan(templatePosition, spanOf(attribute.valueSpan))) { + const scope = getExpressionScope(info, path, inEvent); + const expressionOffset = attribute.valueSpan.start.offset + 1; + const result = getExpressionSymbol( + scope, ast, templatePosition - expressionOffset, info.template.query); + if (result) { + symbol = result.symbol; + span = offsetSpan(result.span, expressionOffset); + } + return true; + } + } + return false; + }; + path.tail.visit( + { + visitNgContent(ast) {}, + visitEmbeddedTemplate(ast) {}, + visitElement(ast) { + const component = ast.directives.find(d => d.directive.isComponent); + if (component) { + symbol = info.template.query.getTypeSymbol(component.directive.type.reference); + symbol = symbol && new OverrideKindSymbol(symbol, 'component'); + span = spanOf(ast); + } else { + // Find a directive that matches the element name + const directive = + ast.directives.find(d => d.directive.selector.indexOf(ast.name) >= 0); + if (directive) { + symbol = info.template.query.getTypeSymbol(directive.directive.type.reference); + symbol = symbol && new OverrideKindSymbol(symbol, 'directive'); + span = spanOf(ast); + } + } + }, + visitReference(ast) { + symbol = info.template.query.getTypeSymbol(ast.value.reference); + span = spanOf(ast); + }, + visitVariable(ast) {}, + visitEvent(ast) { + if (!attributeValueSymbol(ast.handler, /* inEvent */ true)) { + symbol = findOutputBinding(info, path, ast); + symbol = symbol && new OverrideKindSymbol(symbol, 'event'); + span = spanOf(ast); + } + }, + visitElementProperty(ast) { attributeValueSymbol(ast.value); }, + visitAttr(ast) {}, + visitBoundText(ast) { + const expressionPosition = templatePosition - ast.sourceSpan.start.offset; + if (inSpan(expressionPosition, ast.value.span)) { + const scope = getExpressionScope(info, path, /* includeEvent */ false); + const result = + getExpressionSymbol(scope, ast.value, expressionPosition, info.template.query); + if (result) { + symbol = result.symbol; + span = offsetSpan(result.span, ast.sourceSpan.start.offset); + } + } + }, + visitText(ast) {}, + visitDirective(ast) { + symbol = info.template.query.getTypeSymbol(ast.directive.type.reference); + span = spanOf(ast); + }, + visitDirectiveProperty(ast) { + if (!attributeValueSymbol(ast.value)) { + symbol = findInputBinding(info, path, ast); + span = spanOf(ast); + } + } + }, + null); + if (symbol && span) { + return {symbol, span: offsetSpan(span, info.template.span.start)}; + } + } +} + +function findAttribute(info: TemplateInfo): Attribute { + const templatePosition = info.position - info.template.span.start; + const path = new HtmlAstPath(info.htmlAst, templatePosition); + return path.first(Attribute); +} + +function findInputBinding( + info: TemplateInfo, path: TemplateAstPath, binding: BoundDirectivePropertyAst): Symbol { + const element = path.first(ElementAst); + if (element) { + for (const directive of element.directives) { + const invertedInput = invertMap(directive.directive.inputs); + const fieldName = invertedInput[binding.templateName]; + if (fieldName) { + const classSymbol = info.template.query.getTypeSymbol(directive.directive.type.reference); + if (classSymbol) { + return classSymbol.members().get(fieldName); + } + } + } + } +} + +function findOutputBinding( + info: TemplateInfo, path: TemplateAstPath, binding: BoundEventAst): Symbol { + const element = path.first(ElementAst); + if (element) { + for (const directive of element.directives) { + const invertedOutputs = invertMap(directive.directive.outputs); + const fieldName = invertedOutputs[binding.name]; + if (fieldName) { + const classSymbol = info.template.query.getTypeSymbol(directive.directive.type.reference); + if (classSymbol) { + return classSymbol.members().get(fieldName); + } + } + } + } +} + +function invertMap(obj: {[name: string]: string}): {[name: string]: string} { + const result: {[name: string]: string} = {}; + for (const name of Object.keys(obj)) { + const v = obj[name]; + result[v] = name; + } + return result; +} + +/** + * Wrap a symbol and change its kind to component. + */ +class OverrideKindSymbol implements Symbol { + constructor(private sym: Symbol, private kindOverride: string) {} + + get name(): string { return this.sym.name; } + + get kind(): string { return this.kindOverride; } + + get language(): string { return this.sym.language; } + + get type(): Symbol|undefined { return this.sym.type; } + + get container(): Symbol|undefined { return this.sym.container; } + + get public(): boolean { return this.sym.public; } + + get callable(): boolean { return this.sym.callable; } + + get definition(): Definition { return this.sym.definition; } + + members() { return this.sym.members(); } + + signatures() { return this.sym.signatures(); } + + selectSignature(types: Symbol[]) { return this.sym.selectSignature(types); } + + indexed(argument: Symbol) { return this.sym.indexed(argument); } +} \ No newline at end of file diff --git a/modules/@angular/language-service/src/reflector_host.ts b/modules/@angular/language-service/src/reflector_host.ts new file mode 100644 index 0000000000..f4dfc694e9 --- /dev/null +++ b/modules/@angular/language-service/src/reflector_host.ts @@ -0,0 +1,158 @@ +/** + * @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 {StaticReflectorHost, StaticSymbol} from '@angular/compiler'; +import {MetadataCollector} from '@angular/tsc-wrapped/src/collector'; +import {ModuleMetadata} from '@angular/tsc-wrapped/src/schema'; +import * as path from 'path'; +import * as ts from 'typescript'; + +const EXT = /(\.ts|\.d\.ts|\.js|\.jsx|\.tsx)$/; +const DTS = /\.d\.ts$/; + +let serialNumber = 0; + +class ReflectorModuleModuleResolutionHost implements ts.ModuleResolutionHost { + private forceExists: string[] = []; + + constructor(private host: ts.LanguageServiceHost) { + if (host.directoryExists) + this.directoryExists = directoryName => this.host.directoryExists(directoryName); + } + + fileExists(fileName: string): boolean { + return !!this.host.getScriptSnapshot(fileName) || this.forceExists.indexOf(fileName) >= 0; + } + + readFile(fileName: string): string { + let snapshot = this.host.getScriptSnapshot(fileName); + if (snapshot) { + return snapshot.getText(0, snapshot.getLength()); + } + } + + directoryExists: (directoryName: string) => boolean; + + forceExist(fileName: string): void { this.forceExists.push(fileName); } +} + +export class ReflectorHost implements StaticReflectorHost { + private metadataCollector: MetadataCollector; + private moduleResolverHost: ReflectorModuleModuleResolutionHost; + private _typeChecker: ts.TypeChecker; + private metadataCache = new Map(); + + constructor( + private getProgram: () => ts.Program, private serviceHost: ts.LanguageServiceHost, + private options: ts.CompilerOptions, private basePath: string) { + this.moduleResolverHost = new ReflectorModuleModuleResolutionHost(serviceHost); + this.metadataCollector = new MetadataCollector(); + } + + getCanonicalFileName(fileName: string): string { return fileName; } + + private get program() { return this.getProgram(); } + + public moduleNameToFileName(moduleName: string, containingFile: string) { + if (!containingFile || !containingFile.length) { + if (moduleName.indexOf('.') === 0) { + throw new Error('Resolution of relative paths requires a containing file.'); + } + // Any containing file gives the same result for absolute imports + containingFile = this.getCanonicalFileName(path.join(this.basePath, 'index.ts')); + } + moduleName = moduleName.replace(EXT, ''); + const resolved = + ts.resolveModuleName(moduleName, containingFile, this.options, this.moduleResolverHost) + .resolvedModule; + return resolved ? resolved.resolvedFileName : null; + }; + + /** + * We want a moduleId that will appear in import statements in the generated code. + * These need to be in a form that system.js can load, so absolute file paths don't work. + * Relativize the paths by checking candidate prefixes of the absolute path, to see if + * they are resolvable by the moduleResolution strategy from the CompilerHost. + */ + fileNameToModuleName(importedFile: string, containingFile: string) { + // TODO(tbosch): if a file does not yet exist (because we compile it later), + // we still need to create it so that the `resolve` method works! + if (!this.moduleResolverHost.fileExists(importedFile)) { + this.moduleResolverHost.forceExist(importedFile); + } + + const parts = importedFile.replace(EXT, '').split(path.sep).filter(p => !!p); + + for (let index = parts.length - 1; index >= 0; index--) { + let candidate = parts.slice(index, parts.length).join(path.sep); + if (this.moduleNameToFileName('.' + path.sep + candidate, containingFile) === importedFile) { + return `./${candidate}`; + } + if (this.moduleNameToFileName(candidate, containingFile) === importedFile) { + return candidate; + } + } + throw new Error( + `Unable to find any resolvable import for ${importedFile} relative to ${containingFile}`); + } + + private get typeChecker(): ts.TypeChecker { + let result = this._typeChecker; + if (!result) { + result = this._typeChecker = this.program.getTypeChecker(); + } + return result; + } + + private typeCache = new Map(); + + // TODO(alexeagle): take a statictype + getMetadataFor(filePath: string): ModuleMetadata[] { + if (!this.moduleResolverHost.fileExists(filePath)) { + throw new Error(`No such file '${filePath}'`); + } + if (DTS.test(filePath)) { + const metadataPath = filePath.replace(DTS, '.metadata.json'); + if (this.moduleResolverHost.fileExists(metadataPath)) { + return this.readMetadata(metadataPath); + } + } + + let sf = this.program.getSourceFile(filePath); + if (!sf) { + throw new Error(`Source file ${filePath} not present in program.`); + } + + const entry = this.metadataCache.get(sf.path); + const version = this.serviceHost.getScriptVersion(sf.path); + if (entry && entry.version == version) { + if (!entry.content) return undefined; + return [entry.content]; + } + const metadata = this.metadataCollector.getMetadata(sf); + this.metadataCache.set(sf.path, {version, content: metadata}); + if (metadata) return [metadata]; + } + + readMetadata(filePath: string) { + try { + const text = this.moduleResolverHost.readFile(filePath); + const result = JSON.parse(text); + if (!Array.isArray(result)) return [result]; + return result; + } catch (e) { + console.error(`Failed to read JSON file ${filePath}`); + throw e; + } + } +} + +interface MetadataCacheEntry { + version: string; + content: ModuleMetadata; +} \ No newline at end of file diff --git a/modules/@angular/language-service/src/template_path.ts b/modules/@angular/language-service/src/template_path.ts new file mode 100644 index 0000000000..eacc0b45e2 --- /dev/null +++ b/modules/@angular/language-service/src/template_path.ts @@ -0,0 +1,151 @@ +/** + * @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 {AttrAst, BoundDirectivePropertyAst, BoundElementPropertyAst, BoundEventAst, BoundTextAst, DirectiveAst, ElementAst, EmbeddedTemplateAst, NgContentAst, ReferenceAst, TemplateAst, TemplateAstVisitor, TextAst, VariableAst, templateVisitAll} from '@angular/compiler/src/template_parser/template_ast'; + +import {AstPath} from './ast_path'; +import {inSpan, isNarrower, spanOf} from './utils'; + +export class TemplateAstPath extends AstPath { + constructor(ast: TemplateAst[], public position: number, allowWidening: boolean = false) { + super(buildTemplatePath(ast, position, allowWidening)); + } +} + +function buildTemplatePath( + ast: TemplateAst[], position: number, allowWidening: boolean = false): TemplateAst[] { + const visitor = new TemplateAstPathBuilder(position, allowWidening); + templateVisitAll(visitor, ast); + return visitor.getPath(); +} + +export class NullTemplateVisitor implements TemplateAstVisitor { + visitNgContent(ast: NgContentAst): void {} + visitEmbeddedTemplate(ast: EmbeddedTemplateAst): void {} + visitElement(ast: ElementAst): void {} + visitReference(ast: ReferenceAst): void {} + visitVariable(ast: VariableAst): void {} + visitEvent(ast: BoundEventAst): void {} + visitElementProperty(ast: BoundElementPropertyAst): void {} + visitAttr(ast: AttrAst): void {} + visitBoundText(ast: BoundTextAst): void {} + visitText(ast: TextAst): void {} + visitDirective(ast: DirectiveAst): void {} + visitDirectiveProperty(ast: BoundDirectivePropertyAst): void {} +} + +export class TemplateAstChildVisitor implements TemplateAstVisitor { + constructor(private visitor?: TemplateAstVisitor) {} + + // Nodes with children + visitEmbeddedTemplate(ast: EmbeddedTemplateAst, context: any): any { + return this.visitChildren(context, visit => { + visit(ast.attrs); + visit(ast.references); + visit(ast.variables); + visit(ast.directives); + visit(ast.providers); + visit(ast.children); + }); + } + + visitElement(ast: ElementAst, context: any): any { + return this.visitChildren(context, visit => { + visit(ast.attrs); + visit(ast.inputs); + visit(ast.outputs); + visit(ast.references); + visit(ast.directives); + visit(ast.providers); + visit(ast.children); + }); + } + + visitDirective(ast: DirectiveAst, context: any): any { + return this.visitChildren(context, visit => { + visit(ast.inputs); + visit(ast.hostProperties); + visit(ast.hostEvents); + }); + } + + // Terminal nodes + visitNgContent(ast: NgContentAst, context: any): any {} + visitReference(ast: ReferenceAst, context: any): any {} + visitVariable(ast: VariableAst, context: any): any {} + visitEvent(ast: BoundEventAst, context: any): any {} + visitElementProperty(ast: BoundElementPropertyAst, context: any): any {} + visitAttr(ast: AttrAst, context: any): any {} + visitBoundText(ast: BoundTextAst, context: any): any {} + visitText(ast: TextAst, context: any): any {} + visitDirectiveProperty(ast: BoundDirectivePropertyAst, context: any): any {} + + protected visitChildren( + context: any, + cb: (visit: ((children: V[]|undefined) => void)) => void) { + const visitor = this.visitor || this; + let results: any[][] = []; + function visit(children: T[] | undefined) { + if (children && children.length) results.push(templateVisitAll(visitor, children, context)); + } + cb(visit); + return [].concat.apply([], results); + } +} + +class TemplateAstPathBuilder extends TemplateAstChildVisitor { + private path: TemplateAst[] = []; + + constructor(private position: number, private allowWidening: boolean) { super(); } + + visit(ast: TemplateAst, context: any): any { + let span = spanOf(ast); + if (inSpan(this.position, span)) { + const len = this.path.length; + if (!len || this.allowWidening || isNarrower(span, spanOf(this.path[len - 1]))) { + this.path.push(ast); + } + } else { + // Returning a value here will result in the children being skipped. + return true; + } + } + + visitEmbeddedTemplate(ast: EmbeddedTemplateAst, context: any): any { + return this.visitChildren(context, visit => { + // Ignore reference, variable and providers + visit(ast.attrs); + visit(ast.directives); + visit(ast.children); + }); + } + + visitElement(ast: ElementAst, context: any): any { + return this.visitChildren(context, visit => { + // Ingnore providers + visit(ast.attrs); + visit(ast.inputs); + visit(ast.outputs); + visit(ast.references); + visit(ast.directives); + visit(ast.children); + }); + } + + visitDirective(ast: DirectiveAst, context: any): any { + // Ignore the host properties of a directive + const result = this.visitChildren(context, visit => { visit(ast.inputs); }); + // We never care about the diretive itself, just its inputs. + if (this.path[this.path.length - 1] == ast) { + this.path.pop(); + } + return result; + } + + getPath() { return this.path; } +} diff --git a/modules/@angular/language-service/src/ts_plugin.ts b/modules/@angular/language-service/src/ts_plugin.ts new file mode 100644 index 0000000000..025ac8bb39 --- /dev/null +++ b/modules/@angular/language-service/src/ts_plugin.ts @@ -0,0 +1,77 @@ +/** + * @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 * as ts from 'typescript'; + +import {createLanguageService} from './language_service'; +import {LanguageService, LanguageServiceHost} from './types'; +import {TypeScriptServiceHost} from './typescript_host'; + + +/** A plugin to TypeScript's langauge service that provide language services for + * templates in string literals. + * + * @experimental + */ +export class LanguageServicePlugin { + private ts: typeof ts; + private serviceHost: TypeScriptServiceHost; + private service: LanguageService; + private host: ts.LanguageServiceHost; + + static 'extension-kind' = 'language-service'; + + constructor(config: { + ts: typeof ts; host: ts.LanguageServiceHost; service: ts.LanguageService; + registry?: ts.DocumentRegistry, args?: any + }) { + this.ts = config.ts; + this.host = config.host; + this.serviceHost = new TypeScriptServiceHost(this.ts, config.host, config.service); + this.service = createLanguageService(this.serviceHost); + this.serviceHost.setSite(this.service); + } + + /** + * Augment the diagnostics reported by TypeScript with errors from the templates in string + * literals. + */ + getSemanticDiagnosticsFilter(fileName: string, previous: ts.Diagnostic[]): ts.Diagnostic[] { + let errors = this.service.getDiagnostics(fileName); + if (errors && errors.length) { + let file = this.serviceHost.getSourceFile(fileName); + for (const error of errors) { + previous.push({ + file, + start: error.span.start, + length: error.span.end - error.span.start, + messageText: error.message, + category: this.ts.DiagnosticCategory.Error, + code: 0 + }); + } + } + return previous; + } + + /** + * Get completions for angular templates if one is at the given position. + */ + getCompletionsAtPosition(fileName: string, position: number): ts.CompletionInfo { + let result = this.service.getCompletionsAt(fileName, position); + if (result) { + return { + isMemberCompletion: false, + isNewIdentifierLocation: false, + entries: result.map( + entry => + ({name: entry.name, kind: entry.kind, kindModifiers: '', sortText: entry.sort})) + }; + } + } +} \ No newline at end of file diff --git a/modules/@angular/language-service/src/types.ts b/modules/@angular/language-service/src/types.ts new file mode 100644 index 0000000000..3f6dbada28 --- /dev/null +++ b/modules/@angular/language-service/src/types.ts @@ -0,0 +1,706 @@ +/** + * @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 {CompileDirectiveMetadata, StaticSymbol} from '@angular/compiler'; +import {NgAnalyzedModules} from '@angular/compiler/src/aot/compiler'; +import {CompileMetadataResolver} from '@angular/compiler/src/metadata_resolver'; + + +/** + * The range of a span of text in a source file. + * + * @experimental + */ +export interface Span { + /** + * The first code-point of the span as an offset relative to the beginning of the source assuming + * a UTF-16 encoding. + */ + start: number; + + /** + * The first code-point after the span as an offset relative to the beginning of the source + * assuming a UTF-16 encoding. + */ + end: number; +} + +/** + * The information `LanguageService` needs from the `LanguageServiceHost` to describe the content of + * a template and the + * langauge context the template is in. + * + * A host interface; see `LanguageSeriviceHost`. + * + * @experimental + */ +export interface TemplateSource { + /** + * The source of the template. + */ + readonly source: string; + + /** + * The version of the source. As files are modified the version should change. That is, if the + * `LanguageSerivce` requesting + * template infomration 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. + */ + readonly span: Span; + + /** + * A static symbol for the template's component. + */ + readonly type: StaticSymbol; + + /** + * The `SymbolTable` for the members of the component. + */ + readonly members: SymbolTable; + + /** + * A `SymbolQuery` for the context of the template. + */ + readonly query: SymbolQuery; +} + +/** + * A sequence of template sources. + * + * A host type; see `LanguageSeriviceHost`. + * + * @experimental + */ +export type TemplateSources = TemplateSource[] /* | undefined */; + +/** + * Information about the component declarations. + * + * A file might contain a declaration without a template because the file contains only + * templateUrl references. However, the compoennt declaration might contain errors that + * need to be reported such as the template string is missing or the component is not + * declared in a module. These error should be reported on the declaration, not the + * template. + * + * A host type; see `LanguageSeriviceHost`. + * + * @experimental + */ +export interface Declaration { + /** + * The static symbol of the compponent being declared. + */ + readonly type: StaticSymbol; + + /** + * The span of the declaration annotation reference (e.g. the 'Component' or 'Directive' + * reference). + */ + readonly declarationSpan: Span; + + /** + * Reference to the compiler directive metadata for the declaration. + */ + readonly metadata?: CompileDirectiveMetadata; + + + /** + * Error reported trying to get the metadata. + */ + readonly error?: string; +} + +/** + * A sequence of declarations. + * + * A host type; see `LanguageSeriviceHost`. + * + * @experimental + */ +export type Declarations = Declaration[]; + +/** + * An enumeration of basic types. + * + * A `LanguageServiceHost` interface. + * + * @experimental + */ +export enum BuiltinType { + /** + * The type is a type that can hold any other type. + */ + Any, + + /** + * The type of a string literal. + */ + String, + + /** + * The type of a numeric literal. + */ + Number, + + /** + * The type of the `true` and `false` literals. + */ + Boolean, + + /** + * The type of the `undefined` literal. + */ + Undefined, + + /** + * the type of the `null` literal. + */ + Null, + + /** + * Not a built-in type. + */ + Other +} + +/** + * A symbol describing a language element that can be referenced by expressions + * in an Angular template. + * + * A `LanguageServiceHost` interface. + * + * @experimental + */ +export interface Symbol { + /** + * The name of the symbol as it would be referenced in an Angular expression. + */ + readonly name: string; + + /** + * The kind of completion this symbol should generate if included. + */ + readonly kind: string; + + /** + * The language of the source that defines the symbol. (e.g. typescript for TypeScript, + * ng-template for an Angular template, etc.) + */ + readonly language: string; + + /** + * A symbol representing type of the symbol. + */ + readonly type: Symbol /* | undefined */; + + + /** + * A symbol for the container of this symbol. For example, if this is a method, the container + * is the class or interface of the method. If no container is appropriate, undefined is + * returned. + */ + readonly container: Symbol /* | undefined */; + + /** + * The symbol is public in the container. + */ + readonly public: boolean; + + /** + * `true` if the symbol can be the target of a call. + */ + readonly callable: boolean; + + /** + * The location of the definition of the symbol + */ + readonly definition: Definition; + /** + + * A table of the members of the symbol; that is, the members that can appear + * after a `.` in an Angular expression. + * + */ + members(): SymbolTable; + + /** + * The list of overloaded signatures that can be used if the symbol is the + * target of a call. + */ + signatures(): Signature[]; + + /** + * Return which signature of returned by `signatures()` would be used selected + * given the `types` supplied. If no signature would match, this method should + * return `undefined`. + */ + selectSignature(types: Symbol[]): Signature /* | undefined */; + + /** + * Return the type of the expression if this symbol is indexed by `argument`. + * If the symbol cannot be indexed, this method should return `undefined`. + */ + indexed(argument: Symbol): Symbol /* | undefined */; +} + +/** + * A table of `Symbol`s accessible by name. + * + * A `LanguageServiceHost` interface. + * + * @experimental + */ +export interface SymbolTable { + /** + * The number of symbols in the table. + */ + readonly size: number; + + /** + * Get the symbol corresponding to `key` or `undefined` if there is no symbol in the + * table by the name `key`. + */ + get(key: string): Symbol /* | undefined */; + + /** + * Returns `true` if the table contains a `Symbol` with the name `key`. + */ + has(key: string): boolean; + + /** + * Returns all the `Symbol`s in the table. The order should be, but is not required to be, + * in declaration order. + */ + values(): Symbol[]; +} + +/** + * A description of a function or method signature. + * + * A `LanguageServiceHost` interface. + * + * @experimental + */ +export interface Signature { + /** + * The arguments of the signture. The order of `argumetnts.symbols()` must be in the order + * of argument declaration. + */ + readonly arguments: SymbolTable; + + /** + * The symbol of the signature result type. + */ + readonly result: Symbol; +} + +/** + * Describes the language context in which an Angular expression is evaluated. + * + * A `LanguageServiceHost` interface. + * + * @experimental + */ +export interface SymbolQuery { + /** + * Return the built-in type this symbol represents or Other if it is not a built-in type. + */ + getTypeKind(symbol: Symbol): BuiltinType; + + /** + * Return a symbol representing the given built-in type. + */ + getBuiltinType(kind: BuiltinType): Symbol; + + /** + * Return the symbol for a type that represents the union of all the types given. Any value + * of one of the types given should be assignable to the returned type. If no one type can + * be constructed then this should be the Any type. + */ + getTypeUnion(...types: Symbol[]): Symbol; + + /** + * Return a symbol for an array type that has the `type` as its element type. + */ + getArrayType(type: Symbol): Symbol; + + /** + * Return element type symbol for an array type if the `type` is an array type. Otherwise return + * undefined. + */ + getElementType(type: Symbol): Symbol /* | undefined */; + + /** + * Return a type that is the non-nullable version of the given type. If `type` is already + * non-nullable, return `type`. + */ + getNonNullableType(type: Symbol): Symbol; + + /** + * Return a symbol table for the pipes that are in scope. + */ + getPipes(): SymbolTable; + + /** + * Return the type symbol for the given static symbol. + */ + getTypeSymbol(type: StaticSymbol): Symbol; + + /** + * Return the members that are in the context of a type's template reference. + */ + getTemplateContext(type: StaticSymbol): SymbolTable; + + /** + * Produce a symbol table with the given symbols. Used to produce a symbol table + * for use with mergeSymbolTables(). + */ + createSymbolTable(symbols: SymbolDeclaration[]): SymbolTable; + + /** + * Produce a merged symbol table. If the symbol tables contain duplicate entries + * the entries of the latter symbol tables will obscure the entries in the prior + * symbol tables. + * + * The symbol tables passed to this routine MUST be produces by the same instance + * of SymbolQuery that is being called. + */ + mergeSymbolTable(symbolTables: SymbolTable[]): SymbolTable; + + /** + * Return the span of the narrowest non-token node at the given location. + */ + getSpanAt(line: number, column: number): Span /* | undefined */; +} + +/** + * The host for a `LanguageService`. This provides all the `LanguageSerivce` requires to respond to + * the `LanguageService` requests. + * + * This interface describes the requirements of the `LanguageService` on its host. + * + * The host interface is host language agnostic. + * + * Adding optional member to this interface or any interface that is described as a + * `LanguageSerivceHost` + * interface is not considered a breaking change as defined by SemVer. Removing a method or changing + * a + * member from required to optional will also not be considered a breaking change. + * + * If a member is deprecated it will be changed to optional in a minor release before it is removed + * in + * a major release. + * + * Adding a required member or changing a method's parameters, is considered a breaking change and + * will + * only be done when breaking changes are allowed. When possible, a new optional member will be + * added and + * the old member will be deprecated. The new member will then be made required in and the old + * member will + * be removed only when breaking chnages are allowed. + * + * While an interface is marked as experimental breaking-changes will be allowed between minor + * releases. + * After an interface is marked as stable breaking-changes will only be allowed between major + * releases. + * No breaking changes are allowed between patch releases. + * + * @experimental + */ +export interface LanguageServiceHost { + /** + * The resolver to use to find compiler metadata. + */ + readonly resolver: CompileMetadataResolver; + + /** + * 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. + */ + getTemplates(fileName: string): TemplateSources; + + /** + * Returns the Angular declarations in the given file. + */ + getDeclarations(fileName: string): Declarations; + + /** + * Return a summary of all Angular modules in the project. + */ + getAnalyzedModules(): NgAnalyzedModules; + + /** + * Return a list all the template files referenced by the project. + */ + getTemplateReferences(): string[]; +} + +/** + * The kinds of completions generated by the language service. + * + * A 'LanguageService' interface. + * + * @experimental + */ +export type CompletionKind = 'attribute' | 'html attribute' | 'component' | 'element' | 'entity' | + 'key' | 'method' | 'pipe' | 'property' | 'type' | 'reference' | 'variable'; + +/** + * An item of the completion result to be displayed by an editor. + * + * A `LanguageService` interface. + * + * @experimental + */ +export interface Completion { + /** + * The kind of comletion. + */ + kind: CompletionKind; + + /** + * The name of the completion to be displayed + */ + name: string; + + /** + * The key to use to sort the completions for display. + */ + sort: string; +} + +/** + * A sequence of completions. + * + * @experimental + */ +export type Completions = Completion[] /* | undefined */; + +/** + * A file and span. + */ +export interface Location { + fileName: string; + span: Span; +} + +/** + * A defnition location(s). + */ +export type Definition = Location[] /* | undefined */; + +/** + * The kind of diagnostic message. + * + * @experimental + */ +export enum DiagnosticKind { + Error, + Warning, +} + +/** + * An template diagnostic message to display. + * + * @experimental + */ +export interface Diagnostic { + /** + * The kind of diagnostic message + */ + kind: DiagnosticKind; + + /** + * The source span that should be highlighted. + */ + span: Span; + + /** + * The text of the diagnostic message to display. + */ + message: string; +} + +/** + * A sequence of diagnostic message. + * + * @experimental + */ +export type Diagnostics = Diagnostic[]; + +/** + * Information about the pipes that are available for use in a template. + * + * A `LanguageService` interface. + * + * @experimental + */ +export interface PipeInfo { + /** + * The name of the pipe. + */ + name: string; + + /** + * The static symbol for the pipe's constructor. + */ + symbol: StaticSymbol; +} + +/** + * A sequence of pipe information. + * + * @experimental + */ +export type Pipes = PipeInfo[] /* | undefined */; + +/** + * Describes a symbol to type binding used to build a symbol table. + * + * A `LanguageServiceHost` interface. + * + * @experimental + */ + +export interface SymbolDeclaration { + /** + * The name of the symbol in table. + */ + readonly name: string; + + /** + * The kind of symbol to declare. + */ + readonly kind: CompletionKind; + + /** + * Type of the symbol. The type symbol should refer to a symbol for a type. + */ + readonly type: Symbol; + + /** + * The definion of the symbol if one exists. + */ + readonly definition?: Definition; +} + +/** + * A section of hover text. If the text is code then langauge should be provided. + * Otherwise the text is assumed to be Markdown text that will be sanitized. + */ +export interface HoverTextSection { + /** + * Source code or markdown text describing the symbol a the hover location. + */ + readonly text: string; + + /** + * The langauge of the source if `text` is a souce code fragment. + */ + readonly language?: string; +} + +/** + * Hover infomration for a symbol at the hover location. + */ +export interface Hover { + /** + * The hover text to display for the symbol at the hover location. If the text includes + * source code, the section will specify which langauge it should be interpreted as. + */ + readonly text: HoverTextSection[]; + + /** + * The span of source the hover covers. + */ + readonly span: Span; +} + +/** + * An instance of an Angular language service created by `createLanguageService()`. + * + * The language service returns information about Angular templates that are included in a project + * as + * defined by the `LanguageServiceHost`. + * + * When a method expects a `fileName` this file can either be source file in the project that + * contains + * a template in a string literal or a template file referenced by the project returned by + * `getTemplateReference()`. All other files will cause the method to return `undefined`. + * + * If a method takes a `position`, it is the offset of the UTF-16 code-point relative to the + * beginning + * of the file reference by `fileName`. + * + * This interface and all interfaces and types marked as `LanguageSerivce` types, describe a + * particlar + * implementation of the Angular language service and is not intented to be implemented. Adding + * members + * to the interface will not be considered a breaking change as defined by SemVer. + * + * Removing a member or making a member optional, changing a method parameters, or changing a + * member's + * type will all be considered a breaking change. + * + * While an interface is marked as experimental breaking-changes will be allowed between minor + * releases. + * After an interface is marked as stable breaking-changes will only be allowed between major + * releases. + * No breaking changes are allowed between patch releases. + * + * @experimental + */ +export interface LanguageService { + /** + * Returns a list of all the external templates referenced by the project. + */ + getTemplateReferences(): string[] /* | undefined */; + + /** + * Returns a list of all error for all templates in the given file. + */ + getDiagnostics(fileName: string): Diagnostics /* | undefined */; + + /** + * Return the completions at the given position. + */ + getCompletionsAt(fileName: string, position: number): Completions /* | undefined */; + + /** + * Return the definition location for the symbol at position. + */ + getDefinitionAt(fileName: string, position: number): Definition /* | undefined */; + + /** + * Return the hover information for the symbol at position. + */ + getHoverAt(fileName: string, position: number): Hover /* | undefined */; + + /** + * Return the pipes that are available at the given position. + */ + getPipesAt(fileName: string, position: number): Pipes /* | undefined */; +} diff --git a/modules/@angular/language-service/src/typescript_host.ts b/modules/@angular/language-service/src/typescript_host.ts new file mode 100644 index 0000000000..f1cd36db30 --- /dev/null +++ b/modules/@angular/language-service/src/typescript_host.ts @@ -0,0 +1,1168 @@ +/** + * @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 {CompileDirectiveMetadata, CompilerConfig, StaticReflector, StaticSymbol, StaticSymbolCache, createOfflineCompileUrlResolver} from '@angular/compiler'; +import {NgAnalyzedModules, analyzeNgModules, extractProgramSymbols} from '@angular/compiler/src/aot/compiler'; +import {DirectiveNormalizer} from '@angular/compiler/src/directive_normalizer'; +import {DirectiveResolver} from '@angular/compiler/src/directive_resolver'; +import {CompileMetadataResolver} from '@angular/compiler/src/metadata_resolver'; +import {NgModuleResolver} from '@angular/compiler/src/ng_module_resolver'; +import {PipeResolver} from '@angular/compiler/src/pipe_resolver'; +import {ResourceLoader} from '@angular/compiler/src/resource_loader'; +import {DomElementSchemaRegistry} from '@angular/compiler/src/schema/dom_element_schema_registry'; +import {UrlResolver} from '@angular/compiler/src/url_resolver'; +import {Type, ViewEncapsulation} from '@angular/core'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as ts from 'typescript'; + +import {createLanguageService} from './language_service'; +import {ReflectorHost} from './reflector_host'; +import {BuiltinType, CompletionKind, Declaration, Declarations, Definition, LanguageService, LanguageServiceHost, PipeInfo, Pipes, Signature, Span, Symbol, SymbolDeclaration, SymbolQuery, SymbolTable, TemplateSource, TemplateSources} from './types'; + + + +/** + * Create a `LanguageServiceHost` + */ +export function createLanguageServiceFromTypescript( + typescript: typeof ts, host: ts.LanguageServiceHost, + service: ts.LanguageService): LanguageService { + const ngHost = new TypeScriptServiceHost(typescript, host, service); + const ngServer = createLanguageService(ngHost); + ngHost.setSite(ngServer); + return ngServer; +} + +/** + * An implemntation of a `LanguageSerivceHost` for a TypeScript project. + * + * The `TypeScriptServiceHost` implements the Angular `LanguageServiceHost` using + * the TypeScript language services. + * + * @expermental + */ +export class TypeScriptServiceHost implements LanguageServiceHost { + private ts: typeof ts; + private _resolver: CompileMetadataResolver; + private _staticSymbolCache = new StaticSymbolCache(); + private _reflector: StaticReflector; + private _reflectorHost: ReflectorHost; + private _checker: ts.TypeChecker; + private _typeCache: Symbol[] = []; + private context: string|undefined; + private lastProgram: ts.Program|undefined; + private modulesOutOfDate: boolean = true; + private analyzedModules: NgAnalyzedModules; + private service: LanguageService; + private fileToComponent: Map; + private templateReferences: string[]; + + constructor( + typescript: typeof ts, private host: ts.LanguageServiceHost, + private tsService: ts.LanguageService) { + this.ts = typescript; + } + + setSite(service: LanguageService) { this.service = service; } + + /** + * Angular LanguageServiceHost implementation + */ + get resolver(): CompileMetadataResolver { + this.validate(); + let result = this._resolver; + if (!result) { + const moduleResolver = new NgModuleResolver(this.reflector); + const directiveResolver = new DirectiveResolver(this.reflector); + const pipeResolver = new PipeResolver(this.reflector); + const elementSchemaRegistry = new DomElementSchemaRegistry(); + const resourceLoader = new ResourceLoader(); + const urlResolver = createOfflineCompileUrlResolver(); + // This tracks the CompileConfig in codegen.ts. Currently these options + // are hard-coded except for genDebugInfo which is not applicable as we + // never generate code. + const config = new CompilerConfig({ + genDebugInfo: false, + defaultEncapsulation: ViewEncapsulation.Emulated, + logBindingUpdate: false, + useJit: false + }); + const directiveNormalizer = + new DirectiveNormalizer(resourceLoader, urlResolver, null, config); + + result = this._resolver = new CompileMetadataResolver( + moduleResolver, directiveResolver, pipeResolver, elementSchemaRegistry, + directiveNormalizer, this.reflector); + } + return result; + } + + getTemplateReferences(): string[] { + this.ensureTemplateMap(); + return this.templateReferences; + } + + getTemplateAt(fileName: string, position: number): TemplateSource|undefined { + let sourceFile = this.getSourceFile(fileName); + if (sourceFile) { + this.context = sourceFile.fileName; + let node = this.findNode(sourceFile, position); + if (node) { + return this.getSourceFromNode( + fileName, this.host.getScriptVersion(sourceFile.fileName), node); + } + } else { + this.ensureTemplateMap(); + // TODO: Cannocalize the file? + const componentType = this.fileToComponent.get(fileName); + if (componentType) { + return this.getSourceFromType( + fileName, this.host.getScriptVersion(fileName), componentType); + } + } + } + + getAnalyzedModules(): NgAnalyzedModules { + this.validate(); + let analyzedModules = this.analyzedModules; + if (!analyzedModules) { + const programSymbols = extractProgramSymbols( + this.reflector, this.program.getSourceFiles().map(sf => sf.fileName), {}); + + analyzedModules = this.analyzedModules = analyzeNgModules(programSymbols, {}, this.resolver); + } + return analyzedModules; + } + + getTemplates(fileName: string): TemplateSources { + this.ensureTemplateMap(); + const componentType = this.fileToComponent.get(fileName); + if (componentType) { + const templateSource = this.getTemplateAt(fileName, 0); + if (templateSource) { + return [templateSource]; + } + } else { + let version = this.host.getScriptVersion(fileName); + let result: TemplateSource[] = []; + + // Find each template string in the file + let visit = (child: ts.Node) => { + let templateSource = this.getSourceFromNode(fileName, version, child); + if (templateSource) { + result.push(templateSource); + } else { + this.ts.forEachChild(child, visit); + } + }; + + let sourceFile = this.getSourceFile(fileName); + if (sourceFile) { + this.context = sourceFile.path; + this.ts.forEachChild(sourceFile, visit); + } + return result.length ? result : undefined; + } + } + + getDeclarations(fileName: string): Declarations { + const result: Declarations = []; + const sourceFile = this.getSourceFile(fileName); + if (sourceFile) { + let visit = (child: ts.Node) => { + let declaration = this.getDeclarationFromNode(sourceFile, child); + if (declaration) { + result.push(declaration); + } else { + ts.forEachChild(child, visit); + } + }; + ts.forEachChild(sourceFile, visit); + } + return result; + } + + getSourceFile(fileName: string): ts.SourceFile { + return this.tsService.getProgram().getSourceFile(fileName); + } + + updateAnalyzedModules() { + if (this.modulesOutOfDate) { + this.analyzedModules = null; + this.getAnalyzedModules(); + } + } + + private get program() { return this.tsService.getProgram(); } + + private get checker() { + let checker = this._checker; + if (!checker) { + checker = this._checker = this.program.getTypeChecker(); + } + return checker; + } + + private validate() { + const program = this.program; + if (this.lastProgram != program) { + this.clearCaches(); + this.lastProgram = program; + } + } + + private clearCaches() { + this._checker = null; + this._typeCache = []; + this._resolver = null; + this._reflector = null; + this.modulesOutOfDate = true; + this.templateReferences = null; + this.fileToComponent = null; + } + + private ensureTemplateMap() { + if (!this.fileToComponent || !this.templateReferences) { + const fileToComponent = new Map(); + const templateReference: string[] = []; + const ngModuleSummary = this.getAnalyzedModules(); + const urlResolver = createOfflineCompileUrlResolver(); + for (const module of ngModuleSummary.ngModules) { + for (const directive of module.declaredDirectives) { + const directiveMetadata = + this.resolver.getNonNormalizedDirectiveMetadata(directive.reference); + if (directiveMetadata.isComponent && directiveMetadata.template && + directiveMetadata.template.templateUrl) { + const templateName = + urlResolver.resolve(directive.moduleUrl, directiveMetadata.template.templateUrl); + fileToComponent.set(templateName, directive.reference); + templateReference.push(templateName); + } + } + } + this.fileToComponent = fileToComponent; + this.templateReferences = templateReference; + } + } + + private getSourceFromDeclaration( + fileName: string, version: string, source: string, span: Span, type: StaticSymbol, + declaration: ts.ClassDeclaration, node: ts.Node, sourceFile: ts.SourceFile): TemplateSource + |undefined { + let queryCache: SymbolQuery|undefined = undefined; + const t = this; + if (declaration) { + return { + version, + source, + span, + type, + get members(): + SymbolTable{const checker = t.checker; const program = t.program; + const type = checker.getTypeAtLocation(declaration); + return new TypeWrapper(type, {node, program, checker}).members();}, + get query(): SymbolQuery{ + if (!queryCache) { + queryCache = new TypeScriptSymbolQuery(t.ts, t.program, t.checker, sourceFile, () => { + const pipes = t.service.getPipesAt(fileName, node.getStart()); + const checker = t.checker; + const program = t.program; + return new PipesTable(pipes, {node, program, checker}); + }); + } return queryCache; + } + }; + } + } + + private getSourceFromNode(fileName: string, version: string, node: ts.Node): TemplateSource + |undefined { + let result: TemplateSource|undefined = undefined; + const t = this; + switch (node.kind) { + case this.ts.SyntaxKind.NoSubstitutionTemplateLiteral: + case this.ts.SyntaxKind.StringLiteral: + let [declaration, decorator] = this.getTemplateClassDeclFromNode(node); + let queryCache: SymbolQuery|undefined = undefined; + if (declaration) { + const sourceFile = this.getSourceFile(fileName); + return this.getSourceFromDeclaration( + fileName, version, this.stringOf(node), shrink(spanOf(node)), + this.reflector.getStaticSymbol(sourceFile.fileName, declaration.name.text), + declaration, node, sourceFile); + } + break; + } + return result; + } + + private getSourceFromType(fileName: string, version: string, type: StaticSymbol): TemplateSource + |undefined { + let result: TemplateSource|undefined = undefined; + const declaration = this.getTemplateClassFromStaticSymbol(type); + if (declaration) { + const snapshot = this.host.getScriptSnapshot(fileName); + const source = snapshot.getText(0, snapshot.getLength()); + result = this.getSourceFromDeclaration( + fileName, version, source, {start: 0, end: source.length}, type, declaration, declaration, + declaration.getSourceFile()); + } + return result; + } + + private get reflectorHost(): ReflectorHost { + let result = this._reflectorHost; + if (!result) { + if (!this.context) { + // Make up a context by finding the first script and using that as the base dir. + this.context = this.host.getScriptFileNames()[0]; + } + + // Use the file context's directory as the base directory. + // The host's getCurrentDirectory() is not reliable as it is always "" in + // tsserver. We don't need the exact base directory, just one that contains + // a source file. + const source = this.tsService.getProgram().getSourceFile(this.context); + if (!source) { + throw new Error('Internal error: no context could be determined'); + } + + const tsConfigPath = findTsConfig(source.path); + const basePath = path.dirname(tsConfigPath || this.context); + result = this._reflectorHost = new ReflectorHost( + () => this.tsService.getProgram(), this.host, this.host.getCompilationSettings(), + basePath); + } + return result; + } + + private get reflector(): StaticReflector { + let result = this._reflector; + if (!result) { + result = this._reflector = new StaticReflector(this.reflectorHost, this._staticSymbolCache); + } + return result; + } + + private getTemplateClassFromStaticSymbol(type: StaticSymbol): ts.ClassDeclaration|undefined { + const source = this.getSourceFile(type.filePath); + if (source) { + const declarationNode = this.ts.forEachChild(source, child => { + if (child.kind === ts.SyntaxKind.ClassDeclaration) { + const classDeclaration = child as ts.ClassDeclaration; + if (classDeclaration.name.text === type.name) { + return classDeclaration; + } + } + }); + return declarationNode as ts.ClassDeclaration; + } + + return undefined; + } + + private static missingTemplate = <[ts.ClassDeclaration, ts.Expression]>[]; + + /** + * 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, ts.Expression] { + // 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 !== this.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 !== this.ts.SyntaxKind.ObjectLiteralExpression) { + return TypeScriptServiceHost.missingTemplate; + } + + parentNode = parentNode.parent; // CallExpression + if (!parentNode || parentNode.kind !== this.ts.SyntaxKind.CallExpression) { + return TypeScriptServiceHost.missingTemplate; + } + const callTarget = (parentNode).expression; + + let decorator = parentNode.parent; // Decorator + if (!decorator || decorator.kind !== this.ts.SyntaxKind.Decorator) { + return TypeScriptServiceHost.missingTemplate; + } + + let declaration = decorator.parent; // ClassDeclaration + if (!declaration || declaration.kind !== this.ts.SyntaxKind.ClassDeclaration) { + return TypeScriptServiceHost.missingTemplate; + } + return [declaration, callTarget]; + } + + private getDeclarationFromNode(sourceFile: ts.SourceFile, node: ts.Node): Declaration|undefined { + if (node.kind == ts.SyntaxKind.ClassDeclaration && node.decorators && + (node as ts.ClassDeclaration).name) { + for (const decorator of node.decorators) { + if (decorator.expression && decorator.expression.kind == ts.SyntaxKind.CallExpression) { + const classDeclaration = node as ts.ClassDeclaration; + if (classDeclaration.name) { + const call = decorator.expression as ts.CallExpression; + const target = call.expression; + const type = this.checker.getTypeAtLocation(target); + if (type) { + const staticSymbol = + this._reflector.getStaticSymbol(sourceFile.fileName, classDeclaration.name.text); + try { + if (this.resolver.isDirective(staticSymbol as any)) { + const metadata = + this.resolver.getNonNormalizedDirectiveMetadata(staticSymbol as any); + return {type: staticSymbol, declarationSpan: spanOf(target), metadata}; + } + } catch (e) { + if (e.message) { + return { + type: staticSymbol, + declarationSpan: spanAt(sourceFile, e.line, e.column) || spanOf(target), + error: e.message + }; + } + } + } + } + } + } + } + } + + private stringOf(node: ts.Node): string|undefined { + switch (node.kind) { + case this.ts.SyntaxKind.NoSubstitutionTemplateLiteral: + return (node).text; + case this.ts.SyntaxKind.StringLiteral: + return (node).text; + } + } + + private findNode(sourceFile: ts.SourceFile, position: number): ts.Node|undefined { + let _this = this; + + function find(node: ts.Node): ts.Node|undefined { + if (position >= node.getStart() && position < node.getEnd()) { + return _this.ts.forEachChild(node, find) || node; + } + } + + return find(sourceFile); + } + + private findLiteralType(kind: BuiltinType, context: TypeContext): Symbol { + const checker = this.checker; + let type: ts.Type; + switch (kind) { + case BuiltinType.Any: + type = checker.getTypeAtLocation({ + kind: this.ts.SyntaxKind.AsExpression, + expression: {kind: this.ts.SyntaxKind.TrueKeyword}, + type: {kind: this.ts.SyntaxKind.AnyKeyword} + }); + break; + case BuiltinType.Boolean: + type = checker.getTypeAtLocation({kind: this.ts.SyntaxKind.TrueKeyword}); + break; + case BuiltinType.Null: + type = checker.getTypeAtLocation({kind: this.ts.SyntaxKind.NullKeyword}); + break; + case BuiltinType.Number: + type = checker.getTypeAtLocation({kind: this.ts.SyntaxKind.NumericLiteral}); + break; + case BuiltinType.String: + type = checker.getTypeAtLocation( + {kind: this.ts.SyntaxKind.NoSubstitutionTemplateLiteral}); + break; + case BuiltinType.Undefined: + type = checker.getTypeAtLocation({kind: this.ts.SyntaxKind.VoidExpression}); + break; + default: + throw new Error(`Internal error, unhandled literal kind ${kind}:${BuiltinType[kind]}`); + } + return new TypeWrapper(type, context); + } +} + +class TypeScriptSymbolQuery implements SymbolQuery { + private ts: typeof ts; + private typeCache = new Map(); + private pipesCache: SymbolTable; + + constructor( + typescript: typeof ts, private program: ts.Program, private checker: ts.TypeChecker, + private source: ts.SourceFile, private fetchPipes: () => SymbolTable) { + this.ts = typescript; + } + + getTypeKind(symbol: Symbol): BuiltinType { + const type = this.getTsTypeOf(symbol); + if (type) { + if (type.flags & this.ts.TypeFlags.Any) { + return BuiltinType.Any; + } else if ( + type.flags & (this.ts.TypeFlags.String | this.ts.TypeFlags.StringLike | + this.ts.TypeFlags.StringLiteral)) { + return BuiltinType.String; + } else if (type.flags & (this.ts.TypeFlags.Number | this.ts.TypeFlags.NumberLike)) { + return BuiltinType.Number; + } else if (type.flags & (this.ts.TypeFlags.Undefined)) { + return BuiltinType.Undefined; + } else if (type.flags & (this.ts.TypeFlags.Null)) { + return BuiltinType.Null; + } + } + return BuiltinType.Other; + } + + getBuiltinType(kind: BuiltinType): Symbol { + // TODO: Replace with typeChecker API when available. + let result = this.typeCache.get(kind); + if (!result) { + const type = getBuiltinTypeFromTs( + kind, {checker: this.checker, node: this.source, program: this.program}); + result = + new TypeWrapper(type, {program: this.program, checker: this.checker, node: this.source}); + this.typeCache.set(kind, result); + } + return result; + } + + getTypeUnion(...types: Symbol[]): Symbol { + // TODO: Replace with typeChecker API when available + const checker = this.checker; + + // No API exists so the cheat is to just return the last type any if no types are given. + return types.length ? types[types.length - 1] : this.getBuiltinType(BuiltinType.Any); + } + + getArrayType(type: Symbol): Symbol { + // TODO: Replace with typeChecker API when available + return this.getBuiltinType(BuiltinType.Any); + } + + getElementType(type: Symbol): Symbol { + if (type instanceof TypeWrapper) { + const elementType = getTypeParameterOf(type.tsType, 'Array'); + if (elementType) { + return new TypeWrapper(elementType, type.context); + } + } + } + + getNonNullableType(symbol: Symbol): Symbol { + // TODO: Replace with typeChecker API when available; + return symbol; + } + + getPipes(): SymbolTable { + let result = this.pipesCache; + if (!result) { + result = this.pipesCache = this.fetchPipes(); + } + return result; + } + + getTemplateContext(type: StaticSymbol): SymbolTable { + const context: TypeContext = {node: this.source, program: this.program, checker: this.checker}; + const typeSymbol = findClassSymbolInContext(type, context); + if (typeSymbol) { + const contextType = this.getTemplateRefContextType(typeSymbol); + if (contextType) return new SymbolWrapper(contextType, context).members(); + } + } + + getTypeSymbol(type: StaticSymbol): Symbol { + const context: TypeContext = {node: this.source, program: this.program, checker: this.checker}; + const typeSymbol = findClassSymbolInContext(type, context); + return new SymbolWrapper(typeSymbol, context); + } + + createSymbolTable(symbols: SymbolDeclaration[]): SymbolTable { + const result = new MapSymbolTable(); + result.addAll(symbols.map(s => new DeclaredSymbol(s))); + return result; + } + + mergeSymbolTable(symbolTables: SymbolTable[]): SymbolTable { + const result = new MapSymbolTable(); + for (const symbolTable of symbolTables) { + result.addAll(symbolTable.values()); + } + return result; + } + + getSpanAt(line: number, column: number): Span { return spanAt(this.source, line, column); } + + private getTemplateRefContextType(type: ts.Symbol): ts.Symbol { + const constructor = type.members['__constructor']; + if (constructor) { + const constructorDeclaration = constructor.declarations[0] as ts.ConstructorTypeNode; + for (const parameter of constructorDeclaration.parameters) { + const type = this.checker.getTypeAtLocation(parameter.type); + if (type.symbol.name == 'TemplateRef' && type.flags & ts.TypeFlags.Reference) { + const typeReference = type as ts.TypeReference; + if (typeReference.typeArguments.length === 1) { + return typeReference.typeArguments[0].symbol; + } + } + }; + } + } + + private getTsTypeOf(symbol: Symbol): ts.Type { + const type = this.getTypeWrapper(symbol); + return type && type.tsType; + } + + private getTypeWrapper(symbol: Symbol): TypeWrapper|undefined { + let type: TypeWrapper|undefined = undefined; + if (symbol instanceof TypeWrapper) { + type = symbol; + } else if (symbol.type instanceof TypeWrapper) { + type = symbol.type; + } + return type; + } +} + +interface TypeContext { + node: ts.Node; + program: ts.Program; + checker: ts.TypeChecker; +} + +function typeCallable(type: ts.Type): boolean { + const signatures = type.getCallSignatures(); + return signatures && signatures.length != 0; +} + +function signaturesOf(type: ts.Type, context: TypeContext): Signature[] { + return type.getCallSignatures().map(s => new SignatureWrapper(s, context)); +} + +function selectSignature(type: ts.Type, context: TypeContext, types: Symbol[]): Signature| + undefined { + // TODO: Do a better job of selecting the right signature. + const signatures = type.getCallSignatures(); + return signatures.length ? new SignatureWrapper(signatures[0], context) : undefined; +} + +function toSymbolTable(symbols: ts.Symbol[]): ts.SymbolTable { + const result: ts.SymbolTable = {}; + for (const symbol of symbols) { + result[symbol.name] = symbol; + } + return result; +} + +function toSymbols(symbolTable: ts.SymbolTable, filter?: (symbol: ts.Symbol) => boolean) { + const result: ts.Symbol[] = []; + const own = typeof symbolTable.hasOwnProperty === 'function' ? + (name: string) => symbolTable.hasOwnProperty(name) : + (name: string) => !!symbolTable[name]; + for (const name in symbolTable) { + if (own(name) && (!filter || filter(symbolTable[name]))) { + result.push(symbolTable[name]); + } + } + return result; +} + +class TypeWrapper implements Symbol { + constructor(public tsType: ts.Type, public context: TypeContext) { + if (!tsType) { + throw Error('Internal: null type'); + } + } + + get name(): string { + const symbol = this.tsType.symbol; + return (symbol && symbol.name) || ''; + } + + get kind(): CompletionKind { return 'type'; } + + get language(): string { return 'typescript'; } + + get type(): Symbol|undefined { return undefined; } + + get container(): Symbol|undefined { return undefined; } + + get public(): boolean { return true; } + + get callable(): boolean { return typeCallable(this.tsType); } + + get definition(): Definition { return definitionFromTsSymbol(this.tsType.getSymbol()); } + + members(): SymbolTable { + return new SymbolTableWrapper(this.tsType.getProperties(), this.context); + } + + signatures(): Signature[] { return signaturesOf(this.tsType, this.context); } + + selectSignature(types: Symbol[]): Signature|undefined { + return selectSignature(this.tsType, this.context, types); + } + + indexed(argument: Symbol): Symbol|undefined { return undefined; } +} + +class SymbolWrapper implements Symbol { + private _tsType: ts.Type; + + constructor(private symbol: ts.Symbol, private context: TypeContext) {} + + get name(): string { return this.symbol.name; } + + get kind(): CompletionKind { return this.callable ? 'method' : 'property'; } + + get language(): string { return 'typescript'; } + + get type(): Symbol|undefined { return new TypeWrapper(this.tsType, this.context); } + + get container(): Symbol|undefined { return getContainerOf(this.symbol, this.context); } + + get public(): boolean { + // Symbols that are not explicitly made private are public. + return !(getDeclarationFlagsFromSymbol(this.symbol) & ts.NodeFlags.Private); + } + + get callable(): boolean { return typeCallable(this.tsType); } + + get definition(): Definition { return definitionFromTsSymbol(this.symbol); } + + members(): SymbolTable { return new SymbolTableWrapper(this.symbol.members, this.context); } + + signatures(): Signature[] { return signaturesOf(this.tsType, this.context); } + + selectSignature(types: Symbol[]): Signature|undefined { + return selectSignature(this.tsType, this.context, types); + } + + indexed(argument: Symbol): Symbol|undefined { return undefined; } + + private get tsType(): ts.Type { + let type = this._tsType; + if (!type) { + type = this._tsType = + this.context.checker.getTypeOfSymbolAtLocation(this.symbol, this.context.node); + } + return type; + } +} + +class DeclaredSymbol implements Symbol { + constructor(private declaration: SymbolDeclaration) {} + + get name() { return this.declaration.name; } + + get kind() { return this.declaration.kind; } + + get language(): string { return 'ng-template'; } + + get container(): Symbol|undefined { return undefined; } + + get type() { return this.declaration.type; } + + get callable(): boolean { return this.declaration.type.callable; } + + get public(): boolean { return true; } + + get definition(): Definition { return this.declaration.definition; } + + members(): SymbolTable { return this.declaration.type.members(); } + + signatures(): Signature[] { return this.declaration.type.signatures(); } + + selectSignature(types: Symbol[]): Signature|undefined { + return this.declaration.type.selectSignature(types); + } + + indexed(argument: Symbol): Symbol|undefined { return undefined; } +} + +class SignatureWrapper implements Signature { + constructor(private signature: ts.Signature, private context: TypeContext) {} + + get arguments(): SymbolTable { + return new SymbolTableWrapper(this.signature.getParameters(), this.context); + } + + get result(): Symbol { return new TypeWrapper(this.signature.getReturnType(), this.context); } +} + +class SignatureResultOverride implements Signature { + constructor(private signature: Signature, private resultType: Symbol) {} + + get arguments(): SymbolTable { return this.signature.arguments; } + + get result(): Symbol { return this.resultType; } +} + +class SymbolTableWrapper implements SymbolTable { + private symbols: ts.Symbol[]; + private symbolTable: ts.SymbolTable; + + constructor( + symbols: ts.SymbolTable|ts.Symbol[], private context: TypeContext, + filter?: (symbol: ts.Symbol) => boolean) { + if (Array.isArray(symbols)) { + this.symbols = filter ? symbols.filter(filter) : symbols; + this.symbolTable = toSymbolTable(symbols); + } else { + this.symbols = toSymbols(symbols, filter); + this.symbolTable = filter ? toSymbolTable(this.symbols) : symbols; + } + } + + get size(): number { return this.symbols.length; } + + get(key: string): Symbol|undefined { + const symbol = this.symbolTable[key]; + return symbol ? new SymbolWrapper(symbol, this.context) : undefined; + } + + has(key: string): boolean { return this.symbolTable[key] != null; } + + values(): Symbol[] { return this.symbols.map(s => new SymbolWrapper(s, this.context)); } +} + +class MapSymbolTable implements SymbolTable { + private map = new Map(); + private _values: Symbol[] = []; + + get size(): number { return this.map.size; } + + get(key: string): Symbol|undefined { return this.map.get(key); } + + add(symbol: Symbol) { + if (this.map.has(symbol.name)) { + const previous = this.map.get(symbol.name); + this._values[this._values.indexOf(previous)] = symbol; + } + this.map.set(symbol.name, symbol); + this._values.push(symbol); + } + + addAll(symbols: Symbol[]) { + for (const symbol of symbols) { + this.add(symbol); + } + } + + has(key: string): boolean { return this.map.has(key); } + + values(): Symbol[] { + // Switch to this.map.values once iterables are supported by the target language. + return this._values; + } +} + +class PipesTable implements SymbolTable { + constructor(private pipes: Pipes, private context: TypeContext) {} + + get size() { return this.pipes.length; } + + get(key: string): Symbol { + const pipe = this.pipes.find(pipe => pipe.name == key); + if (pipe) { + return new PipeSymbol(pipe, this.context); + } + } + + has(key: string): boolean { return this.pipes.find(pipe => pipe.name == key) != null; } + + values(): Symbol[] { return this.pipes.map(pipe => new PipeSymbol(pipe, this.context)); } +} + +class PipeSymbol implements Symbol { + private _tsType: ts.Type; + + constructor(private pipe: PipeInfo, private context: TypeContext) {} + + get name(): string { return this.pipe.name; } + + get kind(): CompletionKind { return 'pipe'; } + + get language(): string { return 'typescript'; } + + get type(): Symbol|undefined { return new TypeWrapper(this.tsType, this.context); } + + get container(): Symbol|undefined { return undefined; } + + get callable(): boolean { return true; } + + get public(): boolean { return true; } + + get definition(): Definition { return definitionFromTsSymbol(this.tsType.getSymbol()); } + + members(): SymbolTable { return EmptyTable.instance; } + + signatures(): Signature[] { return signaturesOf(this.tsType, this.context); } + + selectSignature(types: Symbol[]): Signature|undefined { + let signature = selectSignature(this.tsType, this.context, types); + if (types.length == 1) { + const parameterType = types[0]; + if (parameterType instanceof TypeWrapper) { + let resultType: ts.Type = undefined; + switch (this.name) { + case 'async': + switch (parameterType.name) { + case 'Observable': + case 'Promise': + case 'EventEmitter': + resultType = getTypeParameterOf(parameterType.tsType, parameterType.name); + break; + } + break; + case 'slice': + resultType = getTypeParameterOf(parameterType.tsType, 'Array'); + break; + } + if (resultType) { + signature = new SignatureResultOverride( + signature, new TypeWrapper(resultType, parameterType.context)); + } + } + } + return signature; + } + + indexed(argument: Symbol): Symbol|undefined { return undefined; } + + private get tsType(): ts.Type { + let type = this._tsType; + if (!type) { + const classSymbol = this.findClassSymbol(this.pipe.symbol); + if (classSymbol) { + type = this._tsType = this.findTransformMethodType(classSymbol); + } + if (!type) { + type = this._tsType = getBuiltinTypeFromTs(BuiltinType.Any, this.context); + } + } + return type; + } + + private findClassSymbol(type: StaticSymbol): ts.Symbol { + return findClassSymbolInContext(type, this.context); + } + + private findTransformMethodType(classSymbol: ts.Symbol): ts.Type { + const transform = classSymbol.members['transform']; + if (transform) { + return this.context.checker.getTypeOfSymbolAtLocation(transform, this.context.node); + } + } +} + +function findClassSymbolInContext(type: StaticSymbol, context: TypeContext): ts.Symbol { + const sourceFile = context.program.getSourceFile(type.filePath); + if (sourceFile) { + const moduleSymbol = (sourceFile as any).module || (sourceFile as any).symbol; + const exports = context.checker.getExportsOfModule(moduleSymbol); + return (exports || []).find(symbol => symbol.name == type.name); + } +} + +class EmptyTable implements SymbolTable { + get size(): number { return 0; } + get(key: string): Symbol|undefined { return undefined; } + has(key: string): boolean { return false; } + values(): Symbol[] { return []; } + static instance = new EmptyTable(); +} + +function findTsConfig(fileName: string): string { + let dir = path.dirname(fileName); + while (fs.existsSync(dir)) { + const candidate = path.join(dir, 'tsconfig.json'); + if (fs.existsSync(candidate)) return candidate; + dir = path.dirname(dir); + } +} + +function isBindingPattern(node: ts.Node): node is ts.BindingPattern { + return !!node && (node.kind === ts.SyntaxKind.ArrayBindingPattern || + node.kind === ts.SyntaxKind.ObjectBindingPattern); +} + +function walkUpBindingElementsAndPatterns(node: ts.Node): ts.Node { + while (node && (node.kind === ts.SyntaxKind.BindingElement || isBindingPattern(node))) { + node = node.parent; + } + + return node; +} + +function getCombinedNodeFlags(node: ts.Node): ts.NodeFlags { + node = walkUpBindingElementsAndPatterns(node); + + let flags = node.flags; + if (node.kind === ts.SyntaxKind.VariableDeclaration) { + node = node.parent; + } + + if (node && node.kind === ts.SyntaxKind.VariableDeclarationList) { + flags |= node.flags; + node = node.parent; + } + + if (node && node.kind === ts.SyntaxKind.VariableStatement) { + flags |= node.flags; + } + + return flags; +} + +function getDeclarationFlagsFromSymbol(s: ts.Symbol): ts.NodeFlags { + return s.valueDeclaration ? + getCombinedNodeFlags(s.valueDeclaration) : + s.flags & ts.SymbolFlags.Prototype ? ts.NodeFlags.Public | ts.NodeFlags.Static : 0; +} + +function getBuiltinTypeFromTs(kind: BuiltinType, context: TypeContext): ts.Type { + let type: ts.Type; + const checker = context.checker; + const node = context.node; + switch (kind) { + case BuiltinType.Any: + type = checker.getTypeAtLocation(setParents( + { + kind: ts.SyntaxKind.AsExpression, + expression: {kind: ts.SyntaxKind.TrueKeyword}, + type: {kind: ts.SyntaxKind.AnyKeyword} + }, + node)); + break; + case BuiltinType.Boolean: + type = + checker.getTypeAtLocation(setParents({kind: ts.SyntaxKind.TrueKeyword}, node)); + break; + case BuiltinType.Null: + type = + checker.getTypeAtLocation(setParents({kind: ts.SyntaxKind.NullKeyword}, node)); + break; + case BuiltinType.Number: + const numeric = {kind: ts.SyntaxKind.NumericLiteral}; + setParents({kind: ts.SyntaxKind.ExpressionStatement, expression: numeric}, node); + type = checker.getTypeAtLocation(numeric); + break; + case BuiltinType.String: + type = checker.getTypeAtLocation( + setParents({kind: ts.SyntaxKind.NoSubstitutionTemplateLiteral}, node)); + break; + case BuiltinType.Undefined: + type = checker.getTypeAtLocation(setParents( + { + kind: ts.SyntaxKind.VoidExpression, + expression: {kind: ts.SyntaxKind.NumericLiteral} + }, + node)); + break; + default: + throw new Error(`Internal error, unhandled literal kind ${kind}:${BuiltinType[kind]}`); + } + return type; +} + +function setParents(node: T, parent: ts.Node): T { + node.parent = parent; + ts.forEachChild(node, child => setParents(child, node)); + return node; +} + +function spanOf(node: ts.Node): Span { + return {start: node.getStart(), end: node.getEnd()}; +} + +function shrink(span: Span, offset?: number) { + if (offset == null) offset = 1; + return {start: span.start + offset, end: span.end - offset}; +} + +function spanAt(sourceFile: ts.SourceFile, line: number, column: number): Span { + if (line != null && column != null) { + const position = ts.getPositionOfLineAndCharacter(sourceFile, line, column); + const findChild = function findChild(node: ts.Node): ts.Node { + if (node.kind > ts.SyntaxKind.LastToken && node.pos <= position && node.end > position) { + const betterNode = ts.forEachChild(node, findChild); + return betterNode || node; + } + }; + + const node = ts.forEachChild(sourceFile, findChild); + if (node) { + return {start: node.getStart(), end: node.getEnd()}; + } + } +} + +function definitionFromTsSymbol(symbol: ts.Symbol): Definition { + const declarations = symbol.declarations; + if (declarations) { + return declarations.map(declaration => { + const sourceFile = declaration.getSourceFile(); + return { + fileName: sourceFile.fileName, + span: {start: declaration.getStart(), end: declaration.getEnd()} + }; + }); + } +} + +function parentDeclarationOf(node: ts.Node): ts.Node { + while (node) { + switch (node.kind) { + case ts.SyntaxKind.ClassDeclaration: + case ts.SyntaxKind.InterfaceDeclaration: + return node; + case ts.SyntaxKind.SourceFile: + return null; + } + node = node.parent; + } +} + +function getContainerOf(symbol: ts.Symbol, context: TypeContext): Symbol { + if (symbol.getFlags() & ts.SymbolFlags.ClassMember && symbol.declarations) { + for (const declaration of symbol.declarations) { + const parent = parentDeclarationOf(declaration); + if (parent) { + const type = context.checker.getTypeAtLocation(parent); + if (type) { + return new TypeWrapper(type, context); + } + } + } + } +} + +function getTypeParameterOf(type: ts.Type, name: string): ts.Type { + if (type && type.symbol && type.symbol.name == name) { + const typeArguments: ts.Type[] = (type as any).typeArguments; + if (typeArguments && typeArguments.length <= 1) { + return typeArguments[0]; + } + } +} \ No newline at end of file diff --git a/modules/@angular/language-service/src/utils.ts b/modules/@angular/language-service/src/utils.ts new file mode 100644 index 0000000000..43f08975e8 --- /dev/null +++ b/modules/@angular/language-service/src/utils.ts @@ -0,0 +1,99 @@ +/** + * @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 {CompileDirectiveSummary, CompileTypeMetadata} from '@angular/compiler'; +import {ParseSourceSpan} from '@angular/compiler/src/parse_util'; +import {CssSelector, SelectorMatcher} from '@angular/compiler/src/selector'; + +import {SelectorInfo, TemplateInfo} from './common'; +import {Span} from './types'; + +export interface SpanHolder { + sourceSpan: ParseSourceSpan; + endSourceSpan?: ParseSourceSpan; + children?: SpanHolder[]; +} + +export function isParseSourceSpan(value: any): value is ParseSourceSpan { + return value && !!value.start; +} + +export function spanOf(span?: SpanHolder | ParseSourceSpan): Span { + if (!span) return undefined; + if (isParseSourceSpan(span)) { + return {start: span.start.offset, end: span.end.offset}; + } else { + if (span.endSourceSpan) { + return {start: span.sourceSpan.start.offset, end: span.endSourceSpan.end.offset}; + } else if (span.children && span.children.length) { + return { + start: span.sourceSpan.start.offset, + end: spanOf(span.children[span.children.length - 1]).end + }; + } + return {start: span.sourceSpan.start.offset, end: span.sourceSpan.end.offset}; + } +} + +export function inSpan(position: number, span?: Span, exclusive?: boolean): boolean { + return span && exclusive ? position >= span.start && position < span.end : + position >= span.start && position <= span.end; +} + +export function offsetSpan(span: Span, amount: number): Span { + return {start: span.start + amount, end: span.end + amount}; +} + +export function isNarrower(spanA: Span, spanB: Span): boolean { + return spanA.start >= spanB.start && spanA.end <= spanB.end; +} + +export function hasTemplateReference(type: CompileTypeMetadata): boolean { + if (type.diDeps) { + for (let diDep of type.diDeps) { + if (diDep.token.identifier && diDep.token.identifier.name == 'TemplateRef') return true; + } + } + return false; +} + +export function getSelectors(info: TemplateInfo): SelectorInfo { + const map = new Map(); + const selectors = flatten(info.directives.map(directive => { + const selectors = CssSelector.parse(directive.selector); + selectors.forEach(selector => map.set(selector, directive)); + return selectors; + })); + return {selectors, map}; +} + +export function flatten(a: T[][]) { + return ([]).concat(...a); +} + +export function removeSuffix(value: string, suffix: string) { + if (value.endsWith(suffix)) return value.substring(0, value.length - suffix.length); + return value; +} + +export function uniqueByName < T extends { + name: string; +} +> (elements: T[] | undefined): T[]|undefined { + if (elements) { + const result: T[] = []; + const set = new Set(); + for (const element of elements) { + if (!set.has(element.name)) { + set.add(element.name); + result.push(element); + } + } + return result; + } +} diff --git a/modules/@angular/language-service/test/completions_spec.ts b/modules/@angular/language-service/test/completions_spec.ts new file mode 100644 index 0000000000..d9f59adb28 --- /dev/null +++ b/modules/@angular/language-service/test/completions_spec.ts @@ -0,0 +1,236 @@ +/** + * @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 'reflect-metadata'; +import * as ts from 'typescript'; + +import {createLanguageService} from '../src/language_service'; +import {Completions, Diagnostic, Diagnostics} from '../src/types'; +import {TypeScriptServiceHost} from '../src/typescript_host'; + +import {toh} from './test_data'; +import {MockTypescriptHost, includeDiagnostic, noDiagnostics} from './test_utils'; + +describe('completions', () => { + let documentRegistry = ts.createDocumentRegistry(); + let mockHost = new MockTypescriptHost(['/app/main.ts', '/app/parsing-cases.ts'], toh); + let service = ts.createLanguageService(mockHost, documentRegistry); + let program = service.getProgram(); + let ngHost = new TypeScriptServiceHost(ts, mockHost, service); + let ngService = createLanguageService(ngHost); + ngHost.setSite(ngService); + + it('should be able to get entity completions', + () => { contains('/app/test.ng', 'entity-amp', '&', '>', '<', 'ι'); }); + + it('should be able to return html elements', () => { + let htmlTags = ['h1', 'h2', 'div', 'span']; + let locations = ['empty', 'start-tag-h1', 'h1-content', 'start-tag', 'start-tag-after-h']; + for (let location of locations) { + contains('/app/test.ng', location, ...htmlTags); + } + }); + + it('should be able to return element diretives', + () => { contains('/app/test.ng', 'empty', 'my-app'); }); + + it('should be able to return h1 attributes', + () => { contains('/app/test.ng', 'h1-after-space', 'id', 'dir', 'lang', 'onclick'); }); + + it('should be able to find common angular attributes', + () => { contains('/app/test.ng', 'div-attributes', '(click)', '[ngClass]'); }); + + it('should be able to get completions in some random garbage', () => { + const fileName = '/app/test.ng'; + mockHost.override(fileName, ' > {{tle<\n {{retl >\n la ngService.getCompletionsAt(fileName, 31)).not.toThrow(); + mockHost.override(fileName, undefined); + }); + + it('should be able to infer the type of a ngForOf', () => { + addCode( + ` + interface Person { + name: string, + street: string + } + + @Component({template: '
{{person.~{name}name}} { contains('/app/app.component.ts', 'name', 'name', 'street'); }); + }); + + it('should be able to infer the type of a ngForOf with an async pipe', () => { + addCode( + ` + interface Person { + name: string, + street: string + } + + @Component({template: '
{{person.~{name}name}}; + }`, + () => { contains('/app/app.component.ts', 'name', 'name', 'street'); }); + }); + + it('should be able to complete every character in the file', () => { + const fileName = '/app/test.ng'; + + expect(() => { + let chance = 0.05; + let requests = 0; + function tryCompletionsAt(position: number) { + try { + if (Math.random() < chance) { + ngService.getCompletionsAt(fileName, position); + requests++; + } + } catch (e) { + // Emit enough diagnostic information to reproduce the error. + console.log( + `Position: ${position}\nContent: "${mockHost.getFileContent(fileName)}"\nStack:\n${e.stack}`); + throw e; + } + } + try { + const originalContent = mockHost.getFileContent(fileName); + + // For each character in the file, add it to the file and request a completion after it. + for (let index = 0, len = originalContent.length; index < len; index++) { + const content = originalContent.substr(0, index); + mockHost.override(fileName, content); + tryCompletionsAt(index); + } + + // For the complete file, try to get a completion at every character. + mockHost.override(fileName, originalContent); + for (let index = 0, len = originalContent.length; index < len; index++) { + tryCompletionsAt(index); + } + + // Delete random characters in the file until we get an empty file. + let content = originalContent; + while (content.length > 0) { + const deleteIndex = Math.floor(Math.random() * content.length); + content = content.slice(0, deleteIndex - 1) + content.slice(deleteIndex + 1); + mockHost.override(fileName, content); + + const requestIndex = Math.floor(Math.random() * content.length); + tryCompletionsAt(requestIndex); + } + + // Build up the string from zero asking for a completion after every char + buildUp(originalContent, (text, position) => { + mockHost.override(fileName, text); + tryCompletionsAt(position); + }); + } finally { + mockHost.override(fileName, undefined); + } + }).not.toThrow(); + }); + + describe('with regression tests', () => { + it('should not crash with an incomplete component', () => { + expect(() => { + const code = ` +@Component({ + template: '~{inside-template}' +}) +export class MyComponent { + +}`; + addCode(code, fileName => { contains(fileName, 'inside-template', 'h1'); }); + }).not.toThrow(); + }); + + it('should hot crash with an incomplete class', () => { + expect(() => { + addCode('\nexport class', fileName => { ngHost.updateAnalyzedModules(); }); + }).not.toThrow(); + }); + + }); + + function addCode(code: string, cb: (fileName: string, content?: string) => void) { + const fileName = '/app/app.component.ts'; + const originalContent = mockHost.getFileContent(fileName); + const newContent = originalContent + code; + mockHost.override(fileName, originalContent + code); + try { + cb(fileName, newContent); + } finally { + mockHost.override(fileName, undefined); + } + } + + function contains(fileName: string, locationMarker: string, ...names: string[]) { + let location = mockHost.getMarkerLocations(fileName)[locationMarker]; + if (location == null) { + throw new Error(`No marker ${locationMarker} found.`); + } + expectEntries(locationMarker, ngService.getCompletionsAt(fileName, location), ...names); + } +}); + + +function expectEntries(locationMarker: string, completions: Completions, ...names: string[]) { + let entries: {[name: string]: boolean} = {}; + if (!completions) { + throw new Error( + `Expected result from ${locationMarker} to include ${names.join(', ')} but no result provided`); + } + if (!completions.length) { + throw new Error( + `Expected result from ${locationMarker} to include ${names.join(', ')} an empty result provided`); + } else { + for (let entry of completions) { + entries[entry.name] = true; + } + let missing = names.filter(name => !entries[name]); + if (missing.length) { + throw new Error( + `Expected result from ${locationMarker} to include at least one of the following, ${missing.join(', ')}, in the list of entries ${completions.map(entry => entry.name).join(', ')}`); + } + } +} + +function buildUp(originalText: string, cb: (text: string, position: number) => void) { + let count = originalText.length; + + let inString: boolean[] = (new Array(count)).fill(false); + let unused: number[] = (new Array(count)).fill(1).map((v, i) => i); + + function getText() { + return new Array(count) + .fill(1) + .map((v, i) => i) + .filter(i => inString[i]) + .map(i => originalText[i]) + .join(''); + } + + function randomUnusedIndex() { return Math.floor(Math.random() * unused.length); } + + while (unused.length > 0) { + let unusedIndex = randomUnusedIndex(); + let index = unused[unusedIndex]; + if (index == null) throw new Error('Internal test buildup error'); + if (inString[index]) throw new Error('Internal test buildup error'); + inString[index] = true; + unused.splice(unusedIndex, 1); + let text = getText(); + let position = + inString.filter((_, i) => i <= index).map(v => v ? 1 : 0).reduce((p, v) => p + v, 0); + cb(text, position); + } +} diff --git a/modules/@angular/language-service/test/definitions_spec.ts b/modules/@angular/language-service/test/definitions_spec.ts new file mode 100644 index 0000000000..0be563cded --- /dev/null +++ b/modules/@angular/language-service/test/definitions_spec.ts @@ -0,0 +1,169 @@ +/** + * @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 * as ts from 'typescript'; + +import {createLanguageService} from '../src/language_service'; +import {Completions, Diagnostic, Diagnostics, Span} from '../src/types'; +import {TypeScriptServiceHost} from '../src/typescript_host'; + +import {toh} from './test_data'; + +import {MockTypescriptHost,} from './test_utils'; + +describe('definitions', () => { + let documentRegistry = ts.createDocumentRegistry(); + let mockHost = new MockTypescriptHost(['/app/main.ts', '/app/parsing-cases.ts'], toh); + let service = ts.createLanguageService(mockHost, documentRegistry); + let program = service.getProgram(); + let ngHost = new TypeScriptServiceHost(ts, mockHost, service); + let ngService = createLanguageService(ngHost); + ngHost.setSite(ngService); + + it('should be able to find field in an interpolation', () => { + localReference( + ` @Component({template: '{{«name»}}'}) export class MyComponent { «∆name∆: string;» }`); + }); + + it('should be able to find a field in a attribute reference', () => { + localReference( + ` @Component({template: ''}) export class MyComponent { «∆name∆: string;» }`); + }); + + it('should be able to find a method from a call', () => { + localReference( + ` @Component({template: '
'}) export class MyComponent { «∆myClick∆() { }»}`); + }); + + it('should be able to find a field reference in an *ngIf', () => { + localReference( + ` @Component({template: '
'}) export class MyComponent { «∆include∆ = true;»}`); + }); + + it('should be able to find a reference to a component', () => { + reference( + 'parsing-cases.ts', + ` @Component({template: '<«test-comp»>'}) export class MyComponent { }`); + }); + + it('should be able to find an event provider', () => { + reference( + '/app/parsing-cases.ts', 'test', + ` @Component({template: '
'}) export class MyComponent { myHandler() {} }`); + }); + + it('should be able to find an input provider', () => { + reference( + '/app/parsing-cases.ts', 'tcName', + ` @Component({template: '
'}) export class MyComponent { name = 'my name'; }`); + }); + + it('should be able to find a pipe', () => { + reference( + 'async_pipe.d.ts', + ` @Component({template: '
'}) export class MyComponent { input: EventEmitter; }`); + }); + + function localReference(code: string) { + addCode(code, fileName => { + const refResult = mockHost.getReferenceMarkers(fileName); + for (const name in refResult.references) { + const references = refResult.references[name]; + const definitions = refResult.definitions[name]; + expect(definitions).toBeDefined(); // If this fails the test data is wrong. + for (const reference of references) { + const definition = ngService.getDefinitionAt(fileName, reference.start); + if (definition) { + definition.forEach(d => expect(d.fileName).toEqual(fileName)); + const match = matchingSpan(definition.map(d => d.span), definitions); + if (!match) { + throw new Error( + `Expected one of ${stringifySpans(definition.map(d => d.span))} to match one of ${stringifySpans(definitions)}`); + } + } else { + throw new Error('Expected a definition'); + } + } + } + }); + } + + function reference(referencedFile: string, code: string): void; + function reference(referencedFile: string, span: Span, code: string): void; + function reference(referencedFile: string, definition: string, code: string): void; + function reference(referencedFile: string, p1?: any, p2?: any): void { + const code: string = p2 ? p2 : p1; + const definition: string = p2 ? p1 : undefined; + let span: Span = p2 && p1.start != null ? p1 : undefined; + if (definition && !span) { + const referencedFileMarkers = mockHost.getReferenceMarkers(referencedFile); + expect(referencedFileMarkers).toBeDefined(); // If this fails the test data is wrong. + const spans = referencedFileMarkers.definitions[definition]; + expect(spans).toBeDefined(); // If this fails the test data is wrong. + span = spans[0]; + } + addCode(code, fileName => { + const refResult = mockHost.getReferenceMarkers(fileName); + let tests = 0; + for (const name in refResult.references) { + const references = refResult.references[name]; + expect(reference).toBeDefined(); // If this fails the test data is wrong. + for (const reference of references) { + tests++; + const definition = ngService.getDefinitionAt(fileName, reference.start); + if (definition) { + definition.forEach(d => { + if (d.fileName.indexOf(referencedFile) < 0) { + throw new Error( + `Expected reference to file ${referencedFile}, received ${d.fileName}`); + } + if (span) { + expect(d.span).toEqual(span); + } + }); + } else { + throw new Error('Expected a definition'); + } + } + } + if (!tests) { + throw new Error('Expected at least one reference (test data error)'); + } + }); + } + + function addCode(code: string, cb: (fileName: string, content?: string) => void) { + const fileName = '/app/app.component.ts'; + const originalContent = mockHost.getFileContent(fileName); + const newContent = originalContent + code; + mockHost.override(fileName, originalContent + code); + try { + cb(fileName, newContent); + } finally { + mockHost.override(fileName, undefined); + } + } +}); + +function matchingSpan(aSpans: Span[], bSpans: Span[]): Span { + for (const a of aSpans) { + for (const b of bSpans) { + if (a.start == b.start && a.end == b.end) { + return a; + } + } + } +} + +function stringifySpan(span: Span) { + return span ? `(${span.start}-${span.end})` : ''; +} + +function stringifySpans(spans: Span[]) { + return spans ? `[${spans.map(stringifySpan).join(', ')}]` : ''; +} \ No newline at end of file diff --git a/modules/@angular/language-service/test/diagnostics_spec.ts b/modules/@angular/language-service/test/diagnostics_spec.ts new file mode 100644 index 0000000000..cf09934878 --- /dev/null +++ b/modules/@angular/language-service/test/diagnostics_spec.ts @@ -0,0 +1,136 @@ +/** + * @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 * as ts from 'typescript'; + +import {createLanguageService} from '../src/language_service'; +import {Completions, Diagnostic, Diagnostics} from '../src/types'; +import {TypeScriptServiceHost} from '../src/typescript_host'; + +import {toh} from './test_data'; +import {MockTypescriptHost, includeDiagnostic, noDiagnostics} from './test_utils'; + +describe('diagnostics', () => { + let documentRegistry = ts.createDocumentRegistry(); + let mockHost = new MockTypescriptHost(['/app/main.ts', '/app/parsing-cases.ts'], toh); + let service = ts.createLanguageService(mockHost, documentRegistry); + let program = service.getProgram(); + let ngHost = new TypeScriptServiceHost(ts, mockHost, service); + let ngService = createLanguageService(ngHost); + ngHost.setSite(ngService); + + it('should be no diagnostics for test.ng', + () => { expect(ngService.getDiagnostics('/app/test.ng')).toEqual([]); }); + + describe('for semantic errors', () => { + const fileName = '/app/test.ng'; + + function diagnostics(template: string): Diagnostics { + try { + mockHost.override(fileName, template); + return ngService.getDiagnostics(fileName); + } finally { + mockHost.override(fileName, undefined); + } + } + + function accept(template: string) { noDiagnostics(diagnostics(template)); } + + function reject(template: string, message: string): void; + function reject(template: string, message: string, at: string): void; + function reject(template: string, message: string, location: string): void; + function reject(template: string, message: string, location: string, len: number): void; + function reject(template: string, message: string, at?: number | string, len?: number): void { + if (typeof at == 'string') { + len = at.length; + at = template.indexOf(at); + } + includeDiagnostic(diagnostics(template), message, at, len); + } + + describe('with $event', () => { + it('should accept an event', + () => { accept('
Click me!
'); }); + it('should reject it when not in an event binding', () => { + reject('
', '\'$event\' is not defined', '$event'); + }); + }); + }); + + describe('with regression tests', () => { + + it('should not crash with a incomplete *ngFor', () => { + expect(() => { + const code = + '\n@Component({template: \'
~{after-div}\'}) export class MyComponent {}'; + addCode(code, fileName => { ngService.getDiagnostics(fileName); }); + }).not.toThrow(); + }); + + it('should report a component not in a module', () => { + const code = '\n@Component({template: \'
\'}) export class MyComponent {}'; + addCode(code, (fileName, content) => { + const diagnostics = ngService.getDiagnostics(fileName); + const offset = content.lastIndexOf('@Component') + 1; + const len = 'Component'.length; + includeDiagnostic( + diagnostics, 'Component \'MyComponent\' is not included in a module', offset, len); + }); + }); + + it('should not report an error for a form\'s host directives', () => { + const code = '\n@Component({template: \'
\'}) export class MyComponent {}'; + addCode(code, (fileName, content) => { + const diagnostics = ngService.getDiagnostics(fileName); + onlyModuleDiagnostics(diagnostics); + }); + }); + + it('should not throw getting diagnostics for an index expression', () => { + const code = + ` @Component({template: '
'}) export class MyComponent {}`; + addCode( + code, fileName => { expect(() => ngService.getDiagnostics(fileName)).not.toThrow(); }); + }); + + it('should not throw using a directive with no value', () => { + const code = + ` @Component({template: '
'}) export class MyComponent { name = 'some name'; }`; + addCode( + code, fileName => { expect(() => ngService.getDiagnostics(fileName)).not.toThrow(); }); + }); + + it('should report an error for invalid metadata', () => { + const code = + ` @Component({template: '', provider: [{provide: 'foo', useFactor: () => 'foo' }]}) export class MyComponent { name = 'some name'; }`; + addCode(code, (fileName, content) => { + const diagnostics = ngService.getDiagnostics(fileName); + includeDiagnostic( + diagnostics, 'Function calls are not supported.', '() => \'foo\'', content); + }); + }); + + function addCode(code: string, cb: (fileName: string, content?: string) => void) { + const fileName = '/app/app.component.ts'; + const originalContent = mockHost.getFileContent(fileName); + const newContent = originalContent + code; + mockHost.override(fileName, originalContent + code); + try { + cb(fileName, newContent); + } finally { + mockHost.override(fileName, undefined); + } + } + + function onlyModuleDiagnostics(diagnostics: Diagnostics) { + // Expect only the 'MyComponent' diagnostic + expect(diagnostics.length).toBe(1); + expect(diagnostics[0].message.indexOf('MyComponent') >= 0).toBeTruthy(); + } + }); +}); diff --git a/modules/@angular/language-service/test/hover_spec.ts b/modules/@angular/language-service/test/hover_spec.ts new file mode 100644 index 0000000000..fce25a89d9 --- /dev/null +++ b/modules/@angular/language-service/test/hover_spec.ts @@ -0,0 +1,105 @@ +/** + * @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 'reflect-metadata'; + +import * as ts from 'typescript'; + +import {createLanguageService} from '../src/language_service'; +import {Hover, HoverTextSection} from '../src/types'; +import {TypeScriptServiceHost} from '../src/typescript_host'; + +import {toh} from './test_data'; +import {MockTypescriptHost, includeDiagnostic, noDiagnostics} from './test_utils'; + +describe('hover', () => { + let documentRegistry = ts.createDocumentRegistry(); + let mockHost = new MockTypescriptHost(['/app/main.ts', '/app/parsing-cases.ts'], toh); + let service = ts.createLanguageService(mockHost, documentRegistry); + let program = service.getProgram(); + let ngHost = new TypeScriptServiceHost(ts, mockHost, service); + let ngService = createLanguageService(ngHost); + ngHost.setSite(ngService); + + + it('should be able to find field in an interpolation', () => { + hover( + ` @Component({template: '{{«name»}}'}) export class MyComponent { name: string; }`, + 'property name of MyComponent'); + }); + + it('should be able to find a field in a attribute reference', () => { + hover( + ` @Component({template: ''}) export class MyComponent { name: string; }`, + 'property name of MyComponent'); + }); + + it('should be able to find a method from a call', () => { + hover( + ` @Component({template: '
'}) export class MyComponent { myClick() { }}`, + 'method myClick of MyComponent'); + }); + + it('should be able to find a field reference in an *ngIf', () => { + hover( + ` @Component({template: '
'}) export class MyComponent { include = true;}`, + 'property include of MyComponent'); + }); + + it('should be able to find a reference to a component', () => { + hover( + ` @Component({template: '«<∆test∆-comp>»'}) export class MyComponent { }`, + 'component TestComponent'); + }); + + it('should be able to find an event provider', () => { + hover( + ` @Component({template: ''}) export class MyComponent { myHandler() {} }`, + 'event testEvent of TestComponent'); + }); + + it('should be able to find an input provider', () => { + hover( + ` @Component({template: ''}) export class MyComponent { name = 'my name'; }`, + 'property name of TestComponent'); + }); + + function hover(code: string, hoverText: string) { + addCode(code, fileName => { + let tests = 0; + const markers = mockHost.getReferenceMarkers(fileName); + const keys = Object.keys(markers.references).concat(Object.keys(markers.definitions)); + for (const referenceName of keys) { + const references = (markers.references[referenceName] || + []).concat(markers.definitions[referenceName] || []); + for (const reference of references) { + tests++; + const hover = ngService.getHoverAt(fileName, reference.start); + if (!hover) throw new Error(`Expected a hover at location ${reference.start}`); + expect(hover.span).toEqual(reference); + expect(toText(hover)).toEqual(hoverText); + } + } + expect(tests).toBeGreaterThan(0); // If this fails the test is wrong. + }); + } + + function addCode(code: string, cb: (fileName: string, content?: string) => void) { + const fileName = '/app/app.component.ts'; + const originalContent = mockHost.getFileContent(fileName); + const newContent = originalContent + code; + mockHost.override(fileName, originalContent + code); + try { + cb(fileName, newContent); + } finally { + mockHost.override(fileName, undefined); + } + } + + function toText(hover: Hover): string { return hover.text.map(h => h.text).join(''); } +}); \ No newline at end of file diff --git a/modules/@angular/language-service/test/html_info_spec.ts b/modules/@angular/language-service/test/html_info_spec.ts new file mode 100644 index 0000000000..62cefdbfc2 --- /dev/null +++ b/modules/@angular/language-service/test/html_info_spec.ts @@ -0,0 +1,52 @@ +/** + * @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 {DomElementSchemaRegistry} from '@angular/compiler'; +import {SchemaInformation} from '../src/html_info'; + +describe('html_info', () => { + const domRegistry = new DomElementSchemaRegistry(); + + it('should have the same elements as the dom registry', () => { + // If this test fails, replace the SCHEMA constant in html_info with the one + // from dom_element_schema_registry and also verify the code to interpret + // the schema is the same. + const domElements = domRegistry.allKnownElementNames(); + const infoElements = SchemaInformation.instance.allKnownElements(); + const uniqueToDom = uniqueElements(infoElements, domElements); + const uniqueToInfo = uniqueElements(domElements, infoElements); + expect(uniqueToDom).toEqual([]); + expect(uniqueToInfo).toEqual([]); + }); + + it('should have at least a sub-set of properties', () => { + const elements = SchemaInformation.instance.allKnownElements(); + for (const element of elements) { + for (const prop of SchemaInformation.instance.propertiesOf(element)) { + expect(domRegistry.hasProperty(element, prop, [])); + } + } + }); + +}); + +function uniqueElements(a: T[], b: T[]): T[] { + const s = new Set(); + for (const aItem of a) { + s.add(aItem); + } + const result: T[] = []; + const reported = new Set(); + for (const bItem of b) { + if (!s.has(bItem) && !reported.has(bItem)) { + reported.add(bItem); + result.push(bItem); + } + } + return result; +} \ No newline at end of file diff --git a/modules/@angular/language-service/test/template_references_spec.ts b/modules/@angular/language-service/test/template_references_spec.ts new file mode 100644 index 0000000000..e2bee1c73d --- /dev/null +++ b/modules/@angular/language-service/test/template_references_spec.ts @@ -0,0 +1,32 @@ +/** + * @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 * as ts from 'typescript'; + +import {createLanguageService} from '../src/language_service'; +import {Completions, Diagnostic, Diagnostics} from '../src/types'; +import {TypeScriptServiceHost} from '../src/typescript_host'; + +import {toh} from './test_data'; +import {MockTypescriptHost} from './test_utils'; + +describe('references', () => { + let documentRegistry = ts.createDocumentRegistry(); + let mockHost = new MockTypescriptHost(['/app/main.ts', '/app/parsing-cases.ts'], toh); + let service = ts.createLanguageService(mockHost, documentRegistry); + let program = service.getProgram(); + let ngHost = new TypeScriptServiceHost(ts, mockHost, service); + let ngService = createLanguageService(ngHost); + ngHost.setSite(ngService); + + it('should be able to get template references', + () => { expect(() => ngService.getTemplateReferences()).not.toThrow(); }); + + it('should be able to determine that test.ng is a template reference', + () => { expect(ngService.getTemplateReferences()).toContain('/app/test.ng'); }); +}); \ No newline at end of file diff --git a/modules/@angular/language-service/test/test_data.ts b/modules/@angular/language-service/test/test_data.ts new file mode 100644 index 0000000000..7e4aded2a0 --- /dev/null +++ b/modules/@angular/language-service/test/test_data.ts @@ -0,0 +1,231 @@ +/** + * @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 {MockData} from './test_utils'; + +export const toh = { + app: { + 'app.component.ts': `import { Component } from '@angular/core'; + +export class Hero { + id: number; + name: string; +} + +@Component({ + selector: 'my-app', + template: \`~{empty} + <~{start-tag}h~{start-tag-after-h}1~{start-tag-h1} ~{h1-after-space}>~{h1-content} {{~{sub-start}title~{sub-end}}}

+ ~{after-h1}

{{~{h2-hero}hero.~{h2-name}name}} details!

+
{{~{label-hero}hero.~{label-id}id}}
+
+ +
+ &~{entity-amp}amp; + \` +}) +export class AppComponent { + title = 'Tour of Heroes'; + hero: Hero = { + id: 1, + name: 'Windstorm' + }; + private internal: string; +}`, + 'main.ts': ` +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { AppComponent } from './app.component'; +import { CaseIncompleteOpen, CaseMissingClosing, CaseUnknown, Pipes, TemplateReference, NoValueAttribute, + AttributeBinding, StringModel,PropertyBinding, EventBinding, TwoWayBinding, EmptyInterpolation, + ForOfEmpty, ForLetIEqual, ForOfLetEmpty, ForUsingComponent, References, TestComponent} from './parsing-cases'; +import { WrongFieldReference, WrongSubFieldReference, PrivateReference, ExpectNumericType, LowercasePipe } from './expression-cases'; +import { UnknownPeople, UnknownEven, UnknownTrackBy } from './ng-for-cases'; +import { ShowIf } from './ng-if-cases'; + +@NgModule({ + imports: [CommonModule, FormsModule], + declarations: [AppComponent, CaseIncompleteOpen, CaseMissingClosing, CaseUnknown, Pipes, TemplateReference, NoValueAttribute, + AttributeBinding, StringModel, PropertyBinding, EventBinding, TwoWayBinding, EmptyInterpolation, ForOfEmpty, ForOfLetEmpty, + ForLetIEqual, ForUsingComponent, References, TestComponent, WrongFieldReference, WrongSubFieldReference, PrivateReference, + ExpectNumericType, UnknownPeople, UnknownEven, UnknownTrackBy, ShowIf, LowercasePipe] +}) +export class AppModule {} + +declare function bootstrap(v: any): void; + +bootstrap(AppComponent); +`, + 'parsing-cases.ts': ` +import {Component, Directive, Input, Output, EventEmitter} from '@angular/core'; +import {Hero} from './app.component'; + +@Component({template: '

Some <~{incomplete-open-lt}a~{incomplete-open-a} ~{incomplete-open-attr} text

'}) +export class CaseIncompleteOpen {} + +@Component({template: '

Some ~{missing-closing} text

'}) +export class CaseMissingClosing {} + +@Component({template: '

Some text

'}) +export class CaseUnknown {} + +@Component({template: '

{{data | ~{before-pipe}lowe~{in-pipe}rcase~{after-pipe} }}'}) +export class Pipes { + data = 'Some string'; +} + +@Component({template: '

'}) +export class NoValueAttribute {} + + +@Component({template: '

'}) +export class AttributeBinding { + test: string; +} + +@Component({template: '

'}) +export class PropertyBinding { + test: string; +} + +@Component({template: '

'}) +export class EventBinding { + test: string; + + modelChanged() {} +} + +@Component({template: '

'}) +export class TwoWayBinding { + test: string; +} + +@Directive({selector: '[string-model]'}) +export class StringModel { + @Input() model: string; + @Output() modelChanged: EventEmitter; +} + +interface Person { + name: string; + age: number +} + +@Component({template: '
'}) +export class ForOfEmpty {} + +@Component({template: '
'}) +export class ForOfLetEmpty {} + +@Component({template: '
'}) +export class ForLetIEqual {} + +@Component({template: '
Name: {{~{for-interp-person}person.~{for-interp-name}name}}Age: {{person.~{for-interp-age}age}}
'}) +export class ForUsingComponent { + people: Person[]; +} + +@Component({template: '
{{~{test-comp-content}}} {{test1.~{test-comp-after-test}name}} {{div.~{test-comp-after-div}.innerText}}
'}) +export class References {} + +@Component({selector: 'test-comp', template: '
Testing: {{name}}
'}) +export class TestComponent { + «@Input('∆tcName∆') name = 'test';» + «@Output('∆test∆') testEvent = new EventEmitter();» +} + +@Component({templateUrl: 'test.ng'}) +export class TemplateReference { + title = 'Some title'; + hero: Hero = { + id: 1, + name: 'Windstorm' + }; + myClick(event: any) { + + } +} + +@Component({template: '{{~{empty-interpolation}}}'}) +export class EmptyInterpolation { + title = 'Some title'; + subTitle = 'Some sub title'; +} +`, + 'expression-cases.ts': ` +import {Component} from '@angular/core'; + +export interface Person { + name: string; + age: number; +} + +@Component({template: '{{~{foo}foo~{foo-end}}}'}) +export class WrongFieldReference { + bar = 'bar'; +} + +@Component({template: '{{~{nam}person.nam~{nam-end}}}'}) +export class WrongSubFieldReference { + person: Person = { name: 'Bob', age: 23 }; +} + +@Component({template: '{{~{myField}myField~{myField-end}}}'}) +export class PrivateReference { + private myField = 'My Field'; +} + +@Component({template: '{{~{mod}"a" ~{mod-end}% 2}}'}) +export class ExpectNumericType {} + +@Component({template: '{{ (name | lowercase).~{string-pipe}substring }}'}) +export class LowercasePipe { + name: string; +} +`, + 'ng-for-cases.ts': ` +import {Component} from '@angular/core'; + +export interface Person { + name: string; + age: number; +} + +@Component({template: '
{{person.name}}
'}) +export class UnknownPeople {} + +@Component({template: '
{{person.name}}
'}) +export class UnknownEven { + people: Person[]; +} + +@Component({template: '
{{person.name}}
'}) +export class UnknownTrackBy { + people: Person[]; +} +`, + 'ng-if-cases.ts': ` +import {Component} from '@angular/core'; + +@Component({template: '
Showing now!
'}) +export class ShowIf { + show = false; +} +`, + 'test.ng': `~{empty} + <~{start-tag}h~{start-tag-after-h}1~{start-tag-h1} ~{h1-after-space}>~{h1-content} {{~{sub-start}title~{sub-end}}} + ~{after-h1}

{{~{h2-hero}hero.~{h2-name}name}} details!

+
{{~{label-hero}hero.~{label-id}id}}
+
+ +
+ &~{entity-amp}amp; + ` + } +}; diff --git a/modules/@angular/language-service/test/test_utils.ts b/modules/@angular/language-service/test/test_utils.ts new file mode 100644 index 0000000000..9979afe637 --- /dev/null +++ b/modules/@angular/language-service/test/test_utils.ts @@ -0,0 +1,320 @@ +/** + * @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 * as fs from 'fs'; +import * as path from 'path'; +import * as ts from 'typescript'; + +import {Diagnostic, Diagnostics, Span} from '../src/types'; + +export type MockData = string | MockDirectory; + +export type MockDirectory = { + [name: string]: MockData | undefined; +} + +const angularts = /@angular\/(\w|\/|-)+\.tsx?$/; +const rxjsts = /rxjs\/(\w|\/)+\.tsx?$/; +const rxjsmetadata = /rxjs\/(\w|\/)+\.metadata\.json?$/; +const tsxfile = /\.tsx$/; + +/* The missing cache does two things. First it improves performance of the + tests as it reduces the number of OS calls made during testing. Also it + improves debugging experience as fewer exceptions are raised allow you + to use stopping on all exceptions. */ +const missingCache = new Map(); +const cacheUsed = new Set(); +const reportedMissing = new Set(); + +/** + * The cache is valid if all the returned entries are empty. + */ +export function validateCache(): {exists: string[], unused: string[], reported: string[]} { + const exists: string[] = []; + const unused: string[] = []; + for (const fileName of iterableToArray(missingCache.keys())) { + if (fs.existsSync(fileName)) { + exists.push(fileName); + } + if (!cacheUsed.has(fileName)) { + unused.push(fileName); + } + } + return {exists, unused, reported: iterableToArray(reportedMissing.keys())}; +} + +missingCache.set('/node_modules/@angular/core.d.ts', true); +missingCache.set('/node_modules/@angular/common.d.ts', true); +missingCache.set('/node_modules/@angular/forms.d.ts', true); +missingCache.set('/node_modules/@angular/core/src/di/provider.metadata.json', true); +missingCache.set( + '/node_modules/@angular/core/src/change_detection/pipe_transform.metadata.json', true); +missingCache.set('/node_modules/@angular/core/src/reflection/types.metadata.json', true); +missingCache.set( + '/node_modules/@angular/core/src/reflection/platform_reflection_capabilities.metadata.json', + true); +missingCache.set('/node_modules/@angular/forms/src/directives/form_interface.metadata.json', true); + +export class MockTypescriptHost implements ts.LanguageServiceHost { + private angularPath: string; + private nodeModulesPath: string; + private scriptVersion = new Map(); + private overrides = new Map(); + private projectVersion = 0; + + constructor(private scriptNames: string[], private data: MockData) { + let angularIndex = module.filename.indexOf('@angular'); + if (angularIndex >= 0) + this.angularPath = + module.filename.substr(0, angularIndex).replace('/all/', '/packages-dist/'); + let distIndex = module.filename.indexOf('/dist/all'); + if (distIndex >= 0) + this.nodeModulesPath = path.join(module.filename.substr(0, distIndex), 'node_modules'); + } + + override(fileName: string, content: string) { + this.scriptVersion.set(fileName, (this.scriptVersion.get(fileName) || 0) + 1); + if (fileName.endsWith('.ts')) { + this.projectVersion++; + } + if (content) { + this.overrides.set(fileName, content); + } else { + this.overrides.delete(fileName); + } + } + + getCompilationSettings(): ts.CompilerOptions { + return { + target: ts.ScriptTarget.ES5, + module: ts.ModuleKind.CommonJS, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + emitDecoratorMetadata: true, + experimentalDecorators: true, + removeComments: false, + noImplicitAny: false, + lib: ['lib.es2015.d.ts', 'lib.dom.d.ts'], + }; + } + + getProjectVersion(): string { return this.projectVersion.toString(); } + + getScriptFileNames(): string[] { return this.scriptNames; } + + getScriptVersion(fileName: string): string { + return (this.scriptVersion.get(fileName) || 0).toString(); + } + + getScriptSnapshot(fileName: string): ts.IScriptSnapshot { + const content = this.getFileContent(fileName); + if (content) return ts.ScriptSnapshot.fromString(content); + return undefined; + } + + getCurrentDirectory(): string { return '/'; } + + getDefaultLibFileName(options: ts.CompilerOptions): string { return 'lib.d.ts'; } + + directoryExists(directoryName: string): boolean { + let effectiveName = this.getEffectiveName(directoryName); + if (effectiveName === directoryName) + return directoryExists(directoryName, this.data); + else + return fs.existsSync(effectiveName); + } + + getMarkerLocations(fileName: string): {[name: string]: number}|undefined { + let content = this.getRawFileContent(fileName); + if (content) { + return getLocationMarkers(content); + } + } + + getReferenceMarkers(fileName: string): ReferenceResult { + let content = this.getRawFileContent(fileName); + if (content) { + return getReferenceMarkers(content); + } + } + + getFileContent(fileName: string): string { + const content = this.getRawFileContent(fileName); + if (content) return removeReferenceMarkers(removeLocationMarkers(content)); + } + + private getRawFileContent(fileName: string): string { + if (this.overrides.has(fileName)) { + return this.overrides.get(fileName); + } + let basename = path.basename(fileName); + if (/^lib.*\.d\.ts$/.test(basename)) { + let libPath = ts.getDefaultLibFilePath(this.getCompilationSettings()); + return fs.readFileSync(path.join(path.dirname(libPath), basename), 'utf8'); + } else { + if (missingCache.has(fileName)) { + cacheUsed.add(fileName); + return undefined; + } + let effectiveName = this.getEffectiveName(fileName); + if (effectiveName === fileName) + return open(fileName, this.data); + else if ( + !fileName.match(angularts) && !fileName.match(rxjsts) && !fileName.match(rxjsmetadata) && + !fileName.match(tsxfile)) { + if (fs.existsSync(effectiveName)) { + return fs.readFileSync(effectiveName, 'utf8'); + } else { + missingCache.set(fileName, true); + reportedMissing.add(fileName); + cacheUsed.add(fileName); + } + } + } + } + + private getEffectiveName(name: string): string { + const node_modules = 'node_modules'; + const at_angular = '/@angular'; + if (name.startsWith('/' + node_modules)) { + if (this.nodeModulesPath && !name.startsWith('/' + node_modules + at_angular)) { + let result = path.join(this.nodeModulesPath, name.substr(node_modules.length + 1)); + if (!name.match(rxjsts)) + if (fs.existsSync(result)) { + return result; + } + } + if (this.angularPath && name.startsWith('/' + node_modules + at_angular)) { + return path.join( + this.angularPath, name.substr(node_modules.length + at_angular.length + 1)); + } + } + return name; + } +} + +function iterableToArray(iterator: IterableIterator) { + const result: T[] = []; + while (true) { + const next = iterator.next(); + if (next.done) break; + result.push(next.value); + } + return result; +} + +function find(fileName: string, data: MockData): MockData|undefined { + let names = fileName.split('/'); + if (names.length && !names[0].length) names.shift(); + let current = data; + for (let name of names) { + if (typeof current === 'string') + return undefined; + else + current = (current)[name]; + if (!current) return undefined; + } + return current; +} + +function open(fileName: string, data: MockData): string|undefined { + let result = find(fileName, data); + if (typeof result === 'string') { + return result; + } + return undefined; +} + +function directoryExists(dirname: string, data: MockData): boolean { + let result = find(dirname, data); + return result && typeof result !== 'string'; +} + +const locationMarker = /\~\{(\w+(-\w+)*)\}/g; + +function removeLocationMarkers(value: string): string { + return value.replace(locationMarker, ''); +} + +function getLocationMarkers(value: string): {[name: string]: number} { + value = removeReferenceMarkers(value); + let result: {[name: string]: number} = {}; + let adjustment = 0; + value.replace(locationMarker, (match: string, name: string, _: any, index: number): string => { + result[name] = index - adjustment; + adjustment += match.length; + return ''; + }); + return result; +} + +const referenceMarker = /«(((\w|\-)+)|([^∆]*∆(\w+)∆.[^»]*))»/g; +const definitionMarkerGroup = 1; +const nameMarkerGroup = 2; + +export type ReferenceMarkers = { + [name: string]: Span[] +}; +export interface ReferenceResult { + text: string; + definitions: ReferenceMarkers; + references: ReferenceMarkers; +} + +function getReferenceMarkers(value: string): ReferenceResult { + const references: ReferenceMarkers = {}; + const definitions: ReferenceMarkers = {}; + value = removeLocationMarkers(value); + + let adjustment = 0; + const text = value.replace( + referenceMarker, (match: string, text: string, reference: string, _: string, + definition: string, definitionName: string, index: number): string => { + const result = reference ? text : text.replace(/∆/g, ''); + const span: Span = {start: index - adjustment, end: index - adjustment + result.length}; + const markers = reference ? references : definitions; + const name = reference || definitionName; + (markers[name] = (markers[name] || [])).push(span); + adjustment += match.length - result.length; + return result; + }); + + return {text, definitions, references}; +} + +function removeReferenceMarkers(value: string): string { + return value.replace(referenceMarker, (match, text) => text.replace(/∆/g, '')); +} + +export function noDiagnostics(diagnostics: Diagnostics) { + if (diagnostics && diagnostics.length) { + throw new Error(`Unexpected diagnostics: \n ${diagnostics.map(d => d.message).join('\n ')}`); + } +} + +export function includeDiagnostic( + diagnostics: Diagnostics, message: string, text?: string, len?: string): void; +export function includeDiagnostic( + diagnostics: Diagnostics, message: string, at?: number, len?: number): void; +export function includeDiagnostic(diagnostics: Diagnostics, message: string, p1?: any, p2?: any) { + expect(diagnostics).toBeDefined(); + if (diagnostics) { + const diagnostic = diagnostics.find(d => d.message.indexOf(message) >= 0) as Diagnostic; + expect(diagnostic).toBeDefined(); + if (diagnostic && p1 != null) { + const at = typeof p1 === 'number' ? p1 : p2.indexOf(p1); + const len = typeof p2 === 'number' ? p2 : p1.length; + expect(diagnostic.span.start).toEqual(at); + if (len != null) { + expect(diagnostic.span.end - diagnostic.span.start).toEqual(len); + } + } + } +} \ No newline at end of file diff --git a/modules/@angular/language-service/test/ts_plugin_spec.ts b/modules/@angular/language-service/test/ts_plugin_spec.ts new file mode 100644 index 0000000000..d1b8106130 --- /dev/null +++ b/modules/@angular/language-service/test/ts_plugin_spec.ts @@ -0,0 +1,264 @@ +/** + * @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 'reflect-metadata'; + +import * as ts from 'typescript'; + +import {LanguageServicePlugin} from '../src/ts_plugin'; + +import {toh} from './test_data'; +import {MockTypescriptHost} from './test_utils'; + +describe('plugin', () => { + let documentRegistry = ts.createDocumentRegistry(); + let mockHost = new MockTypescriptHost(['/app/main.ts', '/app/parsing-cases.ts'], toh); + let service = ts.createLanguageService(mockHost, documentRegistry); + let program = service.getProgram(); + + it('should not report errors on tour of heroes', () => { + expectNoDiagnostics(service.getCompilerOptionsDiagnostics()); + for (let source of program.getSourceFiles()) { + expectNoDiagnostics(service.getSyntacticDiagnostics(source.fileName)); + expectNoDiagnostics(service.getSemanticDiagnostics(source.fileName)); + } + }); + + let plugin = + new LanguageServicePlugin({ts: ts, host: mockHost, service, registry: documentRegistry}); + + it('should not report template errors on tour of heroes', () => { + for (let source of program.getSourceFiles()) { + // Ignore all 'cases.ts' files as they intentionally contain errors. + if (!source.fileName.endsWith('cases.ts')) { + expectNoDiagnostics(plugin.getSemanticDiagnosticsFilter(source.fileName, [])); + } + } + }); + + it('should be able to get entity completions', + () => { contains('app/app.component.ts', 'entity-amp', '&', '>', '<', 'ι'); }); + + it('should be able to return html elements', () => { + let htmlTags = ['h1', 'h2', 'div', 'span']; + let locations = ['empty', 'start-tag-h1', 'h1-content', 'start-tag', 'start-tag-after-h']; + for (let location of locations) { + contains('app/app.component.ts', location, ...htmlTags); + } + }); + + it('should be able to return element diretives', + () => { contains('app/app.component.ts', 'empty', 'my-app'); }); + + it('should be able to return h1 attributes', + () => { contains('app/app.component.ts', 'h1-after-space', 'id', 'dir', 'lang', 'onclick'); }); + + it('should be able to find common angular attributes', () => { + contains('app/app.component.ts', 'div-attributes', '(click)', '[ngClass]', '*ngIf', '*ngFor'); + }); + + it('should be able to returned attribute names with an incompete attribute', + () => { contains('app/parsing-cases.ts', 'no-value-attribute', 'id', 'dir', 'lang'); }); + + it('should be able to return attributes of an incomplete element', () => { + contains('app/parsing-cases.ts', 'incomplete-open-lt', 'a'); + contains('app/parsing-cases.ts', 'incomplete-open-a', 'a'); + contains('app/parsing-cases.ts', 'incomplete-open-attr', 'id', 'dir', 'lang'); + }); + + it('should be able to return completions with a missing closing tag', + () => { contains('app/parsing-cases.ts', 'missing-closing', 'h1', 'h2'); }); + + it('should be able to return common attributes of in an unknown tag', + () => { contains('app/parsing-cases.ts', 'unknown-element', 'id', 'dir', 'lang'); }); + + it('should be able to get the completions at the beginning of an interpolation', + () => { contains('app/app.component.ts', 'h2-hero', 'hero', 'title'); }); + + it('should not include private members of the of a class', + () => { contains('app/app.component.ts', 'h2-hero', '-internal'); }); + + it('should be able to get the completions at the end of an interpolation', + () => { contains('app/app.component.ts', 'sub-end', 'hero', 'title'); }); + + it('should be able to get the completions in a property read', + () => { contains('app/app.component.ts', 'h2-name', 'name', 'id'); }); + + it('should be able to get a list of pipe values', () => { + contains('app/parsing-cases.ts', 'before-pipe', 'lowercase', 'uppercase'); + contains('app/parsing-cases.ts', 'in-pipe', 'lowercase', 'uppercase'); + contains('app/parsing-cases.ts', 'after-pipe', 'lowercase', 'uppercase'); + }); + + it('should be able get completions in an empty interpolation', + () => { contains('app/parsing-cases.ts', 'empty-interpolation', 'title', 'subTitle'); }); + + describe('with attributes', () => { + it('should be able to complete property value', + () => { contains('app/parsing-cases.ts', 'property-binding-model', 'test'); }); + it('should be able to complete an event', + () => { contains('app/parsing-cases.ts', 'event-binding-model', 'modelChanged'); }); + it('should be able to complete a two-way binding', + () => { contains('app/parsing-cases.ts', 'two-way-binding-model', 'test'); }); + }); + + describe('with a *ngFor', () => { + it('should include a let for empty attribute', + () => { contains('app/parsing-cases.ts', 'for-empty', 'let'); }); + it('should not suggest any entries if in the name part of a let', + () => { expectEmpty('app/parsing-cases.ts', 'for-let-empty'); }); + it('should suggest NgForRow members for let initialization expression', () => { + contains( + 'app/parsing-cases.ts', 'for-let-i-equal', 'index', 'count', 'first', 'last', 'even', + 'odd'); + }); + it('should include a let', () => { contains('app/parsing-cases.ts', 'for-let', 'let'); }); + it('should include an "of"', () => { contains('app/parsing-cases.ts', 'for-of', 'of'); }); + it('should include field reference', + () => { contains('app/parsing-cases.ts', 'for-people', 'people'); }); + it('should include person in the let scope', + () => { contains('app/parsing-cases.ts', 'for-interp-person', 'person'); }); + // TODO: Enable when we can infer the element type of the ngFor + // it('should include determine person\'s type as Person', () => { + // contains('app/parsing-cases.ts', 'for-interp-name', 'name', 'age'); + // contains('app/parsing-cases.ts', 'for-interp-age', 'name', 'age'); + // }); + }); + + describe('for pipes', () => { + it('should be able to resolve lowercase', + () => { contains('app/expression-cases.ts', 'string-pipe', 'substring'); }); + }); + + describe('with references', () => { + it('should list references', + () => { contains('app/parsing-cases.ts', 'test-comp-content', 'test1', 'test2', 'div'); }); + it('should reference the component', + () => { contains('app/parsing-cases.ts', 'test-comp-after-test', 'name'); }); + // TODO: Enable when we have a flag that indicates the project targets the DOM + // it('should refernce the element if no component', () => { + // contains('app/parsing-cases.ts', 'test-comp-after-div', 'innerText'); + // }); + }); + + describe('for semantic errors', () => { + it('should report access to an unknown field', () => { + expectSemanticError( + 'app/expression-cases.ts', 'foo', + 'Identifier \'foo\' is not defined. The component declaration, template variable declarations, and element references do not contain such a member'); + }); + it('should report access to an unknown sub-field', () => { + expectSemanticError( + 'app/expression-cases.ts', 'nam', + 'Identifier \'nam\' is not defined. \'Person\' does not contain such a member'); + }); + it('should report access to a private member', () => { + expectSemanticError( + 'app/expression-cases.ts', 'myField', + 'Identifier \'myField\' refers to a private member of the component'); + }); + it('should report numeric operator erros', + () => { expectSemanticError('app/expression-cases.ts', 'mod', 'Expected a numeric type'); }); + describe('in ngFor', () => { + function expectError(locationMarker: string, message: string) { + expectSemanticError('app/ng-for-cases.ts', locationMarker, message); + } + it('should report an unknown field', () => { + expectError( + 'people_1', + 'Identifier \'people_1\' is not defined. The component declaration, template variable declarations, and element references do not contain such a member'); + }); + it('should report an unknown context reference', () => { + expectError('even_1', 'The template context does not defined a member called \'even_1\''); + }); + it('should report an unknown value in a key expression', () => { + expectError( + 'trackBy_1', + 'Identifier \'trackBy_1\' is not defined. The component declaration, template variable declarations, and element references do not contain such a member'); + }); + }); + describe('in ngIf', () => { + function expectError(locationMarker: string, message: string) { + expectSemanticError('app/ng-if-cases.ts', locationMarker, message); + } + it('should report an implicit context reference', () => { + expectError('implicit', 'The template context does not have an implicit value'); + }); + }); + }); + + function getMarkerLocation(fileName: string, locationMarker: string): number { + const location = mockHost.getMarkerLocations(fileName)[locationMarker]; + if (location == null) { + throw new Error(`No marker ${locationMarker} found.`); + } + return location; + } + function contains(fileName: string, locationMarker: string, ...names: string[]) { + const location = getMarkerLocation(fileName, locationMarker); + expectEntries(locationMarker, plugin.getCompletionsAtPosition(fileName, location), ...names); + } + + function expectEmpty(fileName: string, locationMarker: string) { + const location = getMarkerLocation(fileName, locationMarker); + expect(plugin.getCompletionsAtPosition(fileName, location).entries).toEqual([]); + } + + function expectSemanticError(fileName: string, locationMarker: string, message: string) { + const start = getMarkerLocation(fileName, locationMarker); + const end = getMarkerLocation(fileName, locationMarker + '-end'); + const errors = plugin.getSemanticDiagnosticsFilter(fileName, []); + for (const error of errors) { + if (error.messageText.toString().indexOf(message) >= 0) { + expect(error.start).toEqual(start); + expect(error.length).toEqual(end - start); + return; + } + } + throw new Error( + `Expected error messages to contain ${message}, in messages:\n ${errors.map(e => e.messageText.toString()).join(',\n ')}`); + } +}); + + +function expectEntries(locationMarker: string, info: ts.CompletionInfo, ...names: string[]) { + let entries: {[name: string]: boolean} = {}; + if (!info) { + throw new Error( + `Expected result from ${locationMarker} to include ${names.join(', ')} but no result provided`); + } else { + for (let entry of info.entries) { + entries[entry.name] = true; + } + let shouldContains = names.filter(name => !name.startsWith('-')); + let shouldNotContain = names.filter(name => name.startsWith('-')); + let missing = shouldContains.filter(name => !entries[name]); + let present = shouldNotContain.map(name => name.substr(1)).filter(name => entries[name]); + if (missing.length) { + throw new Error( + `Expected result from ${locationMarker} to include at least one of the following, ${missing.join(', ')}, in the list of entries ${info.entries.map(entry => entry.name).join(', ')}`); + } + if (present.length) { + throw new Error( + `Unexpected member${present.length > 1 ? 's': ''} included in result: ${present.join(', ')}`); + } + } +} + +function expectNoDiagnostics(diagnostics: ts.Diagnostic[]) { + for (const diagnostic of diagnostics) { + let message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); + if (diagnostic.start) { + let {line, character} = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); + console.log(`${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`); + } else { + console.log(`${message}`); + } + } + expect(diagnostics.length).toBe(0); +} diff --git a/modules/@angular/language-service/tsconfig-build.json b/modules/@angular/language-service/tsconfig-build.json new file mode 100644 index 0000000000..60921ad99f --- /dev/null +++ b/modules/@angular/language-service/tsconfig-build.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "declaration": true, + "stripInternal": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "module": "es2015", + "moduleResolution": "node", + "outDir": "../../../dist/packages-dist/language-service", + "paths": { + "@angular/core": ["../../../dist/packages-dist/core"], + "@angular/core/testing": ["../../../dist/packages-dist/core/testing"], + "@angular/core/testing/*": ["../../../dist/packages-dist/core/testing/*"], + "@angular/common": ["../../../dist/packages-dist/common"], + "@angular/compiler": ["../../../dist/packages-dist/compiler"], + "@angular/compiler/*": ["../../../dist/packages-dist/compiler/*"], + "@angular/platform-server": ["../../../dist/packages-dist/platform-server"], + "@angular/platform-browser": ["../../../dist/packages-dist/platform-browser"], + "@angular/tsc-wrapped": ["../../../dist/tools/@angular/tsc-wrapped"], + "@angular/tsc-wrapped/*": ["../../../dist/tools/@angular/tsc-wrapped/*"] + }, + "rootDir": ".", + "sourceMap": true, + "inlineSources": true, + "target": "es5", + "skipLibCheck": true, + "lib": ["es2015", "dom"] + }, + "files": [ + "index.ts", + "../../../node_modules/zone.js/dist/zone.js.d.ts", + "../../../node_modules/@types/node/index.d.ts", + "../../../node_modules/@types/jasmine/index.d.ts" + ] +} diff --git a/modules/tsconfig.json b/modules/tsconfig.json index 7f17032505..a7acf27b4b 100644 --- a/modules/tsconfig.json +++ b/modules/tsconfig.json @@ -13,7 +13,8 @@ "selenium-webdriver": ["../node_modules/@types/selenium-webdriver/index.d.ts"], "rxjs/*": ["../node_modules/rxjs/*"], "@angular/*": ["./@angular/*"], - "@angular/tsc-wrapped": ["../dist/tools/@angular/tsc-wrapped"] + "@angular/tsc-wrapped": ["../dist/tools/@angular/tsc-wrapped"], + "@angular/tsc-wrapped/*": ["../dist/tools/@angular/tsc-wrapped/*"] }, "rootDir": ".", "inlineSourceMap": true, diff --git a/npm-shrinkwrap.clean.json b/npm-shrinkwrap.clean.json index de8621d759..dc43bdbcd4 100644 --- a/npm-shrinkwrap.clean.json +++ b/npm-shrinkwrap.clean.json @@ -1853,6 +1853,9 @@ "esprima": { "version": "2.7.1" }, + "estree-walker": { + "version": "0.2.1" + }, "etag": { "version": "1.7.0" }, @@ -3309,6 +3312,9 @@ } } }, + "magic-string": { + "version": "0.16.0" + }, "map-obj": { "version": "1.0.1" }, @@ -3851,6 +3857,20 @@ } } }, + "rollup-plugin-commonjs": { + "version": "5.0.5", + "dependencies": { + "acorn": { + "version": "4.0.3" + }, + "resolve": { + "version": "1.1.7" + } + } + }, + "rollup-pluginutils": { + "version": "1.5.2" + }, "rxjs": { "version": "5.0.0-beta.12" }, @@ -4382,6 +4402,9 @@ } } }, + "vlq": { + "version": "0.2.1" + }, "vm-browserify": { "version": "0.0.4" }, diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 9b4319ee1c..a79e1b9a18 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -110,7 +110,8 @@ }, "amdefine": { "version": "1.0.1", - "from": "amdefine@>=0.0.4" + "from": "amdefine@1.0.1", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz" }, "angular": { "version": "1.5.0", @@ -2588,7 +2589,7 @@ }, "core-js": { "version": "2.4.1", - "from": "core-js@2.4.1", + "from": "core-js@latest", "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.4.1.tgz" }, "core-util-is": { @@ -2910,6 +2911,11 @@ "from": "esprima@>=2.6.0 <3.0.0", "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.1.tgz" }, + "estree-walker": { + "version": "0.2.1", + "from": "estree-walker@>=0.2.1 <0.3.0", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.2.1.tgz" + }, "etag": { "version": "1.7.0", "from": "etag@>=1.7.0 <1.8.0", @@ -4262,7 +4268,8 @@ "dependencies": { "amdefine": { "version": "1.0.1", - "from": "amdefine@>=0.0.4" + "from": "amdefine@>=0.0.4", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz" } } } @@ -4559,7 +4566,7 @@ }, "jasmine": { "version": "2.4.1", - "from": "jasmine@>=2.4.0 <2.5.0", + "from": "jasmine@2.4.1", "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-2.4.1.tgz", "dependencies": { "glob": { @@ -4752,7 +4759,8 @@ "dependencies": { "amdefine": { "version": "1.0.1", - "from": "amdefine@>=0.0.4" + "from": "amdefine@>=0.0.4", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz" } } } @@ -5256,6 +5264,11 @@ } } }, + "magic-string": { + "version": "0.16.0", + "from": "magic-string@>=0.16.0 <0.17.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.16.0.tgz" + }, "map-obj": { "version": "1.0.1", "from": "map-obj@>=1.0.0 <2.0.0", @@ -6116,7 +6129,8 @@ "dependencies": { "amdefine": { "version": "1.0.1", - "from": "amdefine@>=0.0.4" + "from": "amdefine@>=0.0.4", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz" } } }, @@ -6137,6 +6151,28 @@ } } }, + "rollup-plugin-commonjs": { + "version": "5.0.5", + "from": "https://registry.npmjs.org/rollup-plugin-commonjs/-/rollup-plugin-commonjs-5.0.5.tgz", + "resolved": "https://registry.npmjs.org/rollup-plugin-commonjs/-/rollup-plugin-commonjs-5.0.5.tgz", + "dependencies": { + "acorn": { + "version": "4.0.3", + "from": "acorn@>=4.0.1 <5.0.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-4.0.3.tgz" + }, + "resolve": { + "version": "1.1.7", + "from": "resolve@>=1.1.7 <2.0.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz" + } + } + }, + "rollup-pluginutils": { + "version": "1.5.2", + "from": "rollup-pluginutils@>=1.5.1 <2.0.0", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-1.5.2.tgz" + }, "rxjs": { "version": "5.0.0-beta.12", "from": "rxjs@5.0.0-beta.12", @@ -6574,7 +6610,7 @@ }, "through2": { "version": "0.6.5", - "from": "through2@>=0.6.3 <0.7.0", + "from": "through2@>=0.6.5 <0.7.0", "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz", "dependencies": { "readable-stream": { @@ -6718,6 +6754,7 @@ "tsickle": { "version": "0.2.2", "from": "tsickle@0.2.2", + "resolved": "https://registry.npmjs.org/tsickle/-/tsickle-0.2.2.tgz", "dependencies": { "source-map": { "version": "0.5.6", @@ -6985,6 +7022,11 @@ } } }, + "vlq": { + "version": "0.2.1", + "from": "vlq@>=0.2.1 <0.3.0", + "resolved": "https://registry.npmjs.org/vlq/-/vlq-0.2.1.tgz" + }, "vm-browserify": { "version": "0.0.4", "from": "vm-browserify@>=0.0.1 <0.1.0", diff --git a/package.json b/package.json index 69052d4a30..641ee10cdc 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "react": "^0.14.0", "rewire": "^2.3.3", "rollup": "^0.26.3", + "rollup-plugin-commonjs": "^5.0.5", "selenium-webdriver": "^2.53.3", "semver": "^5.1.0", "source-map": "^0.3.0", diff --git a/scripts/ci-lite/build.sh b/scripts/ci-lite/build.sh index d960503eef..4952ffc621 100755 --- a/scripts/ci-lite/build.sh +++ b/scripts/ci-lite/build.sh @@ -17,5 +17,6 @@ node dist/tools/@angular/tsc-wrapped/src/main -p modules/@angular/core/tsconfig- node dist/tools/@angular/tsc-wrapped/src/main -p modules/@angular/common/tsconfig-build.json node dist/tools/@angular/tsc-wrapped/src/main -p modules/@angular/platform-browser/tsconfig-build.json node dist/tools/@angular/tsc-wrapped/src/main -p modules/@angular/router/tsconfig-build.json +node dist/tools/@angular/tsc-wrapped/src/main -p modules/@angular/forms/tsconfig-build.json echo 'travis_fold:end:BUILD'