diff --git a/.changeset/khaki-baboons-report.md b/.changeset/khaki-baboons-report.md new file mode 100644 index 00000000..fcbce47d --- /dev/null +++ b/.changeset/khaki-baboons-report.md @@ -0,0 +1,6 @@ +--- +"marko-vscode": patch +"@marko/language-server": patch +--- + +Add auto completion for tag import shorthand. diff --git a/server/src/service/marko/complete/OpenTagName.ts b/server/src/service/marko/complete/OpenTagName.ts index c26903db..4c25637d 100644 --- a/server/src/service/marko/complete/OpenTagName.ts +++ b/server/src/service/marko/complete/OpenTagName.ts @@ -1,16 +1,8 @@ -import path from "path"; -import { URI } from "vscode-uri"; -import { - CompletionItemKind, - CompletionItem, - InsertTextFormat, - MarkupKind, - TextEdit, -} from "vscode-languageserver"; -import type { TagDefinition } from "@marko/babel-utils"; import { type Node, NodeType } from "../../../utils/parser"; import { getDocFile } from "../../../utils/doc-file"; import type { CompletionMeta, CompletionResult } from "."; +import getTagNameCompletion from "../util/get-tag-name-completion"; +import type { CompletionItem } from "vscode-languageserver"; export function OpenTagName({ document, @@ -18,86 +10,59 @@ export function OpenTagName({ parsed, node, }: CompletionMeta): CompletionResult { - const currentTemplateFilePath = getDocFile(document); + const importer = getDocFile(document); const tag = node.parent; - const tagNameLocation = parsed.locationAt(node); - let tags: TagDefinition[]; + const range = parsed.locationAt(node); + const isAttrTag = tag.type === NodeType.AttrTag; + const result: CompletionItem[] = []; - if (tag.type === NodeType.AttrTag) { + if (isAttrTag) { let parentTag = tag.owner; while (parentTag?.type === NodeType.AttrTag) parentTag = parentTag.owner; const parentTagDef = parentTag && parentTag.nameText && lookup.getTag(parentTag.nameText); - tags = - (parentTagDef && - parentTagDef.nestedTags && - Object.values(parentTagDef.nestedTags)) || - []; - } else { - tags = lookup.getTagsSorted().filter((it) => !it.isNestedTag); - } - - return tags - .filter((it) => !it.deprecated) - .filter((it) => it.name !== "*") - .filter( - (it) => /^[^_]/.test(it.name) || !/\/node_modules\//.test(it.filePath) - ) - .map((it) => { - let label = it.isNestedTag ? `@${it.name}` : it.name; - const fileForTag = it.template || it.renderer || it.filePath; - const fileURIForTag = URI.file(fileForTag).toString(); - const nodeModuleMatch = /\/node_modules\/((?:@[^/]+\/)?[^/]+)/.exec( - fileForTag - ); - - const nodeModuleName = nodeModuleMatch && nodeModuleMatch[1]; - const isCoreTag = nodeModuleName === "marko"; - - const documentation = { - kind: MarkupKind.Markdown, - value: it.html - ? `Built in [<${it.name}>](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/${it.name}) HTML tag.` - : nodeModuleName - ? isCoreTag - ? `Core Marko [<${it.name}>](${fileURIForTag}) tag.` - : `Custom Marko tag discovered from the ["${nodeModuleName}"](${fileURIForTag}) npm package.` - : `Custom Marko tag discovered from:\n\n[${ - currentTemplateFilePath - ? path.relative(currentTemplateFilePath, fileForTag) - : currentTemplateFilePath - }](${fileURIForTag})`, - }; - - if (it.description) { - documentation.value += `\n\n${it.description}`; - } - - const autocomplete = it.autocomplete && it.autocomplete[0]; - if (autocomplete) { - if (autocomplete.displayText) { - label = autocomplete.displayText; - } - - if (autocomplete.description) { - documentation.value += `\n\n${autocomplete.description}`; - } - - if (autocomplete.descriptionMoreURL) { - documentation.value += `\n\n[More Info](${autocomplete.descriptionMoreURL})`; + if (parentTagDef) { + const { nestedTags } = parentTagDef; + for (const key in nestedTags) { + if (key !== "*") { + const tag = nestedTags[key]; + result.push( + getTagNameCompletion({ + tag, + range, + importer, + showAutoComplete: true, + }) + ); } } + } + } else { + const skipStatements = !( + tag.concise && tag.parent.type === NodeType.Program + ); + for (const tag of lookup.getTagsSorted()) { + if ( + !( + tag.name === "*" || + tag.isNestedTag || + (skipStatements && tag.parseOptions?.statement) || + (tag.name[0] === "_" && + /^@?marko[/-]|[\\/]node_modules[\\/]/.test(tag.filePath)) + ) + ) { + result.push( + getTagNameCompletion({ + tag, + range, + importer, + showAutoComplete: true, + }) + ); + } + } + } - return { - label, - documentation, - kind: CompletionItemKind.Class, - insertTextFormat: InsertTextFormat.Snippet, - textEdit: TextEdit.replace( - tagNameLocation, - (autocomplete && autocomplete.snippet) || label - ), - } as CompletionItem; - }); + return result; } diff --git a/server/src/service/marko/complete/Statement.ts b/server/src/service/marko/complete/Statement.ts new file mode 100644 index 00000000..2caa75ef --- /dev/null +++ b/server/src/service/marko/complete/Statement.ts @@ -0,0 +1,59 @@ +import type { Node } from "../../../utils/parser"; +import type { CompletionMeta, CompletionResult } from "."; +import { CompletionItem, TextEdit } from "vscode-languageserver"; +import getTagNameCompletion from "../util/get-tag-name-completion"; +import { getDocFile } from "../../../utils/doc-file"; + +const importTagReg = /(['"])<((?:[^\1\\>]+|\\.)*)>?\1/g; + +export function Statement({ + code, + node, + parsed, + lookup, + document, +}: CompletionMeta): CompletionResult { + // check for import statement + if (code[node.start] === "i") { + importTagReg.lastIndex = 0; + const value = parsed.read(node); + const match = importTagReg.exec(value); + if (match) { + const importer = getDocFile(document); + const [{ length }] = match; + const range = parsed.locationAt({ + start: node.start + match.index + 1, + end: node.start + match.index + length - 1, + }); + + const result: CompletionItem[] = []; + + for (const tag of lookup.getTagsSorted()) { + if ( + (tag.template || tag.renderer) && + !( + tag.html || + tag.parser || + tag.translator || + tag.isNestedTag || + tag.name === "*" || + tag.parseOptions?.statement || + /^@?marko[/-]/.test(tag.taglibId) || + (tag.name[0] === "_" && /[\\/]node_modules[\\/]/.test(tag.filePath)) + ) + ) { + const completion = getTagNameCompletion({ + tag, + importer, + }); + + completion.label = `<${completion.label}>`; + completion.textEdit = TextEdit.replace(range, completion.label); + result.push(completion); + } + } + + return result; + } + } +} diff --git a/server/src/service/marko/complete/index.ts b/server/src/service/marko/complete/index.ts index d8515429..c5396da2 100644 --- a/server/src/service/marko/complete/index.ts +++ b/server/src/service/marko/complete/index.ts @@ -10,6 +10,7 @@ import { Tag } from "./Tag"; import { OpenTagName } from "./OpenTagName"; import { AttrName } from "./AttrName"; import { AttrValue } from "./AttrValue"; +import { Statement } from "./Statement"; import type { Plugin, Result } from "../../types"; import { NodeType } from "../../../utils/parser"; @@ -32,6 +33,7 @@ const handlers: Record< OpenTagName, AttrName, AttrValue, + Statement, }; export const doComplete: Plugin["doComplete"] = async (doc, params) => { diff --git a/server/src/service/marko/util/get-tag-name-completion.ts b/server/src/service/marko/util/get-tag-name-completion.ts new file mode 100644 index 00000000..6fcc2931 --- /dev/null +++ b/server/src/service/marko/util/get-tag-name-completion.ts @@ -0,0 +1,78 @@ +import path from "path"; +import type { TagDefinition } from "@marko/babel-utils"; +import { + type CompletionItem, + type Range, + CompletionItemKind, + CompletionItemTag, + InsertTextFormat, + MarkupKind, + TextEdit, +} from "vscode-languageserver"; +import { URI } from "vscode-uri"; + +const deprecated = [CompletionItemTag.Deprecated] as CompletionItemTag[]; + +export default function getTagNameCompletion({ + tag, + range, + showAutoComplete, + importer, +}: { + tag: TagDefinition; + range?: Range; + importer?: string; + showAutoComplete?: true; +}): CompletionItem { + let label = tag.isNestedTag ? `@${tag.name}` : tag.name; + const fileForTag = tag.template || tag.renderer || tag.filePath; + const fileURIForTag = URI.file(fileForTag).toString(); + const nodeModuleMatch = /\/node_modules\/((?:@[^/]+\/)?[^/]+)/.exec( + fileForTag + ); + + const nodeModuleName = nodeModuleMatch && nodeModuleMatch[1]; + const isCoreTag = nodeModuleName === "marko"; + + const documentation = { + kind: MarkupKind.Markdown, + value: tag.html + ? `Built in [<${tag.name}>](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/${tag.name}) HTML tag.` + : nodeModuleName + ? isCoreTag + ? `Core Marko [<${tag.name}>](${fileURIForTag}) tag.` + : `Custom Marko tag discovered from the ["${nodeModuleName}"](${fileURIForTag}) npm package.` + : `Custom Marko tag discovered from:\n\n[${ + importer ? path.relative(importer, fileForTag) : fileForTag + }](${fileURIForTag})`, + }; + + if (tag.description) { + documentation.value += `\n\n${tag.description}`; + } + + const autocomplete = showAutoComplete ? tag.autocomplete?.[0] : undefined; + + if (autocomplete) { + if (autocomplete.displayText) { + label = autocomplete.displayText; + } + + if (autocomplete.description) { + documentation.value += `\n\n${autocomplete.description}`; + } + + if (autocomplete.descriptionMoreURL) { + documentation.value += `\n\n[More Info](${autocomplete.descriptionMoreURL})`; + } + } + + return { + label, + documentation, + tags: tag.deprecated ? deprecated : undefined, + insertTextFormat: autocomplete ? InsertTextFormat.Snippet : undefined, + kind: tag.html ? CompletionItemKind.Property : CompletionItemKind.Class, + textEdit: range && TextEdit.replace(range, autocomplete?.snippet || label), + }; +}