diff --git a/editors/vscode/src/language-server.cjs b/editors/vscode/src/language-server.cjs index 426b1823..18057976 100644 --- a/editors/vscode/src/language-server.cjs +++ b/editors/vscode/src/language-server.cjs @@ -1,5 +1,5 @@ #!/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 // @bun @bun-cjs (function(exports, require, module, __filename, __dirname) {var __create = Object.create; @@ -196009,6 +196009,9 @@ function getLanguageService(options = defaultLanguageServiceOptions) { findLinkedEditingRanges }; } +function newHTMLDataProvider(id, customData) { + return new HTMLDataProvider(id, customData); +} // packages/language-server/src/html-regions.ts function viewRegions(text) { @@ -196098,7 +196101,13 @@ var semanticTokensLegend2 = { }; // 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) { const virtual = virtualHtmlDocument(document); return TextDocument2.create(virtual.uri, "html", document.version ?? 1, virtual.text); @@ -196111,9 +196120,18 @@ function markdown(value) { } 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) { if (!isInsideHtml(document, offsetAt2(document.text, position))) return []; + if (isApiAttributeValuePosition(document, position)) + return []; const virtual = htmlDocument(document); const parsed = service.parseHTMLDocument(virtual); const list = service.doComplete(virtual, position, parsed); @@ -196419,6 +196437,10 @@ async function handle(message) { result(message.id, apiCallCompletions(document.text)); break; } + if (document && isApiAttributeValuePosition(document, 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); diff --git a/packages/language-server/src/html-service.ts b/packages/language-server/src/html-service.ts index 90f218bb..ef3195a9 100644 --- a/packages/language-server/src/html-service.ts +++ b/packages/language-server/src/html-service.ts @@ -5,6 +5,7 @@ // cleanly, so import it explicitly. import { getLanguageService, + newHTMLDataProvider, TextDocument as HtmlTextDocument, } from "vscode-html-languageservice/lib/esm/htmlLanguageService.js"; import { isInsideHtml, virtualHtmlDocument } from "./html-regions.ts"; @@ -19,7 +20,21 @@ export interface HtmlCompletionItem { insertText?: string; } -const service = getLanguageService(); +/** + * `api=""` / `api="()"` / `api="({ 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. */ 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 * 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[] { if (!isInsideHtml(document, offsetAt(document.text, position))) return []; + if (isApiAttributeValuePosition(document, position)) return []; const virtual = htmlDocument(document); const parsed = service.parseHTMLDocument(virtual); diff --git a/packages/language-server/src/server.ts b/packages/language-server/src/server.ts index 09a7cde3..3c0315cd 100644 --- a/packages/language-server/src/server.ts +++ b/packages/language-server/src/server.ts @@ -26,6 +26,7 @@ import { htmlHover, htmlLinkedEditingRanges, htmlTagComplete, + isApiAttributeValuePosition, mergeCompletions, } from "./html-service.ts"; import { clearHtmlRegionCache } from "./html-regions.ts"; @@ -288,6 +289,10 @@ async function handle(message: JsonRpc): Promise { result(message.id, apiCallCompletions(document.text)); break; } + if (document && isApiAttributeValuePosition(document, 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); diff --git a/packages/language-server/test/api-attribute.test.ts b/packages/language-server/test/api-attribute.test.ts new file mode 100644 index 00000000..a2b9d132 --- /dev/null +++ b/packages/language-server/test/api-attribute.test.ts @@ -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 { } +} +`; + +// `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 = " } +} +`; + 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 { } +} +`; + 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 { } +} +`; + 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 { +`; + + 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 { } +} +`; + + expect(() => apiCallCompletions(legacy)).not.toThrow(); + expect(apiCallCompletions(legacy)).toEqual([]); + expect(() => documentDiagnostics(doc(legacy))).not.toThrow(); +});