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
|
#!/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([]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user