Provide completion suggestions in component props (#1082)
* Provide completion suggestions in component props * Limit prop completion to props that the component exports
This commit is contained in:
parent
aa20be9138
commit
7cd3689a8e
4 changed files with 173 additions and 18 deletions
|
@ -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 {
|
||||
|
|
|
@ -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<CompletionItem[]> {
|
||||
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)
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<ts.LanguageService> {
|
||||
return getLanguageServiceForPath(filePath, this.workspaceUris, this.docContext);
|
||||
}
|
||||
|
||||
async getSnapshotManager(filePath: string): Promise<SnapshotManager> {
|
||||
return (await this.getTypeScriptLanguageService(filePath)).snapshotManager;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue