/** * 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 { const found = new Map(); 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(); 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"); }