feat(language-server): understand the api binding attribute
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { documentDiagnostics } from "../src/index.ts";
|
||||
import { htmlCompletions, isApiAttributeValuePosition } from "../src/html-service.ts";
|
||||
import { apiCallCompletions } from "../src/server.ts";
|
||||
|
||||
const SOURCE = `page Search {
|
||||
apis {
|
||||
searchUsers POST /api/users {
|
||||
request { body { name?: string } }
|
||||
response { return data.users }
|
||||
}
|
||||
}
|
||||
|
||||
view { <button api="searchUsers">Go</button> }
|
||||
}
|
||||
`;
|
||||
|
||||
// `html-regions.ts` caches view regions by `uri` + `version`, so each fixture
|
||||
// needs its own uri — otherwise a later document reuses an earlier one's
|
||||
// cached regions and the position checks below silently look at stale spans.
|
||||
let nextDocId = 0;
|
||||
function doc(text: string) {
|
||||
nextDocId += 1;
|
||||
return { uri: `file:///Page-${nextDocId}.wrn`, text, version: 1 };
|
||||
}
|
||||
|
||||
function positionOf(text: string, needle: string) {
|
||||
const offset = text.indexOf(needle) + needle.length;
|
||||
const before = text.slice(0, offset);
|
||||
const lines = before.split("\n");
|
||||
return { line: lines.length - 1, character: lines[lines.length - 1]!.length };
|
||||
}
|
||||
|
||||
test('api="searchUsers" produces no unknown-attribute diagnostic', () => {
|
||||
const diagnostics = documentDiagnostics(doc(SOURCE));
|
||||
expect(diagnostics.filter((item) => item.severity === 1)).toEqual([]);
|
||||
expect(
|
||||
diagnostics.some((item) => /unknown/i.test(item.message) && /api/i.test(item.message)),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("`api` is offered as an attribute name on any element, not flagged unknown", () => {
|
||||
const opening = " <button ";
|
||||
const text = `page A {
|
||||
view {
|
||||
${opening}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const items = htmlCompletions(doc(text), positionOf(text, opening));
|
||||
expect(items.some((item) => item.label === "api")).toBe(true);
|
||||
});
|
||||
|
||||
test('completion inside api="…" quotes offers the page\'s block names for all three call forms', () => {
|
||||
const bare = `page Search {
|
||||
apis {
|
||||
searchUsers POST /api/users {
|
||||
response { return data.users }
|
||||
}
|
||||
}
|
||||
view { <button api="sear"></button> }
|
||||
}
|
||||
`;
|
||||
const barePosition = isApiAttributeValuePosition(doc(bare), positionOf(bare, 'api="sear'));
|
||||
expect(barePosition).toBe(true);
|
||||
expect(apiCallCompletions(bare).map((item) => item.label)).toContain("searchUsers");
|
||||
|
||||
const call = `page Search {
|
||||
apis {
|
||||
searchUsers POST /api/users {
|
||||
response { return data.users }
|
||||
}
|
||||
}
|
||||
view { <button api="searchUsers("></button> }
|
||||
}
|
||||
`;
|
||||
expect(isApiAttributeValuePosition(doc(call), positionOf(call, 'api="searchUsers('))).toBe(true);
|
||||
|
||||
const callWithArgs = `page Search {
|
||||
apis {
|
||||
searchUsers POST /api/users {
|
||||
request { body { name?: string } }
|
||||
response { return data.users }
|
||||
}
|
||||
}
|
||||
view { <button api="searchUsers({ name: '"></button> }
|
||||
}
|
||||
`;
|
||||
expect(
|
||||
isApiAttributeValuePosition(doc(callWithArgs), positionOf(callWithArgs, "{ name: '")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('html completion defers to apiCallCompletions inside api="…" — it does not offer its own value completion', () => {
|
||||
const items = htmlCompletions(doc(SOURCE), positionOf(SOURCE, 'api="search'));
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
|
||||
test("a badly-broken document (unclosed braces, truncated apis block) still answers rather than throwing", () => {
|
||||
const broken = `page Search {
|
||||
apis {
|
||||
searchUsers POST /api/users {
|
||||
request { body { name?: string
|
||||
view { <button api="sear"></button>
|
||||
`;
|
||||
|
||||
expect(() => apiCallCompletions(broken)).not.toThrow();
|
||||
expect(apiCallCompletions(broken)).toEqual([]);
|
||||
|
||||
expect(() => documentDiagnostics(doc(broken))).not.toThrow();
|
||||
expect(() =>
|
||||
isApiAttributeValuePosition(doc(broken), positionOf(broken, 'api="sear')),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
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 { <button api="x"></button> }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(() => apiCallCompletions(legacy)).not.toThrow();
|
||||
expect(apiCallCompletions(legacy)).toEqual([]);
|
||||
expect(() => documentDiagnostics(doc(legacy))).not.toThrow();
|
||||
});
|
||||
Reference in New Issue
Block a user