242 lines
11 KiB
JavaScript
242 lines
11 KiB
JavaScript
"use strict";
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.CompletionsProviderImpl = void 0;
|
|
const vscode_html_languageservice_1 = require("vscode-html-languageservice");
|
|
const vscode_languageserver_1 = require("vscode-languageserver");
|
|
const utils_1 = require("../../../core/documents/utils");
|
|
const astro_attributes_1 = require("../../html/features/astro-attributes");
|
|
const utils_2 = require("../../html/utils");
|
|
const utils_3 = require("../../typescript/utils");
|
|
class CompletionsProviderImpl {
|
|
constructor(languageServiceManager) {
|
|
this.lastCompletion = null;
|
|
this.directivesHTMLLang = (0, vscode_html_languageservice_1.getLanguageService)({
|
|
customDataProviders: [astro_attributes_1.astroDirectives],
|
|
useDefaultDataProvider: false,
|
|
});
|
|
this.languageServiceManager = languageServiceManager;
|
|
this.ts = languageServiceManager.docContext.ts;
|
|
}
|
|
async getCompletions(document, position, completionContext) {
|
|
let items = [];
|
|
const html = document.html;
|
|
const offset = document.offsetAt(position);
|
|
const node = html.findNodeAt(offset);
|
|
const insideExpression = (0, utils_1.isInsideExpression)(document.getText(), node.start, offset);
|
|
if (completionContext?.triggerCharacter === '-' && node.parent === undefined && !insideExpression) {
|
|
const frontmatter = this.getComponentScriptCompletion(document, position);
|
|
if (frontmatter)
|
|
items.push(frontmatter);
|
|
}
|
|
if ((0, utils_1.isInComponentStartTag)(html, offset) && !insideExpression) {
|
|
const { completions: props, componentFilePath } = await this.getPropCompletionsAndFilePath(document, position, completionContext);
|
|
if (props.length) {
|
|
items.push(...props);
|
|
}
|
|
const isAstro = componentFilePath?.endsWith('.astro');
|
|
if (!isAstro && node.tag !== 'Fragment') {
|
|
const directives = (0, utils_2.removeDataAttrCompletion)(this.directivesHTMLLang.doComplete(document, position, html).items);
|
|
items.push(...directives);
|
|
}
|
|
}
|
|
return vscode_languageserver_1.CompletionList.create(items, true);
|
|
}
|
|
getComponentScriptCompletion(document, position) {
|
|
const base = {
|
|
kind: vscode_languageserver_1.CompletionItemKind.Snippet,
|
|
label: '---',
|
|
sortText: '\0',
|
|
preselect: true,
|
|
detail: 'Create component script block',
|
|
insertTextFormat: vscode_languageserver_1.InsertTextFormat.Snippet,
|
|
commitCharacters: [],
|
|
};
|
|
const prefix = document.getLineUntilOffset(document.offsetAt(position));
|
|
if (document.astroMeta.frontmatter.state === null) {
|
|
return {
|
|
...base,
|
|
insertText: '---\n$0\n---',
|
|
textEdit: prefix.match(/^\s*\-+/)
|
|
? vscode_languageserver_1.TextEdit.replace({ start: { ...position, character: 0 }, end: position }, '---\n$0\n---')
|
|
: undefined,
|
|
};
|
|
}
|
|
if (document.astroMeta.frontmatter.state === 'open') {
|
|
let insertText = '---';
|
|
// If the current line is a full component script starter/ender, the user expects a full frontmatter
|
|
// completion and not just a completion for "---" on the same line (which result in, well, nothing)
|
|
if (prefix === '---') {
|
|
insertText = '---\n$0\n---';
|
|
}
|
|
return {
|
|
...base,
|
|
insertText,
|
|
detail: insertText === '---' ? 'Close component script block' : 'Create component script block',
|
|
textEdit: prefix.match(/^\s*\-+/)
|
|
? vscode_languageserver_1.TextEdit.replace({ start: { ...position, character: 0 }, end: position }, insertText)
|
|
: undefined,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
async getPropCompletionsAndFilePath(document, position, completionContext) {
|
|
const offset = document.offsetAt(position);
|
|
const html = document.html;
|
|
const node = html.findNodeAt(offset);
|
|
if (!(0, utils_1.isPossibleComponent)(node)) {
|
|
return { completions: [], componentFilePath: null };
|
|
}
|
|
const inAttribute = node.start + node.tag.length < offset;
|
|
if (!inAttribute) {
|
|
return { completions: [], componentFilePath: null };
|
|
}
|
|
if (completionContext?.triggerCharacter === '/' || completionContext?.triggerCharacter === '>') {
|
|
return { completions: [], componentFilePath: null };
|
|
}
|
|
// If inside of attribute value, skip.
|
|
if (completionContext &&
|
|
completionContext.triggerKind === vscode_languageserver_1.CompletionTriggerKind.TriggerCharacter &&
|
|
completionContext.triggerCharacter === '"') {
|
|
return { completions: [], componentFilePath: null };
|
|
}
|
|
const componentName = node.tag;
|
|
const { lang, tsDoc } = await this.languageServiceManager.getLSAndTSDoc(document);
|
|
// Get the source file
|
|
const tsFilePath = tsDoc.filePath;
|
|
const program = lang.getProgram();
|
|
const sourceFile = program?.getSourceFile(tsFilePath);
|
|
const typeChecker = program?.getTypeChecker();
|
|
if (!sourceFile || !typeChecker) {
|
|
return { completions: [], componentFilePath: null };
|
|
}
|
|
// Get the import statement
|
|
const imp = this.getImportedSymbol(sourceFile, componentName);
|
|
const importType = imp && typeChecker.getTypeAtLocation(imp);
|
|
if (!importType) {
|
|
return { completions: [], componentFilePath: null };
|
|
}
|
|
const symbol = importType.getSymbol();
|
|
if (!symbol) {
|
|
return { completions: [], componentFilePath: null };
|
|
}
|
|
const symbolDeclaration = symbol.declarations;
|
|
if (!symbolDeclaration) {
|
|
return { completions: [], componentFilePath: null };
|
|
}
|
|
const filePath = symbolDeclaration[0].getSourceFile().fileName;
|
|
const componentSnapshot = await this.languageServiceManager.getSnapshot(filePath);
|
|
if (this.lastCompletion) {
|
|
if (this.lastCompletion.tag === componentName &&
|
|
this.lastCompletion.documentVersion == componentSnapshot.version) {
|
|
return { completions: this.lastCompletion.completions, componentFilePath: filePath };
|
|
}
|
|
}
|
|
// Get the component's props type
|
|
const componentType = this.getPropType(symbolDeclaration, typeChecker);
|
|
if (!componentType) {
|
|
return { completions: [], componentFilePath: null };
|
|
}
|
|
let completionItems = [];
|
|
// Add completions for this component's props type properties
|
|
const properties = componentType.getProperties().filter((property) => property.name !== 'children') || [];
|
|
properties.forEach((property) => {
|
|
const type = typeChecker.getTypeOfSymbolAtLocation(property, imp);
|
|
let completionItem = this.getCompletionItemForProperty(property, typeChecker, type);
|
|
completionItems.push(completionItem);
|
|
});
|
|
this.lastCompletion = {
|
|
tag: componentName,
|
|
documentVersion: componentSnapshot.version,
|
|
completions: completionItems,
|
|
};
|
|
return { completions: completionItems, componentFilePath: filePath };
|
|
}
|
|
getImportedSymbol(sourceFile, identifier) {
|
|
for (let list of sourceFile.getChildren()) {
|
|
for (let node of list.getChildren()) {
|
|
if (this.ts.isImportDeclaration(node)) {
|
|
let clauses = node.importClause;
|
|
if (!clauses)
|
|
continue;
|
|
let namedImport = clauses.getChildAt(0);
|
|
if (this.ts.isNamedImports(namedImport)) {
|
|
for (let imp of namedImport.elements) {
|
|
// Iterate the named imports
|
|
if (imp.name.getText() === identifier) {
|
|
return imp;
|
|
}
|
|
}
|
|
}
|
|
else if (this.ts.isIdentifier(namedImport)) {
|
|
if (namedImport.getText() === identifier) {
|
|
return namedImport;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
getPropType(declarations, typeChecker) {
|
|
for (const decl of declarations) {
|
|
const fileName = (0, utils_3.toVirtualFilePath)(decl.getSourceFile().fileName);
|
|
if (fileName.endsWith('.tsx') || fileName.endsWith('.jsx') || fileName.endsWith('.d.ts')) {
|
|
if (!this.ts.isFunctionDeclaration(decl) && !this.ts.isFunctionTypeNode(decl)) {
|
|
console.error(`We only support functions declarations at the moment`);
|
|
continue;
|
|
}
|
|
const fn = decl;
|
|
if (!fn.parameters.length)
|
|
continue;
|
|
const param1 = fn.parameters[0];
|
|
const propType = typeChecker.getTypeAtLocation(param1);
|
|
return propType;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
getCompletionItemForProperty(mem, typeChecker, type) {
|
|
const typeString = typeChecker.typeToString(type);
|
|
let insertText = mem.name;
|
|
switch (typeString) {
|
|
case 'string':
|
|
insertText = `${mem.name}="$1"`;
|
|
break;
|
|
case 'boolean':
|
|
insertText = mem.name;
|
|
break;
|
|
default:
|
|
insertText = `${mem.name}={$1}`;
|
|
break;
|
|
}
|
|
let item = {
|
|
label: mem.name,
|
|
detail: typeString,
|
|
insertText: insertText,
|
|
insertTextFormat: vscode_languageserver_1.InsertTextFormat.Snippet,
|
|
commitCharacters: [],
|
|
// Ensure that props shows up first as a completion, despite this plugin being ran after the HTML one
|
|
sortText: '\0',
|
|
};
|
|
if (mem.flags & this.ts.SymbolFlags.Optional) {
|
|
item.filterText = item.label;
|
|
item.label += '?';
|
|
// Put optional props at a lower priority
|
|
item.sortText = '_';
|
|
}
|
|
mem.getDocumentationComment(typeChecker);
|
|
let description = mem
|
|
.getDocumentationComment(typeChecker)
|
|
.map((val) => val.text)
|
|
.join('\n');
|
|
if (description) {
|
|
let docs = {
|
|
kind: vscode_languageserver_1.MarkupKind.Markdown,
|
|
value: description,
|
|
};
|
|
item.documentation = docs;
|
|
}
|
|
return item;
|
|
}
|
|
}
|
|
exports.CompletionsProviderImpl = CompletionsProviderImpl;
|