import type { Completion, CompletionResult } from "@codemirror/autocomplete"; import { config, editor, lua } from "@silverbulletmd/silverbullet/syscalls"; import type { CompleteEvent, EnrichedClickEvent, } from "@silverbulletmd/silverbullet/type/client"; import type { LuaFunctionInfo, LuaPropertyInspection, } from "@silverbulletmd/silverbullet/type/index"; import { renderApiDocumentationMarkdown } from "../../client/space_lua/api_documentation.ts"; const LUA_KEYWORDS = new Set([ "do", "if", "then", "for", "else", "end", "function", "local", "return", "in", ]); const LINK_NODES = new Set([ "WikiLink", "Link", "Image", "Autolink", "NakedURL", "Hashtag", "FootnoteRef", ]); type DocumentedCompletion = Completion & { documentation?: string; snippet?: string; }; function luaCodeFromParents(parentNodes?: string[]): string | null { for (const parent of parentNodes ?? []) { const fencedPrefix = "FencedCode:space-lua"; if (parent.startsWith(fencedPrefix)) { return parent.slice(fencedPrefix.length).replace(/^\n/, ""); } const directivePrefix = "LuaDirective:"; if (parent.startsWith(directivePrefix)) { return parent.slice(directivePrefix.length); } } return null; } function inLuaContext(parentNodes?: string[]): boolean { return (parentNodes ?? []).some( (parent) => parent === "LuaDirective" || parent.startsWith("LuaDirective:") || parent.startsWith("FencedCode:space-lua"), ); } function onLink(parentNodes?: string[]): boolean { return (parentNodes ?? []).some((parent) => LINK_NODES.has(parent)); } function inComment(line: string): boolean { return line.includes("--"); } function inString(line: string): boolean { let singleQuotes = 0; let doubleQuotes = 0; let brackets = 0; for (let i = 0; i < line.length; i++) { const character = line[i]; if (character === "'") { singleQuotes++; } else if (character === '"') { doubleQuotes++; } else if ( character === "[" && line[i + 1] === "[" && line.slice(Math.max(0, i - 5), i + 1) !== "query[" ) { brackets++; } else if (character === "]" && line[i - 1] === "]") { brackets--; } } return singleQuotes % 2 === 1 || doubleQuotes % 2 === 1 || brackets > 0; } function schemaToDummyValue(schema: any): unknown { switch (schema?.type) { case "string": return ""; case "number": return 0; case "boolean": return true; case "array": return []; case "object": { const value: Record = {}; for (const [key, property] of Object.entries(schema.properties ?? {})) { value[key] = schemaToDummyValue(property); } return value; } default: return null; } } async function schemaLocals(luaCode: string): Promise> { const locals: Record = {}; const declarations = /(?:from|local)\s+(\w+)\s+=\s*(?:index\.objects\s*"(\w+)"|index\.objects\s*\("(\w+)"\)|tags\.(\w+))/g; for (const match of luaCode.matchAll(declarations)) { const tag = match[2] ?? match[3] ?? match[4]; const schema = await config.get(["tags", tag, "schema"], null); if (schema) { locals[match[1]] = schemaToDummyValue(schema); } } return locals; } function luaType(value: unknown): string { if (value === null || value === undefined) return "nil"; if (Array.isArray(value) || typeof value === "object") return "table"; return typeof value; } function localProperties(value: unknown): LuaPropertyInspection[] { if (value === null || value === undefined || typeof value !== "object") { return []; } return Object.entries(value).map(([key, child]) => ({ key, type: luaType(child), })); } function inspectLocalPath( locals: Record, path: string[], ): LuaPropertyInspection[] | null { if (path.length === 0) { return Object.entries(locals).map(([key, value]) => ({ key, type: luaType(value), })); } if (!Object.hasOwn(locals, path[0])) return null; let value = locals[path[0]]; for (const key of path.slice(1)) { if ( value === null || value === undefined || typeof value !== "object" || !Object.hasOwn(value, key) ) { return []; } value = (value as Record)[key]; } return localProperties(value); } function completionLabel(name: string, info?: LuaFunctionInfo): string { if (info?.parameters) { const parameters = info.parameters.map( (parameter) => `${parameter.name ?? "?"}${parameter.optional ? "?" : ""}`, ); return `${name}(${parameters.join(", ")})`; } const signature = info?.signatures?.[0]; const parameters = signature?.match(/\(([^)]*)\)/)?.[1]; return `${name}(${parameters ?? ""})`; } function completionDetail( info: LuaFunctionInfo | undefined, fallback: string, ): string { return ( info?.description ?.split("\n") .map((line) => line.trim()) .find(Boolean) ?? fallback ); } function functionDocumentation( info: LuaFunctionInfo, fallbackName: string, ): string { const documentedInfo = { ...info, name: info.name ?? fallbackName, }; const separator = documentedInfo.name.lastIndexOf("."); const namespace = separator > 0 ? documentedInfo.name.slice(0, separator) : undefined; return renderApiDocumentationMarkdown([documentedInfo], namespace); } export async function luaComplete( event: CompleteEvent, ): Promise { const luaCode = luaCodeFromParents(event.parentNodes); if ( luaCode === null || inComment(event.linePrefix) || inString(event.linePrefix) ) { return null; } const access = /((?:[a-zA-Z_0-9]+\.)*[a-zA-Z_0-9]*)$/.exec( event.linePrefix, )?.[1]; if (!access) return null; const parts = access.split("."); const prefix = parts.pop() ?? ""; if (LUA_KEYWORDS.has(prefix)) return null; const locals = await schemaLocals(luaCode); const localCandidates = inspectLocalPath(locals, parts); let candidates: LuaPropertyInspection[]; if (localCandidates !== null && parts.length > 0) { candidates = localCandidates; } else { const inspection = await lua.inspect(parts); candidates = inspection?.properties ?? []; if (parts.length === 0) { const merged = new Map( candidates.map((candidate) => [candidate.key, candidate]), ); for (const candidate of localCandidates ?? []) { merged.set(candidate.key, candidate); } candidates = [...merged.values()]; } } const options: DocumentedCompletion[] = []; for (const candidate of candidates) { if (!candidate.key.startsWith(prefix)) continue; if (candidate.type === "function") { const fullName = [...parts, candidate.key].join("."); const fallback = candidate.functionInfo?.kind === "lua" ? "function" : "built-in"; options.push({ label: completionLabel(candidate.key, candidate.functionInfo), snippet: `${candidate.key}(\${})`, detail: completionDetail(candidate.functionInfo, fallback), documentation: candidate.functionInfo ? functionDocumentation(candidate.functionInfo, fullName) : undefined, }); } else { options.push({ label: candidate.key, detail: candidate.type, }); } } return options.length > 0 ? { from: event.pos - prefix.length, options, } : null; } function symbolAt(text: string, position: number): string | null { let start = Math.min(Math.max(position, 0), text.length); while (start > 0 && /[a-zA-Z0-9._]/.test(text[start - 1])) start--; let end = Math.min(Math.max(position, 0), text.length); while (end < text.length && /[a-zA-Z0-9_]/.test(text[end])) end++; const symbol = text.slice(start, end); return /^[a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*$/.test(symbol) ? symbol : null; } export async function luaNavigate(event: EnrichedClickEvent): Promise { if ( !(event.metaKey || event.ctrlKey) || onLink(event.parentNodes) || !inLuaContext(event.parentNodes) ) { return; } const symbol = symbolAt(await editor.getText(), event.pos); if (!symbol) return; const inspection = await lua.inspect(symbol.split(".")); if (inspection?.definition) { await editor.navigate(inspection.definition); return; } await editor.flashNotification( "Cannot navigate to definition; not defined in Lua.", ); }