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,
|
filterIncompleteCompletions: !evt.initializationOptions?.dontFilterIncompleteCompletions,
|
||||||
definitionLinkSupport: !!evt.capabilities.textDocument?.definition?.linkSupport,
|
definitionLinkSupport: !!evt.capabilities.textDocument?.definition?.linkSupport,
|
||||||
});
|
});
|
||||||
pluginHost.register(new AstroPlugin(docManager, configManager, workspaceUris));
|
|
||||||
pluginHost.register(new HTMLPlugin(docManager, configManager));
|
pluginHost.register(new HTMLPlugin(docManager, configManager));
|
||||||
pluginHost.register(new CSSPlugin(docManager, configManager));
|
pluginHost.register(new CSSPlugin(docManager, configManager));
|
||||||
pluginHost.register(new TypeScriptPlugin(docManager, configManager, workspaceUris));
|
pluginHost.register(new TypeScriptPlugin(docManager, configManager, workspaceUris));
|
||||||
|
pluginHost.register(new AstroPlugin(docManager, configManager, workspaceUris));
|
||||||
configManager.updateEmmetConfig(evt.initializationOptions?.configuration?.emmet || evt.initializationOptions?.emmetConfig || {});
|
configManager.updateEmmetConfig(evt.initializationOptions?.configuration?.emmet || evt.initializationOptions?.emmetConfig || {});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -8,14 +8,18 @@ import {
|
||||||
CompletionList,
|
CompletionList,
|
||||||
CompletionItem,
|
CompletionItem,
|
||||||
CompletionItemKind,
|
CompletionItemKind,
|
||||||
|
CompletionTriggerKind,
|
||||||
InsertTextFormat,
|
InsertTextFormat,
|
||||||
LocationLink,
|
LocationLink,
|
||||||
FoldingRange,
|
FoldingRange,
|
||||||
|
MarkupContent,
|
||||||
|
MarkupKind,
|
||||||
Range,
|
Range,
|
||||||
TextEdit,
|
TextEdit,
|
||||||
} from 'vscode-languageserver';
|
} from 'vscode-languageserver';
|
||||||
import { Node } from 'vscode-html-languageservice';
|
import { Node } from 'vscode-html-languageservice';
|
||||||
import { isPossibleClientComponent, pathToUrl, urlToPath } from '../../utils';
|
import { isPossibleClientComponent, pathToUrl, urlToPath } from '../../utils';
|
||||||
|
import { toVirtualAstroFilePath } from '../typescript/utils';
|
||||||
import { isInsideFrontmatter } from '../../core/documents/utils';
|
import { isInsideFrontmatter } from '../../core/documents/utils';
|
||||||
import * as ts from 'typescript';
|
import * as ts from 'typescript';
|
||||||
import { LanguageServiceManager as TypeScriptLanguageServiceManager } from '../typescript/LanguageServiceManager';
|
import { LanguageServiceManager as TypeScriptLanguageServiceManager } from '../typescript/LanguageServiceManager';
|
||||||
|
@ -50,6 +54,13 @@ export class AstroPlugin implements CompletionsProvider, FoldingRangeProvider {
|
||||||
if (clientHint) items.push(...clientHint);
|
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);
|
return CompletionList.create(items, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -88,27 +99,13 @@ export class AstroPlugin implements CompletionsProvider, FoldingRangeProvider {
|
||||||
|
|
||||||
const [componentName] = node.tag!.split(':');
|
const [componentName] = node.tag!.split(':');
|
||||||
|
|
||||||
const filePath = urlToPath(document.uri);
|
const { lang } = await this.tsLanguageServiceManager.getTypeScriptDoc(document);
|
||||||
const tsFilePath = filePath + '.ts';
|
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) {
|
if (!defs) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const tsFragment = await tsDoc.getFragment();
|
|
||||||
const startRange: Range = Range.create(Position.create(0, 0), Position.create(0, 0));
|
const startRange: Range = Range.create(Position.create(0, 0), Position.create(0, 0));
|
||||||
const links = defs.map((def) => {
|
const links = defs.map((def) => {
|
||||||
const defFilePath = ensureRealFilePath(def.fileName);
|
const defFilePath = ensureRealFilePath(def.fileName);
|
||||||
|
@ -170,6 +167,101 @@ export class AstroPlugin implements CompletionsProvider, FoldingRangeProvider {
|
||||||
return null;
|
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) {
|
private isInsideFrontmatter(document: Document, position: Position) {
|
||||||
return isInsideFrontmatter(document.getText(), document.offsetAt(position));
|
return isInsideFrontmatter(document.getText(), document.offsetAt(position));
|
||||||
}
|
}
|
||||||
|
@ -182,6 +274,28 @@ export class AstroPlugin implements CompletionsProvider, FoldingRangeProvider {
|
||||||
return /[A-Z]/.test(firstChar);
|
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 {
|
private getImportSpecifierForIdentifier(sourceFile: ts.SourceFile, identifier: string): ts.Expression | undefined {
|
||||||
let importSpecifier: ts.Expression | undefined = undefined;
|
let importSpecifier: ts.Expression | undefined = undefined;
|
||||||
ts.forEachChild(sourceFile, (tsNode) => {
|
ts.forEachChild(sourceFile, (tsNode) => {
|
||||||
|
@ -197,4 +311,26 @@ export class AstroPlugin implements CompletionsProvider, FoldingRangeProvider {
|
||||||
});
|
});
|
||||||
return importSpecifier;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const offset = document.offsetAt(position);
|
||||||
|
const node = html.findNodeAt(offset);
|
||||||
|
|
||||||
|
if(this.isComponentTag(node)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const emmetResults: CompletionList = {
|
const emmetResults: CompletionList = {
|
||||||
isIncomplete: true,
|
isIncomplete: true,
|
||||||
items: [],
|
items: [],
|
||||||
|
@ -124,4 +131,12 @@ export class HTMLPlugin implements CompletionsProvider, FoldingRangeProvider {
|
||||||
private isInsideFrontmatter(document: Document, position: Position) {
|
private isInsideFrontmatter(document: Document, position: Position) {
|
||||||
return isInsideFrontmatter(document.getText(), document.offsetAt(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 { Document, DocumentManager } from '../../core/documents';
|
||||||
import type { ConfigManager } from '../../core/config';
|
import type { ConfigManager } from '../../core/config';
|
||||||
import { urlToPath, pathToUrl, debounceSameArg } from '../../utils';
|
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 { SnapshotManager } from './SnapshotManager';
|
||||||
import { DocumentSnapshot } from './DocumentSnapshot';
|
import { DocumentSnapshot } from './DocumentSnapshot';
|
||||||
|
|
||||||
|
@ -71,6 +71,10 @@ export class LanguageServiceManager {
|
||||||
return { tsDoc, lang };
|
return { tsDoc, lang };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getTypeScriptLangForPath(filePath: string): Promise<ts.LanguageService> {
|
||||||
|
return getLanguageServiceForPath(filePath, this.workspaceUris, this.docContext);
|
||||||
|
}
|
||||||
|
|
||||||
async getSnapshotManager(filePath: string): Promise<SnapshotManager> {
|
async getSnapshotManager(filePath: string): Promise<SnapshotManager> {
|
||||||
return (await this.getTypeScriptLanguageService(filePath)).snapshotManager;
|
return (await this.getTypeScriptLanguageService(filePath)).snapshotManager;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue