feat(language-server): complete and describe api block calls

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 12:35:31 +05:30
co-authored by Claude Opus 5
parent 2bb52487eb
commit f57bd05a03
2 changed files with 156 additions and 0 deletions
+83
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { parse, type DataApiBlock } from "@wrnexus/syntax";
import { import {
completionItems, completionItems,
definitionLocation, definitionLocation,
@@ -29,6 +30,78 @@ import {
} from "./html-service.ts"; } from "./html-service.ts";
import { clearHtmlRegionCache } from "./html-regions.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 }; type JsonRpc = { jsonrpc?: string; id?: number | string; method?: string; params?: any };
const documents = new Map<string, TextDocument>(); const documents = new Map<string, TextDocument>();
const diagnosticTimers = new Map<string, ReturnType<typeof setTimeout>>(); const diagnosticTimers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -211,6 +284,10 @@ async function handle(message: JsonRpc): Promise<void> {
} }
case "textDocument/completion": { case "textDocument/completion": {
const document = documents.get(params.textDocument.uri); 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 wrnexus = [...completionItems(), ...workspaceCompletionItems(workspaceRoot)];
const html = document ? htmlCompletions(document, params.position) : []; const html = document ? htmlCompletions(document, params.position) : [];
result(message.id, html.length ? mergeCompletions(wrnexus, html) : wrnexus); result(message.id, html.length ? mergeCompletions(wrnexus, html) : wrnexus);
@@ -228,6 +305,12 @@ async function handle(message: JsonRpc): Promise<void> {
} }
case "textDocument/hover": { case "textDocument/hover": {
const document = documents.get(params.textDocument.uri); 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; const html = document ? htmlHover(document, params.position) : null;
result(message.id, html ?? (document ? hover(document, params.position) : null)); result(message.id, html ?? (document ? hover(document, params.position) : null));
break; break;
@@ -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<void> {
await api.
}
}
view { <main>x</main> }
}
`;
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 { <main>x</main> } }`)).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 { <main>x</main> }
}
`;
expect(() => apiCallCompletions(legacy)).not.toThrow();
expect(apiCallCompletions(legacy)).toEqual([]);
});