Skip to content

Commit 6ed31d0

Browse files
authored
(feat) event completion/hover for components with a type definition (#1057)
Uses the TS type checker to retrieve the events type of the component. Enables completions and hover info for components defined in d.ts files
1 parent 5079797 commit 6ed31d0

File tree

7 files changed

+214
-12
lines changed

7 files changed

+214
-12
lines changed
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { ComponentEvents } from 'svelte2tsx';
2+
import ts from 'typescript';
3+
import { isNotNullOrUndefined } from '../../utils';
4+
import { findContainingNode } from './features/utils';
5+
6+
type ComponentEventInfo = ReturnType<ComponentEvents['getAll']>;
7+
8+
export class JsOrTsComponentInfoProvider implements ComponentInfoProvider {
9+
private constructor(
10+
private readonly typeChecker: ts.TypeChecker,
11+
private readonly classType: ts.Type
12+
) {}
13+
14+
getEvents(): ComponentEventInfo {
15+
const symbol = this.classType.getProperty('$$events_def');
16+
if (!symbol) {
17+
return [];
18+
}
19+
20+
const declaration = symbol.valueDeclaration;
21+
if (!declaration) {
22+
return [];
23+
}
24+
25+
const eventType = this.typeChecker.getTypeOfSymbolAtLocation(symbol, declaration);
26+
27+
return eventType
28+
.getProperties()
29+
.map((prop) => {
30+
if (!prop.valueDeclaration) {
31+
return;
32+
}
33+
34+
return {
35+
name: prop.name,
36+
type: this.typeChecker.typeToString(
37+
this.typeChecker.getTypeOfSymbolAtLocation(prop, prop.valueDeclaration)
38+
),
39+
doc: ts.displayPartsToString(prop.getDocumentationComment(this.typeChecker))
40+
};
41+
})
42+
.filter(isNotNullOrUndefined);
43+
}
44+
45+
static create(lang: ts.LanguageService, def: ts.DefinitionInfo): ComponentInfoProvider | null {
46+
const program = lang.getProgram();
47+
const sourceFile = program?.getSourceFile(def.fileName);
48+
49+
if (!program || !sourceFile) {
50+
return null;
51+
}
52+
53+
const defClass = findContainingNode(sourceFile, def.textSpan, ts.isClassDeclaration);
54+
55+
if (!defClass) {
56+
return null;
57+
}
58+
59+
const typeChecker = program.getTypeChecker();
60+
const classType = typeChecker.getTypeAtLocation(defClass);
61+
62+
if (!classType) {
63+
return null;
64+
}
65+
66+
return new JsOrTsComponentInfoProvider(typeChecker, classType);
67+
}
68+
}
69+
70+
export interface ComponentInfoProvider {
71+
getEvents(): ComponentEventInfo;
72+
}

packages/language-server/src/plugins/typescript/DocumentSnapshot.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
isInTag
1414
} from '../../lib/documents';
1515
import { pathToUrl } from '../../utils';
16+
import { ComponentInfoProvider, JsOrTsComponentInfoProvider } from './ComponentInfoProvider';
1617
import { ConsumerDocumentMapper } from './DocumentMapper';
1718
import {
1819
getScriptKindFromAttributes,
@@ -224,7 +225,7 @@ function preprocessSvelteFile(document: Document, options: SvelteSnapshotOptions
224225
/**
225226
* A svelte document snapshot suitable for the ts language service and the plugin.
226227
*/
227-
export class SvelteDocumentSnapshot implements DocumentSnapshot {
228+
export class SvelteDocumentSnapshot implements DocumentSnapshot, ComponentInfoProvider {
228229
private fragment?: SvelteSnapshotFragment;
229230

230231
version = this.parent.version;
@@ -325,6 +326,8 @@ export class JSOrTSDocumentSnapshot
325326
scriptKind = getScriptKindFromFileName(this.filePath);
326327
scriptInfo = null;
327328

329+
private readonly componentInfos = new Map<ts.DefinitionInfo, ComponentInfoProvider | null>();
330+
328331
constructor(public version: number, public readonly filePath: string, private text: string) {
329332
super(pathToUrl(filePath));
330333
}
@@ -377,6 +380,21 @@ export class JSOrTSDocumentSnapshot
377380

378381
this.version++;
379382
}
383+
384+
getComponentInfo(
385+
lang: ts.LanguageService,
386+
def: ts.DefinitionInfo
387+
): ComponentInfoProvider | null {
388+
// there might multiple component class in a js or ts file
389+
if (this.componentInfos.has(def)) {
390+
return this.componentInfos.get(def) ?? null;
391+
}
392+
393+
const componentInfoProvider = JsOrTsComponentInfoProvider.create(lang, def);
394+
this.componentInfos.set(def, componentInfoProvider);
395+
396+
return componentInfoProvider;
397+
}
380398
}
381399

382400
/**

packages/language-server/src/plugins/typescript/features/CompletionProvider.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,14 +210,14 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
210210
tsDoc: SvelteDocumentSnapshot,
211211
originalPosition: Position
212212
): Promise<Array<AppCompletionItem<CompletionEntryWithIdentifer>>> {
213-
const snapshot = await getComponentAtPosition(
213+
const componentInfo = await getComponentAtPosition(
214214
this.lsAndTsDocResolver,
215215
lang,
216216
doc,
217217
tsDoc,
218218
originalPosition
219219
);
220-
if (!snapshot) {
220+
if (!componentInfo) {
221221
return [];
222222
}
223223

@@ -227,7 +227,7 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
227227
right: /[^\w$:]/
228228
});
229229

230-
return snapshot.getEvents().map((event) => {
230+
return componentInfo.getEvents().map((event) => {
231231
const eventName = 'on:' + event.name;
232232
return {
233233
label: eventName,

packages/language-server/src/plugins/typescript/features/utils.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,26 @@ import {
66
getNodeIfIsInComponentStartTag,
77
isInTag
88
} from '../../../lib/documents';
9-
import { DocumentSnapshot, SnapshotFragment, SvelteDocumentSnapshot } from '../DocumentSnapshot';
9+
import { ComponentInfoProvider } from '../ComponentInfoProvider';
10+
import {
11+
DocumentSnapshot,
12+
JSOrTSDocumentSnapshot,
13+
SnapshotFragment,
14+
SvelteDocumentSnapshot
15+
} from '../DocumentSnapshot';
1016
import { LSAndTSDocResolver } from '../LSAndTSDocResolver';
1117

1218
/**
1319
* If the given original position is within a Svelte starting tag,
1420
* return the snapshot of that component.
1521
*/
1622
export async function getComponentAtPosition(
17-
lsAndTsDocResovler: LSAndTSDocResolver,
23+
lsAndTsDocResolver: LSAndTSDocResolver,
1824
lang: ts.LanguageService,
1925
doc: Document,
2026
tsDoc: SvelteDocumentSnapshot,
2127
originalPosition: Position
22-
): Promise<SvelteDocumentSnapshot | null> {
28+
): Promise<ComponentInfoProvider | null> {
2329
if (tsDoc.parserError) {
2430
return null;
2531
}
@@ -47,11 +53,17 @@ export async function getComponentAtPosition(
4753
return null;
4854
}
4955

50-
const snapshot = await lsAndTsDocResovler.getSnapshot(def.fileName);
51-
if (!(snapshot instanceof SvelteDocumentSnapshot)) {
52-
return null;
56+
const snapshot = await lsAndTsDocResolver.getSnapshot(def.fileName);
57+
58+
if (snapshot instanceof SvelteDocumentSnapshot) {
59+
return snapshot;
5360
}
54-
return snapshot;
61+
62+
if (snapshot instanceof JSOrTSDocumentSnapshot) {
63+
return snapshot.getComponentInfo(lang, def);
64+
}
65+
66+
return null;
5567
}
5668

5769
export function isComponentAtPosition(
@@ -138,3 +150,27 @@ export function isAfterSvelte2TsxPropsReturn(text: string, end: number) {
138150
return true;
139151
}
140152
}
153+
154+
export function findContainingNode<T extends ts.Node>(
155+
node: ts.Node,
156+
textSpan: ts.TextSpan,
157+
predicate: (node: ts.Node) => node is T
158+
): T | undefined {
159+
const children = node.getChildren();
160+
const end = textSpan.start + textSpan.length;
161+
162+
for (const child of children) {
163+
if (!(child.getStart() <= textSpan.start && child.getEnd() >= end)) {
164+
continue;
165+
}
166+
167+
if (predicate(child)) {
168+
return child;
169+
}
170+
171+
const foundInChildren = findContainingNode(child, textSpan, predicate);
172+
if (foundInChildren) {
173+
return foundInChildren;
174+
}
175+
}
176+
}

packages/language-server/test/plugins/typescript/features/CompletionProvider.test.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,6 @@ describe('CompletionProviderImpl', () => {
123123
);
124124
assert.ok(completions!.items.length > 0, 'Expected completions to have length');
125125

126-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
127126
const eventCompletions = completions!.items.filter((item) => item.label.startsWith('on:'));
128127

129128
assert.deepStrictEqual(eventCompletions, <CompletionItem[]>[
@@ -238,6 +237,64 @@ describe('CompletionProviderImpl', () => {
238237
]);
239238
});
240239

240+
it('provides event completion for components with type definition', async () => {
241+
const { completionProvider, document } = setup('component-events-completion-ts-def.svelte');
242+
243+
const completions = await completionProvider.getCompletions(
244+
document,
245+
Position.create(4, 17),
246+
{
247+
triggerKind: CompletionTriggerKind.Invoked
248+
}
249+
);
250+
251+
const eventCompletions = completions!.items.filter((item) => item.label.startsWith('on:'));
252+
253+
assert.deepStrictEqual(eventCompletions, <CompletionItem[]>[
254+
{
255+
detail: 'event1: CustomEvent<null>',
256+
documentation: '',
257+
label: 'on:event1',
258+
sortText: '-1',
259+
textEdit: {
260+
newText: 'on:event1',
261+
range: {
262+
end: {
263+
character: 17,
264+
line: 4
265+
},
266+
start: {
267+
character: 14,
268+
line: 4
269+
}
270+
}
271+
}
272+
},
273+
{
274+
detail: 'event2: CustomEvent<string>',
275+
documentation: {
276+
kind: 'markdown',
277+
value: 'documentation for event2'
278+
},
279+
label: 'on:event2',
280+
sortText: '-1',
281+
textEdit: {
282+
newText: 'on:event2',
283+
range: {
284+
end: {
285+
character: 17,
286+
line: 4
287+
},
288+
start: {
289+
character: 14,
290+
line: 4
291+
}
292+
}
293+
}
294+
}
295+
]);
296+
});
297+
241298
it('does not provide completions inside style tag', async () => {
242299
const { completionProvider, document } = setup('completionsstyle.svelte');
243300

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/// <reference lib="dom" />
2+
import { SvelteComponentTyped } from 'svelte';
3+
4+
export class ComponentDef extends SvelteComponentTyped<
5+
{},
6+
{
7+
event1: CustomEvent<null>;
8+
/**
9+
* documentation for event2
10+
*/
11+
event2: CustomEvent<string>;
12+
},
13+
{}
14+
> {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<script>
2+
import { ComponentDef } from './ComponentDef';
3+
</script>
4+
5+
<ComponentDef on:></ComponentDef>

0 commit comments

Comments
 (0)