310 lines
8.4 KiB
TypeScript
310 lines
8.4 KiB
TypeScript
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<string, unknown> = {};
|
|
for (const [key, property] of Object.entries(schema.properties ?? {})) {
|
|
value[key] = schemaToDummyValue(property);
|
|
}
|
|
return value;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function schemaLocals(luaCode: string): Promise<Record<string, unknown>> {
|
|
const locals: Record<string, unknown> = {};
|
|
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<string, unknown>,
|
|
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<string, unknown>)[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<CompletionResult | null> {
|
|
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<void> {
|
|
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.",
|
|
);
|
|
}
|