Skip to content

Commit 137b378

Browse files
authored
(feat) component events hover info (#485)
Provides hover info for events with name, type, doc, if available
1 parent 5068244 commit 137b378

File tree

11 files changed

+313
-131
lines changed

11 files changed

+313
-131
lines changed

packages/language-server/src/lib/documents/utils.ts

+19
Original file line numberDiff line numberDiff line change
@@ -313,3 +313,22 @@ export function getNodeIfIsInComponentStartTag(
313313
return node;
314314
}
315315
}
316+
317+
/**
318+
* Gets word at position.
319+
* Delimiter is by default a whitespace, but can be adjusted.
320+
*/
321+
export function getWordAt(
322+
str: string,
323+
pos: number,
324+
delimiterRegex = { left: /\S+$/, right: /\s/ },
325+
): string {
326+
const left = str.slice(0, pos + 1).search(delimiterRegex.left);
327+
const right = str.slice(pos).search(delimiterRegex.right);
328+
329+
if (right < 0) {
330+
return str.slice(left);
331+
}
332+
333+
return str.slice(left, right + pos);
334+
}

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

+7-37
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,7 @@ import {
1313
SymbolInformation,
1414
WorkspaceEdit,
1515
} from 'vscode-languageserver';
16-
import {
17-
Document,
18-
DocumentManager,
19-
mapHoverToParent,
20-
mapSymbolInformationToOriginal,
21-
} from '../../lib/documents';
16+
import { Document, DocumentManager, mapSymbolInformationToOriginal } from '../../lib/documents';
2217
import { LSConfigManager, LSTypescriptConfig } from '../../ls-config';
2318
import { pathToUrl } from '../../utils';
2419
import {
@@ -42,15 +37,11 @@ import {
4237
CompletionsProviderImpl,
4338
} from './features/CompletionProvider';
4439
import { DiagnosticsProviderImpl } from './features/DiagnosticsProvider';
40+
import { HoverProviderImpl } from './features/HoverProvider';
41+
import { RenameProviderImpl } from './features/RenameProvider';
4542
import { UpdateImportsProviderImpl } from './features/UpdateImportsProvider';
4643
import { LSAndTSDocResolver } from './LSAndTSDocResolver';
47-
import {
48-
convertRange,
49-
convertToLocationRange,
50-
getScriptKindFromFileName,
51-
symbolKindFromString,
52-
} from './utils';
53-
import { RenameProviderImpl } from './features/RenameProvider';
44+
import { convertToLocationRange, getScriptKindFromFileName, symbolKindFromString } from './utils';
5445

5546
export class TypeScriptPlugin
5647
implements
@@ -70,6 +61,7 @@ export class TypeScriptPlugin
7061
private readonly updateImportsProvider: UpdateImportsProviderImpl;
7162
private readonly diagnosticsProvider: DiagnosticsProviderImpl;
7263
private readonly renameProvider: RenameProviderImpl;
64+
private readonly hoverProvider: HoverProviderImpl;
7365

7466
constructor(
7567
docManager: DocumentManager,
@@ -86,6 +78,7 @@ export class TypeScriptPlugin
8678
this.updateImportsProvider = new UpdateImportsProviderImpl(this.lsAndTsDocResolver);
8779
this.diagnosticsProvider = new DiagnosticsProviderImpl(this.lsAndTsDocResolver);
8880
this.renameProvider = new RenameProviderImpl(this.lsAndTsDocResolver);
81+
this.hoverProvider = new HoverProviderImpl(this.lsAndTsDocResolver);
8982
}
9083

9184
async getDiagnostics(document: Document): Promise<Diagnostic[]> {
@@ -101,30 +94,7 @@ export class TypeScriptPlugin
10194
return null;
10295
}
10396

104-
const { lang, tsDoc } = this.getLSAndTSDoc(document);
105-
const fragment = await tsDoc.getFragment();
106-
const info = lang.getQuickInfoAtPosition(
107-
tsDoc.filePath,
108-
fragment.offsetAt(fragment.getGeneratedPosition(position)),
109-
);
110-
if (!info) {
111-
return null;
112-
}
113-
const declaration = ts.displayPartsToString(info.displayParts);
114-
const documentation =
115-
typeof info.documentation === 'string'
116-
? info.documentation
117-
: ts.displayPartsToString(info.documentation);
118-
119-
// https://microsoft.github.io/language-server-protocol/specification#textDocument_hover
120-
const contents = ['```typescript', declaration, '```']
121-
.concat(documentation ? ['---', documentation] : [])
122-
.join('\n');
123-
124-
return mapHoverToParent(fragment, {
125-
range: convertRange(fragment, info.textSpan),
126-
contents,
127-
});
97+
return this.hoverProvider.doHover(document, position);
12898
}
12999

130100
async getDocumentSymbols(document: Document): Promise<SymbolInformation[]> {

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

+13-50
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,29 @@ import {
33
CompletionContext,
44
CompletionList,
55
CompletionTriggerKind,
6+
MarkupContent,
7+
MarkupKind,
68
Position,
79
Range,
810
TextDocumentIdentifier,
911
TextEdit,
10-
MarkupContent,
11-
MarkupKind,
1212
} from 'vscode-languageserver';
1313
import {
1414
Document,
1515
isInTag,
1616
mapCompletionItemToOriginal,
1717
mapRangeToOriginal,
18-
getNodeIfIsInComponentStartTag,
1918
} from '../../../lib/documents';
20-
import { isNotNullOrUndefined, pathToUrl, getRegExpMatches, flatten } from '../../../utils';
19+
import { flatten, getRegExpMatches, isNotNullOrUndefined, pathToUrl } from '../../../utils';
2120
import { AppCompletionItem, AppCompletionList, CompletionsProvider } from '../../interfaces';
22-
import { SvelteSnapshotFragment, SvelteDocumentSnapshot } from '../DocumentSnapshot';
21+
import { SvelteDocumentSnapshot, SvelteSnapshotFragment } from '../DocumentSnapshot';
2322
import { LSAndTSDocResolver } from '../LSAndTSDocResolver';
2423
import {
2524
convertRange,
2625
getCommitCharactersForScriptElement,
2726
scriptElementKindToCompletionItemKind,
2827
} from '../utils';
28+
import { getComponentAtPosition } from './utils';
2929

3030
export interface CompletionEntryWithIdentifer extends ts.CompletionEntry, TextDocumentIdentifier {
3131
position: Position;
@@ -135,7 +135,14 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
135135
fragment: SvelteSnapshotFragment,
136136
originalPosition: Position,
137137
): AppCompletionItem<CompletionEntryWithIdentifer>[] {
138-
const snapshot = this.getComponentAtPosition(lang, doc, tsDoc, fragment, originalPosition);
138+
const snapshot = getComponentAtPosition(
139+
this.lsAndTsDocResovler,
140+
lang,
141+
doc,
142+
tsDoc,
143+
fragment,
144+
originalPosition,
145+
);
139146
if (!snapshot) {
140147
return [];
141148
}
@@ -148,50 +155,6 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
148155
}));
149156
}
150157

151-
/**
152-
* If the completion happens inside the template and within the
153-
* tag of a Svelte component, then retrieve its snapshot.
154-
*/
155-
private getComponentAtPosition(
156-
lang: ts.LanguageService,
157-
doc: Document,
158-
tsDoc: SvelteDocumentSnapshot,
159-
fragment: SvelteSnapshotFragment,
160-
originalPosition: Position,
161-
): SvelteDocumentSnapshot | null {
162-
if (tsDoc.parserError) {
163-
return null;
164-
}
165-
166-
if (
167-
isInTag(originalPosition, doc.scriptInfo) ||
168-
isInTag(originalPosition, doc.moduleScriptInfo)
169-
) {
170-
// Inside script tags -> not a component
171-
return null;
172-
}
173-
174-
const node = getNodeIfIsInComponentStartTag(doc.html, doc.offsetAt(originalPosition));
175-
if (!node) {
176-
return null;
177-
}
178-
179-
const generatedPosition = fragment.getGeneratedPosition(doc.positionAt(node.start + 1));
180-
const def = lang.getDefinitionAtPosition(
181-
tsDoc.filePath,
182-
fragment.offsetAt(generatedPosition),
183-
)?.[0];
184-
if (!def) {
185-
return null;
186-
}
187-
188-
const snapshot = this.lsAndTsDocResovler.getSnapshot(def.fileName);
189-
if (!(snapshot instanceof SvelteDocumentSnapshot)) {
190-
return null;
191-
}
192-
return snapshot;
193-
}
194-
195158
private toCompletionItem(
196159
fragment: SvelteSnapshotFragment,
197160
comp: ts.CompletionEntry,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import ts from 'typescript';
2+
import { Hover, Position } from 'vscode-languageserver';
3+
import { Document, getWordAt, mapHoverToParent } from '../../../lib/documents';
4+
import { HoverProvider } from '../../interfaces';
5+
import { SvelteDocumentSnapshot, SvelteSnapshotFragment } from '../DocumentSnapshot';
6+
import { LSAndTSDocResolver } from '../LSAndTSDocResolver';
7+
import { convertRange } from '../utils';
8+
import { getComponentAtPosition } from './utils';
9+
10+
export class HoverProviderImpl implements HoverProvider {
11+
constructor(private readonly lsAndTsDocResolver: LSAndTSDocResolver) {}
12+
13+
async doHover(document: Document, position: Position): Promise<Hover | null> {
14+
const { lang, tsDoc } = this.getLSAndTSDoc(document);
15+
const fragment = await tsDoc.getFragment();
16+
17+
const eventHoverInfo = this.getEventHoverInfo(lang, document, tsDoc, fragment, position);
18+
if (eventHoverInfo) {
19+
return eventHoverInfo;
20+
}
21+
22+
const info = lang.getQuickInfoAtPosition(
23+
tsDoc.filePath,
24+
fragment.offsetAt(fragment.getGeneratedPosition(position)),
25+
);
26+
if (!info) {
27+
return null;
28+
}
29+
30+
const declaration = ts.displayPartsToString(info.displayParts);
31+
const documentation =
32+
typeof info.documentation === 'string'
33+
? info.documentation
34+
: ts.displayPartsToString(info.documentation);
35+
36+
// https://microsoft.github.io/language-server-protocol/specification#textDocument_hover
37+
const contents = ['```typescript', declaration, '```']
38+
.concat(documentation ? ['---', documentation] : [])
39+
.join('\n');
40+
41+
return mapHoverToParent(fragment, {
42+
range: convertRange(fragment, info.textSpan),
43+
contents,
44+
});
45+
}
46+
47+
private getEventHoverInfo(
48+
lang: ts.LanguageService,
49+
doc: Document,
50+
tsDoc: SvelteDocumentSnapshot,
51+
fragment: SvelteSnapshotFragment,
52+
originalPosition: Position,
53+
): Hover | null {
54+
const possibleEventName = getWordAt(doc.getText(), doc.offsetAt(originalPosition), {
55+
left: /\S+$/,
56+
right: /[\s=]/,
57+
});
58+
if (!possibleEventName.startsWith('on:')) {
59+
return null;
60+
}
61+
62+
const component = getComponentAtPosition(
63+
this.lsAndTsDocResolver,
64+
lang,
65+
doc,
66+
tsDoc,
67+
fragment,
68+
originalPosition,
69+
);
70+
if (!component) {
71+
return null;
72+
}
73+
74+
const eventName = possibleEventName.substr('on:'.length);
75+
const event = component.getEvents().find((event) => event.name === eventName);
76+
if (!event) {
77+
return null;
78+
}
79+
80+
return {
81+
contents: [
82+
'```typescript',
83+
`${event.name}: ${event.type}`,
84+
'```',
85+
event.doc || '',
86+
].join('\n'),
87+
};
88+
}
89+
90+
private getLSAndTSDoc(document: Document) {
91+
return this.lsAndTsDocResolver.getLSAndTSDoc(document);
92+
}
93+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import ts from 'typescript';
2+
import { Position } from 'vscode-languageserver';
3+
import { Document, getNodeIfIsInComponentStartTag, isInTag } from '../../../lib/documents';
4+
import { SvelteDocumentSnapshot, SvelteSnapshotFragment } from '../DocumentSnapshot';
5+
import { LSAndTSDocResolver } from '../LSAndTSDocResolver';
6+
7+
/**
8+
* If the given original position is within a Svelte starting tag,
9+
* return the snapshot of that component.
10+
*/
11+
export function getComponentAtPosition(
12+
lsAndTsDocResovler: LSAndTSDocResolver,
13+
lang: ts.LanguageService,
14+
doc: Document,
15+
tsDoc: SvelteDocumentSnapshot,
16+
fragment: SvelteSnapshotFragment,
17+
originalPosition: Position,
18+
): SvelteDocumentSnapshot | null {
19+
if (tsDoc.parserError) {
20+
return null;
21+
}
22+
23+
if (
24+
isInTag(originalPosition, doc.scriptInfo) ||
25+
isInTag(originalPosition, doc.moduleScriptInfo)
26+
) {
27+
// Inside script tags -> not a component
28+
return null;
29+
}
30+
31+
const node = getNodeIfIsInComponentStartTag(doc.html, doc.offsetAt(originalPosition));
32+
if (!node) {
33+
return null;
34+
}
35+
36+
const generatedPosition = fragment.getGeneratedPosition(doc.positionAt(node.start + 1));
37+
const def = lang.getDefinitionAtPosition(
38+
tsDoc.filePath,
39+
fragment.offsetAt(generatedPosition),
40+
)?.[0];
41+
if (!def) {
42+
return null;
43+
}
44+
45+
const snapshot = lsAndTsDocResovler.getSnapshot(def.fileName);
46+
if (!(snapshot instanceof SvelteDocumentSnapshot)) {
47+
return null;
48+
}
49+
return snapshot;
50+
}

packages/language-server/test/lib/documents/utils.test.ts

+26
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
extractStyleTag,
55
extractScriptTags,
66
updateRelativeImport,
7+
getWordAt,
78
} from '../../../src/lib/documents/utils';
89
import { Position } from 'vscode-languageserver';
910

@@ -328,4 +329,29 @@ describe('document/utils', () => {
328329
assert.deepStrictEqual(newPath, './oldPath/someTsFile');
329330
});
330331
});
332+
333+
describe('#getWordAt', () => {
334+
it('returns word between whitespaces', () => {
335+
assert.equal(getWordAt('qwd asd qwd', 5), 'asd');
336+
});
337+
338+
it('returns word between whitespace and end of string', () => {
339+
assert.equal(getWordAt('qwd asd', 5), 'asd');
340+
});
341+
342+
it('returns word between start of string and whitespace', () => {
343+
assert.equal(getWordAt('asd qwd', 2), 'asd');
344+
});
345+
346+
it('returns word between start of string and end of string', () => {
347+
assert.equal(getWordAt('asd', 2), 'asd');
348+
});
349+
350+
it('returns word with custom delimiters', () => {
351+
assert.equal(
352+
getWordAt('asd on:asd-qwd="asd" ', 10, { left: /\S+$/, right: /[\s=]/ }),
353+
'on:asd-qwd',
354+
);
355+
});
356+
});
331357
});

0 commit comments

Comments
 (0)