From 05f79fb53e6f213e9fe3f2fb78d920d356b1dc32 Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 20 Feb 2026 00:45:59 +0000 Subject: [PATCH] feat: additional Lua completions and docs --- web/package-lock.json | 28 +++++----------------------- web/src/components/monaco-editor.tsx | 254 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------- web/src/components/ui/chart.tsx | 4 ++-- web/src/lib/lua-completions.ts | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----- web/src/lib/lua-hover.ts | 148 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/lib/lua-parser.ts | 39 +++++++++++++++++++++++++++++++++++++++ 6 file(s) changed, 503 insertion(s)(+), 47 deletion(s)(-) diff --git a/web/package-lock.json b/web/package-lock.json --- a/web/package-lock.json +++ b/web/package-lock.json @@ -110,7 +110,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -676,7 +675,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1947,7 +1945,6 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -4220,7 +4217,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4231,7 +4227,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4242,7 +4237,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4259,7 +4253,8 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/unist": { "version": "3.0.3", @@ -4325,7 +4320,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -4852,7 +4846,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5239,7 +5232,6 @@ "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -5334,7 +5326,6 @@ "url": "https://github.com/sponsors/ai" } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6298,6 +6289,7 @@ "version": "3.2.7", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -6636,7 +6628,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6777,7 +6768,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7246,7 +7236,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -8039,7 +8028,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -9459,6 +9447,7 @@ "version": "14.0.0", "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -11355,7 +11344,6 @@ "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11386,7 +11374,6 @@ "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11406,7 +11393,6 @@ "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -11545,8 +11531,7 @@ "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -12754,7 +12739,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13022,7 +13006,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13754,7 +13737,6 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/src/components/monaco-editor.tsx b/web/src/components/monaco-editor.tsx --- a/web/src/components/monaco-editor.tsx +++ b/web/src/components/monaco-editor.tsx @@ -12,7 +12,8 @@ type LuaCompletions, } from "@/lib/lua-completions"; import { lexiconJsonSchema, LEXICON_SCHEMA_URI } from "@/lib/lexicon-schema"; import { resolveCssColor } from "@/lib/css-utils"; -import { parseLuaIdentifiers, parseRecordVariables } from "@/lib/lua-parser"; +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 }); @@ -172,6 +173,135 @@ }, }), ); + // 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( @@ -187,10 +317,64 @@ 0, position.column - 1, ); + // Inside db.query({ ... }) — suggest option keys + if (/db\.query\(\s*\{[^}]*$/.test(textBeforeCursor)) { + const optionEntries = + completionsRef.current?.["db.query"] ?? []; + 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.query) and db.count + const collectionAssignMatch = textBeforeCursor.match( + /(?:db\.count\(\s*"([^"]*)$|collection\s*=\s*"([^"]*)$)/, + ); + // Record("...") collection completions const recordMatch = textBeforeCursor.match(/Record\(\s*"([^"]*)$/); - if (recordMatch) { + if (recordMatch || collectionAssignMatch) { const cols = collectionsRef.current; if (!cols?.length) return { suggestions: [] }; const quoteCol = textBeforeCursor.lastIndexOf('"'); @@ -223,24 +407,53 @@ // Build entries based on context let entries = completionsRef.current?.[prefix]; if (!entries && prefix !== "Record" && prefix !== "db") { - // Variable access — check if it's a Record variable const fullSource = model.getValue(); - const varMap = parseRecordVariables(fullSource); - const collection = varMap[prefix]; + + // Check if it's a db.query() result variable + const dbQueryVars = parseDbQueryVariables(fullSource); + if (prefix in dbQueryVars) { + entries = + completionsRef.current?.["db.query_result"] ?? []; + } - if (collection) { - // Record instance methods/fields - const instanceEntries = - completionsRef.current?.["Record"]?.filter( - (e) => - e.detail === "method" || e.detail?.endsWith("?"), - ) ?? []; + // 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] ?? []; + // Collection-specific record properties (merged into completions by parent) + const schemaEntries = + completionsRef.current?.[collection] ?? []; - entries = [...instanceEntries, ...schemaEntries]; + entries = [...instanceEntries, ...schemaEntries]; + } } } @@ -274,7 +487,14 @@ ? monaco.languages.CompletionItemKind.Method : monaco.languages.CompletionItemKind.Field, detail: entry.detail, documentation: entry.description, - insertText: entry.label, + insertText: entry.insertText ?? entry.label, + ...(entry.insertText + ? { + insertTextRules: + monaco.languages.CompletionItemInsertTextRule + .InsertAsSnippet, + } + : {}), range, })), }; diff --git a/web/src/components/ui/chart.tsx b/web/src/components/ui/chart.tsx --- a/web/src/components/ui/chart.tsx +++ b/web/src/components/ui/chart.tsx @@ -118,7 +118,7 @@ formatter, color, nameKey, labelKey, -}: React.ComponentProps & +}: RechartsPrimitive.TooltipContentProps & React.ComponentProps<"div"> & { hideLabel?: boolean hideIndicator?: boolean @@ -259,7 +259,7 @@ payload, verticalAlign = "bottom", nameKey, }: React.ComponentProps<"div"> & - Pick & { + Pick & { hideIcon?: boolean nameKey?: string }) { diff --git a/web/src/lib/lua-completions.ts b/web/src/lib/lua-completions.ts --- a/web/src/lib/lua-completions.ts +++ b/web/src/lib/lua-completions.ts @@ -2,6 +2,7 @@ export interface LuaCompletionEntry { label: string; detail?: string; description?: string; + insertText?: string; } export interface LuaSnippetEntry { @@ -12,14 +13,17 @@ description?: string; } export const LUA_KEYWORDS = [ - "and", "break", "do", "else", "end", "false", - "in", "nil", "not", "or", "then", "true", "until", + "and", "break", "do", "else", "elseif", "end", "false", + "for", "function", "goto", "if", "in", "local", "nil", + "not", "or", "repeat", "return", "then", "true", "until", "while", ]; export const LUA_BUILTINS = [ "print", "tostring", "tonumber", "type", "pairs", "ipairs", "next", "select", "unpack", "error", "pcall", "xpcall", "assert", "setmetatable", "getmetatable", "rawget", "rawset", "rawequal", + // Standard library modules + "string", "table", "math", "coroutine", "utf8", // HappyView sandbox globals "input", "params", "caller_did", "collection", "method", "now", "log", "TID", @@ -114,9 +118,34 @@ { label: "_key_type", detail: "string?", description: "Record key type from lexicon (tid, any, nsid, literal:*)" }, { label: "_rkey", detail: "string?", description: "Record key (set via set_rkey or generate_rkey)" }, ], db: [ - { label: "query", detail: "function", description: "Execute a SQL query and return rows" }, - { label: "get", detail: "function", description: "Execute a SQL query and return a single row" }, - { label: "count", detail: "function", description: "Execute a count query" }, + { + label: "query", + detail: "function", + description: "Query records — db.query({ collection, did?, limit?, offset? }) → { records, cursor? }", + insertText: "query({\n\tcollection = ${1:collection},\n})", + }, + { + label: "get", + detail: "function", + description: "Get a single record by AT URI — db.get(uri) → record or nil", + insertText: "get(${1:uri})", + }, + { + label: "count", + detail: "function", + description: "Count records — db.count(collection, did?) → integer", + insertText: "count(${1:collection})", + }, + ], + "db.query": [ + { label: "collection", detail: "string", description: "Collection NSID (required)" }, + { label: "did", detail: "string?", description: "Filter records by DID" }, + { label: "limit", detail: "integer?", description: "Max records to return (max 100, default 20)" }, + { label: "offset", detail: "integer?", description: "Pagination offset (default 0)" }, + ], + "db.query_result": [ + { label: "records", detail: "table[]", description: "Array of record tables (each includes uri)" }, + { label: "cursor", detail: "string?", description: "Pagination cursor (present when more results exist)" }, ], // Lua standard library modules string: [ @@ -137,20 +166,58 @@ ], table: [ { label: "concat", detail: "function", description: "Concatenate table elements — table.concat(list [, sep [, i [, j]]])" }, { label: "insert", detail: "function", description: "Insert element — table.insert(list, [pos,] value)" }, + { label: "move", detail: "function", description: "Move elements between tables — table.move(a1, f, e, t [, a2])" }, + { label: "pack", detail: "function", description: "Pack arguments into table with n field — table.pack(···)" }, { label: "remove", detail: "function", description: "Remove element — table.remove(list [, pos])" }, { label: "sort", detail: "function", description: "Sort table in-place — table.sort(list [, comp])" }, { label: "unpack", detail: "function", description: "Unpack table elements — table.unpack(list [, i [, j]])" }, ], math: [ { label: "abs", detail: "function", description: "Absolute value — math.abs(x)" }, + { label: "acos", detail: "function", description: "Arc cosine — math.acos(x)" }, + { label: "asin", detail: "function", description: "Arc sine — math.asin(x)" }, + { label: "atan", detail: "function", description: "Arc tangent — math.atan(y [, x])" }, { label: "ceil", detail: "function", description: "Round up — math.ceil(x)" }, + { label: "cos", detail: "function", description: "Cosine — math.cos(x)" }, + { label: "deg", detail: "function", description: "Radians to degrees — math.deg(x)" }, + { label: "exp", detail: "function", description: "e^x — math.exp(x)" }, { label: "floor", detail: "function", description: "Round down — math.floor(x)" }, + { label: "fmod", detail: "function", description: "Remainder — math.fmod(x, y)" }, + { label: "log", detail: "function", description: "Logarithm — math.log(x [, base])" }, { label: "max", detail: "function", description: "Maximum value — math.max(x, ···)" }, + { label: "maxinteger", detail: "number", description: "Maximum integer value" }, { label: "min", detail: "function", description: "Minimum value — math.min(x, ···)" }, + { label: "mininteger", detail: "number", description: "Minimum integer value" }, + { label: "modf", detail: "function", description: "Integer and fractional parts — math.modf(x)" }, + { label: "rad", detail: "function", description: "Degrees to radians — math.rad(x)" }, { label: "random", detail: "function", description: "Generate random number — math.random([m [, n]])" }, + { label: "randomseed", detail: "function", description: "Set random seed — math.randomseed([x [, y]])" }, + { label: "sin", detail: "function", description: "Sine — math.sin(x)" }, { label: "sqrt", detail: "function", description: "Square root — math.sqrt(x)" }, + { label: "tan", detail: "function", description: "Tangent — math.tan(x)" }, + { label: "tointeger", detail: "function", description: "Convert to integer or nil — math.tointeger(x)" }, + { label: "type", detail: "function", description: "Number type (\"integer\", \"float\", or false) — math.type(x)" }, + { label: "ult", detail: "function", description: "Unsigned integer comparison — math.ult(m, n)" }, { label: "huge", detail: "number", description: "Infinity value" }, { label: "pi", detail: "number", description: "Pi constant (3.14159...)" }, + ], + coroutine: [ + { label: "create", detail: "function", description: "Create a coroutine — coroutine.create(f)" }, + { label: "resume", detail: "function", description: "Resume a coroutine — coroutine.resume(co [, val1, ···])" }, + { label: "yield", detail: "function", description: "Suspend coroutine — coroutine.yield(···)" }, + { label: "status", detail: "function", description: "Coroutine status — coroutine.status(co)" }, + { label: "wrap", detail: "function", description: "Create iterator from coroutine — coroutine.wrap(f)" }, + { label: "isyieldable", detail: "function", description: "Check if running coroutine can yield — coroutine.isyieldable()" }, + { label: "running", detail: "function", description: "Returns running coroutine — coroutine.running()" }, + { label: "close", detail: "function", description: "Close a coroutine — coroutine.close(co)" }, + ], + utf8: [ + { label: "char", detail: "function", description: "UTF-8 string from codepoints — utf8.char(···)" }, + { label: "charpattern", detail: "string", description: "Pattern matching one UTF-8 character" }, + { label: "codepoint", detail: "function", description: "Codepoints from string — utf8.codepoint(s [, i [, j [, lax]]])" }, + { label: "codes", detail: "function", description: "Iterator over UTF-8 codepoints — utf8.codes(s [, lax])" }, + { label: "len", detail: "function", description: "UTF-8 string length — utf8.len(s [, i [, j [, lax]]])" }, + { label: "offset", detail: "function", description: "Byte offset of nth character — utf8.offset(s, n [, i])" }, ], }; diff --git a/web/src/lib/lua-hover.ts b/web/src/lib/lua-hover.ts new file mode 100644 --- /dev/null +++ b/web/src/lib/lua-hover.ts @@ -0,0 +1,148 @@ +export interface HoverEntry { + signature: string; + description: string; + module?: string; +} + +export const HOVER_DOCS = new Map([ + // ── Lua keywords ────────────────────────────────────────────────────── + ["and", { signature: "and", description: "Logical AND operator — returns first falsy operand or last operand" }], + ["break", { signature: "break", description: "Exit the innermost loop" }], + ["do", { signature: "do ... end", description: "Block delimiter — creates a new scope" }], + ["else", { signature: "else", description: "Alternative branch in an if statement" }], + ["elseif", { signature: "elseif condition then", description: "Additional condition branch in an if statement" }], + ["end", { signature: "end", description: "Closes a block (function, if, for, while, do)" }], + ["false", { signature: "false", description: "Boolean false value" }], + ["for", { signature: "for var = start, stop [, step] do ... end", description: "Numeric or generic loop" }], + ["function", { signature: "function name(...) ... end", description: "Define a function" }], + ["goto", { signature: "goto label", description: "Jump to a label within the same block" }], + ["if", { signature: "if condition then ... end", description: "Conditional branch" }], + ["in", { signature: "in", description: "Iterator separator in generic for loops" }], + ["local", { signature: "local name [= value]", description: "Declare a local variable" }], + ["nil", { signature: "nil", description: "The nil value — represents absence of a value" }], + ["not", { signature: "not", description: "Logical NOT operator" }], + ["or", { signature: "or", description: "Logical OR operator — returns first truthy operand or last operand" }], + ["repeat", { signature: "repeat ... until condition", description: "Loop that runs at least once" }], + ["return", { signature: "return [values]", description: "Return values from a function" }], + ["then", { signature: "then", description: "Begins the body of an if/elseif branch" }], + ["true", { signature: "true", description: "Boolean true value" }], + ["until", { signature: "until condition", description: "End condition of a repeat loop" }], + ["while", { signature: "while condition do ... end", description: "Loop while condition is true" }], + + // ── Global builtins ─────────────────────────────────────────────────── + ["print", { signature: "print(···)", description: "Print values to stdout (separated by tabs)" }], + ["tostring", { signature: "tostring(v)", description: "Convert any value to a string" }], + ["tonumber", { signature: "tonumber(e [, base])", description: "Convert a value to a number, or nil if not convertible" }], + ["type", { signature: "type(v)", description: "Returns the type of a value as a string" }], + ["pairs", { signature: "pairs(t)", description: "Returns an iterator over all key-value pairs in a table" }], + ["ipairs", { signature: "ipairs(t)", description: "Returns an iterator over integer-keyed elements (1, 2, ...)" }], + ["next", { signature: "next(table [, index])", description: "Returns the next key-value pair after the given key" }], + ["select", { signature: "select(index, ···)", description: "Returns arguments after the given index, or count with '#'" }], + ["unpack", { signature: "unpack(list [, i [, j]])", description: "Returns elements from a table (alias for table.unpack)" }], + ["error", { signature: "error(message [, level])", description: "Raise an error with a message" }], + ["pcall", { signature: "pcall(f [, arg1, ···])", description: "Call a function in protected mode — returns ok, result" }], + ["xpcall", { signature: "xpcall(f, msgh [, arg1, ···])", description: "Like pcall but with a custom error handler" }], + ["assert", { signature: "assert(v [, message])", description: "Raise an error if v is falsy" }], + ["setmetatable", { signature: "setmetatable(table, metatable)", description: "Set the metatable for a table" }], + ["getmetatable", { signature: "getmetatable(object)", description: "Returns the metatable of an object, or nil" }], + ["rawget", { signature: "rawget(table, index)", description: "Get a value without invoking __index metamethod" }], + ["rawset", { signature: "rawset(table, index, value)", description: "Set a value without invoking __newindex metamethod" }], + ["rawequal", { signature: "rawequal(v1, v2)", description: "Check equality without invoking __eq metamethod" }], + + // ── HappyView sandbox globals ───────────────────────────────────────── + ["input", { signature: "input", description: "Procedure input table (from request body)" }], + ["params", { signature: "params", description: "Query parameters table (from URL query string)" }], + ["caller_did", { signature: "caller_did", description: "DID of the authenticated caller" }], + ["collection", { signature: "collection", description: "Target collection NSID for this lexicon" }], + ["method", { signature: "method", description: "XRPC method name being called" }], + ["now", { signature: "now()", description: "Current UTC timestamp as ISO 8601 string" }], + ["log", { signature: "log(···)", description: "Log values to server console" }], + ["TID", { signature: "TID()", description: "Generate a new TID (timestamp identifier)" }], + + // ── string module ───────────────────────────────────────────────────── + ["string.byte", { signature: "string.byte(s [, i [, j]])", description: "Returns internal numeric codes of characters", module: "string" }], + ["string.char", { signature: "string.char(···)", description: "Returns a string from character codes", module: "string" }], + ["string.find", { signature: "string.find(s, pattern [, init [, plain]])", description: "Find first match of pattern in string", module: "string" }], + ["string.format", { signature: "string.format(formatstring, ···)", description: "Format a string (like sprintf)", module: "string" }], + ["string.gmatch", { signature: "string.gmatch(s, pattern)", description: "Returns an iterator for all pattern matches", module: "string" }], + ["string.gsub", { signature: "string.gsub(s, pattern, repl [, n])", description: "Global substitution — replace pattern matches", module: "string" }], + ["string.len", { signature: "string.len(s)", description: "Returns the byte length of a string", module: "string" }], + ["string.lower", { signature: "string.lower(s)", description: "Returns lowercase copy of a string", module: "string" }], + ["string.match", { signature: "string.match(s, pattern [, init])", description: "Find first match and return captures", module: "string" }], + ["string.rep", { signature: "string.rep(s, n [, sep])", description: "Returns a repeated copy of a string", module: "string" }], + ["string.reverse", { signature: "string.reverse(s)", description: "Returns reversed string", module: "string" }], + ["string.sub", { signature: "string.sub(s, i [, j])", description: "Returns a substring", module: "string" }], + ["string.upper", { signature: "string.upper(s)", description: "Returns uppercase copy of a string", module: "string" }], + + // ── table module ────────────────────────────────────────────────────── + ["table.concat", { signature: "table.concat(list [, sep [, i [, j]]])", description: "Concatenate table elements into a string", module: "table" }], + ["table.insert", { signature: "table.insert(list, [pos,] value)", description: "Insert an element into a table", module: "table" }], + ["table.move", { signature: "table.move(a1, f, e, t [, a2])", description: "Move elements between tables", module: "table" }], + ["table.pack", { signature: "table.pack(···)", description: "Pack arguments into a table with n field", module: "table" }], + ["table.remove", { signature: "table.remove(list [, pos])", description: "Remove an element from a table", module: "table" }], + ["table.sort", { signature: "table.sort(list [, comp])", description: "Sort a table in-place", module: "table" }], + ["table.unpack", { signature: "table.unpack(list [, i [, j]])", description: "Unpack table elements as multiple return values", module: "table" }], + + // ── math module ─────────────────────────────────────────────────────── + ["math.abs", { signature: "math.abs(x)", description: "Absolute value", module: "math" }], + ["math.acos", { signature: "math.acos(x)", description: "Arc cosine (in radians)", module: "math" }], + ["math.asin", { signature: "math.asin(x)", description: "Arc sine (in radians)", module: "math" }], + ["math.atan", { signature: "math.atan(y [, x])", description: "Arc tangent of y/x (in radians)", module: "math" }], + ["math.ceil", { signature: "math.ceil(x)", description: "Round up to nearest integer", module: "math" }], + ["math.cos", { signature: "math.cos(x)", description: "Cosine (x in radians)", module: "math" }], + ["math.deg", { signature: "math.deg(x)", description: "Convert radians to degrees", module: "math" }], + ["math.exp", { signature: "math.exp(x)", description: "Returns e^x", module: "math" }], + ["math.floor", { signature: "math.floor(x)", description: "Round down to nearest integer", module: "math" }], + ["math.fmod", { signature: "math.fmod(x, y)", description: "Remainder of x / y", module: "math" }], + ["math.huge", { signature: "math.huge", description: "Infinity value (HUGE_VAL)", module: "math" }], + ["math.log", { signature: "math.log(x [, base])", description: "Logarithm of x in the given base (default: e)", module: "math" }], + ["math.max", { signature: "math.max(x, ···)", description: "Returns the maximum value among arguments", module: "math" }], + ["math.maxinteger", { signature: "math.maxinteger", description: "Maximum integer value (2^63 - 1)", module: "math" }], + ["math.min", { signature: "math.min(x, ···)", description: "Returns the minimum value among arguments", module: "math" }], + ["math.mininteger", { signature: "math.mininteger", description: "Minimum integer value (-2^63)", module: "math" }], + ["math.modf", { signature: "math.modf(x)", description: "Returns integer and fractional parts of x", module: "math" }], + ["math.pi", { signature: "math.pi", description: "Pi constant (3.14159...)", module: "math" }], + ["math.rad", { signature: "math.rad(x)", description: "Convert degrees to radians", module: "math" }], + ["math.random", { signature: "math.random([m [, n]])", description: "Generate a pseudo-random number", module: "math" }], + ["math.randomseed", { signature: "math.randomseed([x [, y]])", description: "Set the random seed", module: "math" }], + ["math.sin", { signature: "math.sin(x)", description: "Sine (x in radians)", module: "math" }], + ["math.sqrt", { signature: "math.sqrt(x)", description: "Square root of x", module: "math" }], + ["math.tan", { signature: "math.tan(x)", description: "Tangent (x in radians)", module: "math" }], + ["math.tointeger", { signature: "math.tointeger(x)", description: "Convert to integer if possible, otherwise nil", module: "math" }], + ["math.type", { signature: "math.type(x)", description: "Returns \"integer\", \"float\", or false", module: "math" }], + ["math.ult", { signature: "math.ult(m, n)", description: "Unsigned integer less-than comparison", module: "math" }], + + // ── coroutine module ────────────────────────────────────────────────── + ["coroutine.create", { signature: "coroutine.create(f)", description: "Create a new coroutine from function f", module: "coroutine" }], + ["coroutine.resume", { signature: "coroutine.resume(co [, val1, ···])", description: "Resume a suspended coroutine", module: "coroutine" }], + ["coroutine.yield", { signature: "coroutine.yield(···)", description: "Suspend the running coroutine", module: "coroutine" }], + ["coroutine.status", { signature: "coroutine.status(co)", description: "Returns coroutine status: \"running\", \"suspended\", \"normal\", or \"dead\"", module: "coroutine" }], + ["coroutine.wrap", { signature: "coroutine.wrap(f)", description: "Create an iterator function from a coroutine", module: "coroutine" }], + ["coroutine.isyieldable", { signature: "coroutine.isyieldable()", description: "Returns true if the running coroutine can yield", module: "coroutine" }], + ["coroutine.running", { signature: "coroutine.running()", description: "Returns the running coroutine and a boolean (true if main)", module: "coroutine" }], + ["coroutine.close", { signature: "coroutine.close(co)", description: "Close a coroutine and release its resources", module: "coroutine" }], + + // ── utf8 module ─────────────────────────────────────────────────────── + ["utf8.char", { signature: "utf8.char(···)", description: "Returns a UTF-8 string from codepoint values", module: "utf8" }], + ["utf8.charpattern", { signature: "utf8.charpattern", description: "Pattern that matches exactly one UTF-8 character", module: "utf8" }], + ["utf8.codepoint", { signature: "utf8.codepoint(s [, i [, j [, lax]]])", description: "Returns codepoint values of characters in a string", module: "utf8" }], + ["utf8.codes", { signature: "utf8.codes(s [, lax])", description: "Returns an iterator over UTF-8 codepoints", module: "utf8" }], + ["utf8.len", { signature: "utf8.len(s [, i [, j [, lax]]])", description: "Returns the number of UTF-8 characters in a string", module: "utf8" }], + ["utf8.offset", { signature: "utf8.offset(s, n [, i])", description: "Returns the byte position of the nth UTF-8 character", module: "utf8" }], + + // ── HappyView Record API ────────────────────────────────────────────── + ["Record", { signature: "Record(collection)", description: "Create a new record for the given collection NSID" }], + ["Record.load", { signature: "Record.load(uri)", description: "Load a record from the database by AT URI" }], + ["Record.load_all", { signature: "Record.load_all({uri1, uri2, ...})", description: "Load multiple records from the database" }], + ["Record.save_all", { signature: "Record.save_all({r1, r2, ...})", description: "Save multiple records in parallel" }], + ["Record:save", { signature: "record:save()", description: "Save this record (creates or updates on PDS and database)" }], + ["Record:delete", { signature: "record:delete()", description: "Delete this record from PDS and database" }], + ["Record:set_key_type", { signature: "record:set_key_type(type)", description: "Set the record key type (tid, any, nsid, literal:*)" }], + ["Record:set_rkey", { signature: "record:set_rkey(key)", description: "Set a specific rkey for this record" }], + ["Record:generate_rkey", { signature: "record:generate_rkey()", description: "Generate an rkey based on the record's _key_type" }], + + // ── HappyView db API ────────────────────────────────────────────────── + ["db.query", { signature: "db.query({collection, did?, limit?, offset?})", description: "Query records — returns {records, cursor?}", module: "db" }], + ["db.get", { signature: "db.get(uri)", description: "Get a single record by AT URI — returns record or nil", module: "db" }], + ["db.count", { signature: "db.count(collection [, did])", description: "Count records in a collection", module: "db" }], +]); diff --git a/web/src/lib/lua-parser.ts b/web/src/lib/lua-parser.ts --- a/web/src/lib/lua-parser.ts +++ b/web/src/lib/lua-parser.ts @@ -37,3 +37,42 @@ map[m[1]] = m[2]; } return map; } + +/** Parse Lua source for `db.query({ collection = "..." })` assignments. + * Returns a map of variable name → collection NSID. */ +export function parseDbQueryVariables( + source: string, +): Record { + const map: Record = {}; + const re = /(?:local\s+)?(\w+)\s*=\s*db\.query\s*\(\s*\{([^}]*)\}/g; + let m; + while ((m = re.exec(source)) !== null) { + const varName = m[1]; + const body = m[2]; + const colMatch = body.match(/collection\s*=\s*"([^"]+)"/); + map[varName] = colMatch ? colMatch[1] : null; + } + return map; +} + +/** Parse `for _, var in ipairs(result.records)` loops and resolve the + * iterator variable back to the collection from a db.query result. + * Returns a map of iterator variable name → collection NSID. */ +export function parseDbQueryRecordIterators( + source: string, + queryVars: Record, +): Record { + const map: Record = {}; + const re = + /\bfor\s+(?:\w+\s*,\s*)?(\w+)\s+in\s+i?pairs\s*\(\s*(\w+)\.records\s*\)/g; + let m; + while ((m = re.exec(source)) !== null) { + const iterVar = m[1]; + const resultVar = m[2]; + const collection = queryVars[resultVar]; + if (collection) { + map[iterVar] = collection; + } + } + return map; +} -- tangled.sh