feat(language-server): complete and describe api block calls
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, TextDocument>();
|
||||
const diagnosticTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
@@ -211,6 +284,10 @@ async function handle(message: JsonRpc): Promise<void> {
|
||||
}
|
||||
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<void> {
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
Reference in New Issue
Block a user