From fb32a47be7b869c159a1d18e55873f7580007bf2 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 26 Aug 2020 12:37:26 +0200 Subject: [PATCH 1/2] (feat) component events hover info Provides hover info for events with name, type, doc, if available --- .../src/lib/documents/utils.ts | 19 ++++ .../plugins/typescript/TypeScriptPlugin.ts | 44 ++------- .../typescript/features/CompletionProvider.ts | 63 +++---------- .../typescript/features/HoverProvider.ts | 93 +++++++++++++++++++ .../src/plugins/typescript/features/utils.ts | 50 ++++++++++ .../test/lib/documents/utils.test.ts | 26 ++++++ .../typescript/TypescriptPlugin.test.ts | 40 +------- .../typescript/features/HoverProvider.test.ts | 80 ++++++++++++++++ .../hover/hover-events-interface.svelte | 13 +++ .../testfiles/hover/hoverinfo.svelte | 10 ++ .../typescript/testfiles/hoverinfo.svelte | 6 -- 11 files changed, 313 insertions(+), 131 deletions(-) create mode 100644 packages/language-server/src/plugins/typescript/features/HoverProvider.ts create mode 100644 packages/language-server/src/plugins/typescript/features/utils.ts create mode 100644 packages/language-server/test/plugins/typescript/features/HoverProvider.test.ts create mode 100644 packages/language-server/test/plugins/typescript/testfiles/hover/hover-events-interface.svelte create mode 100644 packages/language-server/test/plugins/typescript/testfiles/hover/hoverinfo.svelte delete mode 100644 packages/language-server/test/plugins/typescript/testfiles/hoverinfo.svelte diff --git a/packages/language-server/src/lib/documents/utils.ts b/packages/language-server/src/lib/documents/utils.ts index 3821dab44..c3ea5f004 100644 --- a/packages/language-server/src/lib/documents/utils.ts +++ b/packages/language-server/src/lib/documents/utils.ts @@ -313,3 +313,22 @@ export function getNodeIfIsInComponentStartTag( return node; } } + +/** + * Gets word at position. + * Delimiter is by default a whitespace, but can be adjusted. + */ +export function getWordAt( + str: string, + pos: number, + delimiterRegex = { left: /\S+$/, right: /\s/ }, +): string { + const left = str.slice(0, pos + 1).search(delimiterRegex.left); + const right = str.slice(pos).search(delimiterRegex.right); + + if (right < 0) { + return str.slice(left); + } + + return str.slice(left, right + pos); +} diff --git a/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts b/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts index f13c0d96f..759c43f89 100644 --- a/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts +++ b/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts @@ -13,12 +13,7 @@ import { SymbolInformation, WorkspaceEdit, } from 'vscode-languageserver'; -import { - Document, - DocumentManager, - mapHoverToParent, - mapSymbolInformationToOriginal, -} from '../../lib/documents'; +import { Document, DocumentManager, mapSymbolInformationToOriginal } from '../../lib/documents'; import { LSConfigManager, LSTypescriptConfig } from '../../ls-config'; import { pathToUrl } from '../../utils'; import { @@ -42,15 +37,11 @@ import { CompletionsProviderImpl, } from './features/CompletionProvider'; import { DiagnosticsProviderImpl } from './features/DiagnosticsProvider'; +import { HoverProviderImpl } from './features/HoverProvider'; +import { RenameProviderImpl } from './features/RenameProvider'; import { UpdateImportsProviderImpl } from './features/UpdateImportsProvider'; import { LSAndTSDocResolver } from './LSAndTSDocResolver'; -import { - convertRange, - convertToLocationRange, - getScriptKindFromFileName, - symbolKindFromString, -} from './utils'; -import { RenameProviderImpl } from './features/RenameProvider'; +import { convertToLocationRange, getScriptKindFromFileName, symbolKindFromString } from './utils'; export class TypeScriptPlugin implements @@ -70,6 +61,7 @@ export class TypeScriptPlugin private readonly updateImportsProvider: UpdateImportsProviderImpl; private readonly diagnosticsProvider: DiagnosticsProviderImpl; private readonly renameProvider: RenameProviderImpl; + private readonly hoverProvider: HoverProviderImpl; constructor( docManager: DocumentManager, @@ -86,6 +78,7 @@ export class TypeScriptPlugin this.updateImportsProvider = new UpdateImportsProviderImpl(this.lsAndTsDocResolver); this.diagnosticsProvider = new DiagnosticsProviderImpl(this.lsAndTsDocResolver); this.renameProvider = new RenameProviderImpl(this.lsAndTsDocResolver); + this.hoverProvider = new HoverProviderImpl(this.lsAndTsDocResolver); } async getDiagnostics(document: Document): Promise { @@ -101,30 +94,7 @@ export class TypeScriptPlugin return null; } - const { lang, tsDoc } = this.getLSAndTSDoc(document); - const fragment = await tsDoc.getFragment(); - const info = lang.getQuickInfoAtPosition( - tsDoc.filePath, - fragment.offsetAt(fragment.getGeneratedPosition(position)), - ); - if (!info) { - return null; - } - const declaration = ts.displayPartsToString(info.displayParts); - const documentation = - typeof info.documentation === 'string' - ? info.documentation - : ts.displayPartsToString(info.documentation); - - // https://microsoft.github.io/language-server-protocol/specification#textDocument_hover - const contents = ['```typescript', declaration, '```'] - .concat(documentation ? ['---', documentation] : []) - .join('\n'); - - return mapHoverToParent(fragment, { - range: convertRange(fragment, info.textSpan), - contents, - }); + return this.hoverProvider.doHover(document, position); } async getDocumentSymbols(document: Document): Promise { diff --git a/packages/language-server/src/plugins/typescript/features/CompletionProvider.ts b/packages/language-server/src/plugins/typescript/features/CompletionProvider.ts index 01d3cfc72..b222acf90 100644 --- a/packages/language-server/src/plugins/typescript/features/CompletionProvider.ts +++ b/packages/language-server/src/plugins/typescript/features/CompletionProvider.ts @@ -3,29 +3,29 @@ import { CompletionContext, CompletionList, CompletionTriggerKind, + MarkupContent, + MarkupKind, Position, Range, TextDocumentIdentifier, TextEdit, - MarkupContent, - MarkupKind, } from 'vscode-languageserver'; import { Document, isInTag, mapCompletionItemToOriginal, mapRangeToOriginal, - getNodeIfIsInComponentStartTag, } from '../../../lib/documents'; -import { isNotNullOrUndefined, pathToUrl, getRegExpMatches, flatten } from '../../../utils'; +import { flatten, getRegExpMatches, isNotNullOrUndefined, pathToUrl } from '../../../utils'; import { AppCompletionItem, AppCompletionList, CompletionsProvider } from '../../interfaces'; -import { SvelteSnapshotFragment, SvelteDocumentSnapshot } from '../DocumentSnapshot'; +import { SvelteDocumentSnapshot, SvelteSnapshotFragment } from '../DocumentSnapshot'; import { LSAndTSDocResolver } from '../LSAndTSDocResolver'; import { convertRange, getCommitCharactersForScriptElement, scriptElementKindToCompletionItemKind, } from '../utils'; +import { getComponentAtPosition } from './utils'; export interface CompletionEntryWithIdentifer extends ts.CompletionEntry, TextDocumentIdentifier { position: Position; @@ -135,7 +135,14 @@ export class CompletionsProviderImpl implements CompletionsProvider[] { - const snapshot = this.getComponentAtPosition(lang, doc, tsDoc, fragment, originalPosition); + const snapshot = getComponentAtPosition( + this.lsAndTsDocResovler, + lang, + doc, + tsDoc, + fragment, + originalPosition, + ); if (!snapshot) { return []; } @@ -148,50 +155,6 @@ export class CompletionsProviderImpl implements CompletionsProvider not a component - return null; - } - - const node = getNodeIfIsInComponentStartTag(doc.html, doc.offsetAt(originalPosition)); - if (!node) { - return null; - } - - const generatedPosition = fragment.getGeneratedPosition(doc.positionAt(node.start + 1)); - const def = lang.getDefinitionAtPosition( - tsDoc.filePath, - fragment.offsetAt(generatedPosition), - )?.[0]; - if (!def) { - return null; - } - - const snapshot = this.lsAndTsDocResovler.getSnapshot(def.fileName); - if (!(snapshot instanceof SvelteDocumentSnapshot)) { - return null; - } - return snapshot; - } - private toCompletionItem( fragment: SvelteSnapshotFragment, comp: ts.CompletionEntry, diff --git a/packages/language-server/src/plugins/typescript/features/HoverProvider.ts b/packages/language-server/src/plugins/typescript/features/HoverProvider.ts new file mode 100644 index 000000000..07a490c4b --- /dev/null +++ b/packages/language-server/src/plugins/typescript/features/HoverProvider.ts @@ -0,0 +1,93 @@ +import ts from 'typescript'; +import { Hover, Position } from 'vscode-languageserver'; +import { Document, getWordAt, mapHoverToParent } from '../../../lib/documents'; +import { HoverProvider } from '../../interfaces'; +import { SvelteDocumentSnapshot, SvelteSnapshotFragment } from '../DocumentSnapshot'; +import { LSAndTSDocResolver } from '../LSAndTSDocResolver'; +import { convertRange } from '../utils'; +import { getComponentAtPosition } from './utils'; + +export class HoverProviderImpl implements HoverProvider { + constructor(private readonly lsAndTsDocResolver: LSAndTSDocResolver) {} + + async doHover(document: Document, position: Position): Promise { + const { lang, tsDoc } = this.getLSAndTSDoc(document); + const fragment = await tsDoc.getFragment(); + + const eventHoverInfo = this.getEventHoverInfo(lang, document, tsDoc, fragment, position); + if (eventHoverInfo) { + return eventHoverInfo; + } + + const info = lang.getQuickInfoAtPosition( + tsDoc.filePath, + fragment.offsetAt(fragment.getGeneratedPosition(position)), + ); + if (!info) { + return null; + } + + const declaration = ts.displayPartsToString(info.displayParts); + const documentation = + typeof info.documentation === 'string' + ? info.documentation + : ts.displayPartsToString(info.documentation); + + // https://microsoft.github.io/language-server-protocol/specification#textDocument_hover + const contents = ['```typescript', declaration, '```'] + .concat(documentation ? ['---', documentation] : []) + .join('\n'); + + return mapHoverToParent(fragment, { + range: convertRange(fragment, info.textSpan), + contents, + }); + } + + private getEventHoverInfo( + lang: ts.LanguageService, + doc: Document, + tsDoc: SvelteDocumentSnapshot, + fragment: SvelteSnapshotFragment, + originalPosition: Position, + ): Hover | null { + const possibleEventName = getWordAt(doc.getText(), doc.offsetAt(originalPosition), { + left: /\S+$/, + right: /[\s=]/, + }); + if (!possibleEventName.startsWith('on:')) { + return null; + } + + const component = getComponentAtPosition( + this.lsAndTsDocResolver, + lang, + doc, + tsDoc, + fragment, + originalPosition, + ); + if (!component) { + return null; + } + + const eventName = possibleEventName.substr('on:'.length); + const event = component.getEvents().find((event) => event.name === eventName); + if (!event) { + return null; + } + + return { + contents: [ + '```typescript', + `${event.name}: ${event.type}`, + '```', + event.doc || '', + ].join('\n'), + }; + } + + private getLSAndTSDoc(document: Document) { + return this.lsAndTsDocResolver.getLSAndTSDoc(document); + } +} diff --git a/packages/language-server/src/plugins/typescript/features/utils.ts b/packages/language-server/src/plugins/typescript/features/utils.ts new file mode 100644 index 000000000..d08dead2d --- /dev/null +++ b/packages/language-server/src/plugins/typescript/features/utils.ts @@ -0,0 +1,50 @@ +import ts from 'typescript'; +import { Position } from 'vscode-languageserver'; +import { Document, getNodeIfIsInComponentStartTag, isInTag } from '../../../lib/documents'; +import { SvelteDocumentSnapshot, SvelteSnapshotFragment } from '../DocumentSnapshot'; +import { LSAndTSDocResolver } from '../LSAndTSDocResolver'; + +/** + * If the given original position is within a Svelte starting tag, + * return the snapshot of that component. + */ +export function getComponentAtPosition( + lsAndTsDocResovler: LSAndTSDocResolver, + lang: ts.LanguageService, + doc: Document, + tsDoc: SvelteDocumentSnapshot, + fragment: SvelteSnapshotFragment, + originalPosition: Position, +): SvelteDocumentSnapshot | null { + if (tsDoc.parserError) { + return null; + } + + if ( + isInTag(originalPosition, doc.scriptInfo) || + isInTag(originalPosition, doc.moduleScriptInfo) + ) { + // Inside script tags -> not a component + return null; + } + + const node = getNodeIfIsInComponentStartTag(doc.html, doc.offsetAt(originalPosition)); + if (!node) { + return null; + } + + const generatedPosition = fragment.getGeneratedPosition(doc.positionAt(node.start + 1)); + const def = lang.getDefinitionAtPosition( + tsDoc.filePath, + fragment.offsetAt(generatedPosition), + )?.[0]; + if (!def) { + return null; + } + + const snapshot = lsAndTsDocResovler.getSnapshot(def.fileName); + if (!(snapshot instanceof SvelteDocumentSnapshot)) { + return null; + } + return snapshot; +} diff --git a/packages/language-server/test/lib/documents/utils.test.ts b/packages/language-server/test/lib/documents/utils.test.ts index fdf7e1b95..013fc9fd0 100644 --- a/packages/language-server/test/lib/documents/utils.test.ts +++ b/packages/language-server/test/lib/documents/utils.test.ts @@ -4,6 +4,7 @@ import { extractStyleTag, extractScriptTags, updateRelativeImport, + getWordAt, } from '../../../src/lib/documents/utils'; import { Position } from 'vscode-languageserver'; @@ -328,4 +329,29 @@ describe('document/utils', () => { assert.deepStrictEqual(newPath, './oldPath/someTsFile'); }); }); + + describe('#getWordAt', () => { + it('returns word between whitespaces', () => { + assert.equal(getWordAt('qwd asd qwd', 5), 'asd'); + }); + + it('returns word between whitespace and end of string', () => { + assert.equal(getWordAt('qwd asd', 5), 'asd'); + }); + + it('returns word between start of string and whitespace', () => { + assert.equal(getWordAt('asd qwd', 2), 'asd'); + }); + + it('returns word between start of string and end of string', () => { + assert.equal(getWordAt('asd', 2), 'asd'); + }); + + it('returns word with custom delimiters', () => { + assert.equal( + getWordAt('asd on:asd-qwd="asd" ', 10, { left: /\S+$/, right: /[\s=]/ }), + 'on:asd-qwd', + ); + }); + }); }); diff --git a/packages/language-server/test/plugins/typescript/TypescriptPlugin.test.ts b/packages/language-server/test/plugins/typescript/TypescriptPlugin.test.ts index 5472c9ac8..cb1801052 100644 --- a/packages/language-server/test/plugins/typescript/TypescriptPlugin.test.ts +++ b/packages/language-server/test/plugins/typescript/TypescriptPlugin.test.ts @@ -1,8 +1,8 @@ import * as assert from 'assert'; import * as path from 'path'; import ts from 'typescript'; -import { FileChangeType, Hover, Position } from 'vscode-languageserver'; -import { DocumentManager, Document } from '../../../src/lib/documents'; +import { FileChangeType, Position } from 'vscode-languageserver'; +import { Document, DocumentManager } from '../../../src/lib/documents'; import { LSConfigManager } from '../../../src/ls-config'; import { TypeScriptPlugin } from '../../../src/plugins'; import { INITIAL_VERSION } from '../../../src/plugins/typescript/DocumentSnapshot'; @@ -25,42 +25,6 @@ describe('TypescriptPlugin', () => { return { plugin, document }; } - it('provides basic hover info when no docstring exists', async () => { - const { plugin, document } = setup('hoverinfo.svelte'); - - assert.deepStrictEqual(await plugin.doHover(document, Position.create(4, 10)), { - contents: '```typescript\nconst withoutDocs: true\n```', - range: { - start: { - character: 10, - line: 4, - }, - end: { - character: 21, - line: 4, - }, - }, - }); - }); - - it('provides formatted hover info when a docstring exists', async () => { - const { plugin, document } = setup('hoverinfo.svelte'); - - assert.deepStrictEqual(await plugin.doHover(document, Position.create(2, 10)), { - contents: '```typescript\nconst withDocs: true\n```\n---\nDocumentation string', - range: { - start: { - character: 10, - line: 2, - }, - end: { - character: 18, - line: 2, - }, - }, - }); - }); - it('provides document symbols', async () => { const { plugin, document } = setup('documentsymbols.svelte'); const symbols = await plugin.getDocumentSymbols(document); diff --git a/packages/language-server/test/plugins/typescript/features/HoverProvider.test.ts b/packages/language-server/test/plugins/typescript/features/HoverProvider.test.ts new file mode 100644 index 000000000..e0c14f2a0 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/HoverProvider.test.ts @@ -0,0 +1,80 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import ts from 'typescript'; +import { Hover, Position } from 'vscode-languageserver'; +import { Document, DocumentManager } from '../../../../src/lib/documents'; +import { HoverProviderImpl } from '../../../../src/plugins/typescript/features/HoverProvider'; +import { LSAndTSDocResolver } from '../../../../src/plugins/typescript/LSAndTSDocResolver'; +import { pathToUrl } from '../../../../src/utils'; + +const testDir = path.join(__dirname, '..'); + +describe('HoverProvider', () => { + function getFullPath(filename: string) { + return path.join(testDir, 'testfiles', 'hover', filename); + } + + function setup(filename: string) { + const docManager = new DocumentManager( + (textDocument) => new Document(textDocument.uri, textDocument.text), + ); + const lsAndTsDocResolver = new LSAndTSDocResolver(docManager, testDir); + const provider = new HoverProviderImpl(lsAndTsDocResolver); + const document = openDoc(filename); + return { provider, document }; + + function openDoc(filename: string) { + const filePath = getFullPath(filename); + const doc = docManager.openDocument({ + uri: pathToUrl(filePath), + text: ts.sys.readFile(filePath) || '', + }); + return doc; + } + } + + it('provides basic hover info when no docstring exists', async () => { + const { provider, document } = setup('hoverinfo.svelte'); + + assert.deepStrictEqual(await provider.doHover(document, Position.create(6, 10)), { + contents: '```typescript\nconst withoutDocs: true\n```', + range: { + start: { + character: 10, + line: 6, + }, + end: { + character: 21, + line: 6, + }, + }, + }); + }); + + it('provides formatted hover info when a docstring exists', async () => { + const { provider, document } = setup('hoverinfo.svelte'); + + assert.deepStrictEqual(await provider.doHover(document, Position.create(4, 10)), { + contents: '```typescript\nconst withDocs: true\n```\n---\nDocumentation string', + range: { + start: { + character: 10, + line: 4, + }, + end: { + character: 18, + line: 4, + }, + }, + }); + }); + + it('provides formatted hover info for component events', async () => { + const { provider, document } = setup('hoverinfo.svelte'); + + assert.deepStrictEqual(await provider.doHover(document, Position.create(9, 26)), { + contents: + '```typescript\nabc: MouseEvent\n```\n\nTEST\n```ts\nconst abc: boolean = true;\n```\n', + }); + }); +}); diff --git a/packages/language-server/test/plugins/typescript/testfiles/hover/hover-events-interface.svelte b/packages/language-server/test/plugins/typescript/testfiles/hover/hover-events-interface.svelte new file mode 100644 index 000000000..e2cd5ec82 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/testfiles/hover/hover-events-interface.svelte @@ -0,0 +1,13 @@ + \ No newline at end of file diff --git a/packages/language-server/test/plugins/typescript/testfiles/hover/hoverinfo.svelte b/packages/language-server/test/plugins/typescript/testfiles/hover/hoverinfo.svelte new file mode 100644 index 000000000..0d476b1ab --- /dev/null +++ b/packages/language-server/test/plugins/typescript/testfiles/hover/hoverinfo.svelte @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/packages/language-server/test/plugins/typescript/testfiles/hoverinfo.svelte b/packages/language-server/test/plugins/typescript/testfiles/hoverinfo.svelte deleted file mode 100644 index cb5b80d58..000000000 --- a/packages/language-server/test/plugins/typescript/testfiles/hoverinfo.svelte +++ /dev/null @@ -1,6 +0,0 @@ - From 2409e26beb5f488c4aa19e1eb7575ad81421edc9 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 27 Aug 2020 08:17:52 +0200 Subject: [PATCH 2/2] adjust --- .../test/plugins/typescript/features/HoverProvider.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/language-server/test/plugins/typescript/features/HoverProvider.test.ts b/packages/language-server/test/plugins/typescript/features/HoverProvider.test.ts index e0c14f2a0..6690d4999 100644 --- a/packages/language-server/test/plugins/typescript/features/HoverProvider.test.ts +++ b/packages/language-server/test/plugins/typescript/features/HoverProvider.test.ts @@ -18,7 +18,7 @@ describe('HoverProvider', () => { const docManager = new DocumentManager( (textDocument) => new Document(textDocument.uri, textDocument.text), ); - const lsAndTsDocResolver = new LSAndTSDocResolver(docManager, testDir); + const lsAndTsDocResolver = new LSAndTSDocResolver(docManager, [testDir]); const provider = new HoverProviderImpl(lsAndTsDocResolver); const document = openDoc(filename); return { provider, document };