Files
WRNexusJS/packages/syntax/src/api-sections.ts
T
ClintchizandClaude Opus 5 953b1cd692 fix(syntax): reject bare apis-container bodies and cross-mode duplicate api names
Bare bodies inside apis {} silently discarded their text with no error,
producing a do-nothing block. They now throw a ParseError naming the entry
and pointing at the response {} section. Duplicate-name detection for
dataApis moved from an incremental, order-dependent check (only saw prior
entries in the array) to a single post-parse pass over the whole ast.dataApis,
so it catches cross-mode duplicates (apis {} vs ssr { api }) regardless of
declaration order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 02:33:05 +05:30

226 lines
6.7 KiB
TypeScript

/**
* Parse the sectioned form of an `api` block body.
*
* Returns null when no section keyword is present, which is how the legacy
* bare-body form stays valid: the caller keeps treating the body as the
* response expression.
*
* Detection and slicing both drive the tokenizer's own string/comment-aware
* scanning (`skipLiteralOrComment`, `Lexer.readBalancedBraces`) instead of a
* second hand-rolled brace counter, so a `}` inside a string or a `request {`
* mentioned in a comment can't be mistaken for a real section.
*/
import { Lexer, LexError, isIdentPart, isIdentStart, skipLiteralOrComment } from "./tokenizer.ts";
export interface ApiFieldDecl {
name: string;
optional: boolean;
type: string;
}
export interface ApiSections {
parameters: ApiFieldDecl[];
body: ApiFieldDecl[];
response: string;
error: string;
}
const SECTION_NAMES = ["request", "response", "error"] as const;
const REQUEST_SUBSECTION_NAMES = ["parameters", "body"] as const;
interface Span {
text: string;
/** Offset of `text[0]` within the source that was scanned. */
start: number;
}
/**
* Walk `source` at brace-depth 0, looking for `name { ... }` where `name` is
* one of `names`. Strings, template literals, and comments are skipped via
* `skipLiteralOrComment` — the same rules `readBalancedBraces` uses — so a
* keyword mentioned inside a string or comment, or nested inside an unrelated
* `{ }` (e.g. an object literal in a legacy body), is never mistaken for a
* section. Matched blocks are sliced via `Lexer.readBalancedBraces()` itself,
* not a reimplementation of it.
*/
function scanTopLevelBlocks(source: string, names: readonly string[]): Map<string, Span> {
const found = new Map<string, Span>();
const lx = new Lexer(source);
let depth = 0;
let i = 0;
let atLineStart = true;
while (i < source.length) {
const c = source[i]!;
if (c === "\n") {
atLineStart = true;
i++;
continue;
}
const skipped = skipLiteralOrComment(source, i, atLineStart);
if (skipped !== null) {
i = skipped;
atLineStart = false;
continue;
}
if (c !== " " && c !== "\t" && c !== "\r") atLineStart = false;
if (depth === 0 && isIdentStart(c)) {
let j = i + 1;
while (j < source.length && isIdentPart(source[j]!)) j++;
const word = source.slice(i, j);
// Skip trivia between the identifier and a possible '{' without
// treating anything in between as significant yet.
let k = j;
let lineStartAtK = false;
while (k < source.length) {
const kc = source[k]!;
if (kc === " " || kc === "\t" || kc === "\r") {
k++;
continue;
}
if (kc === "\n") {
lineStartAtK = true;
k++;
continue;
}
const kSkipped = skipLiteralOrComment(source, k, lineStartAtK);
if (kSkipped !== null) {
k = kSkipped;
lineStartAtK = false;
continue;
}
break;
}
if (names.includes(word) && source[k] === "{") {
lx.pos = k;
const start = k + 1;
const text = lx.readBalancedBraces();
if (!found.has(word)) found.set(word, { text, start });
i = lx.pos;
continue;
}
i = j;
continue;
}
if (c === "{") depth++;
else if (c === "}") depth = Math.max(0, depth - 1);
i++;
}
return found;
}
/** Rebase a span captured from `outer.text` back onto the original source. */
function absolutize(span: Span | undefined, outer: Span | undefined): Span | undefined {
if (!span) return undefined;
return outer ? { text: span.text, start: outer.start + span.start } : span;
}
/** `name?: string` -> { name, optional, type }. Blank lines and comments are skipped. */
function parseFields(span: Span | undefined): ApiFieldDecl[] {
if (!span) return [];
const fields: ApiFieldDecl[] = [];
let cursor = 0;
for (const rawLine of span.text.split("\n")) {
const lineOffset = span.start + cursor;
cursor += rawLine.length + 1;
const line = rawLine.trim().replace(/,$/, "");
if (!line || line.startsWith("//")) continue;
const match = /^([A-Za-z_$][A-Za-z0-9_$]*)(\?)?\s*:\s*(.+)$/.exec(line);
if (!match) {
throw new LexError(
`Expected "name: type" in an api request section, got "${line}" at offset ${lineOffset}`,
);
}
fields.push({ name: match[1]!, optional: match[2] === "?", type: match[3]!.trim() });
}
return fields;
}
export function parseApiSections(source: string): ApiSections | null {
const top = scanTopLevelBlocks(source, SECTION_NAMES);
if (top.size === 0) return null;
const request = top.get("request");
const sub = request
? scanTopLevelBlocks(request.text, REQUEST_SUBSECTION_NAMES)
: new Map<string, Span>();
return {
parameters: parseFields(absolutize(sub.get("parameters"), request)),
body: parseFields(absolutize(sub.get("body"), request)),
response: top.get("response")?.text ?? "",
error: top.get("error")?.text ?? "",
};
}
/** True when the block declares a `request` section. */
export function hasRequestSection(source: string): boolean {
return scanTopLevelBlocks(source, SECTION_NAMES).has("request");
}
/** 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
* `<name> <METHOD> <path> { ... }` 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();
const sections = parseApiSections(entryBody);
if (sections === null) {
throw new LexError(
`Api entry "${nameToken.value}" has a bare body; declare a "response { }" section instead`,
);
}
entries.push({
mode: "any",
name: nameToken.value,
method: methodToken.value.toUpperCase(),
path,
body: "",
sections,
});
}
return entries;
}