feat(language-service): add quick info for inline templates in ivy (#39060)
Adds implementation for `getQuickInfoAtPosition` to the Ivy Language Service, which now returns `ts.QuickInfo` for inline templates. PR Close #39060
This commit is contained in:
parent
4fe673d518
commit
904adb72d2
|
@ -15,6 +15,8 @@ ts_library(
|
|||
"//packages/compiler-cli/src/ngtsc/shims",
|
||||
"//packages/compiler-cli/src/ngtsc/typecheck",
|
||||
"//packages/compiler-cli/src/ngtsc/typecheck/api",
|
||||
# TODO(atscott): Pull functions/variables common to VE and Ivy into a new package
|
||||
"//packages/language-service",
|
||||
"@npm//typescript",
|
||||
],
|
||||
)
|
||||
|
|
|
@ -10,6 +10,8 @@ import {AbsoluteSourceSpan, ParseSourceSpan} from '@angular/compiler';
|
|||
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 {isTemplateNode, isTemplateNodeWithKeyAndValue} from './utils';
|
||||
|
||||
/**
|
||||
* Return the template AST node or expression AST node that most accurately
|
||||
* represents the node at the specified cursor `position`.
|
||||
|
@ -165,24 +167,6 @@ class ExpressionVisitor extends e.RecursiveAstVisitor {
|
|||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 isExpressionNode(node: t.Node|e.AST): node is e.AST {
|
||||
return node instanceof e.AST;
|
||||
}
|
||||
|
||||
function getSpanIncludingEndTag(ast: t.Node) {
|
||||
const result = {
|
||||
start: ast.sourceSpan.start.offset,
|
||||
|
|
|
@ -16,6 +16,8 @@ import {TypeCheckShimGenerator} from '@angular/compiler-cli/src/ngtsc/typecheck'
|
|||
import {OptimizeFor, TypeCheckingProgramStrategy} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
|
||||
import * as ts from 'typescript/lib/tsserverlibrary';
|
||||
|
||||
import {QuickInfoBuilder} from './quick_info';
|
||||
|
||||
export class LanguageService {
|
||||
private options: CompilerOptions;
|
||||
private lastKnownProgram: ts.Program|null = null;
|
||||
|
@ -45,6 +47,12 @@ export class LanguageService {
|
|||
throw new Error('Ivy LS currently does not support external template');
|
||||
}
|
||||
|
||||
getQuickInfoAtPosition(fileName: string, position: number): ts.QuickInfo|undefined {
|
||||
const program = this.strategy.getProgram();
|
||||
const compiler = this.createCompiler(program);
|
||||
return new QuickInfoBuilder(this.tsLS, compiler).get(fileName, position);
|
||||
}
|
||||
|
||||
private createCompiler(program: ts.Program): NgCompiler {
|
||||
return new NgCompiler(
|
||||
this.adapter,
|
||||
|
|
|
@ -0,0 +1,237 @@
|
|||
/**
|
||||
* @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 {AST, BindingPipe, ImplicitReceiver, MethodCall, TmplAstBoundAttribute, TmplAstNode, TmplAstTextAttribute} from '@angular/compiler';
|
||||
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
|
||||
import {DirectiveSymbol, DomBindingSymbol, ElementSymbol, ExpressionSymbol, InputBindingSymbol, OutputBindingSymbol, ReferenceSymbol, ShimLocation, Symbol, SymbolKind, VariableSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
|
||||
import * as ts from 'typescript';
|
||||
|
||||
import {createQuickInfo, SYMBOL_PUNC, SYMBOL_SPACE, SYMBOL_TEXT} from '../src/hover';
|
||||
|
||||
import {findNodeAtPosition} from './hybrid_visitor';
|
||||
import {filterAliasImports, getDirectiveMatches, getDirectiveMatchesForAttribute, getTemplateInfoAtPosition, getTextSpanOfNode} from './utils';
|
||||
|
||||
/**
|
||||
* The type of Angular directive. Used for QuickInfo in template.
|
||||
*/
|
||||
export enum QuickInfoKind {
|
||||
COMPONENT = 'component',
|
||||
DIRECTIVE = 'directive',
|
||||
EVENT = 'event',
|
||||
REFERENCE = 'reference',
|
||||
ELEMENT = 'element',
|
||||
VARIABLE = 'variable',
|
||||
PIPE = 'pipe',
|
||||
PROPERTY = 'property',
|
||||
METHOD = 'method',
|
||||
TEMPLATE = 'template',
|
||||
}
|
||||
|
||||
export class QuickInfoBuilder {
|
||||
private readonly typeChecker = this.compiler.getNextProgram().getTypeChecker();
|
||||
constructor(private readonly tsLS: ts.LanguageService, private readonly compiler: NgCompiler) {}
|
||||
|
||||
get(fileName: string, position: number): ts.QuickInfo|undefined {
|
||||
const templateInfo = getTemplateInfoAtPosition(fileName, position, this.compiler);
|
||||
if (templateInfo === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const {template, component} = templateInfo;
|
||||
|
||||
const node = findNodeAtPosition(template, position);
|
||||
if (node === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const symbol = this.compiler.getTemplateTypeChecker().getSymbolOfNode(node, component);
|
||||
if (symbol === null) {
|
||||
return isDollarAny(node) ? createDollarAnyQuickInfo(node) : undefined;
|
||||
}
|
||||
|
||||
return this.getQuickInfoForSymbol(symbol, node);
|
||||
}
|
||||
|
||||
private getQuickInfoForSymbol(symbol: Symbol, node: TmplAstNode|AST): ts.QuickInfo|undefined {
|
||||
switch (symbol.kind) {
|
||||
case SymbolKind.Input:
|
||||
case SymbolKind.Output:
|
||||
return this.getQuickInfoForBindingSymbol(symbol, node);
|
||||
case SymbolKind.Template:
|
||||
return createNgTemplateQuickInfo(node);
|
||||
case SymbolKind.Element:
|
||||
return this.getQuickInfoForElementSymbol(symbol);
|
||||
case SymbolKind.Variable:
|
||||
return this.getQuickInfoForVariableSymbol(symbol, node);
|
||||
case SymbolKind.Reference:
|
||||
return this.getQuickInfoForReferenceSymbol(symbol, node);
|
||||
case SymbolKind.DomBinding:
|
||||
return this.getQuickInfoForDomBinding(node, symbol);
|
||||
case SymbolKind.Directive:
|
||||
return this.getQuickInfoAtShimLocation(symbol.shimLocation, node);
|
||||
case SymbolKind.Expression:
|
||||
return node instanceof BindingPipe ?
|
||||
this.getQuickInfoForPipeSymbol(symbol, node) :
|
||||
this.getQuickInfoAtShimLocation(symbol.shimLocation, node);
|
||||
}
|
||||
}
|
||||
|
||||
private getQuickInfoForBindingSymbol(
|
||||
symbol: InputBindingSymbol|OutputBindingSymbol, node: TmplAstNode|AST): ts.QuickInfo
|
||||
|undefined {
|
||||
if (symbol.bindings.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const kind = symbol.kind === SymbolKind.Input ? QuickInfoKind.PROPERTY : QuickInfoKind.EVENT;
|
||||
|
||||
const quickInfo = this.getQuickInfoAtShimLocation(symbol.bindings[0].shimLocation, node);
|
||||
return quickInfo === undefined ? undefined : updateQuickInfoKind(quickInfo, kind);
|
||||
}
|
||||
|
||||
private getQuickInfoForElementSymbol(symbol: ElementSymbol): ts.QuickInfo {
|
||||
const {templateNode} = symbol;
|
||||
const matches = getDirectiveMatches(symbol.directives, templateNode.name);
|
||||
if (matches.size > 0) {
|
||||
return this.getQuickInfoForDirectiveSymbol(matches.values().next().value, templateNode);
|
||||
}
|
||||
|
||||
return createQuickInfo(
|
||||
templateNode.name, QuickInfoKind.ELEMENT, getTextSpanOfNode(templateNode),
|
||||
undefined /* containerName */, this.typeChecker.typeToString(symbol.tsType));
|
||||
}
|
||||
|
||||
private getQuickInfoForVariableSymbol(symbol: VariableSymbol, node: TmplAstNode|AST):
|
||||
ts.QuickInfo {
|
||||
const documentation = this.getDocumentationFromTypeDefAtLocation(symbol.shimLocation);
|
||||
return createQuickInfo(
|
||||
symbol.declaration.name, QuickInfoKind.VARIABLE, getTextSpanOfNode(node),
|
||||
undefined /* containerName */, this.typeChecker.typeToString(symbol.tsType), documentation);
|
||||
}
|
||||
|
||||
private getQuickInfoForReferenceSymbol(symbol: ReferenceSymbol, node: TmplAstNode|AST):
|
||||
ts.QuickInfo {
|
||||
const documentation = this.getDocumentationFromTypeDefAtLocation(symbol.shimLocation);
|
||||
return createQuickInfo(
|
||||
symbol.declaration.name, QuickInfoKind.REFERENCE, getTextSpanOfNode(node),
|
||||
undefined /* containerName */, this.typeChecker.typeToString(symbol.tsType), documentation);
|
||||
}
|
||||
|
||||
private getQuickInfoForPipeSymbol(symbol: ExpressionSymbol, node: TmplAstNode|AST): ts.QuickInfo
|
||||
|undefined {
|
||||
const quickInfo = this.getQuickInfoAtShimLocation(symbol.shimLocation, node);
|
||||
return quickInfo === undefined ? undefined : updateQuickInfoKind(quickInfo, QuickInfoKind.PIPE);
|
||||
}
|
||||
|
||||
private getQuickInfoForDomBinding(node: TmplAstNode|AST, symbol: DomBindingSymbol) {
|
||||
if (!(node instanceof TmplAstTextAttribute) && !(node instanceof TmplAstBoundAttribute)) {
|
||||
return undefined;
|
||||
}
|
||||
const directives = getDirectiveMatchesForAttribute(
|
||||
node.name, symbol.host.templateNode, symbol.host.directives);
|
||||
if (directives.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.getQuickInfoForDirectiveSymbol(directives.values().next().value, node);
|
||||
}
|
||||
|
||||
private getQuickInfoForDirectiveSymbol(dir: DirectiveSymbol, node: TmplAstNode|AST):
|
||||
ts.QuickInfo {
|
||||
const kind = dir.isComponent ? QuickInfoKind.COMPONENT : QuickInfoKind.DIRECTIVE;
|
||||
const documentation = this.getDocumentationFromTypeDefAtLocation(dir.shimLocation);
|
||||
return createQuickInfo(
|
||||
this.typeChecker.typeToString(dir.tsType), kind, getTextSpanOfNode(node),
|
||||
undefined /* containerName */, undefined, documentation);
|
||||
}
|
||||
|
||||
private getDocumentationFromTypeDefAtLocation(shimLocation: ShimLocation):
|
||||
ts.SymbolDisplayPart[]|undefined {
|
||||
const typeDefs = this.tsLS.getTypeDefinitionAtPosition(
|
||||
shimLocation.shimPath, shimLocation.positionInShimFile);
|
||||
if (typeDefs === undefined || typeDefs.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return this.tsLS.getQuickInfoAtPosition(typeDefs[0].fileName, typeDefs[0].textSpan.start)
|
||||
?.documentation;
|
||||
}
|
||||
|
||||
private getQuickInfoAtShimLocation(location: ShimLocation, node: TmplAstNode|AST): ts.QuickInfo
|
||||
|undefined {
|
||||
const quickInfo =
|
||||
this.tsLS.getQuickInfoAtPosition(location.shimPath, location.positionInShimFile);
|
||||
if (quickInfo === undefined || quickInfo.displayParts === undefined) {
|
||||
return quickInfo;
|
||||
}
|
||||
|
||||
quickInfo.displayParts = filterAliasImports(quickInfo.displayParts);
|
||||
|
||||
const textSpan = getTextSpanOfNode(node);
|
||||
return {...quickInfo, textSpan};
|
||||
}
|
||||
}
|
||||
|
||||
function updateQuickInfoKind(quickInfo: ts.QuickInfo, kind: QuickInfoKind): ts.QuickInfo {
|
||||
if (quickInfo.displayParts === undefined) {
|
||||
return quickInfo;
|
||||
}
|
||||
|
||||
const startsWithKind = quickInfo.displayParts.length >= 3 &&
|
||||
displayPartsEqual(quickInfo.displayParts[0], {text: '(', kind: SYMBOL_PUNC}) &&
|
||||
quickInfo.displayParts[1].kind === SYMBOL_TEXT &&
|
||||
displayPartsEqual(quickInfo.displayParts[2], {text: ')', kind: SYMBOL_PUNC});
|
||||
if (startsWithKind) {
|
||||
quickInfo.displayParts[1].text = kind;
|
||||
} else {
|
||||
quickInfo.displayParts = [
|
||||
{text: '(', kind: SYMBOL_PUNC},
|
||||
{text: kind, kind: SYMBOL_TEXT},
|
||||
{text: ')', kind: SYMBOL_PUNC},
|
||||
{text: ' ', kind: SYMBOL_SPACE},
|
||||
...quickInfo.displayParts,
|
||||
];
|
||||
}
|
||||
return quickInfo;
|
||||
}
|
||||
|
||||
function displayPartsEqual(a: {text: string, kind: string}, b: {text: string, kind: string}) {
|
||||
return a.text === b.text && a.kind === b.kind;
|
||||
}
|
||||
|
||||
function isDollarAny(node: TmplAstNode|AST): node is MethodCall {
|
||||
return node instanceof MethodCall && node.receiver instanceof ImplicitReceiver &&
|
||||
node.name === '$any' && node.args.length === 1;
|
||||
}
|
||||
|
||||
function createDollarAnyQuickInfo(node: MethodCall): ts.QuickInfo {
|
||||
return createQuickInfo(
|
||||
'$any',
|
||||
QuickInfoKind.METHOD,
|
||||
getTextSpanOfNode(node),
|
||||
/** containerName */ undefined,
|
||||
'any',
|
||||
[{
|
||||
kind: SYMBOL_TEXT,
|
||||
text: 'function to cast an expression to the `any` type',
|
||||
}],
|
||||
);
|
||||
}
|
||||
|
||||
// TODO(atscott): Create special `ts.QuickInfo` for `ng-template` and `ng-container` as well.
|
||||
function createNgTemplateQuickInfo(node: TmplAstNode|AST): ts.QuickInfo {
|
||||
return createQuickInfo(
|
||||
'ng-template',
|
||||
QuickInfoKind.TEMPLATE,
|
||||
getTextSpanOfNode(node),
|
||||
/** containerName */ undefined,
|
||||
/** type */ undefined,
|
||||
[{
|
||||
kind: SYMBOL_TEXT,
|
||||
text:
|
||||
'The `<ng-template>` is an Angular element for rendering HTML. It is never displayed directly.',
|
||||
}],
|
||||
);
|
||||
}
|
|
@ -10,7 +10,8 @@ import {ParseError, parseTemplate} from '@angular/compiler';
|
|||
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 {findNodeAtPosition, isExpressionNode, isTemplateNode} from '../hybrid_visitor';
|
||||
import {findNodeAtPosition} from '../hybrid_visitor';
|
||||
import {isExpressionNode, isTemplateNode} from '../utils';
|
||||
|
||||
interface ParseResult {
|
||||
nodes: t.Node[];
|
||||
|
|
|
@ -17,7 +17,7 @@ describe('parseNgCompilerOptions', () => {
|
|||
const options = parseNgCompilerOptions(project);
|
||||
expect(options).toEqual(jasmine.objectContaining({
|
||||
enableIvy: true, // default for ivy is true
|
||||
fullTemplateTypeCheck: true,
|
||||
strictTemplates: true,
|
||||
strictInjectionParameters: true,
|
||||
}));
|
||||
});
|
||||
|
|
|
@ -0,0 +1,393 @@
|
|||
/**
|
||||
* @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 * as ts from 'typescript/lib/tsserverlibrary';
|
||||
|
||||
import {LanguageService} from '../language_service';
|
||||
|
||||
import {APP_COMPONENT, setup, TEST_TEMPLATE} from './mock_host';
|
||||
|
||||
describe('quick info', () => {
|
||||
const {project, service, tsLS} = setup();
|
||||
const ngLS = new LanguageService(project, tsLS);
|
||||
|
||||
beforeEach(() => {
|
||||
service.reset();
|
||||
});
|
||||
|
||||
describe('elements', () => {
|
||||
it('should work for native elements', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<butt¦on></button>`,
|
||||
expectedSpanText: '<button></button>',
|
||||
expectedDisplayString: '(element) button: HTMLButtonElement'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('templates', () => {
|
||||
it('should return undefined for ng-templates', () => {
|
||||
const {documentation} = expectQuickInfo({
|
||||
templateOverride: `<ng-templ¦ate></ng-template>`,
|
||||
expectedSpanText: '<ng-template></ng-template>',
|
||||
expectedDisplayString: '(template) ng-template'
|
||||
});
|
||||
expect(toText(documentation))
|
||||
.toContain('The `<ng-template>` is an Angular element for rendering HTML.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('directives', () => {
|
||||
it('should work for directives', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<div string-model¦></div>`,
|
||||
expectedSpanText: 'string-model',
|
||||
// TODO(atscott): Find a way to include the module
|
||||
// expectedDisplayParts: '(directive) AppModule.StringModel'
|
||||
expectedDisplayString: '(directive) StringModel'
|
||||
});
|
||||
});
|
||||
|
||||
it('should work for components', () => {
|
||||
const {documentation} = expectQuickInfo({
|
||||
templateOverride: `<t¦est-comp></test-comp>`,
|
||||
expectedSpanText: '<test-comp></test-comp>',
|
||||
// TODO(atscott): Find a way to include the module
|
||||
// expectedDisplayParts: '(component) AppModule.TestComponent'
|
||||
expectedDisplayString: '(component) TestComponent'
|
||||
});
|
||||
expect(toText(documentation)).toBe('This Component provides the `test-comp` selector.');
|
||||
});
|
||||
|
||||
it('should work for structural directives', () => {
|
||||
const {documentation} = expectQuickInfo({
|
||||
templateOverride: `<div *¦ngFor="let item of heroes"></div>`,
|
||||
expectedSpanText: 'ngFor',
|
||||
expectedDisplayString: '(directive) NgForOf<Hero, Array<Hero>>'
|
||||
});
|
||||
expect(toText(documentation))
|
||||
.toContain('A [structural directive](guide/structural-directives) that renders');
|
||||
});
|
||||
|
||||
it('should work for directives with compound selectors, some of which are bindings', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<ng-template ngF¦or let-hero [ngForOf]="heroes">{{item}}</ng-template>`,
|
||||
expectedSpanText: 'ngFor',
|
||||
expectedDisplayString: '(directive) NgForOf<Hero, Array<Hero>>'
|
||||
});
|
||||
});
|
||||
|
||||
it('should work for data-let- syntax', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride:
|
||||
`<ng-template ngFor data-let-he¦ro [ngForOf]="heroes">{{item}}</ng-template>`,
|
||||
expectedSpanText: 'hero',
|
||||
expectedDisplayString: '(variable) hero: Hero'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('bindings', () => {
|
||||
describe('inputs', () => {
|
||||
it('should work for input providers', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<test-comp [tcN¦ame]="name"></test-comp>`,
|
||||
expectedSpanText: 'tcName',
|
||||
expectedDisplayString: '(property) TestComponent.name: string'
|
||||
});
|
||||
});
|
||||
|
||||
it('should work for bind- syntax', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<test-comp bind-tcN¦ame="name"></test-comp>`,
|
||||
expectedSpanText: 'tcName',
|
||||
expectedDisplayString: '(property) TestComponent.name: string'
|
||||
});
|
||||
expectQuickInfo({
|
||||
templateOverride: `<test-comp data-bind-tcN¦ame="name"></test-comp>`,
|
||||
expectedSpanText: 'tcName',
|
||||
expectedDisplayString: '(property) TestComponent.name: string'
|
||||
});
|
||||
});
|
||||
|
||||
it('should work for structural directive inputs ngForTrackBy', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<div *ngFor="let item of heroes; tr¦ackBy: test;"></div>`,
|
||||
expectedSpanText: 'trackBy',
|
||||
expectedDisplayString:
|
||||
'(property) NgForOf<Hero, Hero[]>.ngForTrackBy: TrackByFunction<Hero>'
|
||||
});
|
||||
});
|
||||
|
||||
it('should work for structural directive inputs ngForOf', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<div *ngFor="let item o¦f heroes; trackBy: test;"></div>`,
|
||||
expectedSpanText: 'of',
|
||||
expectedDisplayString:
|
||||
'(property) NgForOf<Hero, Hero[]>.ngForOf: Hero[] | (Hero[] & Iterable<Hero>) | null | undefined'
|
||||
});
|
||||
});
|
||||
|
||||
it('should work for two-way binding providers', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<test-comp string-model [(mo¦del)]="title"></test-comp>`,
|
||||
expectedSpanText: 'model',
|
||||
expectedDisplayString: '(property) StringModel.model: string'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('outputs', () => {
|
||||
it('should work for event providers', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<test-comp (te¦st)="myClick($event)"></test-comp>`,
|
||||
expectedSpanText: '(test)="myClick($event)"',
|
||||
expectedDisplayString: '(event) TestComponent.testEvent: EventEmitter<any>'
|
||||
});
|
||||
});
|
||||
|
||||
it('should work for on- syntax binding', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<test-comp on-te¦st="myClick($event)"></test-comp>`,
|
||||
expectedSpanText: 'on-test="myClick($event)"',
|
||||
expectedDisplayString: '(event) TestComponent.testEvent: EventEmitter<any>'
|
||||
});
|
||||
expectQuickInfo({
|
||||
templateOverride: `<test-comp data-on-te¦st="myClick($event)"></test-comp>`,
|
||||
expectedSpanText: 'data-on-test="myClick($event)"',
|
||||
expectedDisplayString: '(event) TestComponent.testEvent: EventEmitter<any>'
|
||||
});
|
||||
});
|
||||
|
||||
it('should work for $event from EventEmitter', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<div string-model (modelChange)="myClick($e¦vent)"></div>`,
|
||||
expectedSpanText: '$event',
|
||||
expectedDisplayString: '(parameter) $event: string'
|
||||
});
|
||||
});
|
||||
|
||||
it('should work for $event from native element', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<div (click)="myClick($e¦vent)"></div>`,
|
||||
expectedSpanText: '$event',
|
||||
expectedDisplayString: '(parameter) $event: MouseEvent'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('references', () => {
|
||||
it('should work for element reference declarations', () => {
|
||||
const {documentation} = expectQuickInfo({
|
||||
templateOverride: `<div #¦chart></div>`,
|
||||
expectedSpanText: '#chart',
|
||||
expectedDisplayString: '(reference) chart: HTMLDivElement'
|
||||
});
|
||||
expect(toText(documentation))
|
||||
.toEqual(
|
||||
'Provides special properties (beyond the regular HTMLElement ' +
|
||||
'interface it also has available to it by inheritance) for manipulating <div> elements.');
|
||||
});
|
||||
|
||||
it('should work for ref- syntax', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<div ref-ch¦art></div>`,
|
||||
expectedSpanText: 'ref-chart',
|
||||
expectedDisplayString: '(reference) chart: HTMLDivElement'
|
||||
});
|
||||
expectQuickInfo({
|
||||
templateOverride: `<div data-ref-ch¦art></div>`,
|
||||
expectedSpanText: 'data-ref-chart',
|
||||
expectedDisplayString: '(reference) chart: HTMLDivElement'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('variables', () => {
|
||||
it('should work for array members', () => {
|
||||
const {documentation} = expectQuickInfo({
|
||||
templateOverride: `<div *ngFor="let hero of heroes">{{her¦o}}</div>`,
|
||||
expectedSpanText: 'hero',
|
||||
expectedDisplayString: '(variable) hero: Hero'
|
||||
});
|
||||
expect(toText(documentation)).toEqual('The most heroic being.');
|
||||
});
|
||||
|
||||
it('should work for ReadonlyArray members (#36191)', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<div *ngFor="let hero of readonlyHeroes">{{her¦o}}</div>`,
|
||||
expectedSpanText: 'hero',
|
||||
expectedDisplayString: '(variable) hero: Readonly<Hero>'
|
||||
});
|
||||
});
|
||||
|
||||
it('should work for const array members (#36191)', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<div *ngFor="let name of constNames">{{na¦me}}</div>`,
|
||||
expectedSpanText: 'name',
|
||||
expectedDisplayString: '(variable) name: { readonly name: "name"; }'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('pipes', () => {
|
||||
it('should work for pipes', () => {
|
||||
const templateOverride = `<p>The hero's birthday is {{birthday | da¦te: "MM/dd/yy"}}</p>`;
|
||||
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: `<div>{{ tit¦le }}</div>`,
|
||||
expectedSpanText: 'title',
|
||||
expectedDisplayString: '(property) AppComponent.title: string'
|
||||
});
|
||||
});
|
||||
|
||||
it('should work for accessed property reads', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<div>{{title.len¦gth}}</div>`,
|
||||
expectedSpanText: 'length',
|
||||
expectedDisplayString: '(property) String.length: number'
|
||||
});
|
||||
});
|
||||
|
||||
it('should find members in an attribute interpolation', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<div string-model model="{{tit¦le}}"></div>`,
|
||||
expectedSpanText: 'title',
|
||||
expectedDisplayString: '(property) AppComponent.title: string'
|
||||
});
|
||||
});
|
||||
|
||||
it('should find members of input binding', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<test-comp [tcName]="ti¦tle"></test-comp>`,
|
||||
expectedSpanText: 'title',
|
||||
expectedDisplayString: '(property) AppComponent.title: string'
|
||||
});
|
||||
});
|
||||
|
||||
it('should find input binding on text attribute', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<test-comp tcN¦ame="title"></test-comp>`,
|
||||
expectedSpanText: 'tcName="title"',
|
||||
expectedDisplayString: '(property) TestComponent.name: string'
|
||||
});
|
||||
});
|
||||
|
||||
it('should find members of event binding', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<test-comp (test)="ti¦tle=$event"></test-comp>`,
|
||||
expectedSpanText: 'title',
|
||||
expectedDisplayString: '(property) AppComponent.title: string'
|
||||
});
|
||||
});
|
||||
|
||||
it('should work for method calls', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<div (click)="setT¦itle('title')"></div>`,
|
||||
expectedSpanText: 'setTitle',
|
||||
expectedDisplayString: '(method) AppComponent.setTitle(newTitle: string): void'
|
||||
});
|
||||
});
|
||||
|
||||
it('should work for accessed properties in writes', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<div (click)="hero.i¦d = 2"></div>`,
|
||||
expectedSpanText: 'id',
|
||||
expectedDisplayString: '(property) Hero.id: number'
|
||||
});
|
||||
});
|
||||
|
||||
it('should work for method call arguments', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<div (click)="setTitle(hero.nam¦e)"></div>`,
|
||||
expectedSpanText: 'name',
|
||||
expectedDisplayString: '(property) Hero.name: string'
|
||||
});
|
||||
});
|
||||
|
||||
it('should find members of two-way binding', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<input [(ngModel)]="ti¦tle" />`,
|
||||
expectedSpanText: 'title',
|
||||
expectedDisplayString: '(property) AppComponent.title: string'
|
||||
});
|
||||
});
|
||||
|
||||
it('should find members in a structural directive', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<div *ngIf="anyV¦alue"></div>`,
|
||||
expectedSpanText: 'anyValue',
|
||||
expectedDisplayString: '(property) AppComponent.anyValue: any'
|
||||
});
|
||||
});
|
||||
|
||||
it('should work for members in structural directives', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<div *ngFor="let item of her¦oes; trackBy: test;"></div>`,
|
||||
expectedSpanText: 'heroes',
|
||||
expectedDisplayString: '(property) AppComponent.heroes: Hero[]'
|
||||
});
|
||||
});
|
||||
|
||||
it('should work for the $any() cast function', () => {
|
||||
expectQuickInfo({
|
||||
templateOverride: `<div>{{$an¦y(title)}}</div>`,
|
||||
expectedSpanText: '$any',
|
||||
expectedDisplayString: '(method) $any: any'
|
||||
});
|
||||
});
|
||||
|
||||
it('should provide documentation', () => {
|
||||
const {position} = service.overwriteInlineTemplate(APP_COMPONENT, `<div>{{¦title}}</div>`);
|
||||
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, '<butt¦on></button>');
|
||||
const quickInfo = ngLS.getQuickInfoAtPosition(TEST_TEMPLATE, position);
|
||||
expect(quickInfo).toBeTruthy();
|
||||
const {textSpan, displayParts} = quickInfo!;
|
||||
expect(text.substring(textSpan.start, textSpan.start + textSpan.length))
|
||||
.toEqual('<button></button>');
|
||||
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('');
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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<DirectiveSymbol> {
|
||||
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<DirectiveSymbol> {
|
||||
const attributes: Array<t.TextAttribute|t.BoundAttribute> =
|
||||
[...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<DirectiveSymbol>();
|
||||
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;
|
||||
});
|
||||
}
|
|
@ -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 ?
|
||||
|
|
|
@ -13,6 +13,7 @@ ts_library(
|
|||
srcs = [
|
||||
"test_utils.ts",
|
||||
],
|
||||
visibility = ["//packages/language-service:__subpackages__"],
|
||||
deps = [
|
||||
"//packages/compiler",
|
||||
"//packages/compiler-cli/test:test_utils",
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
}
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"fullTemplateTypeCheck": true,
|
||||
"strictTemplates": true,
|
||||
"strictInjectionParameters": true
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue