"use client";
import { useEffect, useRef } from "react";
import dynamic from "next/dynamic";
import { useTheme } from "next-themes";
import type { editor, Position } from "monaco-editor";
import {
LUA_KEYWORDS,
LUA_BUILTINS,
LUA_SNIPPETS,
type LuaCompletions,
} from "@/lib/lua-completions";
import { lexiconJsonSchema, LEXICON_SCHEMA_URI } from "@/lib/lexicon-schema";
import { resolveCssColor } from "@/lib/css-utils";
import { parseLuaIdentifiers, parseRecordVariables, parseDbQueryVariables, parseDbQueryRecordIterators } from "@/lib/lua-parser";
import { HOVER_DOCS } from "@/lib/lua-hover";
const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
interface MonacoEditorProps {
value: string;
onChange?: (value: string) => void;
language: string;
readOnly?: boolean;
className?: string;
completions?: LuaCompletions;
collections?: string[];
tabSize?: number;
}
export function MonacoEditor({
value,
onChange,
language,
readOnly,
className,
completions,
collections,
tabSize = 2,
}: MonacoEditorProps) {
const { resolvedTheme } = useTheme();
const completionsRef = useRef(completions);
const collectionsRef = useRef(collections);
const disposablesRef = useRef<{ dispose(): void }[]>([]);
// Sync refs during render (not in useEffect) so the completion
// provider closure always reads the latest values immediately.
completionsRef.current = completions;
collectionsRef.current = collections;
useEffect(() => {
return () => {
for (const d of disposablesRef.current) d.dispose();
disposablesRef.current = [];
};
}, []);
return (
onChange?.(v ?? "")}
loading="Loading editor..."
path={language === "json" ? "lexicon.json" : undefined}
beforeMount={(monaco) => {
const bg = resolveCssColor("var(--sidebar)");
monaco.editor.defineTheme("happyview-dark", {
base: "vs-dark",
inherit: true,
rules: [],
colors: { "editor.background": bg },
});
// Configure JSON language service with Lexicon schema
if (language === "json") {
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
allowComments: false,
trailingCommas: "error",
schemas: [
{
uri: LEXICON_SCHEMA_URI,
fileMatch: ["lexicon.json"],
schema: lexiconJsonSchema,
},
],
});
}
}}
onMount={(_editor, monaco) => {
if (language !== "lua") return;
// Provider 1: Lua keywords, builtins, and snippets
disposablesRef.current.push(
monaco.languages.registerCompletionItemProvider("lua", {
provideCompletionItems(
model: editor.ITextModel,
position: Position,
) {
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
const lineContent = model.getLineContent(position.lineNumber);
const textBeforeCursor = lineContent.substring(
0,
position.column - 1,
);
// Don't suggest keywords after . or : (those are member access)
if (/[.:]$/.test(textBeforeCursor.trimEnd())) {
return { suggestions: [] };
}
// Extract identifiers from the current document
const staticLabels = new Set([
...LUA_KEYWORDS,
...LUA_BUILTINS,
...LUA_SNIPPETS.map((s) => s.label),
]);
const fullSource = model.getValue();
const identifiers = parseLuaIdentifiers(fullSource);
// Remove the word currently being typed
if (word.word) identifiers.delete(word.word);
const suggestions = [
...LUA_KEYWORDS.map((kw) => ({
label: kw,
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: kw,
detail: "keyword",
range,
})),
...LUA_BUILTINS.map((fn) => ({
label: fn,
kind: monaco.languages.CompletionItemKind.Function,
insertText: fn,
detail: "function",
range,
})),
...LUA_SNIPPETS.map((snip) => ({
label: snip.label,
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: snip.insertText,
insertTextRules:
monaco.languages.CompletionItemInsertTextRule
.InsertAsSnippet,
detail: snip.detail,
documentation: snip.description,
range,
})),
...[...identifiers]
.filter((id) => !staticLabels.has(id))
.map((id) => ({
label: id,
kind: monaco.languages.CompletionItemKind.Variable,
insertText: id,
detail: "identifier",
range,
})),
];
return { suggestions };
},
}),
);
// Provider 3: Hover documentation
disposablesRef.current.push(
monaco.languages.registerHoverProvider("lua", {
provideHover(
model: editor.ITextModel,
position: Position,
) {
const word = model.getWordAtPosition(position);
if (!word) return null;
const lineContent = model.getLineContent(position.lineNumber);
const charBefore = lineContent[word.startColumn - 2];
let key = word.word;
if (charBefore === "." || charBefore === ":") {
// Find the module/object prefix before the dot/colon
const textBefore = lineContent.substring(0, word.startColumn - 2);
const prefixMatch = textBefore.match(/(\w+)$/);
if (prefixMatch) {
const sep = charBefore === ":" ? ":" : ".";
key = `${prefixMatch[1]}${sep}${word.word}`;
}
}
const entry = HOVER_DOCS.get(key);
if (!entry) return null;
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
return {
range,
contents: [
{ value: `\`\`\`lua\n${entry.signature}\n\`\`\`` },
{ value: entry.description },
],
};
},
}),
);
// Provider 4: Signature help
disposablesRef.current.push(
monaco.languages.registerSignatureHelpProvider("lua", {
signatureHelpTriggerCharacters: ["(", ","],
provideSignatureHelp(
model: editor.ITextModel,
position: Position,
) {
const lineContent = model.getLineContent(position.lineNumber);
const textBeforeCursor = lineContent.substring(0, position.column - 1);
// Walk backward to find the opening ( and the function name
let depth = 0;
let parenPos = -1;
let activeParam = 0;
for (let i = textBeforeCursor.length - 1; i >= 0; i--) {
const ch = textBeforeCursor[i];
if (ch === ")") depth++;
else if (ch === "(") {
if (depth === 0) {
parenPos = i;
break;
}
depth--;
} else if (ch === "," && depth === 0) {
activeParam++;
}
}
if (parenPos < 0) return null;
// Extract the function name before the (
const textBeforeParen = textBeforeCursor.substring(0, parenPos);
const fnMatch = textBeforeParen.match(/([\w.]+[:.]\w+|\w+)\s*$/);
if (!fnMatch) return null;
const fnName = fnMatch[1];
// Normalize colon to look up both Record:save and Record.save
const entry = HOVER_DOCS.get(fnName) ?? HOVER_DOCS.get(fnName.replace(":", "."));
if (!entry) return null;
// Parse parameters from the signature: extract content inside parens
const sigParenMatch = entry.signature.match(/\(([^)]*)\)/);
if (!sigParenMatch) return null;
const paramString = sigParenMatch[1].trim();
if (!paramString) return null;
// Split parameters on commas, respecting brackets
const params: string[] = [];
let current = "";
let bracketDepth = 0;
for (const ch of paramString) {
if (ch === "[") bracketDepth++;
else if (ch === "]") bracketDepth--;
else if (ch === "," && bracketDepth === 0) {
params.push(current.trim());
current = "";
continue;
}
current += ch;
}
if (current.trim()) params.push(current.trim());
return {
value: {
signatures: [
{
label: entry.signature,
documentation: entry.description,
parameters: params.map((p) => ({
label: p.replace(/^\[?\s*/, "").replace(/\s*\]?\s*$/, ""),
})),
},
],
activeSignature: 0,
activeParameter: Math.min(activeParam, params.length - 1),
},
dispose() {},
};
},
}),
);
// Provider 2: HappyView-specific completions (Record, db, collections)
if (!completions) return;
disposablesRef.current.push(
monaco.languages.registerCompletionItemProvider("lua", {
triggerCharacters: [".", ":", '"'],
provideCompletionItems(
model: editor.ITextModel,
position: Position,
) {
const lineContent = model.getLineContent(position.lineNumber);
const textBeforeCursor = lineContent.substring(
0,
position.column - 1,
);
// Inside db.query/search/backlinks({ ... }) — suggest option keys
const dbOptionsMatch = textBeforeCursor.match(
/db\.(query|search|backlinks)\(\s*\{[^}]*$/,
);
if (dbOptionsMatch) {
const optionEntries =
completionsRef.current?.[`db.${dbOptionsMatch[1]}`] ?? [];
if (optionEntries.length) {
// Check for collection = " inside db.query — offer NSID completions
const collectionQuoteMatch = textBeforeCursor.match(
/collection\s*=\s*"([^"]*)$/,
);
if (collectionQuoteMatch) {
const cols = collectionsRef.current;
if (!cols?.length) return { suggestions: [] };
const quoteCol = textBeforeCursor.lastIndexOf('"');
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: quoteCol + 2,
endColumn: position.column,
};
return {
suggestions: cols.map((col) => ({
label: col,
kind: monaco.languages.CompletionItemKind.Value,
insertText: col,
range,
})),
};
}
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
return {
suggestions: optionEntries.map((entry) => ({
label: entry.label,
kind: monaco.languages.CompletionItemKind.Property,
detail: entry.detail,
documentation: entry.description,
insertText: entry.label,
range,
})),
};
}
}
// Collection NSID completions for collection = " (outside db options) and db.count
const collectionAssignMatch = textBeforeCursor.match(
/(?:db\.count\(\s*"([^"]*)$|collection\s*=\s*"([^"]*)$)/,
);
// Record("...") collection completions
const recordMatch =
textBeforeCursor.match(/Record\(\s*"([^"]*)$/);
if (recordMatch || collectionAssignMatch) {
const cols = collectionsRef.current;
if (!cols?.length) return { suggestions: [] };
const quoteCol = textBeforeCursor.lastIndexOf('"');
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: quoteCol + 2, // after the opening "
endColumn: position.column,
};
return {
suggestions: cols.map((col) => ({
label: col,
kind: monaco.languages.CompletionItemKind.Value,
insertText: col,
range,
})),
};
}
// Dot or colon-triggered completions (Record., r:, db., etc.)
const dotMatch = textBeforeCursor.match(/(\w+)\.\w*$/);
const colonMatch = textBeforeCursor.match(/(\w+):\w*$/);
const match = dotMatch || colonMatch;
const isColon = !!colonMatch;
if (!match) return { suggestions: [] };
const prefix = match[1];
// Build entries based on context
let entries = completionsRef.current?.[prefix];
if (!entries && prefix !== "Record" && prefix !== "db") {
const fullSource = model.getValue();
// Check if it's a db.query() result variable
const dbQueryVars = parseDbQueryVariables(fullSource);
if (prefix in dbQueryVars) {
entries =
completionsRef.current?.["db.query_result"] ?? [];
}
// Check if it's an iterator over db.query().records
if (!entries) {
const iterMap = parseDbQueryRecordIterators(
fullSource,
dbQueryVars,
);
const iterCollection = iterMap[prefix];
if (iterCollection) {
const schemaEntries =
completionsRef.current?.[iterCollection] ?? [];
const uriEntry = {
label: "uri",
detail: "string",
description: "AT URI of the record",
};
entries = [uriEntry, ...schemaEntries];
}
}
if (!entries) {
// Variable access — check if it's a Record variable
const varMap = parseRecordVariables(fullSource);
const collection = varMap[prefix];
if (collection) {
// Record instance methods/fields
const instanceEntries =
completionsRef.current?.["Record"]?.filter(
(e) =>
e.detail === "method" || e.detail?.endsWith("?"),
) ?? [];
// Collection-specific record properties (merged into completions by parent)
const schemaEntries =
completionsRef.current?.[collection] ?? [];
entries = [...instanceEntries, ...schemaEntries];
}
}
}
if (isColon && !entries) {
// Colon on unknown variable — show Record instance methods + string methods
const recordMethods =
completionsRef.current?.["Record"]?.filter(
(e) => e.detail === "method",
) ?? [];
const stringMethods =
completionsRef.current?.["string"] ?? [];
entries = [...recordMethods, ...stringMethods];
}
if (!entries?.length) return { suggestions: [] };
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
return {
suggestions: entries.map((entry) => ({
label: entry.label,
kind:
entry.detail === "method" || entry.detail === "function"
? monaco.languages.CompletionItemKind.Method
: monaco.languages.CompletionItemKind.Field,
detail: entry.detail,
documentation: entry.description,
insertText: entry.insertText ?? entry.label,
...(entry.insertText
? {
insertTextRules:
monaco.languages.CompletionItemInsertTextRule
.InsertAsSnippet,
}
: {}),
range,
})),
};
},
}),
);
}}
options={{
readOnly,
minimap: { enabled: false },
automaticLayout: true,
scrollBeyondLastLine: false,
wordWrap: "on",
fontSize: 12,
tabSize,
snippetSuggestions: "inline",
renderLineHighlight: readOnly ? "none" : "line",
hideCursorInOverviewRuler: readOnly,
overviewRulerLanes: readOnly ? 0 : 3,
quickSuggestions: true,
}}
/>
);
}