From 87a00de5f356c18682012513f15e5b1537d57389 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Thu, 20 Aug 2026 02:28:23 +0530 Subject: [PATCH] feat(syntax): parse the apis container block Co-Authored-By: Claude Opus 5 --- editors/vscode/src/compiler.cjs | 47 +++++++++++++++- editors/vscode/src/extension.bundle.cjs | 2 +- editors/vscode/src/language-server.cjs | 39 +++++++++++++- packages/syntax/src/api-sections.ts | 48 +++++++++++++++++ packages/syntax/src/parser.ts | 20 ++++++- packages/syntax/test/apis-block.test.ts | 71 +++++++++++++++++++++++++ 6 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 packages/syntax/test/apis-block.test.ts diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs index 3f695cd8..3bfd42e9 100644 --- a/editors/vscode/src/compiler.cjs +++ b/editors/vscode/src/compiler.cjs @@ -1,6 +1,6 @@ "use strict"; // Generated by scripts/build-editor-compiler.mjs. Do not edit directly. -// WRN editor compiler source hash: 59b39cb658ef79c3ee7deb2147a92ac8bbb6c4427d732bf1de63e489c2471df8 +// WRN editor compiler source hash: ed63852e856493749abcfd8160c27519e4b7e0d38e93e2b5cf64e94e29bf581f // WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8 // Generated with TypeScript: 6.0.3 const __nodeRequire = require; @@ -4659,6 +4659,7 @@ function generateDeclarations(ast) { Object.defineProperty(exports, "__esModule", { value: true }); exports.parseApiSections = parseApiSections; exports.hasRequestSection = hasRequestSection; +exports.parseApiEntries = parseApiEntries; /** * Parse the sectioned form of an `api` block body. * @@ -4797,6 +4798,39 @@ function parseApiSections(source) { function hasRequestSection(source) { return scanTopLevelBlocks(source, SECTION_NAMES).has("request"); } +const emptySections = { parameters: [], body: [], response: "", error: "" }; +/** + * Parse the body of a page-level `apis { }` container: a sequence of + * ` { ... }` entries with no leading `api` keyword + * (the container supplies it). Each entry's braces are sliced with the + * tokenizer's own `Lexer.readBalancedBraces()`, so the same string- and + * comment-aware rules that protect `parseApiSections` apply here too. + */ +function parseApiEntries(source) { + const entries = []; + const lx = new tokenizer_ts_1.Lexer(source); + while (lx.peek().type !== "eof") { + const nameToken = lx.next(); + if (nameToken.type !== "ident") { + throw new tokenizer_ts_1.LexError(`Expected an api entry name at offset ${nameToken.pos}`); + } + const methodToken = lx.next(); + if (methodToken.type !== "ident") { + throw new tokenizer_ts_1.LexError(`Expected an HTTP method after "${nameToken.value}"`); + } + const path = lx.readPath(); + const entryBody = lx.readBalancedBraces(); + entries.push({ + mode: "any", + name: nameToken.value, + method: methodToken.value.toUpperCase(), + path, + body: "", + sections: parseApiSections(entryBody) ?? emptySections, + }); + } + return entries; +} }, "packages/syntax/src/diagnostics.ts": function (module, exports, require, __filename, __dirname) { @@ -6756,6 +6790,17 @@ function parse(source) { persist = (0, v060_ts_1.parsePersist)(lx.readBalancedBraces()); break; } + case "apis": { + lx.next(); + const body = lx.readBalancedBraces(); + for (const entry of (0, api_sections_ts_1.parseApiEntries)(body)) { + if (dataApis.some((block) => block.name === entry.name)) { + throw new ParseError(`Duplicate api entry "${entry.name}" in apis block`); + } + dataApis.push(entry); + } + break; + } default: throw new ParseError(`Unknown page member '${kw.value}' at offset ${kw.pos}`); } diff --git a/editors/vscode/src/extension.bundle.cjs b/editors/vscode/src/extension.bundle.cjs index a235ecaf..10e361fb 100644 --- a/editors/vscode/src/extension.bundle.cjs +++ b/editors/vscode/src/extension.bundle.cjs @@ -1,4 +1,4 @@ -// WRN editor extension source hash: 0192e263f6213e194dc903c4c4d6436e4ab05cbfe06d2d865a3de7ca1507d3d6 +// WRN editor extension source hash: e37604bc3978c372d7b76ee019cb883a5c284877a9be16e8740d7e1bddfde5c4 // WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728 "use strict"; var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); diff --git a/editors/vscode/src/language-server.cjs b/editors/vscode/src/language-server.cjs index 2d7835a4..4e175a19 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: 8f158f0d23d75de9b08a12cef6e20d2e5cdb48b6242db3b724634880277acd8c +// WRN editor language server source hash: a5d091c01e106fd98dba6ef8bda030e821bf1f04ebeb63a6d12cc2d0783e93e2 // WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72 // @bun @bun-cjs (function(exports, require, module, __filename, __dirname) {var __create = Object.create; @@ -170869,6 +170869,32 @@ function parseApiSections(source) { function hasRequestSection(source) { return scanTopLevelBlocks(source, SECTION_NAMES).has("request"); } +var emptySections = { parameters: [], body: [], response: "", error: "" }; +function parseApiEntries(source) { + const entries = []; + const lx = new Lexer(source); + while (lx.peek().type !== "eof") { + const nameToken = lx.next(); + if (nameToken.type !== "ident") { + throw new LexError(`Expected an api entry name at offset ${nameToken.pos}`); + } + const methodToken = lx.next(); + if (methodToken.type !== "ident") { + throw new LexError(`Expected an HTTP method after "${nameToken.value}"`); + } + const path = lx.readPath(); + const entryBody = lx.readBalancedBraces(); + entries.push({ + mode: "any", + name: nameToken.value, + method: methodToken.value.toUpperCase(), + path, + body: "", + sections: parseApiSections(entryBody) ?? emptySections + }); + } + return entries; +} // packages/syntax/src/types.ts function runtimeTypeOf(annotation) { @@ -171881,6 +171907,17 @@ function parse(source) { persist = parsePersist(lx.readBalancedBraces()); break; } + case "apis": { + lx.next(); + const body = lx.readBalancedBraces(); + for (const entry of parseApiEntries(body)) { + if (dataApis.some((block) => block.name === entry.name)) { + throw new ParseError(`Duplicate api entry "${entry.name}" in apis block`); + } + dataApis.push(entry); + } + break; + } default: throw new ParseError(`Unknown page member '${kw.value}' at offset ${kw.pos}`); } diff --git a/packages/syntax/src/api-sections.ts b/packages/syntax/src/api-sections.ts index 67b02928..61396e9d 100644 --- a/packages/syntax/src/api-sections.ts +++ b/packages/syntax/src/api-sections.ts @@ -171,3 +171,51 @@ export function parseApiSections(source: string): ApiSections | null { export function hasRequestSection(source: string): boolean { return scanTopLevelBlocks(source, SECTION_NAMES).has("request"); } + +const emptySections: ApiSections = { parameters: [], body: [], response: "", error: "" }; + +/** Shape of an entry parsed from an `apis { }` container body. */ +export interface ApiEntry { + mode: "any"; + name: string; + method: string; + path: string; + body: string; + sections: ApiSections; +} + +/** + * Parse the body of a page-level `apis { }` container: a sequence of + * ` { ... }` entries with no leading `api` keyword + * (the container supplies it). Each entry's braces are sliced with the + * tokenizer's own `Lexer.readBalancedBraces()`, so the same string- and + * comment-aware rules that protect `parseApiSections` apply here too. + */ +export function parseApiEntries(source: string): ApiEntry[] { + const entries: ApiEntry[] = []; + const lx = new Lexer(source); + + while (lx.peek().type !== "eof") { + const nameToken = lx.next(); + if (nameToken.type !== "ident") { + throw new LexError(`Expected an api entry name at offset ${nameToken.pos}`); + } + const methodToken = lx.next(); + if (methodToken.type !== "ident") { + throw new LexError(`Expected an HTTP method after "${nameToken.value}"`); + } + const path = lx.readPath(); + const entryBody = lx.readBalancedBraces(); + + entries.push({ + mode: "any", + name: nameToken.value, + method: methodToken.value.toUpperCase(), + path, + body: "", + sections: parseApiSections(entryBody) ?? emptySections, + }); + } + + return entries; +} diff --git a/packages/syntax/src/parser.ts b/packages/syntax/src/parser.ts index 576f871d..40662b19 100644 --- a/packages/syntax/src/parser.ts +++ b/packages/syntax/src/parser.ts @@ -1,5 +1,10 @@ import { WRN_RUNTIME_TARGETS } from "./spec.ts"; -import { parseApiSections, hasRequestSection, type ApiSections } from "./api-sections.ts"; +import { + parseApiSections, + parseApiEntries, + hasRequestSection, + type ApiSections, +} from "./api-sections.ts"; /** * Recursive-descent parser for `.wrn`, producing a small AST. @@ -142,7 +147,7 @@ export interface ApiBlock { export type SeoBlock = Record; -export type DataMode = "ssr" | "client"; +export type DataMode = "ssr" | "client" | "any"; export interface DataApiBlock { mode: DataMode; @@ -845,6 +850,17 @@ export function parse(source: string): PageAst { persist = parsePersist(lx.readBalancedBraces()); break; } + case "apis": { + lx.next(); + const body = lx.readBalancedBraces(); + for (const entry of parseApiEntries(body)) { + if (dataApis.some((block) => block.name === entry.name)) { + throw new ParseError(`Duplicate api entry "${entry.name}" in apis block`); + } + dataApis.push(entry); + } + break; + } default: throw new ParseError(`Unknown page member '${kw.value}' at offset ${kw.pos}`); } diff --git a/packages/syntax/test/apis-block.test.ts b/packages/syntax/test/apis-block.test.ts new file mode 100644 index 00000000..c48eb35d --- /dev/null +++ b/packages/syntax/test/apis-block.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from "bun:test"; +import { parse } from "../src/index.ts"; + +const page = (inner: string) => `page Repro { + apis { +${inner} + } + + view {
x
} +} +`; + +test("parses a mode-less entry with its sections", () => { + const ast = parse( + page(` searchUsers POST /api/users { + request { + body { + name?: string + } + } + + response { return data.users } + error { return [] } + }`), + ); + + const block = ast.dataApis[0]!; + expect(block.name).toBe("searchUsers"); + expect(block.method).toBe("POST"); + expect(block.path).toBe("/api/users"); + expect(block.mode).toBe("any"); + expect(block.sections?.body).toEqual([{ name: "name", optional: true, type: "string" }]); + expect(block.sections?.response.trim()).toBe("return data.users"); + expect(block.sections?.error.trim()).toBe("return []"); +}); + +test("parses several entries in one container", () => { + const ast = parse( + page(` a GET /api/a { response { return data } } + b POST /api/b { response { return data } }`), + ); + + expect(ast.dataApis.map((block) => block.name)).toEqual(["a", "b"]); +}); + +test("a GET entry declares parameters", () => { + const ast = parse( + page(` listTeams GET /api/teams { + request { + parameters { + team: string + } + } + + response { return data.teams } + }`), + ); + + expect(ast.dataApis[0]!.sections?.parameters).toEqual([ + { name: "team", optional: false, type: "string" }, + ]); +}); + +test("duplicate names inside one container are rejected", () => { + expect(() => + parse( + page(` dup GET /api/a { response { return data } } + dup POST /api/b { response { return data } }`), + ), + ).toThrow(/duplicate/i); +});