diff --git a/tools/language-server/astro.d.ts b/tools/language-server/astro.d.ts new file mode 100644 index 0000000000..267f6f1089 --- /dev/null +++ b/tools/language-server/astro.d.ts @@ -0,0 +1,27 @@ +type AstroRenderedHTML = string; + +type FetchContentResult = Record> = { + astro: { + headers: string[]; + source: string; + html: AstroRenderedHTML; + }; + url: URL; +} & ContentFrontmatter; + +interface AstroPageRequest { + url: URL; + canonicalURL: URL; +} + +interface Astro { + isPage: boolean; + fetchContent(globStr: string): FetchContentResult[]; + props: Record; + request: AstroPageRequest; + site: URL; +} + +declare const Astro: Astro; + +export default function(): string; \ No newline at end of file diff --git a/tools/language-server/src/index.ts b/tools/language-server/src/index.ts index 5e4c736a2e..1ca7172c82 100644 --- a/tools/language-server/src/index.ts +++ b/tools/language-server/src/index.ts @@ -64,6 +64,11 @@ export function startServer() { ':', ], }, + hoverProvider: true, + signatureHelpProvider: { + triggerCharacters: ['(', ',', '<'], + retriggerCharacters: [')'] + } }, }; }); @@ -107,9 +112,11 @@ export function startServer() { return pluginHost.resolveCompletion(data, completionItem); }); + connection.onHover((evt) => pluginHost.doHover(evt.textDocument, evt.position)); connection.onDefinition((evt) => pluginHost.getDefinitions(evt.textDocument, evt.position)); connection.onFoldingRanges((evt) => pluginHost.getFoldingRanges(evt.textDocument)); connection.onRequest(TagCloseRequest, (evt: any) => pluginHost.doTagComplete(evt.textDocument, evt.position)); + connection.onSignatureHelp((evt, cancellationToken) => pluginHost.getSignatureHelp(evt.textDocument, evt.position, evt.context, cancellationToken)); connection.listen(); } diff --git a/tools/language-server/src/plugins/PluginHost.ts b/tools/language-server/src/plugins/PluginHost.ts index 3741845c49..f3e50e4d09 100644 --- a/tools/language-server/src/plugins/PluginHost.ts +++ b/tools/language-server/src/plugins/PluginHost.ts @@ -1,8 +1,19 @@ -import { CompletionContext, CompletionItem, CompletionList, DefinitionLink, Location, Position, TextDocumentIdentifier } from 'vscode-languageserver'; +import type { + CancellationToken, + CompletionContext, + CompletionItem, + DefinitionLink, + Location, + Position, + SignatureHelp, + SignatureHelpContext, + TextDocumentIdentifier +} from 'vscode-languageserver'; import type { DocumentManager } from '../core/documents'; import type * as d from './interfaces'; import { flatten } from '../utils'; -import { FoldingRange } from 'vscode-languageserver-types'; +import { CompletionList } from 'vscode-languageserver'; +import { Hover, FoldingRange } from 'vscode-languageserver-types'; enum ExecuteMode { None, @@ -60,6 +71,15 @@ export class PluginHost { return result ?? completionItem; } + async doHover(textDocument: TextDocumentIdentifier, position: Position): Promise { + const document = this.getDocument(textDocument.uri); + if (!document) { + throw new Error('Cannot call methods on an unopened document'); + } + + return this.execute('doHover', [document, position], ExecuteMode.FirstNonNull); + } + async doTagComplete(textDocument: TextDocumentIdentifier, position: Position): Promise { const document = this.getDocument(textDocument.uri); if (!document) { @@ -95,6 +115,24 @@ export class PluginHost { } } + async getSignatureHelp( + textDocument: TextDocumentIdentifier, + position: Position, + context: SignatureHelpContext | undefined, + cancellationToken: CancellationToken + ): 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, cancellationToken], + ExecuteMode.FirstNonNull + ); + } + onWatchFileChanges(onWatchFileChangesParams: any[]): void { for (const support of this.plugins) { support.onWatchFileChanges?.(onWatchFileChangesParams); @@ -121,7 +159,10 @@ export class PluginHost { } return null; case ExecuteMode.Collect: - return Promise.all(plugins.map((plugin) => this.tryExecutePlugin(plugin, name, args, []))); + return Promise.all(plugins.map((plugin) => { + let ret = this.tryExecutePlugin(plugin, name, args, []); + return ret; + })); case ExecuteMode.None: await Promise.all(plugins.map((plugin) => this.tryExecutePlugin(plugin, name, args, null))); return; diff --git a/tools/language-server/src/plugins/astro/AstroPlugin.ts b/tools/language-server/src/plugins/astro/AstroPlugin.ts index 535375eebc..d8c08089db 100644 --- a/tools/language-server/src/plugins/astro/AstroPlugin.ts +++ b/tools/language-server/src/plugins/astro/AstroPlugin.ts @@ -26,6 +26,7 @@ export class AstroPlugin implements CompletionsProvider, FoldingRangeProvider { private readonly docManager: DocumentManager; private readonly configManager: ConfigManager; private readonly tsLanguageServiceManager: TypeScriptLanguageServiceManager; + public pluginName = 'Astro'; constructor(docManager: DocumentManager, configManager: ConfigManager, workspaceUris: string[]) { this.docManager = docManager; diff --git a/tools/language-server/src/plugins/css/CSSPlugin.ts b/tools/language-server/src/plugins/css/CSSPlugin.ts index 26c90ac66b..3083edc56e 100644 --- a/tools/language-server/src/plugins/css/CSSPlugin.ts +++ b/tools/language-server/src/plugins/css/CSSPlugin.ts @@ -16,6 +16,7 @@ export class CSSPlugin implements CompletionsProvider { private configManager: ConfigManager; private documents = new WeakMap(); private triggerCharacters = new Set(['.', ':', '-', '/']); + public pluginName = 'CSS'; constructor(docManager: DocumentManager, configManager: ConfigManager) { this.docManager = docManager; diff --git a/tools/language-server/src/plugins/html/HTMLPlugin.ts b/tools/language-server/src/plugins/html/HTMLPlugin.ts index 7e0ab4861d..d4f75e0d3c 100644 --- a/tools/language-server/src/plugins/html/HTMLPlugin.ts +++ b/tools/language-server/src/plugins/html/HTMLPlugin.ts @@ -11,6 +11,7 @@ export class HTMLPlugin implements CompletionsProvider, FoldingRangeProvider { private documents = new WeakMap(); private styleScriptTemplate = new Set(['template', 'style', 'script']); private configManager: ConfigManager; + public pluginName = 'HTML'; constructor(docManager: DocumentManager, configManager: ConfigManager) { docManager.on('documentChange', (document) => { diff --git a/tools/language-server/src/plugins/interfaces.ts b/tools/language-server/src/plugins/interfaces.ts index b68100de18..84e4cbda39 100644 --- a/tools/language-server/src/plugins/interfaces.ts +++ b/tools/language-server/src/plugins/interfaces.ts @@ -164,4 +164,8 @@ export interface LSPProviderConfig { definitionLinkSupport: boolean; } -export type Plugin = Partial; +interface NamedPlugin { + pluginName: string; +} + +export type Plugin = Partial; diff --git a/tools/language-server/src/plugins/typescript/DocumentSnapshot.ts b/tools/language-server/src/plugins/typescript/DocumentSnapshot.ts index 9b8420f658..d7473c7b38 100644 --- a/tools/language-server/src/plugins/typescript/DocumentSnapshot.ts +++ b/tools/language-server/src/plugins/typescript/DocumentSnapshot.ts @@ -1,11 +1,12 @@ import * as ts from 'typescript'; +import { readFileSync } from 'fs'; import { TextDocumentContentChangeEvent, Position } from 'vscode-languageserver'; import { Document, DocumentMapper, IdentityMapper } from '../../core/documents'; import { isInTag, positionAt, offsetAt } from '../../core/documents/utils'; import { pathToUrl } from '../../utils'; import { getScriptKindFromFileName, isAstroFilePath, toVirtualAstroFilePath } from './utils'; -const FILLER_DEFAULT_EXPORT = `\nexport default function() { return ''; };`; +const ASTRO_DEFINITION = readFileSync(require.resolve('../../../astro.d.ts')); /** * The mapper to get from original snapshot positions to generated and vice versa. @@ -74,11 +75,9 @@ class AstroDocumentSnapshot implements DocumentSnapshot { /** @internal */ private transformContent(content: string) { - return ( - content.replace(/---/g, '///') + - // TypeScript needs this to know there's a default export. - FILLER_DEFAULT_EXPORT - ); + return content.replace(/---/g, '///') + + // Add TypeScript definitions + ASTRO_DEFINITION; } get filePath() { @@ -140,11 +139,9 @@ export class DocumentFragmentSnapshot implements Omit { + return this.hoverProvider.doHover(document, position); } async getCompletions(document: Document, position: Position, completionContext?: CompletionContext): Promise | null> { @@ -117,6 +134,20 @@ export class TypeScriptPlugin implements CompletionsProvider { } } + async getSignatureHelp( + document: Document, + position: Position, + context: SignatureHelpContext | undefined, + cancellationToken?: CancellationToken + ): Promise { + return this.signatureHelpProvider.getSignatureHelp( + document, + position, + context, + cancellationToken + ); + } + /** * * @internal diff --git a/tools/language-server/src/plugins/typescript/astro-sys.ts b/tools/language-server/src/plugins/typescript/astro-sys.ts index 57cd3b4975..c8d23254d7 100644 --- a/tools/language-server/src/plugins/typescript/astro-sys.ts +++ b/tools/language-server/src/plugins/typescript/astro-sys.ts @@ -9,9 +9,6 @@ export function createAstroSys(getSnapshot: (fileName: string) => DocumentSnapsh const AstroSys: ts.System = { ...ts.sys, fileExists(path: string) { - if (isAstroFilePath(path) || isVirtualAstroFilePath(path)) { - console.log('fileExists', path, ts.sys.fileExists(ensureRealAstroFilePath(path))); - } return ts.sys.fileExists(ensureRealAstroFilePath(path)); }, readFile(path: string) { diff --git a/tools/language-server/src/plugins/typescript/features/CompletionsProvider.ts b/tools/language-server/src/plugins/typescript/features/CompletionsProvider.ts index d13269c5c0..daeed97661 100644 --- a/tools/language-server/src/plugins/typescript/features/CompletionsProvider.ts +++ b/tools/language-server/src/plugins/typescript/features/CompletionsProvider.ts @@ -1,10 +1,17 @@ +import type { CompletionContext, CompletionItem, Position, TextDocumentIdentifier, MarkupContent } from 'vscode-languageserver'; +import type { LanguageServiceManager } from '../LanguageServiceManager'; import { isInsideFrontmatter } from '../../../core/documents/utils'; import { Document } from '../../../core/documents'; import * as ts from 'typescript'; -import { CompletionContext, CompletionList, CompletionItem, Position, TextDocumentIdentifier, TextEdit, MarkupKind, MarkupContent } from 'vscode-languageserver'; +import { CompletionList, MarkupKind } from 'vscode-languageserver'; import { AppCompletionItem, AppCompletionList, CompletionsProvider } from '../../interfaces'; -import type { LanguageServiceManager } from '../LanguageServiceManager'; -import { scriptElementKindToCompletionItemKind, getCommitCharactersForScriptElement } from '../utils'; +import { scriptElementKindToCompletionItemKind, getCommitCharactersForScriptElement, toVirtualAstroFilePath } from '../utils'; + +const completionOptions: ts.GetCompletionsAtPositionOptions = Object.freeze({ + importModuleSpecifierPreference: 'relative', + importModuleSpecifierEnding: 'js', + quotePreference: 'single', +}); export interface CompletionEntryWithIdentifer extends ts.CompletionEntry, TextDocumentIdentifier { position: Position; @@ -13,7 +20,7 @@ export interface CompletionEntryWithIdentifer extends ts.CompletionEntry, TextDo export class CompletionsProviderImpl implements CompletionsProvider { constructor(private lang: LanguageServiceManager) {} - async getCompletions(document: Document, position: Position, completionContext?: CompletionContext): Promise | null> { + async getCompletions(document: Document, position: Position, _completionContext?: CompletionContext): Promise | null> { // TODO: handle inside expression if (!isInsideFrontmatter(document.getText(), document.offsetAt(position))) { return null; @@ -26,12 +33,9 @@ export class CompletionsProviderImpl implements CompletionsProvider this.toCompletionItem(fragment, entry, document.uri, position, new Set())) @@ -44,18 +48,22 @@ export class CompletionsProviderImpl implements CompletionsProvider { + const { lang, tsDoc } = await this.getLSAndTSDoc(document); + const fragment = await tsDoc.getFragment(); + + const offset = fragment.offsetAt(fragment.getGeneratedPosition(position)); + const filePath = toVirtualAstroFilePath(tsDoc.filePath); + let info = lang.getQuickInfoAtPosition(filePath, offset); + if (!info) { + return null; + } + + const textSpan = info.textSpan; + + const declaration = ts.displayPartsToString(info.displayParts); + const documentation = getMarkdownDocumentation(info.documentation, info.tags); + + // https://microsoft.github.io/language-server-protocol/specification#textDocument_hover + const contents = ['```typescript', declaration, '```'] + .concat(documentation ? ['---', documentation] : []) + .join('\n'); + + return mapObjWithRangeToOriginal(fragment, { + range: convertRange(fragment, textSpan), + contents + }); + } + + private async getLSAndTSDoc(document: Document) { + return this.lang.getTypeScriptDoc(document); + } +} \ No newline at end of file diff --git a/tools/language-server/src/plugins/typescript/features/SignatureHelpProvider.ts b/tools/language-server/src/plugins/typescript/features/SignatureHelpProvider.ts new file mode 100644 index 0000000000..1be2862469 --- /dev/null +++ b/tools/language-server/src/plugins/typescript/features/SignatureHelpProvider.ts @@ -0,0 +1,158 @@ +import type { LanguageServiceManager } from '../LanguageServiceManager'; +import type { SignatureHelpProvider } from '../../interfaces'; +import ts from 'typescript'; +import { + Position, + SignatureHelpContext, + SignatureHelp, + SignatureHelpTriggerKind, + SignatureInformation, + ParameterInformation, + MarkupKind, + CancellationToken +} from 'vscode-languageserver'; +import { Document } from '../../../core/documents'; +import { getMarkdownDocumentation } from '../previewer'; +import { toVirtualAstroFilePath } from '../utils'; + +export class SignatureHelpProviderImpl implements SignatureHelpProvider { + constructor(private readonly lang: LanguageServiceManager) {} + + private static readonly triggerCharacters = ['(', ',', '<']; + private static readonly retriggerCharacters = [')']; + + async getSignatureHelp( + document: Document, + position: Position, + context: SignatureHelpContext | undefined, + cancellationToken?: CancellationToken + ): Promise { + const { lang, tsDoc } = await this.lang.getTypeScriptDoc(document); + const fragment = await tsDoc.getFragment(); + + if (cancellationToken?.isCancellationRequested) { + return null; + } + + const offset = fragment.offsetAt(fragment.getGeneratedPosition(position)); + const triggerReason = this.toTsTriggerReason(context); + const info = lang.getSignatureHelpItems( + toVirtualAstroFilePath(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') + ); + } +} \ No newline at end of file diff --git a/tools/language-server/src/plugins/typescript/previewer.ts b/tools/language-server/src/plugins/typescript/previewer.ts new file mode 100644 index 0000000000..deedae1e8a --- /dev/null +++ b/tools/language-server/src/plugins/typescript/previewer.ts @@ -0,0 +1,140 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * adopted from https://github.com/microsoft/vscode/blob/10722887b8629f90cc38ee7d90d54e8246dc895f/extensions/typescript-language-features/src/utils/previewer.ts + */ + + import ts from 'typescript'; + import { isNotNullOrUndefined } from '../../utils'; + + function replaceLinks(text: string): string { + return ( + text + // Http(s) links + .replace( + /\{@(link|linkplain|linkcode) (https?:\/\/[^ |}]+?)(?:[| ]([^{}\n]+?))?\}/gi, + (_, tag: string, link: string, text?: string) => { + switch (tag) { + case 'linkcode': + return `[\`${text ? text.trim() : link}\`](${link})`; + + default: + return `[${text ? text.trim() : link}](${link})`; + } + } + ) + ); + } + + function processInlineTags(text: string): string { + return replaceLinks(text); + } + + function getTagBodyText(tag: ts.JSDocTagInfo): string | undefined { + if (!tag.text) { + return undefined; + } + + // Convert to markdown code block if it is not already one + function makeCodeblock(text: string): string { + if (text.match(/^\s*[~`]{3}/g)) { + return text; + } + return '```\n' + text + '\n```'; + } + + function makeExampleTag(text: string) { + // check for caption tags, fix for https://github.com/microsoft/vscode/issues/79704 + const captionTagMatches = text.match(/(.*?)<\/caption>\s*(\r\n|\n)/); + if (captionTagMatches && captionTagMatches.index === 0) { + return ( + captionTagMatches[1] + + '\n\n' + + makeCodeblock(text.substr(captionTagMatches[0].length)) + ); + } else { + return makeCodeblock(text); + } + } + + function makeEmailTag(text: string) { + // fix obsucated email address, https://github.com/microsoft/vscode/issues/80898 + const emailMatch = text.match(/(.+)\s<([-.\w]+@[-.\w]+)>/); + + if (emailMatch === null) { + return text; + } else { + return `${emailMatch[1]} ${emailMatch[2]}`; + } + } + + switch (tag.name) { + case 'example': + return makeExampleTag(ts.displayPartsToString(tag.text)); + case 'author': + return makeEmailTag(ts.displayPartsToString(tag.text)); + case 'default': + return makeCodeblock(ts.displayPartsToString(tag.text)); + } + + return processInlineTags(ts.displayPartsToString(tag.text)); + } + + export function getTagDocumentation(tag: ts.JSDocTagInfo): string | undefined { + function getWithType() { + const body = (ts.displayPartsToString(tag.text) || '').split(/^(\S+)\s*-?\s*/); + if (body?.length === 3) { + const param = body[1]; + const doc = body[2]; + const label = `*@${tag.name}* \`${param}\``; + if (!doc) { + return label; + } + return ( + label + + (doc.match(/\r\n|\n/g) + ? ' \n' + processInlineTags(doc) + : ` — ${processInlineTags(doc)}`) + ); + } + } + + switch (tag.name) { + case 'augments': + case 'extends': + case 'param': + case 'template': + return getWithType(); + } + + // Generic tag + const label = `*@${tag.name}*`; + const text = getTagBodyText(tag); + if (!text) { + return label; + } + return label + (text.match(/\r\n|\n/g) ? ' \n' + text : ` — ${text}`); + } + + export function plain(parts: ts.SymbolDisplayPart[] | string): string { + return processInlineTags(typeof parts === 'string' ? parts : ts.displayPartsToString(parts)); + } + + export function getMarkdownDocumentation( + documentation: ts.SymbolDisplayPart[] | undefined, + tags: ts.JSDocTagInfo[] | undefined + ) { + let result: Array = []; + if (documentation) { + result.push(plain(documentation)); + } + + if (tags) { + result = result.concat(tags.map(getTagDocumentation)); + } + + return result.filter(isNotNullOrUndefined).join('\n\n'); + } \ No newline at end of file diff --git a/tools/language-server/src/plugins/typescript/utils.ts b/tools/language-server/src/plugins/typescript/utils.ts index d84f35da1a..4b767c8e18 100644 --- a/tools/language-server/src/plugins/typescript/utils.ts +++ b/tools/language-server/src/plugins/typescript/utils.ts @@ -178,6 +178,9 @@ export function isVirtualFilePath(filePath: string) { } export function toVirtualAstroFilePath(filePath: string) { + if(isVirtualFrameworkFilePath('astro', filePath)) { + return filePath; + } return `${filePath}.ts`; }