feat(language-service): hover information for component NgModules (#33118)
Enables providing information about the NgModule a component is in when its selector is hovered on in a template. Also enables differentiation of a component and a directive when a directive class name is hovered over in a TypeScript file. Next step is to enable hover information for directives. Part of #32565. PR Close #33118
This commit is contained in:
parent
0de2a5e408
commit
e409ed0eab
@ -6,6 +6,7 @@
|
|||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {CompileSummaryKind, StaticSymbol} from '@angular/compiler';
|
||||||
import * as ts from 'typescript';
|
import * as ts from 'typescript';
|
||||||
import {AstResult} from './common';
|
import {AstResult} from './common';
|
||||||
import {locateSymbol} from './locate_symbol';
|
import {locateSymbol} from './locate_symbol';
|
||||||
@ -23,13 +24,21 @@ const SYMBOL_TEXT = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.text];
|
|||||||
* return the corresponding quick info.
|
* return the corresponding quick info.
|
||||||
* @param info template AST
|
* @param info template AST
|
||||||
* @param position location of the symbol
|
* @param position location of the symbol
|
||||||
|
* @param host Language Service host to query
|
||||||
*/
|
*/
|
||||||
export function getHover(info: AstResult, position: number): ts.QuickInfo|undefined {
|
export function getHover(info: AstResult, position: number, host: Readonly<TypeScriptServiceHost>):
|
||||||
|
ts.QuickInfo|undefined {
|
||||||
const symbolInfo = locateSymbol(info, position);
|
const symbolInfo = locateSymbol(info, position);
|
||||||
if (!symbolInfo) {
|
if (!symbolInfo) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const {symbol, span} = symbolInfo;
|
const {symbol, span, compileTypeSummary} = symbolInfo;
|
||||||
|
const textSpan = {start: span.start, length: span.end - span.start};
|
||||||
|
|
||||||
|
if (compileTypeSummary && compileTypeSummary.summaryKind === CompileSummaryKind.Directive) {
|
||||||
|
return getDirectiveModule(compileTypeSummary.type.reference, textSpan, host);
|
||||||
|
}
|
||||||
|
|
||||||
const containerDisplayParts: ts.SymbolDisplayPart[] = symbol.container ?
|
const containerDisplayParts: ts.SymbolDisplayPart[] = symbol.container ?
|
||||||
[
|
[
|
||||||
{text: symbol.container.name, kind: symbol.container.kind},
|
{text: symbol.container.name, kind: symbol.container.kind},
|
||||||
@ -39,10 +48,7 @@ export function getHover(info: AstResult, position: number): ts.QuickInfo|undefi
|
|||||||
return {
|
return {
|
||||||
kind: symbol.kind as ts.ScriptElementKind,
|
kind: symbol.kind as ts.ScriptElementKind,
|
||||||
kindModifiers: '', // kindModifier info not available on 'ng.Symbol'
|
kindModifiers: '', // kindModifier info not available on 'ng.Symbol'
|
||||||
textSpan: {
|
textSpan,
|
||||||
start: span.start,
|
|
||||||
length: span.end - span.start,
|
|
||||||
},
|
|
||||||
// this would generate a string like '(property) ClassX.propY'
|
// this would generate a string like '(property) ClassX.propY'
|
||||||
// 'kind' in displayParts does not really matter because it's dropped when
|
// 'kind' in displayParts does not really matter because it's dropped when
|
||||||
// displayParts get converted to string.
|
// displayParts get converted to string.
|
||||||
@ -69,7 +75,17 @@ export function getTsHover(
|
|||||||
if (!node) return;
|
if (!node) return;
|
||||||
switch (node.kind) {
|
switch (node.kind) {
|
||||||
case ts.SyntaxKind.Identifier:
|
case ts.SyntaxKind.Identifier:
|
||||||
return getDirectiveModule(node as ts.Identifier, host);
|
const directiveId = node as ts.Identifier;
|
||||||
|
if (ts.isClassDeclaration(directiveId.parent)) {
|
||||||
|
const directiveName = directiveId.text;
|
||||||
|
const directiveSymbol = host.getStaticSymbol(node.getSourceFile().fileName, directiveName);
|
||||||
|
if (!directiveSymbol) return;
|
||||||
|
return getDirectiveModule(
|
||||||
|
directiveSymbol,
|
||||||
|
{start: directiveId.getStart(), length: directiveId.end - directiveId.getStart()},
|
||||||
|
host);
|
||||||
|
}
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -82,36 +98,33 @@ export function getTsHover(
|
|||||||
* @param host Language Service host to query
|
* @param host Language Service host to query
|
||||||
*/
|
*/
|
||||||
function getDirectiveModule(
|
function getDirectiveModule(
|
||||||
directive: ts.Identifier, host: Readonly<TypeScriptServiceHost>): ts.QuickInfo|undefined {
|
directive: StaticSymbol, textSpan: ts.TextSpan,
|
||||||
if (!ts.isClassDeclaration(directive.parent)) return;
|
host: Readonly<TypeScriptServiceHost>): ts.QuickInfo|undefined {
|
||||||
const directiveName = directive.text;
|
|
||||||
const directiveSymbol = host.getStaticSymbol(directive.getSourceFile().fileName, directiveName);
|
|
||||||
if (!directiveSymbol) return;
|
|
||||||
|
|
||||||
const analyzedModules = host.getAnalyzedModules(false);
|
const analyzedModules = host.getAnalyzedModules(false);
|
||||||
const ngModule = analyzedModules.ngModuleByPipeOrDirective.get(directiveSymbol);
|
const ngModule = analyzedModules.ngModuleByPipeOrDirective.get(directive);
|
||||||
if (!ngModule) return;
|
if (!ngModule) return;
|
||||||
|
|
||||||
|
const isComponent =
|
||||||
|
host.getDeclarations(directive.filePath)
|
||||||
|
.find(decl => decl.type === directive && decl.metadata && decl.metadata.isComponent);
|
||||||
|
|
||||||
const moduleName = ngModule.type.reference.name;
|
const moduleName = ngModule.type.reference.name;
|
||||||
return {
|
return {
|
||||||
kind: ts.ScriptElementKind.classElement,
|
kind: ts.ScriptElementKind.classElement,
|
||||||
kindModifiers:
|
kindModifiers:
|
||||||
ts.ScriptElementKindModifier.none, // kindModifier info not available on 'ng.Symbol'
|
ts.ScriptElementKindModifier.none, // kindModifier info not available on 'ng.Symbol'
|
||||||
textSpan: {
|
textSpan,
|
||||||
start: directive.getStart(),
|
|
||||||
length: directive.end - directive.getStart(),
|
|
||||||
},
|
|
||||||
// This generates a string like '(directive) NgModule.Directive: class'
|
// This generates a string like '(directive) NgModule.Directive: class'
|
||||||
// 'kind' in displayParts does not really matter because it's dropped when
|
// 'kind' in displayParts does not really matter because it's dropped when
|
||||||
// displayParts get converted to string.
|
// displayParts get converted to string.
|
||||||
displayParts: [
|
displayParts: [
|
||||||
{text: '(', kind: SYMBOL_PUNC},
|
{text: '(', kind: SYMBOL_PUNC},
|
||||||
{text: 'directive', kind: SYMBOL_TEXT},
|
{text: isComponent ? 'component' : 'directive', kind: SYMBOL_TEXT},
|
||||||
{text: ')', kind: SYMBOL_PUNC},
|
{text: ')', kind: SYMBOL_PUNC},
|
||||||
{text: ' ', kind: SYMBOL_SPACE},
|
{text: ' ', kind: SYMBOL_SPACE},
|
||||||
{text: moduleName, kind: SYMBOL_CLASS},
|
{text: moduleName, kind: SYMBOL_CLASS},
|
||||||
{text: '.', kind: SYMBOL_PUNC},
|
{text: '.', kind: SYMBOL_PUNC},
|
||||||
{text: directiveName, kind: SYMBOL_CLASS},
|
{text: directive.name, kind: SYMBOL_CLASS},
|
||||||
{text: ':', kind: SYMBOL_PUNC},
|
{text: ':', kind: SYMBOL_PUNC},
|
||||||
{text: ' ', kind: SYMBOL_SPACE},
|
{text: ' ', kind: SYMBOL_SPACE},
|
||||||
{text: ts.ScriptElementKind.classElement, kind: SYMBOL_TEXT},
|
{text: ts.ScriptElementKind.classElement, kind: SYMBOL_TEXT},
|
||||||
|
@ -95,7 +95,7 @@ class LanguageServiceImpl implements LanguageService {
|
|||||||
this.host.getAnalyzedModules(); // same role as 'synchronizeHostData'
|
this.host.getAnalyzedModules(); // same role as 'synchronizeHostData'
|
||||||
const templateInfo = this.host.getTemplateAstAtPosition(fileName, position);
|
const templateInfo = this.host.getTemplateAstAtPosition(fileName, position);
|
||||||
if (templateInfo) {
|
if (templateInfo) {
|
||||||
return getHover(templateInfo, position);
|
return getHover(templateInfo, position, this.host);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to get Angular-specific hover information in a TypeScript file, the NgModule a
|
// Attempt to get Angular-specific hover information in a TypeScript file, the NgModule a
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {AST, Attribute, BoundDirectivePropertyAst, BoundEventAst, ElementAst, TemplateAstPath, findNode, tokenReference} from '@angular/compiler';
|
import {AST, Attribute, BoundDirectivePropertyAst, BoundEventAst, CompileTypeSummary, ElementAst, TemplateAstPath, findNode, tokenReference} from '@angular/compiler';
|
||||||
import {getExpressionScope} from '@angular/compiler-cli/src/language_services';
|
import {getExpressionScope} from '@angular/compiler-cli/src/language_services';
|
||||||
|
|
||||||
import {AstResult} from './common';
|
import {AstResult} from './common';
|
||||||
@ -17,6 +17,7 @@ import {diagnosticInfoFromTemplateInfo, findTemplateAstAt, inSpan, offsetSpan, s
|
|||||||
export interface SymbolInfo {
|
export interface SymbolInfo {
|
||||||
symbol: Symbol;
|
symbol: Symbol;
|
||||||
span: Span;
|
span: Span;
|
||||||
|
compileTypeSummary: CompileTypeSummary|undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -27,6 +28,7 @@ export interface SymbolInfo {
|
|||||||
export function locateSymbol(info: AstResult, position: number): SymbolInfo|undefined {
|
export function locateSymbol(info: AstResult, position: number): SymbolInfo|undefined {
|
||||||
const templatePosition = position - info.template.span.start;
|
const templatePosition = position - info.template.span.start;
|
||||||
const path = findTemplateAstAt(info.templateAst, templatePosition);
|
const path = findTemplateAstAt(info.templateAst, templatePosition);
|
||||||
|
let compileTypeSummary: CompileTypeSummary|undefined = undefined;
|
||||||
if (path.tail) {
|
if (path.tail) {
|
||||||
let symbol: Symbol|undefined = undefined;
|
let symbol: Symbol|undefined = undefined;
|
||||||
let span: Span|undefined = undefined;
|
let span: Span|undefined = undefined;
|
||||||
@ -57,7 +59,8 @@ export function locateSymbol(info: AstResult, position: number): SymbolInfo|unde
|
|||||||
visitElement(ast) {
|
visitElement(ast) {
|
||||||
const component = ast.directives.find(d => d.directive.isComponent);
|
const component = ast.directives.find(d => d.directive.isComponent);
|
||||||
if (component) {
|
if (component) {
|
||||||
symbol = info.template.query.getTypeSymbol(component.directive.type.reference);
|
compileTypeSummary = component.directive;
|
||||||
|
symbol = info.template.query.getTypeSymbol(compileTypeSummary.type.reference);
|
||||||
symbol = symbol && new OverrideKindSymbol(symbol, DirectiveKind.COMPONENT);
|
symbol = symbol && new OverrideKindSymbol(symbol, DirectiveKind.COMPONENT);
|
||||||
span = spanOf(ast);
|
span = spanOf(ast);
|
||||||
} else {
|
} else {
|
||||||
@ -65,7 +68,8 @@ export function locateSymbol(info: AstResult, position: number): SymbolInfo|unde
|
|||||||
const directive = ast.directives.find(
|
const directive = ast.directives.find(
|
||||||
d => d.directive.selector != null && d.directive.selector.indexOf(ast.name) >= 0);
|
d => d.directive.selector != null && d.directive.selector.indexOf(ast.name) >= 0);
|
||||||
if (directive) {
|
if (directive) {
|
||||||
symbol = info.template.query.getTypeSymbol(directive.directive.type.reference);
|
compileTypeSummary = directive.directive;
|
||||||
|
symbol = info.template.query.getTypeSymbol(compileTypeSummary.type.reference);
|
||||||
symbol = symbol && new OverrideKindSymbol(symbol, DirectiveKind.DIRECTIVE);
|
symbol = symbol && new OverrideKindSymbol(symbol, DirectiveKind.DIRECTIVE);
|
||||||
span = spanOf(ast);
|
span = spanOf(ast);
|
||||||
}
|
}
|
||||||
@ -100,7 +104,8 @@ export function locateSymbol(info: AstResult, position: number): SymbolInfo|unde
|
|||||||
},
|
},
|
||||||
visitText(ast) {},
|
visitText(ast) {},
|
||||||
visitDirective(ast) {
|
visitDirective(ast) {
|
||||||
symbol = info.template.query.getTypeSymbol(ast.directive.type.reference);
|
compileTypeSummary = ast.directive;
|
||||||
|
symbol = info.template.query.getTypeSymbol(compileTypeSummary.type.reference);
|
||||||
span = spanOf(ast);
|
span = spanOf(ast);
|
||||||
},
|
},
|
||||||
visitDirectiveProperty(ast) {
|
visitDirectiveProperty(ast) {
|
||||||
@ -112,7 +117,7 @@ export function locateSymbol(info: AstResult, position: number): SymbolInfo|unde
|
|||||||
},
|
},
|
||||||
null);
|
null);
|
||||||
if (symbol && span) {
|
if (symbol && span) {
|
||||||
return {symbol, span: offsetSpan(span, info.template.span.start)};
|
return {symbol, span: offsetSpan(span, info.template.span.start), compileTypeSummary};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -103,7 +103,7 @@ describe('hover', () => {
|
|||||||
expect(quickInfo).toBeTruthy();
|
expect(quickInfo).toBeTruthy();
|
||||||
const {textSpan, displayParts} = quickInfo !;
|
const {textSpan, displayParts} = quickInfo !;
|
||||||
expect(textSpan).toEqual(marker);
|
expect(textSpan).toEqual(marker);
|
||||||
expect(toText(displayParts)).toBe('(component) TestComponent');
|
expect(toText(displayParts)).toBe('(component) AppModule.TestComponent: class');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be able to find an event provider', () => {
|
it('should be able to find an event provider', () => {
|
||||||
@ -149,7 +149,7 @@ describe('hover', () => {
|
|||||||
expect(quickInfo).toBeUndefined();
|
expect(quickInfo).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be able to find a directive module', () => {
|
it('should be able to find the NgModule of a component', () => {
|
||||||
const fileName = '/app/app.component.ts';
|
const fileName = '/app/app.component.ts';
|
||||||
mockHost.override(fileName, `
|
mockHost.override(fileName, `
|
||||||
import {Component} from '@angular/core';
|
import {Component} from '@angular/core';
|
||||||
@ -165,7 +165,24 @@ describe('hover', () => {
|
|||||||
expect(quickInfo).toBeTruthy();
|
expect(quickInfo).toBeTruthy();
|
||||||
const {textSpan, displayParts} = quickInfo !;
|
const {textSpan, displayParts} = quickInfo !;
|
||||||
expect(textSpan).toEqual(marker);
|
expect(textSpan).toEqual(marker);
|
||||||
expect(toText(displayParts)).toBe('(directive) AppModule.AppComponent: class');
|
expect(toText(displayParts)).toBe('(component) AppModule.AppComponent: class');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be able to find the NgModule of a directive', () => {
|
||||||
|
const fileName = '/app/parsing-cases.ts';
|
||||||
|
mockHost.override(fileName, `
|
||||||
|
import {Directive} from '@angular/core';
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[string-model]',
|
||||||
|
})
|
||||||
|
export class «StringModel» {}`);
|
||||||
|
const marker = mockHost.getReferenceMarkerFor(fileName, 'StringModel');
|
||||||
|
const quickInfo = ngLS.getHoverAt(fileName, marker.start);
|
||||||
|
expect(quickInfo).toBeTruthy();
|
||||||
|
const {textSpan, displayParts} = quickInfo !;
|
||||||
|
expect(textSpan).toEqual(marker);
|
||||||
|
expect(toText(displayParts)).toBe('(directive) AppModule.StringModel: class');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user