diff --git a/packages/language-service/ivy/definitions.ts b/packages/language-service/ivy/definitions.ts index edd736a617..a5a4d4ea93 100644 --- a/packages/language-service/ivy/definitions.ts +++ b/packages/language-service/ivy/definitions.ts @@ -6,43 +6,43 @@ * found in the LICENSE file at https://angular.io/license */ -import {AST, TmplAstNode} from '@angular/compiler'; +import {AST, TmplAstNode, TmplAstTextAttribute} from '@angular/compiler'; import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; import {ShimLocation, Symbol, SymbolKind} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; import * as ts from 'typescript'; import {findNodeAtPosition} from './hybrid_visitor'; -import {getTemplateInfoAtPosition, getTextSpanOfNode, isDollarEvent, toTextSpan} from './utils'; +import {flatMap, getDirectiveMatchesForAttribute, getDirectiveMatchesForElementTag, getTemplateInfoAtPosition, getTextSpanOfNode, isDollarEvent, TemplateInfo, toTextSpan} from './utils'; + +interface DefinitionMeta { + node: AST|TmplAstNode; + symbol: Symbol; +} + +interface HasShimLocation { + shimLocation: ShimLocation; +} export class DefinitionBuilder { constructor(private readonly tsLS: ts.LanguageService, private readonly compiler: NgCompiler) {} - // TODO(atscott): getTypeDefinitionAtPosition - getDefinitionAndBoundSpan(fileName: string, position: number): ts.DefinitionInfoAndBoundSpan |undefined { const templateInfo = getTemplateInfoAtPosition(fileName, position, this.compiler); if (templateInfo === undefined) { - return undefined; + return; } - const {template, component} = templateInfo; - - const node = findNodeAtPosition(template, position); + const definitionMeta = this.getDefinitionMetaAtPosition(templateInfo, position); // The `$event` of event handlers would point to the $event parameter in the shim file, as in // `_outputHelper(_t3["x"]).subscribe(function ($event): any { $event }) ;` // If we wanted to return something for this, it would be more appropriate for something like // `getTypeDefinition`. - if (node === undefined || isDollarEvent(node)) { + if (definitionMeta === undefined || isDollarEvent(definitionMeta.node)) { return undefined; } - const symbol = this.compiler.getTemplateTypeChecker().getSymbolOfNode(node, component); - if (symbol === null) { - return undefined; - } - - const definitions = this.getDefinitionsForSymbol(symbol, node); - return {definitions, textSpan: getTextSpanOfNode(node)}; + const definitions = this.getDefinitionsForSymbol(definitionMeta.symbol, definitionMeta.node); + return {definitions, textSpan: getTextSpanOfNode(definitionMeta.node)}; } private getDefinitionsForSymbol(symbol: Symbol, node: TmplAstNode|AST): @@ -65,7 +65,7 @@ export class DefinitionBuilder { return []; case SymbolKind.Input: case SymbolKind.Output: - return this.getDefinitionsForSymbols(symbol.bindings); + return this.getDefinitionsForSymbols(...symbol.bindings); case SymbolKind.Variable: case SymbolKind.Reference: { const definitions: ts.DefinitionInfo[] = []; @@ -81,27 +81,86 @@ export class DefinitionBuilder { }); } if (symbol.kind === SymbolKind.Variable) { - definitions.push(...this.getDefinitionInfos(symbol.shimLocation)); + definitions.push(...this.getDefinitionsForSymbols(symbol)); } return definitions; } case SymbolKind.Expression: { - const {shimLocation} = symbol; - return this.getDefinitionInfos(shimLocation); + return this.getDefinitionsForSymbols(symbol); } } } - private getDefinitionsForSymbols(symbols: {shimLocation: ShimLocation}[]) { - const definitions: ts.DefinitionInfo[] = []; - for (const {shimLocation} of symbols) { - definitions.push(...this.getDefinitionInfos(shimLocation)); - } - return definitions; + private getDefinitionsForSymbols(...symbols: HasShimLocation[]): ts.DefinitionInfo[] { + return flatMap(symbols, ({shimLocation}) => { + const {shimPath, positionInShimFile} = shimLocation; + return this.tsLS.getDefinitionAtPosition(shimPath, positionInShimFile) ?? []; + }); } - private getDefinitionInfos({shimPath, positionInShimFile}: ShimLocation): - readonly ts.DefinitionInfo[] { - return this.tsLS.getDefinitionAtPosition(shimPath, positionInShimFile) ?? []; + getTypeDefinitionsAtPosition(fileName: string, position: number): + readonly ts.DefinitionInfo[]|undefined { + const templateInfo = getTemplateInfoAtPosition(fileName, position, this.compiler); + if (templateInfo === undefined) { + return; + } + const definitionMeta = this.getDefinitionMetaAtPosition(templateInfo, position); + if (definitionMeta === undefined) { + return undefined; + } + + const {symbol, node} = definitionMeta; + switch (symbol.kind) { + case SymbolKind.Template: { + const matches = getDirectiveMatchesForElementTag(symbol.templateNode, symbol.directives); + return this.getTypeDefinitionsForSymbols(...matches); + } + case SymbolKind.Element: { + const matches = getDirectiveMatchesForAttribute( + symbol.templateNode.name, symbol.templateNode, symbol.directives); + // If one of the directive matches is a component, we should not include the native element + // in the results because it is replaced by the component. + return Array.from(matches).some(dir => dir.isComponent) ? + this.getTypeDefinitionsForSymbols(...matches) : + this.getTypeDefinitionsForSymbols(...matches, symbol); + } + case SymbolKind.DomBinding: { + if (!(node instanceof TmplAstTextAttribute)) { + return []; + } + const dirs = getDirectiveMatchesForAttribute( + node.name, symbol.host.templateNode, symbol.host.directives); + return this.getTypeDefinitionsForSymbols(...dirs); + } + case SymbolKind.Output: + case SymbolKind.Input: + return this.getTypeDefinitionsForSymbols(...symbol.bindings); + case SymbolKind.Reference: + case SymbolKind.Directive: + case SymbolKind.Expression: + case SymbolKind.Variable: + return this.getTypeDefinitionsForSymbols(symbol); + } + } + + private getTypeDefinitionsForSymbols(...symbols: HasShimLocation[]): ts.DefinitionInfo[] { + return flatMap(symbols, ({shimLocation}) => { + const {shimPath, positionInShimFile} = shimLocation; + return this.tsLS.getTypeDefinitionAtPosition(shimPath, positionInShimFile) ?? []; + }); + } + + private getDefinitionMetaAtPosition({template, component}: TemplateInfo, position: number): + DefinitionMeta|undefined { + const node = findNodeAtPosition(template, position); + if (node === undefined) { + return; + } + + const symbol = this.compiler.getTemplateTypeChecker().getSymbolOfNode(node, component); + if (symbol === null) { + return; + } + return {node, symbol}; } } diff --git a/packages/language-service/ivy/language_service.ts b/packages/language-service/ivy/language_service.ts index 54efb64ece..fd550cdeec 100644 --- a/packages/language-service/ivy/language_service.ts +++ b/packages/language-service/ivy/language_service.ts @@ -60,6 +60,14 @@ export class LanguageService { return new DefinitionBuilder(this.tsLS, compiler).getDefinitionAndBoundSpan(fileName, position); } + getTypeDefinitionAtPosition(fileName: string, position: number): + readonly ts.DefinitionInfo[]|undefined { + const program = this.strategy.getProgram(); + const compiler = this.createCompiler(program, fileName); + return new DefinitionBuilder(this.tsLS, compiler) + .getTypeDefinitionsAtPosition(fileName, position); + } + getQuickInfoAtPosition(fileName: string, position: number): ts.QuickInfo|undefined { const program = this.strategy.getProgram(); const compiler = this.createCompiler(program, fileName); diff --git a/packages/language-service/ivy/quick_info.ts b/packages/language-service/ivy/quick_info.ts index 9673e0e54e..bafb2c814c 100644 --- a/packages/language-service/ivy/quick_info.ts +++ b/packages/language-service/ivy/quick_info.ts @@ -13,7 +13,7 @@ import * as ts from 'typescript'; import {createQuickInfo, SYMBOL_PUNC, SYMBOL_SPACE, SYMBOL_TEXT} from '../common/quick_info'; import {findNodeAtPosition} from './hybrid_visitor'; -import {filterAliasImports, getDirectiveMatches, getDirectiveMatchesForAttribute, getTemplateInfoAtPosition, getTextSpanOfNode} from './utils'; +import {filterAliasImports, getDirectiveMatchesForAttribute, getDirectiveMatchesForElementTag, getTemplateInfoAtPosition, getTextSpanOfNode} from './utils'; /** * The type of Angular directive. Used for QuickInfo in template. @@ -94,7 +94,7 @@ export class QuickInfoBuilder { private getQuickInfoForElementSymbol(symbol: ElementSymbol): ts.QuickInfo { const {templateNode} = symbol; - const matches = getDirectiveMatches(symbol.directives, templateNode.name); + const matches = getDirectiveMatchesForElementTag(templateNode, symbol.directives); if (matches.size > 0) { return this.getQuickInfoForDirectiveSymbol(matches.values().next().value, templateNode); } diff --git a/packages/language-service/ivy/test/definitions_spec.ts b/packages/language-service/ivy/test/definitions_spec.ts index 7dc8afe88c..8f4dc42364 100644 --- a/packages/language-service/ivy/test/definitions_spec.ts +++ b/packages/language-service/ivy/test/definitions_spec.ts @@ -11,6 +11,7 @@ import * as ts from 'typescript/lib/tsserverlibrary'; import {LanguageService} from '../language_service'; import {APP_COMPONENT, setup} from './mock_host'; +import {humanizeDefinitionInfo} from './test_utils'; describe('definitions', () => { const {project, service, tsLS} = setup(); @@ -404,17 +405,6 @@ describe('definitions', () => { expect(text.substring(textSpan.start, textSpan.start + textSpan.length)) .toEqual(expectedSpanText); expect(definitions).toBeTruthy(); - return definitions!.map(d => humanizeDefinitionInfo(d)); - } - - function humanizeDefinitionInfo(def: ts.DefinitionInfo) { - const snapshot = service.getScriptInfo(def.fileName).getSnapshot(); - return { - fileName: def.fileName, - textSpan: snapshot.getText(def.textSpan.start, def.textSpan.start + def.textSpan.length), - contextSpan: def.contextSpan ? - snapshot.getText(def.contextSpan.start, def.contextSpan.start + def.contextSpan.length) : - undefined, - }; + return definitions!.map(d => humanizeDefinitionInfo(d, service)); } }); diff --git a/packages/language-service/ivy/test/mock_host.ts b/packages/language-service/ivy/test/mock_host.ts index 134748489e..0d718ac8c7 100644 --- a/packages/language-service/ivy/test/mock_host.ts +++ b/packages/language-service/ivy/test/mock_host.ts @@ -110,7 +110,7 @@ interface OverwriteResult { text: string; } -class MockService { +export class MockService { private readonly overwritten = new Set(); constructor( diff --git a/packages/language-service/ivy/test/quick_info_spec.ts b/packages/language-service/ivy/test/quick_info_spec.ts index 15f5cc8e2a..2924df166f 100644 --- a/packages/language-service/ivy/test/quick_info_spec.ts +++ b/packages/language-service/ivy/test/quick_info_spec.ts @@ -28,6 +28,14 @@ describe('quick info', () => { expectedDisplayString: '(element) button: HTMLButtonElement' }); }); + + it('should work for directives which match native element tags', () => { + expectQuickInfo({ + templateOverride: ``, + expectedSpanText: '', + expectedDisplayString: '(directive) AppModule.CompoundCustomButtonDirective' + }); + }); }); describe('templates', () => { @@ -357,8 +365,7 @@ describe('quick info', () => { expect(documentation).toBe('This is the title of the `AppComponent` Component.'); }); - // TODO(atscott): Enable once #39065 is merged - xit('works with external template', () => { + it('works with external template', () => { const {position, text} = service.overwrite(TEST_TEMPLATE, ''); const quickInfo = ngLS.getQuickInfoAtPosition(TEST_TEMPLATE, position); expect(quickInfo).toBeTruthy(); diff --git a/packages/language-service/ivy/test/test_utils.ts b/packages/language-service/ivy/test/test_utils.ts new file mode 100644 index 0000000000..e4b62f8c61 --- /dev/null +++ b/packages/language-service/ivy/test/test_utils.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright Google LLC 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 {MockService} from './mock_host'; + +export interface HumanizedDefinitionInfo { + fileName: string; + textSpan: string; + contextSpan: string|undefined; +} + +export function humanizeDefinitionInfo( + def: ts.DefinitionInfo, service: MockService): HumanizedDefinitionInfo { + const snapshot = service.getScriptInfo(def.fileName).getSnapshot(); + return { + fileName: def.fileName, + textSpan: snapshot.getText(def.textSpan.start, def.textSpan.start + def.textSpan.length), + contextSpan: def.contextSpan ? + snapshot.getText(def.contextSpan.start, def.contextSpan.start + def.contextSpan.length) : + undefined, + }; +} diff --git a/packages/language-service/ivy/test/type_definitions_spec.ts b/packages/language-service/ivy/test/type_definitions_spec.ts new file mode 100644 index 0000000000..fd394b0e0e --- /dev/null +++ b/packages/language-service/ivy/test/type_definitions_spec.ts @@ -0,0 +1,314 @@ +/** + * @license + * Copyright Google LLC 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 {LanguageService} from '../language_service'; + +import {APP_COMPONENT, setup} from './mock_host'; +import {HumanizedDefinitionInfo, humanizeDefinitionInfo} from './test_utils'; + +describe('type definitions', () => { + const {project, service, tsLS} = setup(); + const ngLS = new LanguageService(project, tsLS); + + beforeEach(() => { + service.reset(); + }); + + describe('elements', () => { + it('should work for native elements', () => { + const defs = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride: ``, + }); + expect(defs.length).toEqual(2); + expect(defs[0].fileName).toContain('lib.dom.d.ts'); + expect(defs[0].contextSpan).toContain('interface HTMLButtonElement extends HTMLElement'); + expect(defs[1].contextSpan).toContain('declare var HTMLButtonElement'); + }); + + it('should return directives which match the element tag', () => { + const defs = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride: ``, + }); + expect(defs.length).toEqual(3); + expect(defs[0].contextSpan).toContain('export class CompoundCustomButtonDirective'); + expect(defs[1].contextSpan).toContain('interface HTMLButtonElement extends HTMLElement'); + expect(defs[2].contextSpan).toContain('declare var HTMLButtonElement'); + }); + }); + + describe('templates', () => { + it('should return no definitions for ng-templates', () => { + const {position} = + service.overwriteInlineTemplate(APP_COMPONENT, ``); + const defs = ngLS.getTypeDefinitionAtPosition(APP_COMPONENT, position); + expect(defs).toEqual([]); + }); + }); + + describe('directives', () => { + it('should work for directives', () => { + const definitions = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride: `
`, + }); + expect(definitions.length).toEqual(1); + expect(definitions[0].fileName).toContain('parsing-cases.ts'); + expect(definitions[0].textSpan).toEqual('StringModel'); + expect(definitions[0].contextSpan).toContain('@Directive'); + }); + + it('should work for components', () => { + const definitions = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride: ``, + }); + expect(definitions.length).toEqual(1); + expect(definitions[0].textSpan).toEqual('TestComponent'); + expect(definitions[0].contextSpan).toContain('@Component'); + }); + + it('should work for structural directives', () => { + const definitions = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride: `
`, + }); + expect(definitions.length).toEqual(1); + expect(definitions[0].fileName).toContain('ng_for_of.d.ts'); + expect(definitions[0].textSpan).toEqual('NgForOf'); + expect(definitions[0].contextSpan) + .toContain( + 'export declare class NgForOf = NgIterable> implements DoCheck'); + }); + + it('should work for directives with compound selectors', () => { + let defs = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride: ``, + }); + expect(defs.length).toEqual(1); + expect(defs[0].contextSpan).toContain('export class CompoundCustomButtonDirective'); + defs = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride: ``, + }); + expect(defs.length).toEqual(1); + expect(defs[0].contextSpan).toContain('export class CompoundCustomButtonDirective'); + }); + }); + + describe('bindings', () => { + describe('inputs', () => { + it('should return something for input providers with non-primitive types', () => { + const defs = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride: ``, + }); + expect(defs.length).toEqual(1); + expect(defs[0].textSpan).toEqual('{color?: string}'); + }); + + it('should work for structural directive inputs ngForTrackBy', () => { + const definitions = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride: `
`, + }); + expect(definitions!.length).toEqual(1); + + const [def] = definitions; + expect(def.textSpan).toEqual('TrackByFunction'); + expect(def.contextSpan).toContain('export interface TrackByFunction'); + }); + + it('should work for structural directive inputs ngForOf', () => { + const definitions = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride: `
`, + }); + expectAllArrayDefinitions(definitions); + }); + + it('should return nothing for two-way binding providers', () => { + const definitions = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride: ``, + }); + // TODO(atscott): This should actually return EventEmitter type but we only match the input + // at the moment. + expect(definitions).toEqual([]); + }); + }); + + describe('outputs', () => { + it('should work for event providers', () => { + const definitions = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride: ``, + }); + expect(definitions!.length).toEqual(2); + + const [def, xyz] = definitions; + expect(def.textSpan).toEqual('EventEmitter'); + expect(def.contextSpan).toContain('export interface EventEmitter extends Subject'); + expect(xyz.textSpan).toEqual('EventEmitter'); + }); + }); + }); + + describe('references', () => { + it('should work for element references', () => { + const defs = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride: `
{{char¦t}}`, + }); + expect(defs.length).toEqual(2); + expect(defs[0].contextSpan).toContain('interface HTMLDivElement extends HTMLElement'); + expect(defs[1].contextSpan).toContain('declare var HTMLDivElement'); + }); + + it('should work for directive references', () => { + const defs = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride: `
`, + }); + expect(defs.length).toEqual(1); + expect(defs[0].contextSpan).toContain('@Directive'); + expect(defs[0].contextSpan).toContain('export class StringModel'); + }); + }); + + describe('variables', () => { + it('should work for array members', () => { + const definitions = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride: `
{{her¦o}}
`, + }); + expect(definitions!.length).toEqual(1); + + expect(definitions[0].textSpan).toEqual('Hero'); + expect(definitions[0].contextSpan).toContain('export interface Hero'); + }); + }); + + describe('pipes', () => { + it('should work for pipes', () => { + const templateOverride = `

The hero's birthday is {{birthday | da¦te: "MM/dd/yy"}}

`; + const definitions = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride, + }); + expect(definitions!.length).toEqual(1); + + const [def] = definitions; + expect(def.textSpan).toEqual('transform'); + expect(def.contextSpan).toContain('transform(value: Date'); + }); + }); + + describe('expressions', () => { + it('should return nothing for primitives', () => { + const definitions = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride: `
{{ tit¦le }}
`, + }); + expect(definitions!.length).toEqual(0); + }); + + // TODO(atscott): Investigate why this returns nothing in the test environment. This actually + // works in the extension. + xit('should work for functions on primitives', () => { + const definitions = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride: `
{{ title.toLower¦case() }}
`, + }); + expect(definitions!.length).toEqual(1); + expect(definitions[0].textSpan).toEqual('toLowerCase'); + expect(definitions[0].fileName).toContain('lib.es5.d.ts'); + }); + + it('should work for accessed property reads', () => { + const definitions = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride: `
{{heroes[0].addre¦ss}}
`, + }); + expect(definitions!.length).toEqual(1); + + const [def] = definitions; + expect(def.textSpan).toEqual('Address'); + expect(def.contextSpan).toContain('export interface Address'); + }); + + it('should work for $event', () => { + const definitions = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride: ``, + }); + expect(definitions!.length).toEqual(2); + + const [def1, def2] = definitions; + expect(def1.textSpan).toEqual('MouseEvent'); + expect(def1.contextSpan).toContain(`interface MouseEvent extends UIEvent`); + expect(def2.textSpan).toEqual('MouseEvent'); + expect(def2.contextSpan).toContain(`declare var MouseEvent:`); + }); + + it('should work for method calls', () => { + const definitions = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride: `
`, + }); + expect(definitions!.length).toEqual(1); + + const [def] = definitions; + expect(def.textSpan).toEqual('setTitle'); + expect(def.contextSpan).toContain('setTitle(newTitle: string)'); + }); + + it('should work for accessed properties in writes', () => { + const definitions = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride: `
`, + }); + expect(definitions!.length).toEqual(1); + + const [def] = definitions; + expect(def.textSpan).toEqual('Address'); + expect(def.contextSpan).toContain('export interface Address'); + }); + + it('should work for variables in structural directives', () => { + const definitions = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride: `
`, + }); + expectAllArrayDefinitions(definitions); + }); + + it('should work for uses of members in structural directives', () => { + const definitions = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride: `
{{her¦oes2}}
`, + }); + expectAllArrayDefinitions(definitions); + }); + + it('should work for members in structural directives', () => { + const definitions = getTypeDefinitionsAndAssertBoundSpan({ + templateOverride: `
`, + }); + expectAllArrayDefinitions(definitions); + }); + + it('should return nothing for the $any() cast function', () => { + const {position} = + service.overwriteInlineTemplate(APP_COMPONENT, `
{{$an¦y(title)}}
`); + const definitionAndBoundSpan = ngLS.getTypeDefinitionAtPosition(APP_COMPONENT, position); + expect(definitionAndBoundSpan).toBeUndefined(); + }); + }); + + function getTypeDefinitionsAndAssertBoundSpan({templateOverride}: {templateOverride: string}): + HumanizedDefinitionInfo[] { + const {position} = service.overwriteInlineTemplate(APP_COMPONENT, templateOverride); + const defs = ngLS.getTypeDefinitionAtPosition(APP_COMPONENT, position); + expect(defs).toBeTruthy(); + return defs!.map(d => humanizeDefinitionInfo(d, service)); + } + + function expectAllArrayDefinitions(definitions: HumanizedDefinitionInfo[]) { + expect(definitions!.length).toBeGreaterThan(0); + const actualTextSpans = new Set(definitions.map(d => d.textSpan)); + expect(actualTextSpans).toEqual(new Set(['Array'])); + const possibleFileNames = [ + 'lib.es5.d.ts', 'lib.es2015.core.d.ts', 'lib.es2015.iterable.d.ts', + 'lib.es2015.symbol.wellknown.d.ts', 'lib.es2016.array.include.d.ts' + ]; + for (const def of definitions) { + const fileName = def.fileName.split('/').slice(-1)[0]; + expect(possibleFileNames) + .toContain(fileName, `Expected ${fileName} to be one of: ${possibleFileNames}`); + } + } +}); diff --git a/packages/language-service/ivy/ts_plugin.ts b/packages/language-service/ivy/ts_plugin.ts index 8a238431ea..f76ad6c9f8 100644 --- a/packages/language-service/ivy/ts_plugin.ts +++ b/packages/language-service/ivy/ts_plugin.ts @@ -24,10 +24,6 @@ export function create(info: ts.server.PluginCreateInfo): ts.LanguageService { return diagnostics; } - function getTypeDefinitionAtPosition(fileName: string, position: number) { - return undefined; - } - function getQuickInfoAtPosition(fileName: string, position: number): ts.QuickInfo|undefined { if (angularOnly) { return ngLS.getQuickInfoAtPosition(fileName, position); @@ -38,6 +34,17 @@ export function create(info: ts.server.PluginCreateInfo): ts.LanguageService { } } + function getTypeDefinitionAtPosition( + fileName: string, position: number): readonly ts.DefinitionInfo[]|undefined { + if (angularOnly) { + return ngLS.getTypeDefinitionAtPosition(fileName, position); + } else { + // If TS could answer the query, then return that result. Otherwise, return from Angular LS. + return tsLS.getTypeDefinitionAtPosition(fileName, position) ?? + ngLS.getTypeDefinitionAtPosition(fileName, position); + } + } + function getDefinitionAndBoundSpan( fileName: string, position: number): ts.DefinitionInfoAndBoundSpan|undefined { if (angularOnly) { diff --git a/packages/language-service/ivy/utils.ts b/packages/language-service/ivy/utils.ts index b7c9bdc0ef..110e655a28 100644 --- a/packages/language-service/ivy/utils.ts +++ b/packages/language-service/ivy/utils.ts @@ -14,28 +14,6 @@ import * as ts from 'typescript'; import {ALIAS_NAME, SYMBOL_PUNC} from '../common/quick_info'; -/** - * Given a list of directives and a text to use as a selector, returns the directives which match - * for the selector. - */ -export function getDirectiveMatches( - directives: DirectiveSymbol[], selector: string): Set { - const selectorToMatch = CssSelector.parse(selector); - if (selectorToMatch.length === 0) { - return new Set(); - } - return new Set(directives.filter((dir: DirectiveSymbol) => { - if (dir.selector === null) { - return false; - } - - const matcher = new SelectorMatcher(); - matcher.addSelectables(CssSelector.parse(dir.selector)); - - return matcher.match(selectorToMatch[0], null); - })); -} - export function getTextSpanOfNode(node: t.Node|e.AST): ts.TextSpan { if (isTemplateNodeWithKeyAndValue(node)) { return toTextSpan(node.keySpan); @@ -162,45 +140,108 @@ function getInlineTemplateInfoAtPosition( } /** - * Given an attribute name and the element or template the attribute appears on, determines which - * directives match because the attribute is present. That is, we find which directives are applied - * because of this attribute by elimination: compare the directive matches with the attribute - * present against the directive matches without it. The difference would be the directives which - * match because the attribute is present. - * - * @param attribute The attribute name to use for directive matching. - * @param hostNode The element or template node that the attribute is on. - * @param directives The list of directives to match against. - * @returns The list of directives matching the attribute via the strategy described above. + * Given an attribute node, converts it to string form. */ -export function getDirectiveMatchesForAttribute( - attribute: string, hostNode: t.Template|t.Element, - directives: DirectiveSymbol[]): Set { - const attributes: Array = - [...hostNode.attributes, ...hostNode.inputs]; - if (hostNode instanceof t.Template) { - attributes.push(...hostNode.templateAttrs); - } - function toAttributeString(a: t.TextAttribute|t.BoundAttribute) { - return `[${a.name}=${a.valueSpan?.toString() ?? ''}]`; - } - const attrs = attributes.map(toAttributeString); - const attrsOmit = attributes.map(a => a.name === attribute ? '' : toAttributeString(a)); +function toAttributeString(attribute: t.TextAttribute|t.BoundAttribute): string { + return `[${attribute.name}=${attribute.valueSpan?.toString() ?? ''}]`; +} - const hostNodeName = hostNode instanceof t.Template ? hostNode.tagName : hostNode.name; - const directivesWithAttribute = getDirectiveMatches(directives, hostNodeName + attrs.join('')); - const directivesWithoutAttribute = - getDirectiveMatches(directives, hostNodeName + attrsOmit.join('')); +function getNodeName(node: t.Template|t.Element): string { + return node instanceof t.Template ? node.tagName : node.name; +} - const result = new Set(); - for (const dir of directivesWithAttribute) { - if (!directivesWithoutAttribute.has(dir)) { +/** + * Given a template or element node, returns all attributes on the node. + */ +function getAttributes(node: t.Template|t.Element): Array { + const attributes: Array = [...node.attributes, ...node.inputs]; + if (node instanceof t.Template) { + attributes.push(...node.templateAttrs); + } + return attributes; +} + +/** + * Given two `Set`s, returns all items in the `left` which do not appear in the `right`. + */ +function difference(left: Set, right: Set): Set { + const result = new Set(); + for (const dir of left) { + if (!right.has(dir)) { result.add(dir); } } return result; } +/** + * Given an element or template, determines which directives match because the tag is present. For + * example, if a directive selector is `div[myAttr]`, this would match div elements but would not if + * the selector were just `[myAttr]`. We find which directives are applied because of this tag by + * elimination: compare the directive matches with the tag present against the directive matches + * without it. The difference would be the directives which match because the tag is present. + * + * @param element The element or template node that the attribute/tag is part of. + * @param directives The list of directives to match against. + * @returns The list of directives matching the tag name via the strategy described above. + */ +// TODO(atscott): Add unit tests for this and the one for attributes +export function getDirectiveMatchesForElementTag( + element: t.Template|t.Element, directives: DirectiveSymbol[]): Set { + const attributes = getAttributes(element); + const allAttrs = attributes.map(toAttributeString); + const allDirectiveMatches = + getDirectiveMatchesForSelector(directives, getNodeName(element) + allAttrs.join('')); + const matchesWithoutElement = getDirectiveMatchesForSelector(directives, allAttrs.join('')); + return difference(allDirectiveMatches, matchesWithoutElement); +} + +/** + * Given an attribute name, determines which directives match because the attribute is present. We + * find which directives are applied because of this attribute by elimination: compare the directive + * matches with the attribute present against the directive matches without it. The difference would + * be the directives which match because the attribute is present. + * + * @param name The name of the attribute + * @param hostNode The node which the attribute appears on + * @param directives The list of directives to match against. + * @returns The list of directives matching the tag name via the strategy described above. + */ +export function getDirectiveMatchesForAttribute( + name: string, hostNode: t.Template|t.Element, + directives: DirectiveSymbol[]): Set { + const attributes = getAttributes(hostNode); + const allAttrs = attributes.map(toAttributeString); + const allDirectiveMatches = + getDirectiveMatchesForSelector(directives, getNodeName(hostNode) + allAttrs.join('')); + const attrsExcludingName = attributes.filter(a => a.name !== name).map(toAttributeString); + const matchesWithoutAttr = + getDirectiveMatchesForSelector(directives, attrsExcludingName.join('')); + return difference(allDirectiveMatches, matchesWithoutAttr); +} + +/** + * Given a list of directives and a text to use as a selector, returns the directives which match + * for the selector. + */ +function getDirectiveMatchesForSelector( + directives: DirectiveSymbol[], selector: string): Set { + const selectors = CssSelector.parse(selector); + if (selectors.length === 0) { + return new Set(); + } + return new Set(directives.filter((dir: DirectiveSymbol) => { + if (dir.selector === null) { + return false; + } + + const matcher = new SelectorMatcher(); + matcher.addSelectables(CssSelector.parse(dir.selector)); + + return selectors.some(selector => matcher.match(selector, null)); + })); +} + /** * Returns a new `ts.SymbolDisplayPart` array which has the alias imports from the tcb filtered * out, i.e. `i0.NgForOf`. @@ -231,3 +272,15 @@ export function isDollarEvent(n: t.Node|e.AST): n is e.PropertyRead { return n instanceof e.PropertyRead && n.name === '$event' && n.receiver instanceof e.ImplicitReceiver; } + +/** + * Returns a new array formed by applying a given callback function to each element of the array, + * and then flattening the result by one level. + */ +export function flatMap(items: T[]|readonly T[], f: (item: T) => R[] | readonly R[]): R[] { + const results: R[] = []; + for (const x of items) { + results.push(...f(x)); + } + return results; +} diff --git a/packages/language-service/test/project/app/app.component.ts b/packages/language-service/test/project/app/app.component.ts index ccf6a9c1f0..1bcbbe95eb 100644 --- a/packages/language-service/test/project/app/app.component.ts +++ b/packages/language-service/test/project/app/app.component.ts @@ -8,10 +8,15 @@ import {Component} from '@angular/core'; +export interface Address { + streetName: string; +} + /** The most heroic being. */ export interface Hero { id: number; name: string; + address?: Address; } @Component({ diff --git a/packages/language-service/test/project/app/main.ts b/packages/language-service/test/project/app/main.ts index 3206ad2be6..331a79cd3b 100644 --- a/packages/language-service/test/project/app/main.ts +++ b/packages/language-service/test/project/app/main.ts @@ -24,6 +24,7 @@ import * as ParsingCases from './parsing-cases'; ParsingCases.TestComponent, ParsingCases.TestPipe, ParsingCases.WithContextDirective, + ParsingCases.CompoundCustomButtonDirective, ] }) export class AppModule { diff --git a/packages/language-service/test/project/app/parsing-cases.ts b/packages/language-service/test/project/app/parsing-cases.ts index 0d478db136..74004c02c6 100644 --- a/packages/language-service/test/project/app/parsing-cases.ts +++ b/packages/language-service/test/project/app/parsing-cases.ts @@ -12,6 +12,7 @@ import {Hero} from './app.component'; @Directive({ selector: '[string-model]', + exportAs: 'stringModel', }) export class StringModel { @Input() model: string = 'model'; @@ -69,6 +70,11 @@ export class WithContextDirective { } } +@Directive({selector: 'button[custom-button][compound]'}) +export class CompoundCustomButtonDirective { + @Input() config?: {color?: string}; +} + @Pipe({ name: 'prefixPipe', })