Skip to content

Commit 605f6f3

Browse files
committed
refactor(language-service): enable host binding features
Reworks the language service to enable features like quick info and definitions inside host bindings. **Note:** for the full range of language service features to work, the changes from angular/vscode-ng-language-service#2155 have to be released.
1 parent bf5d995 commit 605f6f3

File tree

9 files changed

+638
-20
lines changed

9 files changed

+638
-20
lines changed

packages/language-service/src/definitions.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,15 @@ function getDefinitionForExpressionAtPosition(
427427
}
428428
}
429429

430+
if (resourceForExpression === null && resource.hostBindings !== null) {
431+
for (const binding of resource.hostBindings) {
432+
if (binding.node === expression) {
433+
resourceForExpression = binding;
434+
break;
435+
}
436+
}
437+
}
438+
430439
if (resourceForExpression === null || !isExternalResource(resourceForExpression)) {
431440
return;
432441
}

packages/language-service/src/language_service.ts

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -804,11 +804,51 @@ function isInTypeCheckContext(program: ts.Program, fileName: string, position: n
804804
return false;
805805
}
806806

807-
let asgn = getPropertyAssignmentFromValue(node, 'template');
808-
if (asgn === null) {
807+
const assignment = getPropertyAssignmentFromValue(node, 'template');
808+
if (assignment !== null) {
809+
return getClassDeclFromDecoratorProp(assignment) !== null;
810+
}
811+
return isHostBindingExpression(node);
812+
}
813+
814+
function isHostBindingExpression(node: ts.Node): boolean {
815+
if (!ts.isStringLiteralLike(node)) {
816+
return false;
817+
}
818+
819+
const assignment = closestAncestorNode(node, ts.isPropertyAssignment);
820+
if (assignment === null || assignment.initializer !== node) {
821+
return false;
822+
}
823+
824+
const literal = closestAncestorNode(assignment, ts.isObjectLiteralExpression);
825+
if (literal === null) {
826+
return false;
827+
}
828+
829+
const parentAssignment = getPropertyAssignmentFromValue(literal, 'host');
830+
if (parentAssignment === null || parentAssignment.initializer !== literal) {
809831
return false;
810832
}
811-
return getClassDeclFromDecoratorProp(asgn) !== null;
833+
834+
return getClassDeclFromDecoratorProp(parentAssignment) !== null;
835+
}
836+
837+
function closestAncestorNode<T extends ts.Node>(
838+
start: ts.Node,
839+
predicate: (node: ts.Node) => node is T,
840+
): T | null {
841+
let current = start.parent;
842+
843+
while (current) {
844+
if (predicate(current)) {
845+
return current;
846+
} else {
847+
current = current.parent;
848+
}
849+
}
850+
851+
return null;
812852
}
813853

814854
function isInAngularContext(program: ts.Program, fileName: string, position: number) {
@@ -821,6 +861,10 @@ function isInAngularContext(program: ts.Program, fileName: string, position: num
821861
return false;
822862
}
823863

864+
if (isHostBindingExpression(node)) {
865+
return true;
866+
}
867+
824868
const assignment =
825869
getPropertyAssignmentFromValue(node, 'template') ??
826870
getPropertyAssignmentFromValue(node, 'templateUrl') ??

packages/language-service/src/template_target.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
TmplAstElement,
3131
TmplAstForLoopBlock,
3232
TmplAstForLoopBlockEmpty,
33+
TmplAstHostElement,
3334
TmplAstIcu,
3435
TmplAstIfBlock,
3536
TmplAstIfBlockBranch,
@@ -430,8 +431,7 @@ class TemplateTargetVisitor implements TmplAstVisitor {
430431
private constructor(private readonly position: number) {}
431432

432433
visit(node: TmplAstNode) {
433-
const {start, end} = getSpanIncludingEndTag(node);
434-
if (end !== null && !isWithin(this.position, {start, end})) {
434+
if (!isWithinNode(this.position, node)) {
435435
return;
436436
}
437437

@@ -461,6 +461,10 @@ class TemplateTargetVisitor implements TmplAstVisitor {
461461
// If cursor is within source span but not within key span or value span,
462462
// do not return the node.
463463
this.path.push(OUTSIDE_K_V_MARKER);
464+
} else if (node instanceof TmplAstHostElement) {
465+
this.path.push(node);
466+
this.visitAll(node.bindings);
467+
this.visitAll(node.listeners);
464468
} else {
465469
this.path.push(node);
466470
node.visit(this);
@@ -673,3 +677,20 @@ function getSpanIncludingEndTag(ast: TmplAstNode) {
673677
}
674678
return result;
675679
}
680+
681+
/** Checks whether a position is within an AST node. */
682+
function isWithinNode(position: number, node: TmplAstNode): boolean {
683+
if (!(node instanceof TmplAstHostElement)) {
684+
return isWithin(position, getSpanIncludingEndTag(node));
685+
}
686+
687+
// Host elements are special in that they don't have a contiguous source span. E.g. some bindings
688+
// can be in the `host` literal in the decorator while others are on class members. That's why we
689+
// need to check each binding, rather than the host element itself.
690+
return (
691+
(node.bindings.length > 0 &&
692+
node.bindings.some((binding) => isWithin(position, binding.sourceSpan))) ||
693+
(node.listeners.length > 0 &&
694+
node.listeners.some((listener) => isWithin(position, listener.sourceSpan)))
695+
);
696+
}

packages/language-service/src/utils/index.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -136,21 +136,43 @@ function getInlineTypeCheckInfoAtPosition(
136136
// Return `undefined` if the position is not on the template expression or the template resource
137137
// is not inline.
138138
const resources = compiler.getDirectiveResources(classDecl);
139+
if (resources === null) {
140+
return undefined;
141+
}
142+
139143
if (
140-
resources === null ||
141-
resources.template === null ||
142-
isExternalResource(resources.template) ||
143-
expression !== resources.template.node
144+
resources.template !== null &&
145+
!isExternalResource(resources.template) &&
146+
expression === resources.template.node
144147
) {
145-
return undefined;
148+
const template = compiler.getTemplateTypeChecker().getTemplate(classDecl);
149+
if (template === null) {
150+
return undefined;
151+
}
152+
153+
return {nodes: template, declaration: classDecl};
146154
}
147155

148-
const template = compiler.getTemplateTypeChecker().getTemplate(classDecl);
149-
if (template === null) {
150-
return undefined;
156+
if (resources.hostBindings !== null) {
157+
const start = expression.getStart();
158+
const end = expression.getEnd();
159+
160+
for (const binding of resources.hostBindings) {
161+
if (
162+
!isExternalResource(binding) &&
163+
start >= binding.node.getStart() &&
164+
end <= binding.node.getEnd()
165+
) {
166+
const hostElement = compiler.getTemplateTypeChecker().getHostElement(classDecl);
167+
168+
if (hostElement !== null) {
169+
return {nodes: [hostElement], declaration: classDecl};
170+
}
171+
}
172+
}
151173
}
152174

153-
return {nodes: template, declaration: classDecl};
175+
return undefined;
154176
}
155177

156178
/**

packages/language-service/test/completions_spec.ts

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
DisplayInfoKind,
1414
unsafeCastDisplayInfoKindToScriptElementKind,
1515
} from '../src/utils/display_parts';
16-
import {LanguageServiceTestEnv, OpenBuffer} from '../testing';
16+
import {LanguageServiceTestEnv, OpenBuffer, TestableOptions} from '../testing';
1717

1818
const DIR_WITH_INPUT = {
1919
'Dir': `
@@ -2057,6 +2057,63 @@ describe('completions', () => {
20572057
expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['hasRing', 'size']);
20582058
});
20592059
});
2060+
2061+
describe('host bindings', () => {
2062+
it('should be able to complete a property host binding', () => {
2063+
const {appFile} = setupInlineTemplate(
2064+
'',
2065+
`title!: string; hero!: number;`,
2066+
undefined,
2067+
`host: {'[title]': 'ti'},`,
2068+
{
2069+
typeCheckHostBindings: true,
2070+
},
2071+
);
2072+
appFile.moveCursorToText(`'ti¦'`);
2073+
const completions = appFile.getCompletionsAtPosition();
2074+
expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['title', 'hero']);
2075+
});
2076+
2077+
it('should be able to complete a listener host binding', () => {
2078+
const {appFile} = setupInlineTemplate(
2079+
'',
2080+
`title!: string; hero!: number;`,
2081+
undefined,
2082+
`host: {'(click)': 't'},`,
2083+
{
2084+
typeCheckHostBindings: true,
2085+
},
2086+
);
2087+
appFile.moveCursorToText(`'(click)': 't¦'`);
2088+
const completions = appFile.getCompletionsAtPosition();
2089+
expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['title', 'hero']);
2090+
});
2091+
2092+
it('should be able to complete inside `host` of a directive', () => {
2093+
const {appFile} = setupInlineTemplate(
2094+
'',
2095+
'',
2096+
{
2097+
'Dir': `
2098+
@Directive({
2099+
host: {'[title]': 'ti'},
2100+
})
2101+
export class Dir {
2102+
title!: string;
2103+
hero!: number;
2104+
}
2105+
`,
2106+
},
2107+
undefined,
2108+
{
2109+
typeCheckHostBindings: true,
2110+
},
2111+
);
2112+
appFile.moveCursorToText(`'ti¦'`);
2113+
const completions = appFile.getCompletionsAtPosition();
2114+
expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['title', 'hero']);
2115+
});
2116+
});
20602117
});
20612118

20622119
function expectContainInsertText(
@@ -2207,6 +2264,8 @@ function setupInlineTemplate(
22072264
template: string,
22082265
classContents: string,
22092266
otherDeclarations: {[name: string]: string} = {},
2267+
componentMetadata = '',
2268+
compilerOptions?: TestableOptions,
22102269
): {
22112270
appFile: OpenBuffer;
22122271
} {
@@ -2215,13 +2274,16 @@ function setupInlineTemplate(
22152274
const otherDirectiveClassDecls = Object.values(otherDeclarations).join('\n\n');
22162275

22172276
const env = LanguageServiceTestEnv.setup();
2218-
const project = env.addProject('test', {
2219-
'test.ts': `
2277+
const project = env.addProject(
2278+
'test',
2279+
{
2280+
'test.ts': `
22202281
import {Component, Directive, NgModule, Pipe, TemplateRef} from '@angular/core';
22212282
22222283
@Component({
22232284
template: '${template}',
22242285
selector: 'app-cmp',
2286+
${componentMetadata}
22252287
})
22262288
export class AppCmp {
22272289
${classContents}
@@ -2234,6 +2296,8 @@ function setupInlineTemplate(
22342296
})
22352297
export class AppModule {}
22362298
`,
2237-
});
2299+
},
2300+
compilerOptions,
2301+
);
22382302
return {appFile: project.openFile('test.ts')};
22392303
}

0 commit comments

Comments
 (0)