Reuse tokenizer.readBalancedBraces string/comment skipping (extracted as skipLiteralOrComment) for both section detection and slicing, instead of a second hand-rolled brace counter. Fixes truncation on braces inside strings and false-positive sectioned detection from keywords inside comments/strings. Also switch api-sections.ts errors from plain Error to LexError so parser.ts upgrades them to ParseError with an offset. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
174 lines
5.2 KiB
TypeScript
174 lines
5.2 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");
|
|
}
|