elements.');
+ });
+
+ it('should work for ref- syntax', () => {
+ expectQuickInfo({
+ templateOverride: `
`,
+ expectedSpanText: 'ref-chart',
+ expectedDisplayString: '(reference) chart: HTMLDivElement'
+ });
+ expectQuickInfo({
+ templateOverride: `
`,
+ expectedSpanText: 'data-ref-chart',
+ expectedDisplayString: '(reference) chart: HTMLDivElement'
+ });
+ });
+ });
+
+ describe('variables', () => {
+ it('should work for array members', () => {
+ const {documentation} = expectQuickInfo({
+ templateOverride: `
{{her¦o}}
`,
+ expectedSpanText: 'hero',
+ expectedDisplayString: '(variable) hero: Hero'
+ });
+ expect(toText(documentation)).toEqual('The most heroic being.');
+ });
+
+ it('should work for ReadonlyArray members (#36191)', () => {
+ expectQuickInfo({
+ templateOverride: `
{{her¦o}}
`,
+ expectedSpanText: 'hero',
+ expectedDisplayString: '(variable) hero: Readonly
'
+ });
+ });
+
+ it('should work for const array members (#36191)', () => {
+ expectQuickInfo({
+ templateOverride: `{{na¦me}}
`,
+ expectedSpanText: 'name',
+ expectedDisplayString: '(variable) name: { readonly name: "name"; }'
+ });
+ });
+ });
+
+ describe('pipes', () => {
+ it('should work for pipes', () => {
+ const templateOverride = `The hero's birthday is {{birthday | da¦te: "MM/dd/yy"}}
`;
+ expectQuickInfo({
+ templateOverride,
+ expectedSpanText: 'date',
+ expectedDisplayString:
+ '(pipe) DatePipe.transform(value: string | number | Date, format?: string | undefined, timezone?: ' +
+ 'string | undefined, locale?: string | undefined): string | null (+2 overloads)'
+ });
+ });
+ });
+
+ describe('expressions', () => {
+ it('should find members in a text interpolation', () => {
+ expectQuickInfo({
+ templateOverride: `{{ tit¦le }}
`,
+ expectedSpanText: 'title',
+ expectedDisplayString: '(property) AppComponent.title: string'
+ });
+ });
+
+ it('should work for accessed property reads', () => {
+ expectQuickInfo({
+ templateOverride: `{{title.len¦gth}}
`,
+ expectedSpanText: 'length',
+ expectedDisplayString: '(property) String.length: number'
+ });
+ });
+
+ it('should find members in an attribute interpolation', () => {
+ expectQuickInfo({
+ templateOverride: ``,
+ expectedSpanText: 'title',
+ expectedDisplayString: '(property) AppComponent.title: string'
+ });
+ });
+
+ it('should find members of input binding', () => {
+ expectQuickInfo({
+ templateOverride: ``,
+ expectedSpanText: 'title',
+ expectedDisplayString: '(property) AppComponent.title: string'
+ });
+ });
+
+ it('should find input binding on text attribute', () => {
+ expectQuickInfo({
+ templateOverride: ``,
+ expectedSpanText: 'tcName="title"',
+ expectedDisplayString: '(property) TestComponent.name: string'
+ });
+ });
+
+ it('should find members of event binding', () => {
+ expectQuickInfo({
+ templateOverride: ``,
+ expectedSpanText: 'title',
+ expectedDisplayString: '(property) AppComponent.title: string'
+ });
+ });
+
+ it('should work for method calls', () => {
+ expectQuickInfo({
+ templateOverride: ``,
+ expectedSpanText: 'setTitle',
+ expectedDisplayString: '(method) AppComponent.setTitle(newTitle: string): void'
+ });
+ });
+
+ it('should work for accessed properties in writes', () => {
+ expectQuickInfo({
+ templateOverride: ``,
+ expectedSpanText: 'id',
+ expectedDisplayString: '(property) Hero.id: number'
+ });
+ });
+
+ it('should work for method call arguments', () => {
+ expectQuickInfo({
+ templateOverride: ``,
+ expectedSpanText: 'name',
+ expectedDisplayString: '(property) Hero.name: string'
+ });
+ });
+
+ it('should find members of two-way binding', () => {
+ expectQuickInfo({
+ templateOverride: ``,
+ expectedSpanText: 'title',
+ expectedDisplayString: '(property) AppComponent.title: string'
+ });
+ });
+
+ it('should find members in a structural directive', () => {
+ expectQuickInfo({
+ templateOverride: ``,
+ expectedSpanText: 'anyValue',
+ expectedDisplayString: '(property) AppComponent.anyValue: any'
+ });
+ });
+
+ it('should work for members in structural directives', () => {
+ expectQuickInfo({
+ templateOverride: ``,
+ expectedSpanText: 'heroes',
+ expectedDisplayString: '(property) AppComponent.heroes: Hero[]'
+ });
+ });
+
+ it('should work for the $any() cast function', () => {
+ expectQuickInfo({
+ templateOverride: `{{$an¦y(title)}}
`,
+ expectedSpanText: '$any',
+ expectedDisplayString: '(method) $any: any'
+ });
+ });
+
+ it('should provide documentation', () => {
+ const {position} = service.overwriteInlineTemplate(APP_COMPONENT, `{{¦title}}
`);
+ const quickInfo = ngLS.getQuickInfoAtPosition(APP_COMPONENT, position);
+ const documentation = toText(quickInfo!.documentation);
+ expect(documentation).toBe('This is the title of the `AppComponent` Component.');
+ });
+
+ // TODO(atscott): Enable once #39065 is merged
+ xit('works with external template', () => {
+ const {position, text} = service.overwrite(TEST_TEMPLATE, '');
+ const quickInfo = ngLS.getQuickInfoAtPosition(TEST_TEMPLATE, position);
+ expect(quickInfo).toBeTruthy();
+ const {textSpan, displayParts} = quickInfo!;
+ expect(text.substring(textSpan.start, textSpan.start + textSpan.length))
+ .toEqual('');
+ expect(toText(displayParts)).toEqual('(element) button: HTMLButtonElement');
+ });
+ });
+
+ function expectQuickInfo(
+ {templateOverride, expectedSpanText, expectedDisplayString}:
+ {templateOverride: string, expectedSpanText: string, expectedDisplayString: string}):
+ ts.QuickInfo {
+ const {position, text} = service.overwriteInlineTemplate(APP_COMPONENT, templateOverride);
+ const quickInfo = ngLS.getQuickInfoAtPosition(APP_COMPONENT, position);
+ expect(quickInfo).toBeTruthy();
+ const {textSpan, displayParts} = quickInfo!;
+ expect(text.substring(textSpan.start, textSpan.start + textSpan.length))
+ .toEqual(expectedSpanText);
+ expect(toText(displayParts)).toEqual(expectedDisplayString);
+ return quickInfo!;
+ }
+});
+
+function toText(displayParts?: ts.SymbolDisplayPart[]): string {
+ return (displayParts || []).map(p => p.text).join('');
+}
diff --git a/packages/language-service/ivy/ts_plugin.ts b/packages/language-service/ivy/ts_plugin.ts
index a575542b38..67941a7796 100644
--- a/packages/language-service/ivy/ts_plugin.ts
+++ b/packages/language-service/ivy/ts_plugin.ts
@@ -28,9 +28,20 @@ export function create(info: ts.server.PluginCreateInfo): ts.LanguageService {
return undefined;
}
+ function getQuickInfoAtPosition(fileName: string, position: number): ts.QuickInfo|undefined {
+ if (angularOnly) {
+ return ngLS.getQuickInfoAtPosition(fileName, position);
+ } else {
+ // If TS could answer the query, then return that result. Otherwise, return from Angular LS.
+ return tsLS.getQuickInfoAtPosition(fileName, position) ??
+ ngLS.getQuickInfoAtPosition(fileName, position);
+ }
+ }
+
return {
...tsLS,
getSemanticDiagnostics,
getTypeDefinitionAtPosition,
+ getQuickInfoAtPosition,
};
}
diff --git a/packages/language-service/ivy/utils.ts b/packages/language-service/ivy/utils.ts
new file mode 100644
index 0000000000..a2ad10423d
--- /dev/null
+++ b/packages/language-service/ivy/utils.ts
@@ -0,0 +1,228 @@
+/**
+ * @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 {AbsoluteSourceSpan, CssSelector, ParseSourceSpan, SelectorMatcher} from '@angular/compiler';
+import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
+import {DirectiveSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
+import * as e from '@angular/compiler/src/expression_parser/ast'; // e for expression AST
+import * as t from '@angular/compiler/src/render3/r3_ast'; // t for template AST
+import * as ts from 'typescript';
+
+import {ALIAS_NAME, SYMBOL_PUNC} from '../src/hover';
+
+/**
+ * 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);
+ } else if (
+ node instanceof e.PropertyWrite || node instanceof e.MethodCall ||
+ node instanceof e.BindingPipe || node instanceof e.PropertyRead) {
+ // The `name` part of a `PropertyWrite`, `MethodCall`, and `BindingPipe` does not
+ // have its own AST so there is no way to retrieve a `Symbol` for just the `name` via a specific
+ // node.
+ return toTextSpan(node.nameSpan);
+ } else {
+ return toTextSpan(node.sourceSpan);
+ }
+}
+
+export function toTextSpan(span: AbsoluteSourceSpan|ParseSourceSpan): ts.TextSpan {
+ let start: number, end: number;
+ if (span instanceof AbsoluteSourceSpan) {
+ start = span.start;
+ end = span.end;
+ } else {
+ start = span.start.offset;
+ end = span.end.offset;
+ }
+ return {start, length: end - start};
+}
+
+interface NodeWithKeyAndValue extends t.Node {
+ keySpan: ParseSourceSpan;
+ valueSpan?: ParseSourceSpan;
+}
+
+export function isTemplateNodeWithKeyAndValue(node: t.Node|e.AST): node is NodeWithKeyAndValue {
+ return isTemplateNode(node) && node.hasOwnProperty('keySpan');
+}
+
+export function isTemplateNode(node: t.Node|e.AST): node is t.Node {
+ // Template node implements the Node interface so we cannot use instanceof.
+ return node.sourceSpan instanceof ParseSourceSpan;
+}
+
+export function isExpressionNode(node: t.Node|e.AST): node is e.AST {
+ return node instanceof e.AST;
+}
+
+export interface TemplateInfo {
+ template: t.Node[];
+ component: ts.ClassDeclaration;
+}
+
+/**
+ * Retrieves the `ts.ClassDeclaration` at a location along with its template nodes.
+ */
+export function getTemplateInfoAtPosition(
+ fileName: string, position: number, compiler: NgCompiler): TemplateInfo|undefined {
+ if (fileName.endsWith('.ts')) {
+ return getInlineTemplateInfoAtPosition(fileName, position, compiler);
+ } else {
+ return getFirstComponentForTemplateFile(fileName, compiler);
+ }
+}
+
+
+/**
+ * First, attempt to sort component declarations by file name.
+ * If the files are the same, sort by start location of the declaration.
+ */
+function tsDeclarationSortComparator(a: ts.Declaration, b: ts.Declaration): number {
+ const aFile = a.getSourceFile().fileName;
+ const bFile = b.getSourceFile().fileName;
+ if (aFile < bFile) {
+ return -1;
+ } else if (aFile > bFile) {
+ return 1;
+ } else {
+ return b.getFullStart() - a.getFullStart();
+ }
+}
+
+function getFirstComponentForTemplateFile(fileName: string, compiler: NgCompiler): TemplateInfo|
+ undefined {
+ const templateTypeChecker = compiler.getTemplateTypeChecker();
+ const components = compiler.getComponentsWithTemplateFile(fileName);
+ const sortedComponents = Array.from(components).sort(tsDeclarationSortComparator);
+ for (const component of sortedComponents) {
+ if (!ts.isClassDeclaration(component)) {
+ continue;
+ }
+ const template = templateTypeChecker.getTemplate(component);
+ if (template === null) {
+ continue;
+ }
+ return {template, component};
+ }
+
+ return undefined;
+}
+
+/**
+ * Retrieves the `ts.ClassDeclaration` at a location along with its template nodes.
+ */
+function getInlineTemplateInfoAtPosition(
+ fileName: string, position: number, compiler: NgCompiler): TemplateInfo|undefined {
+ const sourceFile = compiler.getNextProgram().getSourceFile(fileName);
+ if (!sourceFile) {
+ return undefined;
+ }
+
+ // We only support top level statements / class declarations
+ for (const statement of sourceFile.statements) {
+ if (!ts.isClassDeclaration(statement) || position < statement.pos || position > statement.end) {
+ continue;
+ }
+
+ const template = compiler.getTemplateTypeChecker().getTemplate(statement);
+ if (template === null) {
+ return undefined;
+ }
+
+ return {template, component: statement};
+ }
+
+ return undefined;
+}
+
+/**
+ * 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.
+ */
+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));
+
+ const hostNodeName = hostNode instanceof t.Template ? hostNode.tagName : hostNode.name;
+ const directivesWithAttribute = getDirectiveMatches(directives, hostNodeName + attrs.join(''));
+ const directivesWithoutAttribute =
+ getDirectiveMatches(directives, hostNodeName + attrsOmit.join(''));
+
+ const result = new Set();
+ for (const dir of directivesWithAttribute) {
+ if (!directivesWithoutAttribute.has(dir)) {
+ result.add(dir);
+ }
+ }
+ return result;
+}
+
+/**
+ * Returns a new `ts.SymbolDisplayPart` array which has the alias imports from the tcb filtered
+ * out, i.e. `i0.NgForOf`.
+ */
+export function filterAliasImports(displayParts: ts.SymbolDisplayPart[]): ts.SymbolDisplayPart[] {
+ const tcbAliasImportRegex = /i\d+/;
+ function isImportAlias(part: {kind: string, text: string}) {
+ return part.kind === ALIAS_NAME && tcbAliasImportRegex.test(part.text);
+ }
+ function isDotPunctuation(part: {kind: string, text: string}) {
+ return part.kind === SYMBOL_PUNC && part.text === '.';
+ }
+
+ return displayParts.filter((part, i) => {
+ const previousPart = displayParts[i - 1];
+ const nextPart = displayParts[i + 1];
+
+ const aliasNameFollowedByDot =
+ isImportAlias(part) && nextPart !== undefined && isDotPunctuation(nextPart);
+ const dotPrecededByAlias =
+ isDotPunctuation(part) && previousPart !== undefined && isImportAlias(previousPart);
+
+ return !aliasNameFollowedByDot && !dotPrecededByAlias;
+ });
+}
diff --git a/packages/language-service/src/hover.ts b/packages/language-service/src/hover.ts
index 8940d2db09..781d06d2b9 100644
--- a/packages/language-service/src/hover.ts
+++ b/packages/language-service/src/hover.ts
@@ -13,10 +13,11 @@ import * as ng from './types';
import {inSpan} from './utils';
// Reverse mappings of enum would generate strings
-const SYMBOL_SPACE = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.space];
-const SYMBOL_PUNC = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.punctuation];
-const SYMBOL_TEXT = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.text];
-const SYMBOL_INTERFACE = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.interfaceName];
+export const SYMBOL_SPACE = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.space];
+export const SYMBOL_PUNC = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.punctuation];
+export const SYMBOL_TEXT = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.text];
+export const SYMBOL_INTERFACE = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.interfaceName];
+export const ALIAS_NAME = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.aliasName];
/**
* Traverse the template AST and look for the symbol located at `position`, then
@@ -80,7 +81,7 @@ export function getTsHover(
* @param type user-friendly name of the type
* @param documentation docstring or comment
*/
-function createQuickInfo(
+export function createQuickInfo(
name: string, kind: string, textSpan: ts.TextSpan, containerName?: string, type?: string,
documentation?: ts.SymbolDisplayPart[]): ts.QuickInfo {
const containerDisplayParts = containerName ?
diff --git a/packages/language-service/test/BUILD.bazel b/packages/language-service/test/BUILD.bazel
index 553002ddfe..36bd6f7771 100644
--- a/packages/language-service/test/BUILD.bazel
+++ b/packages/language-service/test/BUILD.bazel
@@ -13,6 +13,7 @@ ts_library(
srcs = [
"test_utils.ts",
],
+ visibility = ["//packages/language-service:__subpackages__"],
deps = [
"//packages/compiler",
"//packages/compiler-cli/test:test_utils",
diff --git a/packages/language-service/test/project/tsconfig.json b/packages/language-service/test/project/tsconfig.json
index d2a130c87a..be758449b5 100644
--- a/packages/language-service/test/project/tsconfig.json
+++ b/packages/language-service/test/project/tsconfig.json
@@ -12,7 +12,7 @@
}
},
"angularCompilerOptions": {
- "fullTemplateTypeCheck": true,
+ "strictTemplates": true,
"strictInjectionParameters": true
}
}