diff --git a/packages/language-server/src/server.ts b/packages/language-server/src/server.ts index 3d7fe93e..09a7cde3 100644 --- a/packages/language-server/src/server.ts +++ b/packages/language-server/src/server.ts @@ -1,4 +1,5 @@ #!/usr/bin/env bun +import { parse, type DataApiBlock } from "@wrnexus/syntax"; import { completionItems, definitionLocation, @@ -29,6 +30,78 @@ import { } from "./html-service.ts"; import { clearHtmlRegionCache } from "./html-regions.ts"; +export interface ApiCallCompletionItem { + label: string; + kind: number; + detail: string; + documentation?: string; +} + +/** `ast.dataApis`, or `[]` when `source` does not parse — the normal case while typing. */ +function dataApisOf(source: string): DataApiBlock[] { + try { + return parse(source).dataApis; + } catch { + return []; + } +} + +function apiDetail(block: DataApiBlock): string { + return `${block.method} ${block.path}`; +} + +/** + * Completion items for `api.<|>` — one per declared `apis { }` entry. + * + * `source` may be mid-edit and fail to parse (the fixture's `await api.` is + * itself invalid syntax); that must yield `[]`, never throw, since completion + * fires constantly while the document does not parse. + */ +export function apiCallCompletions(source: string): ApiCallCompletionItem[] { + return dataApisOf(source).map((block) => ({ + label: block.name, + kind: 2, + detail: apiDetail(block), + documentation: block.sections + ? [ + block.sections.body.length + ? `body: ${block.sections.body.map((field) => `${field.name}${field.optional ? "?" : ""}: ${field.type}`).join(", ")}` + : undefined, + ] + .filter(Boolean) + .join("\n") + : undefined, + })); +} + +/** + * Hover text for the api block named `name`, or undefined when it is not + * declared or `source` does not parse. + */ +export function apiCallHover(source: string, name: string): string | undefined { + const block = dataApisOf(source).find((entry) => entry.name === name); + if (!block) return undefined; + + const lines = [`**${block.name}** \`${block.method} ${block.path}\``]; + if (block.sections?.body.length) { + lines.push( + `request body: ${block.sections.body.map((field) => `${field.name}${field.optional ? "?" : ""}: ${field.type}`).join(", ")}`, + ); + } + if (block.sections?.parameters.length) { + lines.push( + `parameters: ${block.sections.parameters.map((field) => `${field.name}${field.optional ? "?" : ""}: ${field.type}`).join(", ")}`, + ); + } + return lines.join("\n\n"); +} + +/** True when `offset` sits right after `api.` (optionally with a partial identifier after it). */ +function isApiCallPosition(text: string, offset: number): boolean { + const before = text.slice(0, offset); + return /\bapi\.\w*$/.test(before); +} + type JsonRpc = { jsonrpc?: string; id?: number | string; method?: string; params?: any }; const documents = new Map(); const diagnosticTimers = new Map>(); @@ -211,6 +284,10 @@ async function handle(message: JsonRpc): Promise { } case "textDocument/completion": { const document = documents.get(params.textDocument.uri); + if (document && isApiCallPosition(document.text, offsetAt(document.text, params.position))) { + result(message.id, apiCallCompletions(document.text)); + break; + } const wrnexus = [...completionItems(), ...workspaceCompletionItems(workspaceRoot)]; const html = document ? htmlCompletions(document, params.position) : []; result(message.id, html.length ? mergeCompletions(wrnexus, html) : wrnexus); @@ -228,6 +305,12 @@ async function handle(message: JsonRpc): Promise { } case "textDocument/hover": { const document = documents.get(params.textDocument.uri); + const word = document ? wordAt(document.text, params.position)?.word : undefined; + const apiHover = word ? apiCallHover(document!.text, word) : undefined; + if (apiHover) { + result(message.id, { contents: apiHover }); + break; + } const html = document ? htmlHover(document, params.position) : null; result(message.id, html ?? (document ? hover(document, params.position) : null)); break; diff --git a/packages/language-server/test/api-call-completion.test.ts b/packages/language-server/test/api-call-completion.test.ts new file mode 100644 index 00000000..1bb26594 --- /dev/null +++ b/packages/language-server/test/api-call-completion.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from "bun:test"; +import { apiCallCompletions, apiCallHover } from "../src/server.ts"; + +const SOURCE = `page Search { + apis { + searchUsers POST /api/users { + request { body { name?: string } } + response { return data.users } + } + + listTeams GET /api/teams { + response { return data.teams } + } + } + + functions { + client async function go(): Promise { + await api. + } + } + + view {
x
} +} +`; + +test("api. offers every declared block with method and path", () => { + const items = apiCallCompletions(SOURCE); + const labels = items.map((item) => item.label); + + expect(labels).toContain("searchUsers"); + expect(labels).toContain("listTeams"); + + const search = items.find((item) => item.label === "searchUsers")!; + expect(search.detail).toContain("POST"); + expect(search.detail).toContain("/api/users"); +}); + +test("hovering a block name reports its method, path and request fields", () => { + const hover = apiCallHover(SOURCE, "searchUsers"); + + expect(hover).toContain("POST"); + expect(hover).toContain("/api/users"); + expect(hover).toContain("name"); +}); + +test("a page with no apis block offers nothing", () => { + expect(apiCallCompletions(`page P { view {
x
} }`)).toEqual([]); +}); + +test("a badly-broken document (unclosed braces, truncated apis block) answers rather than throwing", () => { + const broken = `page Search { + apis { + searchUsers POST /api/users { + request { body { name?: string +`; + + expect(() => apiCallCompletions(broken)).not.toThrow(); + expect(apiCallCompletions(broken)).toEqual([]); + + expect(() => apiCallHover(broken, "searchUsers")).not.toThrow(); + expect(apiCallHover(broken, "searchUsers")).toBeUndefined(); +}); + +test("an ssr {} data block, which the parser now rejects with a ParseError, still answers rather than throwing", () => { + const legacy = `page Search { + ssr { api x GET /api/x { response { return data } } } + view {
x
} +} +`; + + expect(() => apiCallCompletions(legacy)).not.toThrow(); + expect(apiCallCompletions(legacy)).toEqual([]); +});