import { FileMap, trailingSlash } from "@weborigami/async-tree"; import { ops } from "@weborigami/language"; import path from "node:path"; import { fileURLToPath } from "node:url"; import * as utilities from "./utilities.mjs"; import findInProjectScope from "./findInProjectScope.mjs"; import localDeclarations from "./localDeclarations.mjs"; import languageServerPackage from "vscode-languageserver"; const { CompletionItemKind } = languageServerPackage; /** * @typedef {import("./types.js").OrigamiPosition} OrigamiPosition * @typedef {import("@weborigami/language").AnnotatedCode} AnnotatedCode * @typedef {import("vscode-languageserver").CompletionItemKind} CompletionItemKind * @typedef {import("vscode-languageserver").CompletionItem} CompletionItem * @typedef {import("vscode-languageserver").Position} LSPPosition * @typedef {import("vscode-languageserver").TextDocument} TextDocument */ const cachedFolderCompletions = new Map(); /** * Provide autocompletions for the given document and position. * * @param {TextDocument} document * @param {LSPPosition} lspPosition * @param {string[]} workspaceFolderPaths * @param {import("./types.js").CompileResult} compileResult * @returns {Promise} */ export async function autoComplete( document, lspPosition, workspaceFolderPaths, compileResult, ) { const uri = new URL(document.uri); // Position-based completions (local declarations) work with any scheme. let positionCompletions = []; if (compileResult && !(compileResult instanceof Error)) { positionCompletions = getPositionCompletions(compileResult, lspPosition); } if (uri.protocol === "file:") { const documentPath = fileURLToPath(uri); const folderPath = path.dirname(documentPath); const text = document.getText(); const offset = document.offsetAt(lspPosition); const targetPath = utilities.getPathAtOffset(text, offset, { expandRight: false, requireSlash: true, }); if (targetPath) { const pathCompletions = await getPathCompletions( targetPath, folderPath, workspaceFolderPaths, ); return pathCompletions ?? []; } const scopeCompletions = await getFolderScopeCompletions( folderPath, workspaceFolderPaths, ); return positionCompletions.concat(scopeCompletions); } return positionCompletions; } /** * Called when a folder change is detected. Invalidates all cached folder * completions so the next completion request rebuilds them from disk. * * Clearing the entire cache is intentionally conservative: a file change in * a deeply nested folder invalidates ancestor directory listings too, and * iterating keys to find affected ancestors is more expensive than letting * the cache rebuild lazily on next access. * * @param {string} uri */ export function folderChanged(_uri) { cachedFolderCompletions.clear(); } async function getFolderCompletions(folderPath) { if (cachedFolderCompletions.has(folderPath)) { return cachedFolderCompletions.get(folderPath); } const tree = new FileMap(folderPath); const keys = [...tree.keys()]; const completions = keys.map((key) => ({ label: trailingSlash.remove(key), kind: trailingSlash.has(key) ? CompletionItemKind.Folder : CompletionItemKind.File, })); cachedFolderCompletions.set(folderPath, completions); return completions; } async function getFolderScopeCompletions(folderPath, workspaceFolderPaths) { let parentCompletions; const isWorkspaceFolder = workspaceFolderPaths.some( (workspaceFolder) => path.resolve(workspaceFolder) === path.resolve(folderPath), ); if (!isWorkspaceFolder && folderPath !== path.parse(folderPath).root) { const parentFolder = path.dirname(folderPath); parentCompletions = await getFolderScopeCompletions( parentFolder, workspaceFolderPaths, ); } else { parentCompletions = []; } const folderCompletions = await getFolderCompletions(folderPath); const completions = parentCompletions.concat(folderCompletions); return completions; } async function getPathCompletions( targetPath, folderPath, workspaceFolderPaths, ) { const keys = targetPath.split("/"); keys.pop(); const rootKey = keys.shift(); if (rootKey === undefined) { return null; } const root = await findInProjectScope( rootKey, folderPath, workspaceFolderPaths, ); if (root === null || !(root.value instanceof FileMap)) { return null; } let current = root.value; for (const key of keys) { const next = await current.get(key); if (next instanceof FileMap) { current = next; } else { return null; } } const targetFolderPath = current.path; const completions = await getFolderCompletions(targetFolderPath); return completions; } function getPositionCompletions(code, lspPosition) { const origamiPosition = utilities.lspPositionToOrigamiPosition(lspPosition); const completions = []; for (const declaration of localDeclarations(code, origamiPosition)) { const fn = declaration[0]; switch (fn) { case ops.object: const entries = declaration.slice(1); for (const entry of entries) { let key = entry[0]; // Guard against non-string keys (computed keys, symbols, etc.) if (typeof key !== "string") { continue; } key = utilities.normalizePropertyName(key); completions.push({ label: key, kind: CompletionItemKind.Property, }); } break; case ops.lambda: const parameters = declaration[2]; if (!parameters || !Array.isArray(parameters)) break; for (const parameter of parameters) { const label = parameter[0]; completions.push({ label, kind: CompletionItemKind.Variable, }); } break; } } return completions; }