Compare commits
5
Commits
cd0dffa87d
...
5429057a38
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5429057a38 | ||
|
|
e944fd4496 | ||
|
|
861444b8a3 | ||
|
|
ba957e861e | ||
|
|
29febb2e0c |
@@ -1864,7 +1864,8 @@
|
||||
"./server": [
|
||||
"ApiCallCompletionItem",
|
||||
"apiCallCompletions",
|
||||
"apiCallHover"
|
||||
"apiCallHover",
|
||||
"isApiReferenceAt"
|
||||
]
|
||||
},
|
||||
"@wrnexus/mcp": {
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
"packages/ui/components/SplitHero.wrn": "843387dc7b920b3cf6a1b2b785b8653c281982e524479edc2638a89381ff3577",
|
||||
"packages/ui/components/StatsBar.wrn": "c273bd6ca417a0a20e5e6f1214414f678741b73bedd885fea1037575fa8c5b95",
|
||||
"packages/ui/components/Stepper.wrn": "7ec0902acd7cb74b44d490215b6421dae9a41731735ad1e9aec17e8bfc3fa142",
|
||||
"packages/ui/components/StrongPassword.wrn": "d5ea906fafe268b070a26ab551a252c0575d899c9d2b03b337bd047688b85dc5",
|
||||
"packages/ui/components/StrongPassword.wrn": "407eabcc13affc9323a346ddba8e39c81b885a59ef7043d2bd5567d4b7553282",
|
||||
"packages/ui/components/StyledIcon.wrn": "0874f9a234b053a7c8b9c6983be5d664453a64c4576dff1a049101e249a2866d",
|
||||
"packages/ui/components/Tabs.wrn": "f85214960e62da2fac41228deba511a50c57fd94f2830a8233b75048f6a542e9",
|
||||
"packages/ui/components/TextLink.wrn": "9d561631c5f65a2bcdc59f5c0b166a5dd794a396f42093f2cd26388386f6f954",
|
||||
@@ -86,14 +86,14 @@
|
||||
"packages/ui/components/Timeline.wrn": "fb553f69763840532eef1c0aafa79013f57411dc5335e7cb7e7c20c5a3753008",
|
||||
"packages/ui/components/Toaster.wrn": "5e4c9b2c1bfd5675a81a32e57684b99393ac7fe45d8420c7409a4e95d47d080c",
|
||||
"packages/ui/components/ToggleCount.wrn": "e577195ee05ddb43cceec47145f6b90c0149f2f17055733d58911fcd02de2c7f",
|
||||
"packages/ui/components/TogglePassword.wrn": "b6176453cb2c586fe88c3db6bdd734ae2b4c95db46d57e0de298dfba3d0dd0c2",
|
||||
"packages/ui/components/TogglePassword.wrn": "6bd4d9fa82b0c8e80178430d5a42b1b3ec9725fb62be158585f68af9338c901a",
|
||||
"packages/ui/components/TreeView.wrn": "f54d8495730203b923629bcfb167d5ca41236af0101ea2723d33354413555b4b",
|
||||
"packages/ui/components/Typography.wrn": "9676af57f2a937b21f45512e0a290b5bee4a16267fbf404850297d29645d88b5",
|
||||
"packages/ui/components/WysiwygEditor.wrn": "3f9d1765184581e3c1f335769a9f19969917450258bc95ba076980d99f5610b5",
|
||||
"packages/ui/components/alert.wrn": "d42287e41b918b1e19962e9462ef203002eb5480acac984f115b711e9dbe663d",
|
||||
"packages/ui/components/avatar.wrn": "8bc705e459b13c7f063c57e1506ce540e50fa8ef9020ae85b6e8b6a3579fc5dc",
|
||||
"packages/ui/components/badge.wrn": "e44e33633fb34e897696cd9290f210108e35a3e2dc3b2a4a367411f45c69a1e1",
|
||||
"packages/ui/components/button.wrn": "cba4ccbbd23bb75b3836ec7a53673e40f1e043d18bfcee3d613db96c318f4ba8",
|
||||
"packages/ui/components/button.wrn": "65570c58cb2547f1e17cd158eec7cf7b1ffc721e6976c6486c9df564627b1219",
|
||||
"packages/ui/components/checkbox.wrn": "18046396b75d0b9c6bb2bdb09dbff1846c84fb07490f390a25ca7b5367fd2ef4",
|
||||
"packages/ui/components/input.wrn": "e4c14527f009b610b4aa96d8d7b7d5ac3ca1ffd34237bab348ee9dc4ddc55847",
|
||||
"packages/ui/components/progress.wrn": "6307a90585197d7aab19a8710b2430f5d4ed27ce77e9b90b1414ea0eed876492",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
||||
// WRN editor compiler source hash: 2987e8e9d91b793818a195395e740d35d0d5c2a320ff1c3a5ae77363850c7b8e
|
||||
// WRN editor compiler source hash: 4ba6ddaf22c0a33fa1c2a108a97f077a69c0a46de9fe031c0a00e38863fab613
|
||||
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
|
||||
// Generated with TypeScript: 6.0.3
|
||||
const __nodeRequire = require;
|
||||
@@ -6762,7 +6762,7 @@ function parse(source) {
|
||||
break;
|
||||
}
|
||||
if (lx.peek().type === "lbrace") {
|
||||
throw new ParseError(`"${rawMode} { … }" data blocks were removed. Declare API calls in a page-level "apis { }" block, and move mode-scoped helpers into "functions { shared function … }".`);
|
||||
throw new ParseError(`"${rawMode} { … }" data blocks were removed. Declare API calls in a page-level "apis { }" block, and move mode-scoped helpers into "functions { shared function … }" (at offset ${kw.pos}).`);
|
||||
}
|
||||
throw new ParseError(`Expected a ${rawMode} state block or hydrate assignment`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// WRN editor extension source hash: d0ca0feee6516ae43e902e6aea0424bc5c3d05790708eb8a8c68c98162eb99bb
|
||||
// WRN editor extension source hash: 566bce98338a6a99c6c5c15017d92b3a5fd8ed376dd6abd932179a1e79c00f70
|
||||
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
||||
"use strict";
|
||||
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
// WRN editor language server source hash: 70d2454817cc3aa546304c880e0a82313bb73b50051556bf7febd12e6a46c78c
|
||||
// WRN editor language server source hash: 1efd569f289f736d7dc094e4165d11490593f1c4534ae460a5122bf6dd45344b
|
||||
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
|
||||
// @bun @bun-cjs
|
||||
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
|
||||
@@ -169658,6 +169658,7 @@ var require_main = __commonJS((exports2, module2) => {
|
||||
// packages/language-server/src/server.ts
|
||||
var exports_server = {};
|
||||
__export(exports_server, {
|
||||
isApiReferenceAt: () => isApiReferenceAt,
|
||||
apiCallHover: () => apiCallHover,
|
||||
apiCallCompletions: () => apiCallCompletions
|
||||
});
|
||||
@@ -171799,7 +171800,7 @@ function parse(source) {
|
||||
break;
|
||||
}
|
||||
if (lx.peek().type === "lbrace") {
|
||||
throw new ParseError(`"${rawMode} { … }" data blocks were removed. Declare API calls in a page-level "apis { }" block, and move mode-scoped helpers into "functions { shared function … }".`);
|
||||
throw new ParseError(`"${rawMode} { … }" data blocks were removed. Declare API calls in a page-level "apis { }" block, and move mode-scoped helpers into "functions { shared function … }" (at offset ${kw.pos}).`);
|
||||
}
|
||||
throw new ParseError(`Expected a ${rawMode} state block or hydrate assignment`);
|
||||
}
|
||||
@@ -172995,12 +172996,19 @@ function virtualTypeScriptModule(source, filePath = "component.wrn", appRoot = f
|
||||
const outputType = ast.outputs.map((output) => `${output.name}: (${output.payload ? `${output.payload.name}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}) => void`).join("; ");
|
||||
const serverFunctions = ast.runtimeFunctions.filter((fn) => fn.runtime === "server");
|
||||
const serverType = serverFunctions.map((fn) => `${fn.name}: (${fn.parameters.map((param) => `${param.name}${param.optional ? "?" : ""}: ${param.valueType ?? "unknown"}`).join(", ")}) => ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")}`).join("; ");
|
||||
const apiType = ast.dataApis.map((block) => {
|
||||
const fields = block.sections ? [...block.sections.parameters, ...block.sections.body] : [];
|
||||
const inputType = fields.length ? `{ ${fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join("; ")} }` : "Record<string, never>";
|
||||
return `${block.name}: (input${fields.length ? "" : "?"}: ${inputType}) => Promise<any>`;
|
||||
}).join("; ");
|
||||
append(`declare const output: { [name: string]: (...args: any[]) => void; ${outputType} };`);
|
||||
for (const output of ast.outputs)
|
||||
append(`declare const ${output.name}: (${output.payload ? `${output.payload.name}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}) => void;`);
|
||||
for (const match of source.matchAll(/@event\s+([A-Za-z_$][\w$]*)\s*=\s*function/g))
|
||||
append(`declare const ${match[1]}: ((...args: unknown[]) => void) | undefined;`);
|
||||
append(`declare const server: { ${serverType} };`);
|
||||
if (ast.dataApis.length)
|
||||
append(`declare const api: { ${apiType} };`);
|
||||
append(`declare const props: Readonly<${ast.name}Props>;`);
|
||||
append("declare const refs: Record<string, Element | null>;");
|
||||
append(ast.kind === "global-store" || ast.kind === "page-store" ? storeContract(ast) : componentContract(ast));
|
||||
@@ -196009,6 +196017,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 +196109,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 +196128,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);
|
||||
@@ -196243,6 +196269,12 @@ function isApiCallPosition(text, offset) {
|
||||
const before = text.slice(0, offset);
|
||||
return /\bapi\.\w*$/.test(before);
|
||||
}
|
||||
function isApiReferenceAt(text, offset) {
|
||||
let start = offset;
|
||||
while (start > 0 && /\w/.test(text[start - 1]))
|
||||
start--;
|
||||
return /\bapi\.$/.test(text.slice(0, start));
|
||||
}
|
||||
var documents = new Map;
|
||||
var diagnosticTimers = new Map;
|
||||
var MAX_OPEN_DOCUMENTS = 256;
|
||||
@@ -196419,6 +196451,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);
|
||||
@@ -196437,7 +196473,7 @@ async function handle(message) {
|
||||
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;
|
||||
const apiHover = word && isApiReferenceAt(document.text, offsetAt(document.text, params.position)) ? apiCallHover(document.text, word) : undefined;
|
||||
if (apiHover) {
|
||||
result(message.id, { contents: apiHover });
|
||||
break;
|
||||
|
||||
@@ -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="<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. */
|
||||
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);
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
htmlHover,
|
||||
htmlLinkedEditingRanges,
|
||||
htmlTagComplete,
|
||||
isApiAttributeValuePosition,
|
||||
mergeCompletions,
|
||||
} from "./html-service.ts";
|
||||
import { clearHtmlRegionCache } from "./html-regions.ts";
|
||||
@@ -102,6 +103,18 @@ function isApiCallPosition(text: string, offset: number): boolean {
|
||||
return /\bapi\.\w*$/.test(before);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the identifier containing `offset` is the `<name>` of an `api.<name>`
|
||||
* reference. Hover fires on whatever word is under the cursor, so without this a local
|
||||
* variable that merely collides with a block name would report the block's method and
|
||||
* path instead of its own hover info.
|
||||
*/
|
||||
export function isApiReferenceAt(text: string, offset: number): boolean {
|
||||
let start = offset;
|
||||
while (start > 0 && /\w/.test(text[start - 1]!)) start--;
|
||||
return /\bapi\.$/.test(text.slice(0, start));
|
||||
}
|
||||
|
||||
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>>();
|
||||
@@ -288,6 +301,10 @@ async function handle(message: JsonRpc): Promise<void> {
|
||||
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);
|
||||
@@ -306,7 +323,10 @@ 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;
|
||||
const apiHover =
|
||||
word && isApiReferenceAt(document!.text, offsetAt(document!.text, params.position))
|
||||
? apiCallHover(document!.text, word)
|
||||
: undefined;
|
||||
if (apiHover) {
|
||||
result(message.id, { contents: apiHover });
|
||||
break;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { apiCallCompletions, apiCallHover } from "../src/server.ts";
|
||||
import { apiCallCompletions, apiCallHover, isApiReferenceAt } from "../src/server.ts";
|
||||
|
||||
const SOURCE = `page Search {
|
||||
apis {
|
||||
@@ -71,3 +71,32 @@ test("an ssr {} data block, which the parser now rejects with a ParseError, stil
|
||||
expect(() => apiCallCompletions(legacy)).not.toThrow();
|
||||
expect(apiCallCompletions(legacy)).toEqual([]);
|
||||
});
|
||||
|
||||
test("a bare identifier that merely collides with a block name is not an api reference", () => {
|
||||
// `searchUsers` here is a local variable, not `api.searchUsers` -- hovering it must
|
||||
// not report the API block's method and path.
|
||||
const source = `page Search {
|
||||
apis {
|
||||
searchUsers POST /api/users {
|
||||
response { return data.users }
|
||||
}
|
||||
}
|
||||
|
||||
functions {
|
||||
client function go() {
|
||||
const searchUsers = 1
|
||||
void api.searchUsers({ name: "x" })
|
||||
return searchUsers
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
const collision = source.indexOf("const searchUsers") + "const ".length;
|
||||
const reference = source.indexOf("searchUsers POST");
|
||||
|
||||
expect(isApiReferenceAt(source, collision)).toBe(false);
|
||||
expect(isApiReferenceAt(source, source.indexOf("api.searchUsers") + 4)).toBe(true);
|
||||
expect(reference).toBeGreaterThan(-1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { documentDiagnostics } from "../src/index.ts";
|
||||
|
||||
// documentDiagnostics is the language server's actual diagnostic entry point.
|
||||
// The plan's draft guessed a `diagnoseWrn` export that does not exist.
|
||||
|
||||
// These two tests are the plan's original assertions, kept verbatim. They
|
||||
// already passed before this change — the message produced by the parser
|
||||
// named "apis" from the start — so on their own they are a false-green:
|
||||
// they would not catch a regression to the fix below. They stay as cheap
|
||||
// regression guards, not as the load-bearing coverage for this task.
|
||||
test("an ssr data block is flagged and names the replacement", () => {
|
||||
const diagnostics = documentDiagnostics({
|
||||
uri: "file:///removed-ssr.wrn",
|
||||
text: `page P {
|
||||
ssr { api x GET /api/x { response { return data } } }
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
expect(diagnostics[0]!.message).toContain("apis");
|
||||
});
|
||||
|
||||
test("client state is not flagged", () => {
|
||||
const diagnostics = documentDiagnostics({
|
||||
uri: "file:///client-state.wrn",
|
||||
text: `page P {
|
||||
client state { count = 0 }
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
expect(diagnostics.filter((item) => item.severity === 1)).toEqual([]);
|
||||
});
|
||||
|
||||
// --- Load-bearing tests -----------------------------------------------
|
||||
//
|
||||
// The message alone doesn't prove the diagnostic is useful: before the fix,
|
||||
// documentDiagnostics reported this error at line 0 / character 0 for every
|
||||
// input, no matter where the offending block actually was. These tests pin
|
||||
// the range to the real position of the "ssr" / "client" keyword, with the
|
||||
// block moved off line 1 so a hardcoded line number cannot pass.
|
||||
|
||||
test("an ssr block diagnostic lands on the ssr keyword, not offset zero", () => {
|
||||
const diagnostics = documentDiagnostics({
|
||||
uri: "file:///removed-ssr-position.wrn",
|
||||
text: `page P {
|
||||
ssr { api x GET /api/x { response { return data } } }
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
// " ssr { ... }" — the keyword starts at line 1 (0-indexed), character 2.
|
||||
expect(diagnostics[0]!.range.start).toEqual({ line: 1, character: 2 });
|
||||
});
|
||||
|
||||
test("a client block diagnostic lands on the client keyword when it is not on line 1", () => {
|
||||
const diagnostics = documentDiagnostics({
|
||||
uri: "file:///removed-client-position.wrn",
|
||||
text: `page P {
|
||||
view { <main>x</main> }
|
||||
client { api x GET /api/x { response { return data } } }
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
// The client block is on line 2 (0-indexed) here, preceded by "view { ... }"
|
||||
// on line 1 — a diagnostic hardcoded to line 1 would fail this assertion.
|
||||
expect(diagnostics[0]!.range.start).toEqual({ line: 2, character: 2 });
|
||||
});
|
||||
|
||||
// --- Legal `client` forms stay legal ------------------------------------
|
||||
//
|
||||
// `client` is one word with several jobs. Only the `ssr { }` / `client { }`
|
||||
// data-block form was removed; these forms must never be flagged.
|
||||
|
||||
test("client state block produces no error-severity diagnostic", () => {
|
||||
const diagnostics = documentDiagnostics({
|
||||
uri: "file:///client-state-2.wrn",
|
||||
text: `page P {
|
||||
client state { count = 0 }
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
expect(diagnostics.filter((item) => item.severity === 1)).toEqual([]);
|
||||
});
|
||||
|
||||
test('runtime = "client" produces no error-severity diagnostic', () => {
|
||||
const diagnostics = documentDiagnostics({
|
||||
uri: "file:///runtime-client.wrn",
|
||||
text: `page P {
|
||||
runtime = "client"
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
expect(diagnostics.filter((item) => item.severity === 1)).toEqual([]);
|
||||
});
|
||||
|
||||
test("client function inside functions { } produces no error-severity diagnostic", () => {
|
||||
const diagnostics = documentDiagnostics({
|
||||
uri: "file:///client-function.wrn",
|
||||
text: `page P {
|
||||
functions { client function go() { console.log(1) } }
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
expect(diagnostics.filter((item) => item.severity === 1)).toEqual([]);
|
||||
});
|
||||
@@ -685,7 +685,7 @@ export function parse(source: string): PageAst {
|
||||
}
|
||||
if (lx.peek().type === "lbrace") {
|
||||
throw new ParseError(
|
||||
`"${rawMode} { … }" data blocks were removed. Declare API calls in a page-level "apis { }" block, and move mode-scoped helpers into "functions { shared function … }".`,
|
||||
`"${rawMode} { … }" data blocks were removed. Declare API calls in a page-level "apis { }" block, and move mode-scoped helpers into "functions { shared function … }" (at offset ${kw.pos}).`,
|
||||
);
|
||||
}
|
||||
throw new ParseError(`Expected a ${rawMode} state block or hydrate assignment`);
|
||||
|
||||
@@ -280,6 +280,22 @@ export function virtualTypeScriptModule(
|
||||
`${fn.name}: (${fn.parameters.map((param) => `${param.name}${param.optional ? "?" : ""}: ${param.valueType ?? "unknown"}`).join(", ")}) => ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")}`,
|
||||
)
|
||||
.join("; ");
|
||||
// `apis { }` introduces an `api.<name>(...)` binding, callable from server,
|
||||
// client, and shared functions alike (see codegen.ts/client-codegen.ts). It
|
||||
// must be typed here or every `api.<name>()` call raises a false "Cannot
|
||||
// find name 'api'" plus a knock-on implicit-any on its result. A page with
|
||||
// no `apis { }` block must NOT get this binding — that would mask the
|
||||
// legitimate "Cannot find name 'api'" error on a stray reference, and would
|
||||
// shadow the otherwise-legal `state api`.
|
||||
const apiType = ast.dataApis
|
||||
.map((block) => {
|
||||
const fields = block.sections ? [...block.sections.parameters, ...block.sections.body] : [];
|
||||
const inputType = fields.length
|
||||
? `{ ${fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join("; ")} }`
|
||||
: "Record<string, never>";
|
||||
return `${block.name}: (input${fields.length ? "" : "?"}: ${inputType}) => Promise<any>`;
|
||||
})
|
||||
.join("; ");
|
||||
append(`declare const output: { [name: string]: (...args: any[]) => void; ${outputType} };`);
|
||||
for (const output of ast.outputs)
|
||||
append(
|
||||
@@ -288,6 +304,7 @@ export function virtualTypeScriptModule(
|
||||
for (const match of source.matchAll(/@event\s+([A-Za-z_$][\w$]*)\s*=\s*function/g))
|
||||
append(`declare const ${match[1]}: ((...args: unknown[]) => void) | undefined;`);
|
||||
append(`declare const server: { ${serverType} };`);
|
||||
if (ast.dataApis.length) append(`declare const api: { ${apiType} };`);
|
||||
append(`declare const props: Readonly<${ast.name}Props>;`);
|
||||
append("declare const refs: Record<string, Element | null>;");
|
||||
append(
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { checkWrnSource } from "../src/index.ts";
|
||||
|
||||
function app(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrn-typecheck-api-"));
|
||||
mkdirSync(join(root, "app", "types"), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
test("api.<name>(...) in a client function produces no diagnostic", () => {
|
||||
const root = app();
|
||||
const source = `page Demo {
|
||||
apis {
|
||||
searchDirectory POST /api/directory {
|
||||
request {
|
||||
body {
|
||||
name?: string
|
||||
}
|
||||
}
|
||||
|
||||
response {
|
||||
return data.data.users
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
functions {
|
||||
client async function search(): Promise<void> {
|
||||
await api.searchDirectory({ name: "a" })
|
||||
}
|
||||
}
|
||||
|
||||
view { <div></div> }
|
||||
}`;
|
||||
const diagnostics = checkWrnSource(source, {
|
||||
appRoot: root,
|
||||
filePath: join(root, "app", "pages", "Demo.wrn"),
|
||||
});
|
||||
expect(diagnostics.some((d) => /Cannot find name 'api'/.test(d.message))).toBe(false);
|
||||
expect(diagnostics).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("the implicit-any knock-on from an undeclared api binding is gone", () => {
|
||||
const root = app();
|
||||
const source = `page Demo {
|
||||
apis {
|
||||
searchDirectory POST /api/directory {
|
||||
request {
|
||||
body {
|
||||
name?: string
|
||||
}
|
||||
}
|
||||
|
||||
response {
|
||||
return data.data.users
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
functions {
|
||||
client async function search(): Promise<void> {
|
||||
const result = await api.searchDirectory({ name: "a" })
|
||||
}
|
||||
}
|
||||
|
||||
view { <div></div> }
|
||||
}`;
|
||||
const diagnostics = checkWrnSource(source, {
|
||||
appRoot: root,
|
||||
filePath: join(root, "app", "pages", "Demo.wrn"),
|
||||
});
|
||||
expect(diagnostics.some((d) => d.code === "WRN-TYPE-7006")).toBe(false);
|
||||
});
|
||||
|
||||
test("a page without an apis {} block still errors on a bare api. reference", () => {
|
||||
const root = app();
|
||||
const source = `page Demo {
|
||||
functions {
|
||||
client async function search(): Promise<void> {
|
||||
await api.searchDirectory({ name: "a" })
|
||||
}
|
||||
}
|
||||
|
||||
view { <div></div> }
|
||||
}`;
|
||||
const diagnostics = checkWrnSource(source, {
|
||||
appRoot: root,
|
||||
filePath: join(root, "app", "pages", "Demo.wrn"),
|
||||
});
|
||||
expect(diagnostics.some((d) => /Cannot find name 'api'/.test(d.message))).toBe(true);
|
||||
});
|
||||
|
||||
test("state api on a page without an apis {} block stays legal", () => {
|
||||
const root = app();
|
||||
const source = `page Demo {
|
||||
state api = "idle"
|
||||
|
||||
functions {
|
||||
client function reset(): void {
|
||||
api = "idle"
|
||||
}
|
||||
}
|
||||
|
||||
view { <div>{api}</div> }
|
||||
}`;
|
||||
const diagnostics = checkWrnSource(source, {
|
||||
appRoot: root,
|
||||
filePath: join(root, "app", "pages", "Demo.wrn"),
|
||||
});
|
||||
expect(diagnostics.some((d) => /Cannot find name 'api'/.test(d.message))).toBe(false);
|
||||
});
|
||||
|
||||
test("calling an undeclared block name on api errors", () => {
|
||||
const root = app();
|
||||
const source = `page Demo {
|
||||
apis {
|
||||
searchDirectory POST /api/directory {
|
||||
request {
|
||||
body {
|
||||
name?: string
|
||||
}
|
||||
}
|
||||
|
||||
response {
|
||||
return data.data.users
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
functions {
|
||||
client async function search(): Promise<void> {
|
||||
await api.nope({ name: "a" })
|
||||
}
|
||||
}
|
||||
|
||||
view { <div></div> }
|
||||
}`;
|
||||
const diagnostics = checkWrnSource(source, {
|
||||
appRoot: root,
|
||||
filePath: join(root, "app", "pages", "Demo.wrn"),
|
||||
});
|
||||
expect(diagnostics.some((d) => /Property 'nope' does not exist/.test(d.message))).toBe(true);
|
||||
});
|
||||
Reference in New Issue
Block a user