diff --git a/packages/language-server/src/ls-config.ts b/packages/language-server/src/ls-config.ts index 5bb2d1c4e..ccde02d79 100644 --- a/packages/language-server/src/ls-config.ts +++ b/packages/language-server/src/ls-config.ts @@ -14,7 +14,8 @@ const defaultLSConfig: LSConfig = { documentSymbols: { enable: true }, codeActions: { enable: true }, rename: { enable: true }, - selectionRange: { enable: true } + selectionRange: { enable: true }, + signatureHelp: { enable: true } }, css: { enable: true, @@ -87,6 +88,9 @@ export interface LSTypescriptConfig { selectionRange: { enable: boolean; }; + signatureHelp: { + enable: boolean; + } } export interface LSCSSConfig { diff --git a/packages/language-server/src/plugins/PluginHost.ts b/packages/language-server/src/plugins/PluginHost.ts index d0c83c520..3988090a8 100644 --- a/packages/language-server/src/plugins/PluginHost.ts +++ b/packages/language-server/src/plugins/PluginHost.ts @@ -20,7 +20,9 @@ import { FormattingOptions, ReferenceContext, Location, - SelectionRange + SelectionRange, + SignatureHelp, + SignatureHelpContext } from 'vscode-languageserver'; import { LSConfig, LSConfigManager } from '../ls-config'; import { DocumentManager } from '../lib/documents'; @@ -347,6 +349,23 @@ export class PluginHost implements LSProvider, OnWatchFileChanges { ); } + async getSignatureHelp( + textDocument: TextDocumentIdentifier, + position: Position, + context: SignatureHelpContext | undefined + ): Promise { + const document = this.getDocument(textDocument.uri); + if (!document) { + throw new Error('Cannot call methods on an unopened document'); + } + + return await this.execute( + 'getSignatureHelp', + [document, position, context], + ExecuteMode.FirstNonNull + ); + } + /** * The selection range supports multiple cursors, * each position should return its own selection range tree like `Array.map`. diff --git a/packages/language-server/src/plugins/interfaces.ts b/packages/language-server/src/plugins/interfaces.ts index cc2412761..6b171d8fa 100644 --- a/packages/language-server/src/plugins/interfaces.ts +++ b/packages/language-server/src/plugins/interfaces.ts @@ -1,4 +1,4 @@ -import { CompletionContext, FileChangeType } from 'vscode-languageserver'; +import { CompletionContext, FileChangeType, SignatureHelpContext } from 'vscode-languageserver'; import { CodeAction, CodeActionContext, @@ -19,7 +19,8 @@ import { TextDocumentIdentifier, TextEdit, WorkspaceEdit, - SelectionRange + SelectionRange, + SignatureHelp } from 'vscode-languageserver-types'; import { Document } from '../lib/documents'; @@ -128,6 +129,14 @@ export interface FindReferencesProvider { ): Promise; } +export interface SignatureHelpProvider { + getSignatureHelp( + document: Document, + position: Position, + context: SignatureHelpContext | undefined + ): Resolvable +} + export interface SelectionRangeProvider { getSelectionRange(document: Document, position: Position): Resolvable; } @@ -152,7 +161,8 @@ type ProviderBase = DiagnosticsProvider & UpdateImportsProvider & CodeActionsProvider & FindReferencesProvider & - RenameProvider; + RenameProvider & + SignatureHelpProvider; export type LSProvider = ProviderBase & BackwardsCompatibleDefinitionsProvider; diff --git a/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts b/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts index 55173d196..0ceef118c 100644 --- a/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts +++ b/packages/language-server/src/plugins/typescript/TypeScriptPlugin.ts @@ -15,7 +15,9 @@ import { SymbolInformation, WorkspaceEdit, CompletionList, - SelectionRange + SelectionRange, + SignatureHelp, + SignatureHelpContext } from 'vscode-languageserver'; import { Document, @@ -39,6 +41,7 @@ import { OnWatchFileChanges, RenameProvider, SelectionRangeProvider, + SignatureHelpProvider, UpdateImportsProvider, OnWatchFileChangesPara } from '../interfaces'; @@ -57,6 +60,7 @@ import { convertToLocationRange, getScriptKindFromFileName, symbolKindFromString import { getDirectiveCommentCompletions } from './features/getDirectiveCommentCompletions'; import { FindReferencesProviderImpl } from './features/FindReferencesProvider'; import { SelectionRangeProviderImpl } from './features/SelectionRangeProvider'; +import { SignatureHelpProviderImpl } from './features/SignatureHelpProvider'; import { SnapshotManager } from './SnapshotManager'; export class TypeScriptPlugin @@ -70,6 +74,7 @@ export class TypeScriptPlugin RenameProvider, FindReferencesProvider, SelectionRangeProvider, + SignatureHelpProvider, OnWatchFileChanges, CompletionsProvider { private readonly configManager: LSConfigManager; @@ -82,6 +87,7 @@ export class TypeScriptPlugin private readonly hoverProvider: HoverProviderImpl; private readonly findReferencesProvider: FindReferencesProviderImpl; private readonly selectionRangeProvider: SelectionRangeProviderImpl; + private readonly signatureHelpProvider: SignatureHelpProviderImpl; constructor( docManager: DocumentManager, @@ -101,6 +107,7 @@ export class TypeScriptPlugin this.hoverProvider = new HoverProviderImpl(this.lsAndTsDocResolver); this.findReferencesProvider = new FindReferencesProviderImpl(this.lsAndTsDocResolver); this.selectionRangeProvider = new SelectionRangeProviderImpl(this.lsAndTsDocResolver); + this.signatureHelpProvider = new SignatureHelpProviderImpl(this.lsAndTsDocResolver); } async getDiagnostics(document: Document): Promise { @@ -380,6 +387,16 @@ export class TypeScriptPlugin return this.selectionRangeProvider.getSelectionRange(document, position); } + async getSignatureHelp( + document: Document, position: Position, context: SignatureHelpContext | undefined + ): Promise { + if (!this.featureEnabled('signatureHelp')) { + return null; + } + + return this.signatureHelpProvider.getSignatureHelp(document, position, context); + } + private getLSAndTSDoc(document: Document) { return this.lsAndTsDocResolver.getLSAndTSDoc(document); } diff --git a/packages/language-server/src/plugins/typescript/features/SignatureHelpProvider.ts b/packages/language-server/src/plugins/typescript/features/SignatureHelpProvider.ts new file mode 100644 index 000000000..f8b7355c8 --- /dev/null +++ b/packages/language-server/src/plugins/typescript/features/SignatureHelpProvider.ts @@ -0,0 +1,151 @@ +import ts from 'typescript'; +import { + Position, + SignatureHelpContext, + SignatureHelp, + SignatureHelpTriggerKind, + SignatureInformation, + ParameterInformation, + MarkupKind +} from 'vscode-languageserver'; +import { SignatureHelpProvider } from '../..'; +import { Document } from '../../../lib/documents'; +import { LSAndTSDocResolver } from '../LSAndTSDocResolver'; +import { getMarkdownDocumentation } from '../previewer'; + +export class SignatureHelpProviderImpl implements SignatureHelpProvider { + constructor(private readonly lsAndTsDocResolver: LSAndTSDocResolver) { } + + private static readonly triggerCharacters = ['(', ',', '<']; + private static readonly retriggerCharacters = [')']; + + async getSignatureHelp( + document: Document, + position: Position, + context: SignatureHelpContext | undefined + ): Promise { + const { lang, tsDoc } = this.lsAndTsDocResolver.getLSAndTSDoc(document); + const fragment = await tsDoc.getFragment(); + + const offset = fragment.offsetAt(fragment.getGeneratedPosition(position)); + const triggerReason = this.toTsTriggerReason(context); + const info = lang.getSignatureHelpItems( + tsDoc.filePath, + offset, + triggerReason ? { triggerReason } : undefined + ); + if ( + !info || + info.items.some((signature) => this.isInSvelte2tsxGeneratedFunction(signature)) + ) { + return null; + } + + const signatures = info.items + .map(this.toSignatureHelpInformation); + + return { + signatures, + activeSignature: info.selectedItemIndex, + activeParameter: info.argumentIndex + }; + } + + private isReTrigger( + isRetrigger: boolean, + triggerCharacter: string + ): triggerCharacter is ts.SignatureHelpRetriggerCharacter { + return ( + isRetrigger && + (this.isTriggerCharacter(triggerCharacter) || + SignatureHelpProviderImpl.retriggerCharacters.includes(triggerCharacter)) + ); + } + + private isTriggerCharacter( + triggerCharacter: string + ): triggerCharacter is ts.SignatureHelpTriggerCharacter { + return SignatureHelpProviderImpl.triggerCharacters.includes(triggerCharacter); + } + + /** + * adopted from https://github.com/microsoft/vscode/blob/265a2f6424dfbd3a9788652c7d376a7991d049a3/extensions/typescript-language-features/src/languageFeatures/signatureHelp.ts#L103 + */ + private toTsTriggerReason( + context: SignatureHelpContext | undefined + ): ts.SignatureHelpTriggerReason { + switch (context?.triggerKind) { + case SignatureHelpTriggerKind.TriggerCharacter: + if (context.triggerCharacter) { + if (this.isReTrigger(context.isRetrigger, context.triggerCharacter)) { + return { kind: 'retrigger', triggerCharacter: context.triggerCharacter }; + } + if (this.isTriggerCharacter(context.triggerCharacter)) { + return { + kind: 'characterTyped', + triggerCharacter: context.triggerCharacter + }; + } + } + return { kind: 'invoked' }; + case SignatureHelpTriggerKind.ContentChange: + return context.isRetrigger ? { kind: 'retrigger' } : { kind: 'invoked' }; + + case SignatureHelpTriggerKind.Invoked: + default: + return { kind: 'invoked' }; + } + } + + /** + * adopted from https://github.com/microsoft/vscode/blob/265a2f6424dfbd3a9788652c7d376a7991d049a3/extensions/typescript-language-features/src/languageFeatures/signatureHelp.ts#L73 + */ + private toSignatureHelpInformation(item: ts.SignatureHelpItem): SignatureInformation { + const [prefixLabel, separatorLabel, suffixLabel] = [ + item.prefixDisplayParts, + item.separatorDisplayParts, + item.suffixDisplayParts + ].map(ts.displayPartsToString); + + let textIndex = prefixLabel.length; + let signatureLabel = ''; + const parameters: ParameterInformation[] = []; + const lastIndex = item.parameters.length - 1; + + item.parameters.forEach((parameter, index) => { + const label = ts.displayPartsToString(parameter.displayParts); + + const startIndex = textIndex; + const endIndex = textIndex + label.length; + const doc = ts.displayPartsToString(parameter.documentation); + + signatureLabel += label; + parameters.push(ParameterInformation.create([startIndex, endIndex], doc)); + + if (index < lastIndex) { + textIndex = endIndex + separatorLabel.length; + signatureLabel += separatorLabel; + } + }); + const signatureDocumentation = getMarkdownDocumentation( + item.documentation, + item.tags.filter((tag) => tag.name !== 'param') + ); + + return { + label: prefixLabel + signatureLabel + suffixLabel, + documentation: signatureDocumentation ? { + value: signatureDocumentation, + kind: MarkupKind.Markdown + } : undefined, + parameters + }; + } + + private isInSvelte2tsxGeneratedFunction( + signatureHelpItem: ts.SignatureHelpItem + ) { + return signatureHelpItem.prefixDisplayParts + .some((part) => part.text.includes('__sveltets')); + } +} diff --git a/packages/language-server/src/server.ts b/packages/language-server/src/server.ts index 78cdcb09c..6c977d062 100644 --- a/packages/language-server/src/server.ts +++ b/packages/language-server/src/server.ts @@ -190,7 +190,11 @@ export function startServer(options?: LSOptions) { ? { prepareProvider: true } : true, referencesProvider: true, - selectionRangeProvider: true + selectionRangeProvider: true, + signatureHelpProvider: { + triggerCharacters: ['(', ',', '<'], + retriggerCharacters: [')'] + } } }; }); @@ -263,6 +267,10 @@ export function startServer(options?: LSOptions) { return pluginHost.resolveCompletion(data, completionItem); }); + connection.onSignatureHelp((evt) => + pluginHost.getSignatureHelp(evt.textDocument, evt.position, evt.context) + ); + connection.onSelectionRanges((evt) => pluginHost.getSelectionRanges(evt.textDocument, evt.positions) ); diff --git a/packages/language-server/test/plugins/typescript/features/SignatureHelpProvider.test.ts b/packages/language-server/test/plugins/typescript/features/SignatureHelpProvider.test.ts new file mode 100644 index 000000000..909e2f144 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/features/SignatureHelpProvider.test.ts @@ -0,0 +1,96 @@ +import path from 'path'; +import assert from 'assert'; +import ts from 'typescript'; +import { MarkupKind, Position, SignatureHelp } from 'vscode-languageserver'; +import { Document, DocumentManager } from '../../../../src/lib/documents'; +import { SignatureHelpProviderImpl } from '../../../../src/plugins/typescript/features/SignatureHelpProvider'; +import { LSAndTSDocResolver } from '../../../../src/plugins/typescript/LSAndTSDocResolver'; +import { pathToUrl } from '../../../../src/utils'; + +const testDir = path.join(__dirname, '..'); + +describe('SignatureHelpProvider', () => { + function setup() { + const docManager = new DocumentManager( + (textDocument) => new Document(textDocument.uri, textDocument.text) + ); + const filePath = path.join( + testDir, + 'testfiles', + 'signature-help', + 'signature-help.svelte' + ); + const lsAndTsDocResolver = new LSAndTSDocResolver(docManager, [pathToUrl(testDir)]); + const provider = new SignatureHelpProviderImpl(lsAndTsDocResolver); + const document = docManager.openDocument({ + uri: pathToUrl(filePath), + text: ts.sys.readFile(filePath) + }); + return { provider, document }; + } + + it('provide signature help with formatted documentation', async () => { + const { provider, document } = setup(); + + const result = await provider.getSignatureHelp(document, Position.create(3, 8), undefined); + + assert.deepStrictEqual(result, { + signatures: [ + { + label: 'foo(): boolean', + documentation: { value: 'bars\n\n*@author* — John', kind: MarkupKind.Markdown }, + parameters: [] + } + ], + activeParameter: 0, + activeSignature: 0 + }); + }); + + it('provide signature help with function signatures', async () => { + const { provider, document } = setup(); + + const result = await provider.getSignatureHelp(document, Position.create(4, 12), undefined); + + assert.deepStrictEqual(result, { + signatures: [ + { + label: 'abc(a: number, b: number): string', + documentation: undefined, + parameters: [ + { + label: [4, 13] + }, + { + label: [15, 24] + } + ] + }, + { + label: 'abc(a: number, b: string): string', + documentation: undefined, + parameters: [ + { + label: [4, 13] + }, + { + label: [15, 24], + documentation: 'formatted number' + } + ] + } + ], + activeParameter: 1, + activeSignature: 1 + }); + }); + + it('filter out svelte2tsx signature', async () => { + const { provider, document } = setup(); + + const result = await provider.getSignatureHelp( + document, Position.create(18, 18), undefined); + + assert.equal(result, null); + }); +}); diff --git a/packages/language-server/test/plugins/typescript/testfiles/signature-help/signature-help.svelte b/packages/language-server/test/plugins/typescript/testfiles/signature-help/signature-help.svelte new file mode 100644 index 000000000..6c65c1ea5 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/testfiles/signature-help/signature-help.svelte @@ -0,0 +1,21 @@ + + +{#each items as item} + {item} +{/each} diff --git a/packages/svelte-vscode/README.md b/packages/svelte-vscode/README.md index b2894fd1f..a6b96a363 100644 --- a/packages/svelte-vscode/README.md +++ b/packages/svelte-vscode/README.md @@ -98,7 +98,7 @@ Enable go to definition for TypeScript. _Default_: `true` Enable code actions for TypeScript. _Default_: `true` -##### `svelte.plugin.typescript.codeActions` +##### `svelte.plugin.typescript.selectionRange` Enable selection range for TypeScript. _Default_: `true` @@ -106,6 +106,10 @@ Enable selection range for TypeScript. _Default_: `true` Enable rename functionality for JS/TS variables inside Svelte files. _Default_: `true` +##### `svelte.plugin.typescript.signatureHelp.enable` + +Enable signature help (parameter hints) for JS/TS. _Default_: `true` + ##### `svelte.plugin.css.enable` Enable the CSS plugin. _Default_: `true` diff --git a/packages/svelte-vscode/package.json b/packages/svelte-vscode/package.json index d2868ca09..c73dadb72 100644 --- a/packages/svelte-vscode/package.json +++ b/packages/svelte-vscode/package.json @@ -113,9 +113,15 @@ "svelte.plugin.typescript.selectionRange.enable": { "type": "boolean", "default": true, - "title": "TypeScript: SelectionRange", + "title": "TypeScript: Selection Range", "description": "Enable selection range for TypeScript" }, + "svelte.plugin.typescript.signatureHelp.enable": { + "type": "boolean", + "default": true, + "title": "TypeScript: Signature Help", + "description": "Enable signature help (parameter hints) for TypeScript" + }, "svelte.plugin.typescript.rename.enable": { "type": "boolean", "default": true, @@ -308,7 +314,7 @@ "source.ts": "typescript", "text.pug": "jade" } - }, + }, { "scopeName": "svelte.pug.tags", "path": "./syntaxes/pug-svelte-tags.json", @@ -319,7 +325,7 @@ "source.ts": "typescript", "text.pug": "jade" } - }, + }, { "scopeName": "svelte.pug.dotblock", "path": "./syntaxes/pug-svelte-dotblock.json",