diff --git a/tools/language-server/src/index.ts b/tools/language-server/src/index.ts index 5f741d1f7..e029684cb 100644 --- a/tools/language-server/src/index.ts +++ b/tools/language-server/src/index.ts @@ -23,10 +23,10 @@ export function startServer() { filterIncompleteCompletions: !evt.initializationOptions?.dontFilterIncompleteCompletions, definitionLinkSupport: !!evt.capabilities.textDocument?.definition?.linkSupport, }); - pluginHost.register(new AstroPlugin(docManager, configManager, workspaceUris)); pluginHost.register(new HTMLPlugin(docManager, configManager)); pluginHost.register(new CSSPlugin(docManager, configManager)); pluginHost.register(new TypeScriptPlugin(docManager, configManager, workspaceUris)); + pluginHost.register(new AstroPlugin(docManager, configManager, workspaceUris)); configManager.updateEmmetConfig(evt.initializationOptions?.configuration?.emmet || evt.initializationOptions?.emmetConfig || {}); return { diff --git a/tools/language-server/src/plugins/astro/AstroPlugin.ts b/tools/language-server/src/plugins/astro/AstroPlugin.ts index d8c08089d..dcd0f8cec 100644 --- a/tools/language-server/src/plugins/astro/AstroPlugin.ts +++ b/tools/language-server/src/plugins/astro/AstroPlugin.ts @@ -8,14 +8,18 @@ import { CompletionList, CompletionItem, CompletionItemKind, + CompletionTriggerKind, InsertTextFormat, LocationLink, FoldingRange, + MarkupContent, + MarkupKind, Range, TextEdit, } from 'vscode-languageserver'; import { Node } from 'vscode-html-languageservice'; import { isPossibleClientComponent, pathToUrl, urlToPath } from '../../utils'; +import { toVirtualAstroFilePath } from '../typescript/utils'; import { isInsideFrontmatter } from '../../core/documents/utils'; import * as ts from 'typescript'; import { LanguageServiceManager as TypeScriptLanguageServiceManager } from '../typescript/LanguageServiceManager'; @@ -50,6 +54,13 @@ export class AstroPlugin implements CompletionsProvider, FoldingRangeProvider { if (clientHint) items.push(...clientHint); } + if (!this.isInsideFrontmatter(document, position)) { + const props = await this.getPropCompletions(document, position, completionContext); + if(props.length) { + items.push(...props); + } + } + return CompletionList.create(items, true); } @@ -88,27 +99,13 @@ export class AstroPlugin implements CompletionsProvider, FoldingRangeProvider { const [componentName] = node.tag!.split(':'); - const filePath = urlToPath(document.uri); - const tsFilePath = filePath + '.ts'; + const { lang } = await this.tsLanguageServiceManager.getTypeScriptDoc(document); + const defs = this.getDefinitionsForComponentName(document, lang, componentName); - const { lang, tsDoc } = await this.tsLanguageServiceManager.getTypeScriptDoc(document); - - const sourceFile = lang.getProgram()?.getSourceFile(tsFilePath); - if (!sourceFile) { - return []; - } - - const specifier = this.getImportSpecifierForIdentifier(sourceFile, componentName); - if (!specifier) { - return []; - } - - const defs = lang.getDefinitionAtPosition(tsFilePath, specifier.getStart()); if (!defs) { return []; } - const tsFragment = await tsDoc.getFragment(); const startRange: Range = Range.create(Position.create(0, 0), Position.create(0, 0)); const links = defs.map((def) => { const defFilePath = ensureRealFilePath(def.fileName); @@ -170,6 +167,101 @@ export class AstroPlugin implements CompletionsProvider, FoldingRangeProvider { return null; } + private async getPropCompletions(document: Document, position: Position, completionContext?: CompletionContext): Promise { + const offset = document.offsetAt(position); + const html = document.html; + + const node = html.findNodeAt(offset); + if(!this.isComponentTag(node)) { + return []; + } + const inAttribute = node.start + node.tag!.length < offset; + if(!inAttribute) { + return []; + } + + // If inside of attributes, skip. + if(completionContext && completionContext.triggerKind === CompletionTriggerKind.TriggerCharacter && completionContext.triggerCharacter === '"') { + return []; + } + + const componentName = node.tag!; + const { lang: thisLang } = await this.tsLanguageServiceManager.getTypeScriptDoc(document); + + const defs = this.getDefinitionsForComponentName(document, thisLang, componentName); + + if (!defs) { + return []; + } + + const defFilePath = ensureRealFilePath(defs[0].fileName); + + const lang = await this.tsLanguageServiceManager.getTypeScriptLangForPath(defFilePath); + const program = lang.getProgram(); + const sourceFile = program?.getSourceFile(toVirtualAstroFilePath(defFilePath)); + const typeChecker = program?.getTypeChecker(); + + if(!sourceFile || !typeChecker) { + return []; + } + + let propsNode = this.getPropsNode(sourceFile); + if(!propsNode) { + return []; + } + + const completionItems: CompletionItem[] = []; + + for(let type of typeChecker.getBaseTypes(propsNode as unknown as ts.InterfaceType)) { + type.symbol.members!.forEach(mem => { + let item: CompletionItem = { + label: mem.name, + insertText: mem.name, + commitCharacters: [] + }; + + mem.getDocumentationComment(typeChecker); + let description = mem.getDocumentationComment(typeChecker).map(val => val.text).join('\n'); + + if(description) { + let docs: MarkupContent = { + kind: MarkupKind.Markdown, + value: description + }; + item.documentation = docs; + } + completionItems.push(item); + }); + } + + for(let member of propsNode.members) { + if(!member.name) continue; + + let name = member.name.getText(); + let symbol = typeChecker.getSymbolAtLocation(member.name); + if(!symbol) continue; + let description = symbol.getDocumentationComment(typeChecker).map(val => val.text).join('\n'); + + let item: CompletionItem = { + label: name, + insertText: name, + commitCharacters: [] + }; + + if(description) { + let docs: MarkupContent = { + kind: MarkupKind.Markdown, + value: description + }; + item.documentation = docs; + } + + completionItems.push(item); + } + + return completionItems; + } + private isInsideFrontmatter(document: Document, position: Position) { return isInsideFrontmatter(document.getText(), document.offsetAt(position)); } @@ -182,6 +274,28 @@ export class AstroPlugin implements CompletionsProvider, FoldingRangeProvider { return /[A-Z]/.test(firstChar); } + private getDefinitionsForComponentName(document: Document, lang: ts.LanguageService, componentName: string): readonly ts.DefinitionInfo[] | undefined { + const filePath = urlToPath(document.uri); + const tsFilePath = toVirtualAstroFilePath(filePath!); + + const sourceFile = lang.getProgram()?.getSourceFile(tsFilePath); + if (!sourceFile) { + return undefined; + } + + const specifier = this.getImportSpecifierForIdentifier(sourceFile, componentName); + if (!specifier) { + return []; + } + + const defs = lang.getDefinitionAtPosition(tsFilePath, specifier.getStart()); + if (!defs) { + return undefined; + } + + return defs; + } + private getImportSpecifierForIdentifier(sourceFile: ts.SourceFile, identifier: string): ts.Expression | undefined { let importSpecifier: ts.Expression | undefined = undefined; ts.forEachChild(sourceFile, (tsNode) => { @@ -197,4 +311,26 @@ export class AstroPlugin implements CompletionsProvider, FoldingRangeProvider { }); return importSpecifier; } + + private getPropsNode(sourceFile: ts.SourceFile): ts.InterfaceDeclaration | null { + let found: ts.InterfaceDeclaration | null = null; + ts.forEachChild(sourceFile, node => { + if(isNodeExported(node)) { + if(ts.isInterfaceDeclaration(node)) { + if(ts.getNameOfDeclaration(node)?.getText() === 'Props') { + found = node; + } + } + } + }); + + return found; + } } + +function isNodeExported(node: ts.Node): boolean { + return ( + (ts.getCombinedModifierFlags(node as ts.Declaration) & ts.ModifierFlags.Export) !== 0 || + (!!node.parent && node.parent.kind === ts.SyntaxKind.SourceFile) + ); +} \ No newline at end of file diff --git a/tools/language-server/src/plugins/html/HTMLPlugin.ts b/tools/language-server/src/plugins/html/HTMLPlugin.ts index d4f75e0d3..90c55b502 100644 --- a/tools/language-server/src/plugins/html/HTMLPlugin.ts +++ b/tools/language-server/src/plugins/html/HTMLPlugin.ts @@ -31,6 +31,13 @@ export class HTMLPlugin implements CompletionsProvider, FoldingRangeProvider { return null; } + const offset = document.offsetAt(position); + const node = html.findNodeAt(offset); + + if(this.isComponentTag(node)) { + return null; + } + const emmetResults: CompletionList = { isIncomplete: true, items: [], @@ -124,4 +131,12 @@ export class HTMLPlugin implements CompletionsProvider, FoldingRangeProvider { private isInsideFrontmatter(document: Document, position: Position) { return isInsideFrontmatter(document.getText(), document.offsetAt(position)); } + + private isComponentTag(node: Node): boolean { + if (!node.tag) { + return false; + } + const firstChar = node.tag[0]; + return /[A-Z]/.test(firstChar); + } } diff --git a/tools/language-server/src/plugins/typescript/LanguageServiceManager.ts b/tools/language-server/src/plugins/typescript/LanguageServiceManager.ts index 3ebcfdd77..9ff71abf7 100644 --- a/tools/language-server/src/plugins/typescript/LanguageServiceManager.ts +++ b/tools/language-server/src/plugins/typescript/LanguageServiceManager.ts @@ -2,7 +2,7 @@ import * as ts from 'typescript'; import type { Document, DocumentManager } from '../../core/documents'; import type { ConfigManager } from '../../core/config'; import { urlToPath, pathToUrl, debounceSameArg } from '../../utils'; -import { getLanguageService, getLanguageServiceForDocument, LanguageServiceContainer, LanguageServiceDocumentContext } from './languageService'; +import { getLanguageService, getLanguageServiceForPath, getLanguageServiceForDocument, LanguageServiceContainer, LanguageServiceDocumentContext } from './languageService'; import { SnapshotManager } from './SnapshotManager'; import { DocumentSnapshot } from './DocumentSnapshot'; @@ -71,6 +71,10 @@ export class LanguageServiceManager { return { tsDoc, lang }; } + async getTypeScriptLangForPath(filePath: string): Promise { + return getLanguageServiceForPath(filePath, this.workspaceUris, this.docContext); + } + async getSnapshotManager(filePath: string): Promise { return (await this.getTypeScriptLanguageService(filePath)).snapshotManager; }