feat(language-server): understand the api binding attribute
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// WRN editor language server source hash: 70d2454817cc3aa546304c880e0a82313bb73b50051556bf7febd12e6a46c78c
|
// WRN editor language server source hash: 2e1803dd46a175316931c0390c5af90dc35a5cd2299798014cbe46b075fee15d
|
||||||
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
|
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
|
||||||
// @bun @bun-cjs
|
// @bun @bun-cjs
|
||||||
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
|
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
|
||||||
@@ -196009,6 +196009,9 @@ function getLanguageService(options = defaultLanguageServiceOptions) {
|
|||||||
findLinkedEditingRanges
|
findLinkedEditingRanges
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
function newHTMLDataProvider(id, customData) {
|
||||||
|
return new HTMLDataProvider(id, customData);
|
||||||
|
}
|
||||||
|
|
||||||
// packages/language-server/src/html-regions.ts
|
// packages/language-server/src/html-regions.ts
|
||||||
function viewRegions(text) {
|
function viewRegions(text) {
|
||||||
@@ -196098,7 +196101,13 @@ var semanticTokensLegend2 = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// packages/language-server/src/html-service.ts
|
// packages/language-server/src/html-service.ts
|
||||||
var service = getLanguageService();
|
var wrnexusDataProvider = newHTMLDataProvider("wrnexus", {
|
||||||
|
version: 1,
|
||||||
|
globalAttributes: [
|
||||||
|
{ name: "api", description: "Binds this element to a declared `apis { }` entry." }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
var service = getLanguageService({ customDataProviders: [wrnexusDataProvider] });
|
||||||
function htmlDocument(document) {
|
function htmlDocument(document) {
|
||||||
const virtual = virtualHtmlDocument(document);
|
const virtual = virtualHtmlDocument(document);
|
||||||
return TextDocument2.create(virtual.uri, "html", document.version ?? 1, virtual.text);
|
return TextDocument2.create(virtual.uri, "html", document.version ?? 1, virtual.text);
|
||||||
@@ -196111,9 +196120,18 @@ function markdown(value) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
function isApiAttributeValuePosition(document, position) {
|
||||||
|
const offset = offsetAt2(document.text, position);
|
||||||
|
if (!isInsideHtml(document, offset))
|
||||||
|
return false;
|
||||||
|
const before = document.text.slice(0, offset);
|
||||||
|
return /\bapi\s*=\s*(["'])(?:(?!\1)[^\n])*$/.test(before);
|
||||||
|
}
|
||||||
function htmlCompletions(document, position) {
|
function htmlCompletions(document, position) {
|
||||||
if (!isInsideHtml(document, offsetAt2(document.text, position)))
|
if (!isInsideHtml(document, offsetAt2(document.text, position)))
|
||||||
return [];
|
return [];
|
||||||
|
if (isApiAttributeValuePosition(document, position))
|
||||||
|
return [];
|
||||||
const virtual = htmlDocument(document);
|
const virtual = htmlDocument(document);
|
||||||
const parsed = service.parseHTMLDocument(virtual);
|
const parsed = service.parseHTMLDocument(virtual);
|
||||||
const list = service.doComplete(virtual, position, parsed);
|
const list = service.doComplete(virtual, position, parsed);
|
||||||
@@ -196419,6 +196437,10 @@ async function handle(message) {
|
|||||||
result(message.id, apiCallCompletions(document.text));
|
result(message.id, apiCallCompletions(document.text));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if (document && isApiAttributeValuePosition(document, 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);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// cleanly, so import it explicitly.
|
// cleanly, so import it explicitly.
|
||||||
import {
|
import {
|
||||||
getLanguageService,
|
getLanguageService,
|
||||||
|
newHTMLDataProvider,
|
||||||
TextDocument as HtmlTextDocument,
|
TextDocument as HtmlTextDocument,
|
||||||
} from "vscode-html-languageservice/lib/esm/htmlLanguageService.js";
|
} from "vscode-html-languageservice/lib/esm/htmlLanguageService.js";
|
||||||
import { isInsideHtml, virtualHtmlDocument } from "./html-regions.ts";
|
import { isInsideHtml, virtualHtmlDocument } from "./html-regions.ts";
|
||||||
@@ -19,7 +20,21 @@ export interface HtmlCompletionItem {
|
|||||||
insertText?: string;
|
insertText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const service = getLanguageService();
|
/**
|
||||||
|
* `api="<name>"` / `api="<name>()"` / `api="<name>({ field: value })"` is a
|
||||||
|
* WRNexus data-api binding, not arbitrary markup — declaring it here keeps
|
||||||
|
* the HTML service from treating it as an unrecognized attribute on any
|
||||||
|
* element, without inventing an attribute-value grammar the library would
|
||||||
|
* try to spell-check as prose.
|
||||||
|
*/
|
||||||
|
const wrnexusDataProvider = newHTMLDataProvider("wrnexus", {
|
||||||
|
version: 1,
|
||||||
|
globalAttributes: [
|
||||||
|
{ name: "api", description: "Binds this element to a declared `apis { }` entry." },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = getLanguageService({ customDataProviders: [wrnexusDataProvider] });
|
||||||
|
|
||||||
/** The virtual document as the HTML service's own document type. */
|
/** The virtual document as the HTML service's own document type. */
|
||||||
function htmlDocument(document: TextDocument) {
|
function htmlDocument(document: TextDocument) {
|
||||||
@@ -41,8 +56,24 @@ function markdown(value: unknown): string | undefined {
|
|||||||
* Every item carries the `1` sortText prefix so the server can rank WRNexus
|
* Every item carries the `1` sortText prefix so the server can rank WRNexus
|
||||||
* entries above these without filtering either list.
|
* entries above these without filtering either list.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* True when `offset` sits inside the quotes of an `api="…"` attribute value.
|
||||||
|
*
|
||||||
|
* The value is a call expression (`name`, `name()`, `name({ field: value })`),
|
||||||
|
* not prose, so completion there is routed to `apiCallCompletions` instead of
|
||||||
|
* the HTML service's own (text-oriented) attribute-value completion.
|
||||||
|
*/
|
||||||
|
export function isApiAttributeValuePosition(document: TextDocument, position: Position): boolean {
|
||||||
|
const offset = offsetAt(document.text, position);
|
||||||
|
if (!isInsideHtml(document, offset)) return false;
|
||||||
|
|
||||||
|
const before = document.text.slice(0, offset);
|
||||||
|
return /\bapi\s*=\s*(["'])(?:(?!\1)[^\n])*$/.test(before);
|
||||||
|
}
|
||||||
|
|
||||||
export function htmlCompletions(document: TextDocument, position: Position): HtmlCompletionItem[] {
|
export function htmlCompletions(document: TextDocument, position: Position): HtmlCompletionItem[] {
|
||||||
if (!isInsideHtml(document, offsetAt(document.text, position))) return [];
|
if (!isInsideHtml(document, offsetAt(document.text, position))) return [];
|
||||||
|
if (isApiAttributeValuePosition(document, position)) return [];
|
||||||
|
|
||||||
const virtual = htmlDocument(document);
|
const virtual = htmlDocument(document);
|
||||||
const parsed = service.parseHTMLDocument(virtual);
|
const parsed = service.parseHTMLDocument(virtual);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
htmlHover,
|
htmlHover,
|
||||||
htmlLinkedEditingRanges,
|
htmlLinkedEditingRanges,
|
||||||
htmlTagComplete,
|
htmlTagComplete,
|
||||||
|
isApiAttributeValuePosition,
|
||||||
mergeCompletions,
|
mergeCompletions,
|
||||||
} from "./html-service.ts";
|
} from "./html-service.ts";
|
||||||
import { clearHtmlRegionCache } from "./html-regions.ts";
|
import { clearHtmlRegionCache } from "./html-regions.ts";
|
||||||
@@ -288,6 +289,10 @@ async function handle(message: JsonRpc): Promise<void> {
|
|||||||
result(message.id, apiCallCompletions(document.text));
|
result(message.id, apiCallCompletions(document.text));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if (document && isApiAttributeValuePosition(document, 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);
|
||||||
|
|||||||
@@ -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