diff --git a/.changeset/nasty-spies-think.md b/.changeset/nasty-spies-think.md new file mode 100644 index 00000000..a4911874 --- /dev/null +++ b/.changeset/nasty-spies-think.md @@ -0,0 +1,6 @@ +--- +"marko-vscode": patch +"@marko/language-server": patch +--- + +Improve completions for file system paths. diff --git a/server/src/index.ts b/server/src/index.ts index eff6a4d0..29206484 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -35,8 +35,9 @@ console.error = (...args: unknown[]) => { process.on("uncaughtException", console.error); process.on("unhandledRejection", console.error); -connection.onInitialize(() => { +connection.onInitialize(async (params) => { setupMessages(connection); + await service.initialize(params); return { capabilities: { diff --git a/server/src/service/index.ts b/server/src/service/index.ts index 38cf42a5..89b6e675 100644 --- a/server/src/service/index.ts +++ b/server/src/service/index.ts @@ -23,6 +23,9 @@ const plugins = [MarkoPlugin, StyleSheetPlugin]; * Facade to all embedded plugins, eg css, typescript and our own. */ const service: Plugin = { + async initialize(params) { + await Promise.all(plugins.map((plugin) => plugin.initialize?.(params))); + }, async doComplete(doc, params, cancel) { const result = CompletionList.create([], false); diff --git a/server/src/service/marko/complete/AttrValue.ts b/server/src/service/marko/complete/AttrValue.ts new file mode 100644 index 00000000..3091f306 --- /dev/null +++ b/server/src/service/marko/complete/AttrValue.ts @@ -0,0 +1,70 @@ +import path from "path"; +import { + CompletionItem, + CompletionItemKind, + Range, + TextEdit, +} from "vscode-languageserver"; +import type { Node } from "../../../utils/parser"; +import isDocumentLinkAttr from "../util/is-document-link-attr"; +import fileSystem, { FileType } from "../../../utils/file-system"; +import resolveUrl from "../../../utils/resolve-url"; +import type { CompletionMeta } from "."; + +export async function AttrValue({ + document, + offset, + node, + parsed, + code, +}: CompletionMeta): Promise { + const attr = node.parent; + if (isDocumentLinkAttr(document, attr.parent, attr)) { + const start = node.value.start + 1; + if (code[start] !== ".") return; // only resolve relative paths + + const end = node.value.end - 1; + const relativeOffset = offset - start; + const rawValue = parsed.read({ + start, + end, + }); + + let segmentStart = rawValue.lastIndexOf("/", relativeOffset); + if (segmentStart === -1) segmentStart = relativeOffset; + + const resolveRequest = rawValue.slice(0, segmentStart) || "."; + const dir = resolveUrl(resolveRequest, document.uri); + + if (dir?.[0] === "/") { + const result: CompletionItem[] = []; + const curDir = + resolveRequest === "." ? dir : resolveUrl(".", document.uri); + const curFile = curDir === dir ? path.basename(document.uri) : undefined; + const replaceRange = Range.create( + document.positionAt(start + segmentStart + 1), + document.positionAt(start + rawValue.length) + ); + + for (const [entry, type] of await fileSystem.readDirectory(dir)) { + if (entry[0] !== "." && entry !== curFile) { + const isDir = type === FileType.Directory; + const label = isDir ? `${entry}/` : entry; + result.push({ + label, + kind: isDir ? CompletionItemKind.Folder : CompletionItemKind.File, + textEdit: TextEdit.replace(replaceRange, label), + command: isDir + ? { + title: "Suggest", + command: "editor.action.triggerSuggest", + } + : undefined, + }); + } + } + + return result; + } + } +} diff --git a/server/src/service/marko/complete/index.ts b/server/src/service/marko/complete/index.ts index cb3cd8d5..d8515429 100644 --- a/server/src/service/marko/complete/index.ts +++ b/server/src/service/marko/complete/index.ts @@ -9,6 +9,7 @@ import { getCompilerInfo, parse } from "../../../utils/compiler"; import { Tag } from "./Tag"; import { OpenTagName } from "./OpenTagName"; import { AttrName } from "./AttrName"; +import { AttrValue } from "./AttrValue"; import type { Plugin, Result } from "../../types"; import { NodeType } from "../../../utils/parser"; @@ -30,6 +31,7 @@ const handlers: Record< Tag, OpenTagName, AttrName, + AttrValue, }; export const doComplete: Plugin["doComplete"] = async (doc, params) => { diff --git a/server/src/service/marko/document-links/extract.ts b/server/src/service/marko/document-links/extract.ts index a8a5779d..843b427b 100644 --- a/server/src/service/marko/document-links/extract.ts +++ b/server/src/service/marko/document-links/extract.ts @@ -9,24 +9,9 @@ import { NodeType, } from "../../../utils/parser"; import resolveUrl from "../../../utils/resolve-url"; +import isDocumentLinkAttr from "../util/is-document-link-attr"; const importTagReg = /(['"])<((?:[^\1\\>]+|\\.)*)>?\1/g; -const linkedAttrs: Record> = { - src: new Set([ - "audio", - "embed", - "iframe", - "img", - "input", - "script", - "source", - "track", - "video", - ]), - href: new Set(["a", "area", "link"]), - data: new Set(["object"]), - poster: new Set(["video"]), -}; /** * Iterate over the Marko CST and extract all the file links in the document. @@ -49,23 +34,16 @@ export function extractDocumentLinks( case NodeType.Tag: if (node.attrs && node.nameText) { for (const attr of node.attrs) { - if ( - attr.type === NodeType.AttrNamed && - attr.value?.type === NodeType.AttrValue && - /^['"]$/.test(code[attr.value.value.start]) - ) { - const attrName = read(attr.name); - if (linkedAttrs[attrName]?.has(node.nameText)) { - links.push( - DocumentLink.create( - { - start: parsed.positionAt(attr.value.value.start), - end: parsed.positionAt(attr.value.value.end), - }, - resolveUrl(read(attr.value.value).slice(1, -1), doc.uri) - ) - ); - } + if (isDocumentLinkAttr(doc, node, attr)) { + links.push( + DocumentLink.create( + { + start: parsed.positionAt(attr.value.value.start), + end: parsed.positionAt(attr.value.value.end), + }, + resolveUrl(read(attr.value.value).slice(1, -1), doc.uri) + ) + ); } } } diff --git a/server/src/service/marko/util/is-document-link-attr.ts b/server/src/service/marko/util/is-document-link-attr.ts new file mode 100644 index 00000000..f1fe1738 --- /dev/null +++ b/server/src/service/marko/util/is-document-link-attr.ts @@ -0,0 +1,39 @@ +import type { TextDocument } from "vscode-languageserver-textdocument"; +import { type Node, NodeType } from "../../../utils/parser"; + +const linkedAttrs: Map> = new Map([ + [ + "src", + new Set([ + "audio", + "embed", + "iframe", + "img", + "input", + "script", + "source", + "track", + "video", + ]), + ], + ["href", new Set(["a", "area", "link"])], + ["data", new Set(["object"])], + ["poster", new Set(["video"])], +]); + +export default function isDocumentLinkAttr( + doc: TextDocument, + tag: Node.ParentTag, + attr: Node.AttrNode +): attr is Node.AttrNamed & { value: Node.AttrValue } { + return ( + (tag.nameText && + attr.type === NodeType.AttrNamed && + attr.value?.type === NodeType.AttrValue && + /^['"]$/.test(doc.getText()[attr.value.value.start]) && + linkedAttrs + .get(doc.getText().slice(attr.name.start, attr.name.end)) + ?.has(tag.nameText)) || + false + ); +} diff --git a/server/src/service/stylesheet/index.ts b/server/src/service/stylesheet/index.ts index e6906704..d90f42f5 100644 --- a/server/src/service/stylesheet/index.ts +++ b/server/src/service/stylesheet/index.ts @@ -9,19 +9,22 @@ import { Location, TextEdit, DocumentLink, + InitializeParams, + ColorPresentation, } from "vscode-languageserver"; import { getCSSLanguageService, getLESSLanguageService, getSCSSLanguageService, - LanguageService, + type LanguageService, + type LanguageServiceOptions, } from "vscode-css-languageservice"; import { TextDocument } from "vscode-languageserver-textdocument"; import { getCompilerInfo, parse } from "../../utils/compiler"; -import { START_OF_FILE } from "../../utils/utils"; import type { Plugin } from "../types"; import { extractStyleSheets } from "./extract"; -import resolveUrl from "../../utils/resolve-url"; +import resolveReference from "../../utils/resolve-url"; +import fileSystemProvider from "../../utils/file-system"; interface StyleSheetInfo { virtualDoc: TextDocument; @@ -36,13 +39,20 @@ const cache = new WeakMap< Record >(); -const services: Record LanguageService> = { +const services: Record< + string, + (options: LanguageServiceOptions) => LanguageService +> = { css: getCSSLanguageService, less: getLESSLanguageService, scss: getSCSSLanguageService, }; +let clientCapabilities: InitializeParams["capabilities"] | undefined; const StyleSheetService: Partial = { + initialize(params) { + clientCapabilities = params.capabilities; + }, async doComplete(doc, params) { const infoByExt = getStyleSheetInfo(doc); const sourceOffset = doc.offsetAt(params.position); @@ -54,28 +64,40 @@ const StyleSheetService: Partial = { if (generatedOffset === undefined) continue; const { service, virtualDoc } = info; - const result = service.doComplete( + const result = await service.doComplete2( virtualDoc, virtualDoc.positionAt(generatedOffset), - info.parsed + info.parsed, + { resolveReference } ); - for (const item of result.items) { - if (item.additionalTextEdits) { - for (const textEdit of item.additionalTextEdits) { - updateTextEdit(doc, info, textEdit); + if (result.itemDefaults) { + const { editRange } = result.itemDefaults; + if (editRange) { + if ("start" in editRange) { + result.itemDefaults.editRange = getSourceRange( + doc, + info, + editRange + ); + } else { + editRange.insert = getSourceRange(doc, info, editRange.insert)!; + editRange.replace = getSourceRange(doc, info, editRange.replace)!; } } + } - const { textEdit } = item; - if (textEdit) { - if ((textEdit as TextEdit).range) { - updateTextEdit(doc, info, textEdit as TextEdit); - } + for (const item of result.items) { + if (item.textEdit) { + item.textEdit = getSourceInsertReplaceEdit(doc, info, item.textEdit); + } - if ((textEdit as InsertReplaceEdit).insert) { - updateInsertReplaceEdit(doc, info, textEdit as InsertReplaceEdit); - } + if (item.additionalTextEdits) { + item.additionalTextEdits = getSourceEdits( + doc, + info, + item.additionalTextEdits + ); } } @@ -84,7 +106,7 @@ const StyleSheetService: Partial = { return CompletionList.create([], true); }, - async findDefinition(doc, params) { + findDefinition(doc, params) { const infoByExt = getStyleSheetInfo(doc); const sourceOffset = doc.offsetAt(params.position); @@ -101,14 +123,20 @@ const StyleSheetService: Partial = { info.parsed ); - if (result && updateRange(doc, info, result.range)) { - return result; + if (result) { + const range = getSourceRange(doc, info, result.range); + if (range) { + return { + range, + uri: doc.uri, + }; + } } break; } }, - async findReferences(doc, params) { + findReferences(doc, params) { const infoByExt = getStyleSheetInfo(doc); const sourceOffset = doc.offsetAt(params.position); @@ -126,15 +154,19 @@ const StyleSheetService: Partial = { virtualDoc.positionAt(generatedOffset), info.parsed )) { - if (updateRange(doc, info, location.range)) { - result.push(location); + const range = getSourceRange(doc, info, location.range); + if (range) { + result.push({ + range, + uri: location.uri, + }); } } return result.length ? result : undefined; } }, - async findDocumentLinks(doc) { + findDocumentLinks(doc) { const infoByExt = getStyleSheetInfo(doc); const result: DocumentLink[] = []; @@ -143,19 +175,23 @@ const StyleSheetService: Partial = { const { service, virtualDoc } = info; for (const link of service.findDocumentLinks(virtualDoc, info.parsed, { - resolveReference: resolveUrl, + resolveReference, })) { - if (link.target && updateRange(doc, info, link.range)) { - result.push(link); + const range = getSourceRange(doc, info, link.range); + if (range) { + result.push({ + range, + target: link.target, + tooltip: link.tooltip, + data: link.data, + }); } } } - if (result.length) { - return result; - } + return result.length ? result : undefined; }, - async findDocumentHighlights(doc, params) { + findDocumentHighlights(doc, params) { const infoByExt = getStyleSheetInfo(doc); const sourceOffset = doc.offsetAt(params.position); @@ -173,15 +209,19 @@ const StyleSheetService: Partial = { virtualDoc.positionAt(generatedOffset), info.parsed )) { - if (updateRange(doc, info, highlight.range)) { - result.push(highlight); + const range = getSourceRange(doc, info, highlight.range); + if (range) { + result.push({ + range, + kind: highlight.kind, + }); } } return result.length ? result : undefined; } }, - async findDocumentColors(doc) { + findDocumentColors(doc) { const infoByExt = getStyleSheetInfo(doc); const result: ColorInformation[] = []; @@ -193,17 +233,19 @@ const StyleSheetService: Partial = { virtualDoc, info.parsed )) { - if (updateRange(doc, info, colorInfo.range)) { - result.push(colorInfo); + const range = getSourceRange(doc, info, colorInfo.range); + if (range) { + result.push({ + range, + color: colorInfo.color, + }); } } } - if (result.length) { - return result; - } + return result.length ? result : undefined; }, - async getColorPresentations(doc, params) { + getColorPresentations(doc, params) { const infoByExt = getStyleSheetInfo(doc); const sourceOffset = doc.offsetAt(params.range.start); @@ -219,7 +261,9 @@ const StyleSheetService: Partial = { if (generatedOffsetEnd === undefined) continue; const { service, virtualDoc } = info; - const result = service.getColorPresentations( + const result: ColorPresentation[] = []; + + for (const colorPresentation of service.getColorPresentations( virtualDoc, info.parsed, params.color, @@ -227,24 +271,27 @@ const StyleSheetService: Partial = { virtualDoc.positionAt(generatedOffsetStart), virtualDoc.positionAt(generatedOffsetEnd) ) - ); - - for (const colorPresentation of result) { - if (colorPresentation.textEdit) { - updateTextEdit(doc, info, colorPresentation.textEdit); - } - - if (colorPresentation.additionalTextEdits) { - for (const textEdit of colorPresentation.additionalTextEdits) { - updateTextEdit(doc, info, textEdit); - } + )) { + const textEdit = + colorPresentation.textEdit && + getSourceEdit(doc, info, colorPresentation.textEdit); + const additionalTextEdits = + colorPresentation.additionalTextEdits && + getSourceEdits(doc, info, colorPresentation.additionalTextEdits); + + if (textEdit || additionalTextEdits) { + result.push({ + label: colorPresentation.label, + textEdit, + additionalTextEdits, + }); } } - return result; + return result.length ? result : undefined; } }, - async doHover(doc, params) { + doHover(doc, params) { const infoByExt = getStyleSheetInfo(doc); const sourceOffset = doc.offsetAt(params.position); @@ -261,8 +308,18 @@ const StyleSheetService: Partial = { info.parsed ); - if (result && (!result.range || updateRange(doc, info, result.range))) { - return result; + if (result) { + if (result.range) { + const range = getSourceRange(doc, info, result.range); + if (range) { + return { + range, + contents: result.contents, + }; + } + } else { + return result; + } } } }, @@ -287,9 +344,8 @@ const StyleSheetService: Partial = { if (result.changes) { for (const uri in result.changes) { if (uri === doc.uri) { - for (const textEdit of result.changes[uri]) { - updateTextEdit(doc, info, textEdit); - } + result.changes[uri] = + getSourceEdits(doc, info, result.changes[uri]) || []; } } } @@ -298,9 +354,7 @@ const StyleSheetService: Partial = { for (const change of result.documentChanges) { if (TextDocumentEdit.is(change)) { if (change.textDocument.uri === doc.uri) { - for (const textEdit of change.edits) { - updateTextEdit(doc, info, textEdit); - } + change.edits = getSourceEdits(doc, info, change.edits) || []; } } } @@ -309,7 +363,7 @@ const StyleSheetService: Partial = { return result; } }, - async doCodeActions(doc, params) { + doCodeActions(doc, params) { const infoByExt = getStyleSheetInfo(doc); const sourceOffset = doc.offsetAt(params.range.start); @@ -335,20 +389,17 @@ const StyleSheetService: Partial = { info.parsed ); - if (result) { - for (const command of result) { - const edits = command.arguments?.[2] as TextEdit[]; // we know the css language service returns text edits here. - if (edits) { - for (const textEdit of edits) { - updateTextEdit(doc, info, textEdit); - } - } + for (const command of result) { + const edits = command.arguments?.[2]; + if (edits && Array.isArray(edits) && isTextEdit(edits[0])) { + command.arguments![2] = getSourceEdits(doc, info, edits); } - return result; } + + return result; } }, - async doValidate(doc) { + doValidate(doc) { const infoByExt = getStyleSheetInfo(doc); const result: Diagnostic[] = []; @@ -359,51 +410,100 @@ const StyleSheetService: Partial = { info.virtualDoc, info.parsed )) { - if (updateRange(doc, info, diag.range)) { + const range = getSourceRange(doc, info, diag.range); + if (range) { + diag.range = range; result.push(diag); } } } - return result; + return result.length ? result : undefined; }, }; export { StyleSheetService as default }; -function updateTextEdit( +function getSourceEdits( + doc: TextDocument, + info: StyleSheetInfo, + edits: TextEdit[] +): TextEdit[] | undefined { + const result: TextEdit[] = []; + + for (const edit of edits) { + const sourceEdit = getSourceEdit(doc, info, edit); + if (sourceEdit) { + result.push(sourceEdit); + } + } + + return result.length ? result : undefined; +} + +function getSourceEdit( doc: TextDocument, info: StyleSheetInfo, textEdit: TextEdit -) { - if (!updateRange(doc, info, textEdit.range)) { - textEdit.newText = ""; - textEdit.range = START_OF_FILE; +): TextEdit | undefined { + const range = getSourceRange(doc, info, textEdit.range); + if (range) { + return { + newText: textEdit.newText, + range, + }; } } -function updateInsertReplaceEdit( +function getSourceInsertReplaceEdit( doc: TextDocument, info: StyleSheetInfo, - insertReplaceEdit: InsertReplaceEdit -) { - if (!updateRange(doc, info, insertReplaceEdit.insert)) { - insertReplaceEdit.newText = ""; - insertReplaceEdit.insert = START_OF_FILE; + textEdit: TextEdit | InsertReplaceEdit +): TextEdit | InsertReplaceEdit | undefined { + if (isTextEdit(textEdit)) { + return getSourceEdit(doc, info, textEdit); + } else if (textEdit.replace) { + const range = getSourceRange(doc, info, textEdit.replace); + if (range) { + return { + newText: textEdit.newText, + replace: range, + } as InsertReplaceEdit; + } + } else { + const range = getSourceRange(doc, info, textEdit.insert); + if (range) { + return { + newText: textEdit.newText, + insert: range, + } as InsertReplaceEdit; + } } } -function updateRange(doc: TextDocument, info: StyleSheetInfo, range: Range) { +function getSourceRange( + doc: TextDocument, + info: StyleSheetInfo, + range: Range +): Range | undefined { const start = info.sourceOffsetAt(info.virtualDoc.offsetAt(range.start)); - const end = info.sourceOffsetAt(info.virtualDoc.offsetAt(range.end)); + if (start === undefined) return; - if (start !== undefined || end !== undefined) { - range.start = doc.positionAt(start ?? end!); - range.end = doc.positionAt(end ?? start!); - return true; + let end: number | undefined = start; + + if ( + range.start.line !== range.end.line || + range.start.character !== range.end.character + ) { + end = info.sourceOffsetAt(info.virtualDoc.offsetAt(range.end)); + if (end === undefined) return; } - return false; + const pos = doc.positionAt(start); + return { + start: pos, + end: start === end ? pos : doc.positionAt(end), + }; } function getStyleSheetInfo(doc: TextDocument): Record { @@ -420,7 +520,10 @@ function getStyleSheetInfo(doc: TextDocument): Record { cached = {}; for (const ext in results) { - const service = services[ext]?.(); + const service = services[ext]?.({ + fileSystemProvider, + clientCapabilities, + }); if (!service) continue; const { generated, sourceOffsetAt, generatedOffsetAt } = results[ext]; @@ -445,3 +548,7 @@ function getStyleSheetInfo(doc: TextDocument): Record { return cached; } + +function isTextEdit(edit: TextEdit | InsertReplaceEdit): edit is TextEdit { + return (edit as TextEdit).range !== undefined; +} diff --git a/server/src/service/types.ts b/server/src/service/types.ts index b9cf69cb..f2a43024 100644 --- a/server/src/service/types.ts +++ b/server/src/service/types.ts @@ -19,6 +19,7 @@ import type { DocumentLinkParams, Hover, HoverParams, + InitializeParams, Location, LocationLink, ReferenceParams, @@ -36,6 +37,7 @@ type Handler = ( ) => Result; export type Plugin = { + initialize: (params: InitializeParams) => Promise | void; doComplete: Handler; doValidate: (doc: TextDocument) => Result; doHover: Handler; diff --git a/server/src/utils/file-system.ts b/server/src/utils/file-system.ts new file mode 100644 index 00000000..df64402a --- /dev/null +++ b/server/src/utils/file-system.ts @@ -0,0 +1,47 @@ +import path from "path"; +import fs from "fs/promises"; +import { type FileStat, FileType } from "vscode-css-languageservice"; + +export { FileStat, FileType }; +export default { + stat, + readDirectory, +}; +async function stat(fileName: string): Promise { + const stat = await fs.stat(fileName).catch(() => null); + let type = FileType.Unknown; + let ctime = 0; + let mtime = 0; + let size = 0; + + if (stat) { + if (stat.isDirectory()) type = FileType.Directory; + else if (stat.isFile()) type = FileType.File; + ctime = stat.ctimeMs; + mtime = stat.mtimeMs; + size = stat.size; + } + + return { + type, + ctime, + mtime, + size, + }; +} + +async function readDirectory(dir: string): Promise<[string, FileType][]> { + return ( + await Promise.all( + ( + await fs.readdir(dir).catch(() => []) + ).map( + async (entry) => + [entry, (await stat(path.join(dir, entry))).type] as [ + string, + FileType + ] + ) + ) + ).filter(([, type]) => type !== FileType.Unknown); +} diff --git a/server/src/utils/resolve-url.ts b/server/src/utils/resolve-url.ts index fd866943..6ef64a27 100644 --- a/server/src/utils/resolve-url.ts +++ b/server/src/utils/resolve-url.ts @@ -1,14 +1,13 @@ -import { fileURLToPath } from "url"; - -export default function resolveUrl(ref: string, baseUrl: string) { - const resolved = new URL(ref, new URL(baseUrl, "resolve://")); - if (resolved.protocol === "resolve:") { - // `baseUrl` is a relative URL. - return resolved.pathname + resolved.search + resolved.hash; - } - +export default function resolveUrl(to: string, base: string) { try { - return fileURLToPath(resolved); + const baseUrl = new URL(base, "file://"); + const resolved = new URL(to, baseUrl); + const { origin, protocol } = baseUrl; + if (resolved.origin === origin && resolved.protocol === protocol) { + // result is relative to the base URL. + return resolved.pathname + resolved.search + resolved.hash; + } + return resolved.toString(); } catch { return undefined; }