// V19a / V19a-T — the whole-`.theta`/`.thetalib` program-parser seam. // // This module owns the parser seam the paired `V19a` implementation leaf fills // in: `parseThetaDocument(source, deps)` parses the *entire* `.theta` / `.thetalib` // file into an executable body statement-list AST — the grammar.md // §"Block expressions" `ThetaBody ::= Stmt* Expr?` production — alongside the // parsed frontmatter, and aggregates the whole-file multi-error diagnostic set // by delegating each top-level statement / declaration to the existing V-slice // parse-checkers over the real AST (`cka-49`, // implementation-notes.md §Parser *Contract*). // // The body AST this seam produces is the node stream `V19c`'s statement // executor walks and `V19e`'s composition producer parses; the AST node types // declared here are that cross-leaf contract. // // V19a-T (tests-task) declares the AST node shapes and stubs `parseThetaDocument` // inertly: it returns `{ frontmatter: null, body: { statements: [], tail: null }, // diagnostics: [] }` regardless of input. Every paired V19a-T test therefore // reds on its own primary assertion — an empty body where a `LetStmt` / // `IfStmt` / `SchemaDecl` / … node was expected, a missing tail `Expr`, a wrong // statement count where newline-continuation should have joined (or split) a // statement, or an empty `diagnostics` array where the delegated checkers should // have aggregated multiple sorted errors — not on a compile error, a missing // fixture, or a harness throw. The paired V19a implementation leaf fills the // parser in. // // Spec: implementation-notes.md (§Parser *Contract*), grammar.md // (§"Block expressions", §"fn declarations", §"schema X by ", // §"/// placement", §"Newline continuation"), bindings.md, control-flow.md, // functions.md, return.md, expressions.md, frontmatter.md, descriptions.md, // schemas.md, imports.md, invocation.md, diagnostics.md. import type { Diagnostic, Position, SourceRange } from "../diagnostics/diagnostic"; import { assembleDiagnostics } from "../diagnostics/diagnostic"; import { lexTheta, type ThetaSource, type Token } from "../lexer/lexer"; import { validatePathLiteral } from "../lexer/literals"; import { checkThetaLibTopLevelForm, type ImportSpecifier, type ThetaLibTopLevelForm, } from "./imports"; import type { SystemNoteChannelDeps } from "../extension/system-note-channel"; import { parseFrontmatter, type FrontmatterBodyTypes, type ModelReferenceMatcher, type ParsedFrontmatter, type ParsedToolLoop, type ParsedRespondRepair, } from "./frontmatter"; import { checkReassignment, checkLetBinding, checkAssignmentTarget, checkMutModifier, } from "./bindings"; import { checkDocCommentPlacement } from "./descriptions"; import { checkBreakStatement, checkContinueStatement } from "./control-flow"; import { checkFnPlacement, checkFunctionReference, checkBareReturn, checkUnreachableCode, } from "./functions"; import { checkObjectSchema, checkEnumDeclaration, checkInlineEnumForm, checkVariantAccess, type EnumValueKind, type EnumVariantDecl, } from "./schema-declarations"; import { parseTypeExpression } from "./type-grammar"; import { checkObjectLiteralFields } from "./literal-sublanguage"; import { checkTypeLayer } from "./type-layer-checks"; import { resolveQuerySchemas } from "./query-schema-resolve"; import { buildBodyTypeSchemas } from "./body-type-lowering"; // QRY-19 lives in the runtime discard module (it owns the discarded-query // discipline shared with the QRY-20 runtime obligation); the parser reuses its // pure parse-time check rather than re-deriving the diagnostic. Parser→runtime // type/pure-function imports are an established pattern (system-interpolation, // type-layer-checks). import { checkDiscardedQueryResult } from "../runtime/query-discard"; // A `@`-query template body is captured verbatim at parse time; its `${…}` // interpolations are re-lexed here (the same lexer the render path drives) so // the parse-time whole-document walk can reject the forms expressions.md // §"Not supported" forbids inside `${…}` (a nested `match` or `@`-query). import { lexQueryTemplate } from "../render/query-render"; // -------------------------------------------------------------------------- // Expression AST (the `Expr` node family; grammar.md §Expression sublanguage) // -------------------------------------------------------------------------- /** Common fields carried by every AST node: its source span. */ export interface NodeBase { readonly range: SourceRange; } /** An identifier reference expression. */ export interface IdentExpr extends NodeBase { readonly kind: "ident"; readonly name: string; } /** A numeric literal expression. */ export interface NumberExpr extends NodeBase { readonly kind: "number"; readonly text: string; readonly numericType: "integer" | "number"; } /** A string literal expression (decoded value). */ export interface StringExpr extends NodeBase { readonly kind: "string"; readonly value: string; } /** A boolean literal expression. */ export interface BoolExpr extends NodeBase { readonly kind: "bool"; readonly value: boolean; } /** The `null` literal expression. */ export interface NullExpr extends NodeBase { readonly kind: "null"; } /** An array-construction literal (`[e, ...]`). */ export interface ArrayExpr extends NodeBase { readonly kind: "array"; readonly elements: readonly Expr[]; } /** A binary-operator expression (`a + b`, `a && b`, …). */ export interface BinaryExpr extends NodeBase { readonly kind: "binary"; readonly op: string; readonly left: Expr; readonly right: Expr; } /** A ternary-conditional expression (`cond ? a : b`). */ export interface TernaryExpr extends NodeBase { readonly kind: "ternary"; readonly condition: Expr; readonly consequent: Expr; readonly alternate: Expr; } /** A postfix error-propagation expression (`operand?`; ERR-18). */ export interface TryExpr extends NodeBase { readonly kind: "try"; readonly operand: Expr; } /** A code-tool call expression `(args)` (tool-calls.md). */ export interface CallExpr extends NodeBase { readonly kind: "call"; readonly callee: string; readonly args: readonly Expr[]; } /** An `invoke(...)` / `invoke(...)` call expression (invocation.md). */ export interface InvokeExpr extends NodeBase { readonly kind: "invoke"; /** The literal callee path (`invoke("./x.theta", ...)`). */ readonly path: string; /** * The `invoke` return-type annotation text (`"number"`, `"Plan"`, …), * or `null` for an untyped `invoke(...)`. Feeds the runtime AJV return-value * validation (invocation.md §Typed return; hard-ceilings ceiling #4). */ readonly returnSchema: string | null; readonly args: readonly Expr[]; } /** An `@`…`` model-query expression (query.md). */ export interface QueryExpr extends NodeBase { readonly kind: "query"; /** The explicit `@` annotation, when present. */ readonly schema: string | null; /** The raw template body between the backticks. */ readonly template: string; } /** A postfix member-access expression `target.field` (expressions.md §"Member access"). */ export interface MemberExpr extends NodeBase { readonly kind: "member"; readonly target: Expr; readonly field: string; } /** A postfix index expression `target[index]` (expressions.md §"Index access"). */ export interface IndexExpr extends NodeBase { readonly kind: "index"; readonly target: Expr; readonly index: Expr; } /** One `field: value` entry of an object-literal expression. */ export interface ObjectFieldNode { readonly name: string; readonly value: Expr; } /** * An object-literal / schema-constructor expression (grammar.md §"Theta literal * sublanguage" `BareObjectLit` / `NamedObjectLit`; expressions.md §"Object * construction"). `typeName` is the schema constructor name for `Ident { … }`, * or `null` for a bare `{ … }` object literal. */ export interface ObjectExpr extends NodeBase { readonly kind: "object"; readonly typeName: string | null; readonly fields: readonly ObjectFieldNode[]; } /** * One of the six theta 1.0 `match` pattern forms (expressions.md §"Pattern * grammar (theta 1.0)"). Mirrors the runtime `Pattern` model of * `../runtime/match-result.ts`; the executor maps this parse-shape onto that * runtime shape. A literal pattern carries the primitive literal value; an * object pattern's `typeName` (the schema constructor name) is retained for * diagnostics but ignored by runtime dispatch. */ export type PatternNode = | { readonly kind: "wildcard" } | { readonly kind: "identifier"; readonly name: string } | { readonly kind: "literal"; readonly value: string | number | boolean | null } | { readonly kind: "constructor"; readonly ctor: "Ok" | "Err"; readonly inner: PatternNode } | { readonly kind: "object"; readonly typeName: string | null; readonly fields: readonly { readonly name: string; readonly pattern: PatternNode }[]; } | { readonly kind: "array"; readonly elements: readonly PatternNode[] }; /** One `Pattern "=>" ArmBody` arm of a `match` expression. */ export interface MatchArmNode { readonly pattern: PatternNode; readonly body: Expr; } /** * A `match` expression (expressions.md §`match` expression): a scrutinee and an * ordered arm list, first-matching-arm-wins. */ export interface MatchExpr extends NodeBase { readonly kind: "match"; readonly scrutinee: Expr; readonly arms: readonly MatchArmNode[]; } /** * A `Result` constructor expression `Ok(arg)` / `Err(arg)` in value position * (errors-and-results/error-model.md). A dedicated node — NOT a `call` — so the * effectful-statement-host does not misclassify it as a tool-call checkpoint; * it evaluates purely to `makeOk` / `makeErr`. */ export interface ResultCtorExpr extends NodeBase { readonly kind: "result-ctor"; readonly ctor: "Ok" | "Err"; readonly arg: Expr; } /** * A postfix method-call expression `target.method(args)` — the runtime stdlib * member surface (expressions.md §"Built-in methods and properties"). A * dedicated node — NOT a `call` — so the effectful-statement-host treats it as * pure, not a tool-call checkpoint. */ export interface MethodCallExpr extends NodeBase { readonly kind: "method-call"; readonly target: Expr; readonly method: string; readonly args: readonly Expr[]; } /** * A `par for` parallel fan-out expression (RFC 0003; control-flow.md #par-for, * grammar.md `ParForExpr`). The value-producing counterpart of the `for` * statement: it evaluates its `body` concurrently for each element of `iterand` * and collects one `Result` per element in input order * (`array>`, `U` the body tail type). `max` is the * optional `MaxClause` width operand (any integer-typed expression) or `null`. * `par` is a contextual keyword recognised only immediately before `for` * (grammar.md §"Contextual keywords"); it is not reserved. */ export interface ParForExpr extends NodeBase { readonly kind: "par-for"; readonly variable: string; readonly iterand: Expr; readonly max: Expr | null; readonly body: Block; } /** * The `Expr` node family. A tail `Expr` of a `ThetaBody` / block, a `let` * initialiser, a condition, etc. all use this union. */ export type Expr = | IdentExpr | NumberExpr | StringExpr | BoolExpr | NullExpr | ArrayExpr | BinaryExpr | TernaryExpr | TryExpr | CallExpr | InvokeExpr | QueryExpr | MemberExpr | IndexExpr | ObjectExpr | MatchExpr | ResultCtorExpr | MethodCallExpr | ParForExpr; // -------------------------------------------------------------------------- // Statement / declaration AST (the `Stmt` node family; grammar.md) // -------------------------------------------------------------------------- /** A `let` / `let mut` binding statement (`LetStmt`; bindings.md). */ export interface LetStmt extends NodeBase { readonly kind: "let"; readonly name: string; readonly mutable: boolean; /** The declared binding annotation, when present (`let x: T = …`). */ readonly annotation: string | null; readonly init: Expr | null; } /** A statement-form reassignment (`x = e`, `x += e`, …; bindings.md). */ export interface ReassignStmt extends NodeBase { readonly kind: "reassign"; readonly target: string; readonly op: "=" | "+=" | "-=" | "*=" | "/=" | "%="; readonly value: Expr; } /** A statement-form `if` / `else` (`IfStmt`; control-flow.md). */ export interface IfStmt extends NodeBase { readonly kind: "if"; readonly condition: Expr; readonly then: Block; /** The `else` arm: a chained `IfStmt`, an `else` `Block`, or none. */ readonly otherwise: IfStmt | Block | null; } /** A statement-form `while` loop (`WhileStmt`; control-flow.md). */ export interface WhileStmt extends NodeBase { readonly kind: "while"; readonly condition: Expr; readonly body: Block; } /** A statement-form `for … in` loop (`ForStmt`; control-flow.md). */ export interface ForStmt extends NodeBase { readonly kind: "for"; readonly variable: string; readonly iterand: Expr; readonly body: Block; } /** A `break` statement (control-flow.md). */ export interface BreakStmt extends NodeBase { readonly kind: "break"; /** * `true` when the `break` is followed by a value operand on the same logical * line (`break expr`), which theta 1.0 forbids. Marked at parse time so the * structural checker can raise `theta/parse/break-with-value`. */ readonly hasValue?: boolean; } /** A `continue` statement (control-flow.md). */ export interface ContinueStmt extends NodeBase { readonly kind: "continue"; } /** A single `fn` parameter (`Ident ":" Type`). */ export interface FnParam { readonly name: string; readonly type: string; } /** * One `with { … }` session-config field of a `subagent fn` (RFC 0001; grammar.md * `WithField`). `key` is the field name as written; `value` is the raw value * expression, validated against the like-named frontmatter field's grammar * (FN-7). A key outside the five recognised keys still records here so * `withClauseKeys`-style consumers observe it, but also surfaces * `theta/load/unknown-frontmatter-field` at parse time. */ export interface WithField { readonly key: string; readonly value: Expr; } /** * The `with { … }` session-config clause of a `subagent fn` (RFC 0001; grammar.md * `WithClause`) — the ordered list of its `WithField`s. Overrides any subset of * the five inherited session-config keys (`system`, `model`, `tools`, * `tool_loop`, `respond_repair`); an omitted key inherits from the enclosing * theta (FN-7). */ export type WithClause = readonly WithField[]; /** * The resolved session configuration a `subagent fn` call spawns its fresh * isolated session under (RFC 0001 FN-7). Computed at document-parse time by * merging the enclosing theta's inherited configuration with the `with { … }` * clause's per-key overrides. Absent keys inherit; a `.thetalib` helper carries * only its `with`-clause overrides (its inheritance resolves against the calling * theta at dispatch, FN-9). */ export interface SubagentSessionConfig { readonly model?: string; readonly tools?: readonly string[]; readonly system?: string; /** * The spawned session's tool-loop bound (RFC 0001 FN-7 `tool_loop`), inherited * from the enclosing theta's `tool_loop:` and overridable by a * `with { tool_loop: { max_rounds: N } }` clause. Absent ⇒ the runtime * `{ maxRounds: 25 }` default applies. */ readonly toolLoop?: ParsedToolLoop; /** * The spawned session's respond-repair budget (RFC 0001 FN-7 `respond_repair`), * inherited from the enclosing theta's `respond_repair:` and overridable by a * `with { respond_repair: { attempts: N } }` clause. Absent ⇒ the runtime * `{ attempts: 3 }` default applies. */ readonly respondRepair?: ParsedRespondRepair; /** * True iff a `with { tools: […] }` clause EXPLICITLY overrode the tool set * (RFC 0001 FN-7/FN-9). The production spawn seam then resolves the spawned * session's callable set to the named subset of the CALLING theta's callable * set; when `false` the spawned session inherits the calling theta's full * callable set. `tools` carries the effective names either way (for the FN-7 * inheritance witness). */ readonly toolsOverridden?: boolean; } /** A top-level `fn` declaration (`FnDecl`; functions.md). */ export interface FnDecl extends NodeBase { readonly kind: "fn"; readonly name: string; readonly params: readonly FnParam[]; readonly returnType: string | null; readonly body: Block; /** * True iff the declaration carries the `subagent` modifier (RFC 0001 FN-6): * each call spawns a fresh isolated subagent session for the body. Absent / * `false` on an ordinary `fn`. */ readonly subagent?: boolean; /** * The parsed `with { … }` session-config clause (RFC 0001 FN-7), or `null` * when the `subagent fn` carries none (every key then inherits). Always * `null` on an ordinary `fn`. */ readonly withClause?: WithClause | null; /** * The resolved session configuration a `subagent fn` call spawns under * (RFC 0001 FN-7), attached by `parseThetaDocument` after the enclosing * frontmatter is parsed (inherit-then-`with`-override). Absent on an ordinary * `fn`. */ readonly sessionConfig?: SubagentSessionConfig; } /** A `return` statement (return.md). */ export interface ReturnStmt extends NodeBase { readonly kind: "return"; readonly operand: Expr | null; } /** A query used in statement position (`@`…`` with no binding). */ export interface QueryStmt extends NodeBase { readonly kind: "query"; readonly query: QueryExpr; } /** A code-tool call in statement position (`(args)`). */ export interface ToolCallStmt extends NodeBase { readonly kind: "tool-call"; readonly call: CallExpr; } /** An `invoke(...)` call in statement position. */ export interface InvokeStmt extends NodeBase { readonly kind: "invoke"; readonly invoke: InvokeExpr; } /** A bare expression statement (its value discarded). */ export interface ExprStmt extends NodeBase { readonly kind: "expr"; readonly expr: Expr; } /** * One `schema X { … }` object-body field, as written in source: the field name * and its verbatim type-expression RHS. Retained so a typed `@` query * can resolve the named decl to its declared shape and lower it (QRY-22 / * SUBS-1); the `= …` alias and `by … = …` discriminated-union forms carry no * object field list. */ export interface SchemaFieldSource { readonly name: string; readonly typeSource: string; /** * The explicit `as "WireName"` rename when present (schemas.md §Wire-name * renaming). Absent means the wire name equals the theta-side `name`. Retained * so the runtime can apply outbound wire-name translation when an object of * this schema is interpolated into a query template (QRY-18). */ readonly wireName?: string; } /** A `schema` declaration (`SchemaDecl`; schemas.md). */ export interface SchemaDecl extends NodeBase { readonly kind: "schema"; readonly name: string; /** * The object-body field type sources, present iff the decl is the * `schema X { field: Type, … }` object form. Absent for the `= …` alias and * `by … = …` discriminated-union forms. */ readonly fields?: readonly SchemaFieldSource[]; } /** An `enum` declaration (`EnumDecl`; schemas.md). */ export interface EnumDecl extends NodeBase { readonly kind: "enum"; readonly name: string; /** * The declared variant names in source order, captured so the runtime can * register the enum and resolve `Enum.Variant` access to a first-class enum * value (runtime-value-model.md, enum row). Absent for a non-`{ … }` enum * shape the body parser could not read. */ readonly variants?: readonly string[]; /** * Explicit `= "..."` wire values keyed by variant name (schemas.md §Enum * declarations — "Explicit values override that mapping"). A variant absent * here uses its name verbatim as the wire value. Only string-literal values * are captured; a non-string explicit value is left for enum-declaration * validation and does not override the name. */ readonly variantValues?: Readonly>; /** * The full variant declarations in source order (name + explicit-value kind * and text), captured so the parse pipeline can run `checkEnumDeclaration` * (schemas.md §Enum declarations): empty body, non-string values, duplicate * variant names. Unlike `variantValues` (string wire values only) this * retains non-string explicit values so they can be rejected. Absent for a * non-`{ … }` enum shape the body parser could not read. */ readonly variantDecls?: readonly EnumVariantDecl[]; } /** An `import … from` declaration (imports.md). */ export interface ImportDecl extends NodeBase { readonly kind: "import"; readonly path: string; /** * The LOCAL binding names — the `as` alias where present, else the source name * (imports.md §Visibility). Downstream named-type / reserved-name consumers key * off the local name a `{ A as B }` specifier binds (`B`), not the raw tokens. */ readonly symbols: readonly string[]; /** The `{ source as local }` specifiers, carrying the `as`-alias mapping. */ readonly specifiers: readonly ImportSpecifier[]; } /** An `export … from` declaration (imports.md). */ export interface ExportDecl extends NodeBase { readonly kind: "export"; readonly path: string; /** The downstream-visible names — the `as` alias where present, else the source. */ readonly symbols: readonly string[]; /** The `{ source as exported }` re-export specifiers, carrying the `as`-alias mapping. */ readonly specifiers: readonly ImportSpecifier[]; } /** A `///` doc-comment run (`DocComment`; descriptions.md). */ export interface DocComment extends NodeBase { readonly kind: "doc-comment"; readonly lines: readonly string[]; } /** * The `Stmt` node family: every top-level statement and declaration kind a * `ThetaBody` admits. */ export type Stmt = | LetStmt | ReassignStmt | IfStmt | WhileStmt | ForStmt | BreakStmt | ContinueStmt | FnDecl | ReturnStmt | QueryStmt | ToolCallStmt | InvokeStmt | ExprStmt | SchemaDecl | EnumDecl | ImportDecl | ExportDecl | DocComment; /** * A statement-list block (`ThetaBody ::= Stmt* Expr?` and the `StmtBlock` * production alike): zero or more statements plus an optional tail `Expr`. */ export interface Block { readonly statements: readonly Stmt[]; readonly tail: Expr | null; } /** The `ThetaBody` top-level of a `.theta` / `.thetalib` file. */ export type ThetaBody = Block; /** The result of a whole-file parse. */ export interface ThetaDocument { /** The parsed frontmatter, or `null` when the file carries none. */ readonly frontmatter: ParsedFrontmatter | null; /** The whole-file body statement-list AST the interpreter walks. */ readonly body: ThetaBody; /** * Every diagnostic aggregated across the whole file in one pass (no * fast-fail), sorted `(file, line, col)` per * diagnostics.md §"Multi-error reporting". */ readonly diagnostics: readonly Diagnostic[]; } /** Construction dependencies the whole-file parser consumes. */ export interface ParseThetaDocumentDeps { /** The V7d producer-facing diagnostic-emission channel. */ readonly systemNote: SystemNoteChannelDeps; /** The `model:` reference matcher the frontmatter parse consults (V6a). */ readonly modelMatcher: ModelReferenceMatcher; } /** * Parse an entire `.theta` / `.thetalib` source into `{ frontmatter, body, * diagnostics }`: the whole file — not a single expression — is walked into the * executable `ThetaBody` statement-list AST, and the delegated V-slice * parse-checkers' diagnostics are aggregated in one pass, sorted `(file, line, * col)`, per implementation-notes.md §Parser *Contract* (`cka-49`). * * The whole file — not a single expression — is walked into the executable * `ThetaBody` statement-list AST; the delegated V-slice parse-checkers' * diagnostics are aggregated in one pass and sorted `(file, line, col)`. */ export function parseThetaDocument( source: ThetaSource, deps: ParseThetaDocumentDeps, ): ThetaDocument { const file = source.path; const text = decodeSource(source.bytes); // Separate the optional `---` frontmatter fence from the executable body. // A fence-less source is body-only: the load-time "frontmatter is required" // obligation is the loader's (V6*), not the whole-file body parser's, and // every V19a-T fixture supplies a bare body — so parsing frontmatter only // when a fence is present keeps a spurious `missing mode:` diagnostic out of // the aggregated set. See notes.md. const split = splitFrontmatter(text); // V1a's newline-continuation lexer is the integration witness for statement // joining: its `stmt-sep` tokens mark the boundaries at depth 0, and it // swallows the newline at every continuation trigger (open bracket, // trailing/leading operator, trailing comma). The parser splits any residual // over-joined line by grammar completion — notably the postfix `?`, which // the lexer treats as a trailing trigger but which never continues a // statement. // // The body is lexed + parsed BEFORE the frontmatter so the whole-file // named-type set (body `schema`/`enum` decls + imported symbols) is available // to the frontmatter `params:` named-type resolution and the `system:` // interpolation field checks, both of which resolve a `NamedType` whole-file // (a frontmatter → body forward reference resolves). The body parse does not // depend on the frontmatter, so the reorder is behaviour-preserving. const lex = lexTheta({ path: file, bytes: encodeSource(split.bodyText) }, deps.systemNote); const parser = new BodyParser(lex.tokens, file, split.bodyText); const body = parser.parseBody(); // The `///` doc-comment runs are lexed away (the lexer emits no comment // tokens), so they are recovered by a line scan over the body text and // merged into the statement list in source order; each run's placement is // delegated to V5c's `checkDocCommentPlacement` over the following // production. const docScan = scanDocComments(split.bodyText, file); const mergedStatements = mergeByLine(body.statements, docScan.nodes); // V13b integration — resolve each INDIRECT typed query's response schema from // its surrounding type context (QRY-2) and collect the QRY-4 explicit-schema- // mismatch warnings, BEFORE the downstream checkers and producers read // `QueryExpr.schema`. Option B (tree-rebuild): the returned body carries the // inferred `schema` on each resolvable null-schema query, so // `QueryExpr.schema: string` stays the single source of truth. The direct // `let x: T = @` fast path was already propagated by `parseLet`, so only // null-schema queries at a resolvable sink change here. const resolvedQuery = resolveQuerySchemas( { statements: mergedStatements, tail: body.tail }, file, ); const statements = resolvedQuery.body.statements; const resolvedTail = resolvedQuery.body.tail; const bodyTypes = collectBodyTypes(statements); const frontmatterDiags: Diagnostic[] = []; let frontmatter: ParsedFrontmatter | null = null; if (split.frontmatterText !== null) { // `splitFrontmatter` returns the frontmatter text with the `---` fences // stripped, but `parseFrontmatter` re-requires them (its // `extractFrontmatterBlock` matches a leading/closing `---` fence). Re-wrap // the block in fences so the frontmatter fields (`mode:` / `model:` / …) // actually parse; without this every fenced `.theta` yields `frontmatter: // null` and a spurious `theta/load/missing-mode`. See notes.md (the // frontmatter line numbers are block-relative for a fence at file line 0 — // the common case; a fence preceded by blank lines shifts them by the // blank-line count, which no current obligation asserts). const fm = parseFrontmatter(`---\n${split.frontmatterText}\n---`, { file, modelMatcher: deps.modelMatcher, bodyTypes, }); frontmatter = fm.frontmatter ?? null; frontmatterDiags.push(...fm.diagnostics); } // Run the implemented structural (AST-shape) parse-checkers over the whole // parsed body (C2a wiring): the delegated V-slice checkers that need only the // parse-shape, no type inference (control-flow, `fn` placement/first-class // use, `let` initialiser, `mut`-context member/index assignment is emitted // inline by the parser, bare `return`, unreachable code, empty object // schemas, and the position-sensitive type-grammar checks over declared type // sources). const structuralDiags = checkStructural( { statements, tail: resolvedTail }, file, ); // REQ-EXPR-7 (expressions.md §"Identifier resolution"): a bare identifier in // call or value position that resolves to nothing in scope is // `theta/parse/unknown-identifier`. The root scope folds in every whole-file // binding source (params, tools, imports, fn / schema / enum names, builtins); // theta-level `let` bindings accumulate as the block walk descends. const identRoots = collectIdentRoots(statements, frontmatter); const unknownIdentDiags = checkUnknownIdentifiers( { statements, tail: resolvedTail }, identRoots, file, ); // C-bucket wiring (V20c): run the `type`-phase checkers against the `V20b` // per-expression static-type substrate so they fire in production // (non-boolean condition, non-array iterand, `?` misuse, array/return LUB, // integer narrowing, match-arm mismatch, non-indexable / object-index / // array-join). const typeLayerDiags = checkTypeLayer({ statements, tail: resolvedTail }, file); // imports.md §"`.thetalib` file rules": a `.thetalib` top level may contain only // `import` / `export` / `schema` / `enum` / `fn` declarations; a bare // statement, a `let` binding, or a top-level query is // `theta/parse/thetalib-top-level-statement`. The check keys off the file's // `.thetalib` extension (byte-exact lowercase), so it never fires for a `.theta` // (IMP-4). const thetalibTopLevelDiags = file.endsWith(".thetalib") ? checkThetaLibTopLevel({ statements, tail: resolvedTail }, file) : []; const diagnostics = assembleDiagnostics([ frontmatterDiags, lex.diagnostics, parser.diagnostics, docScan.diagnostics, structuralDiags, unknownIdentDiags, typeLayerDiags, thetalibTopLevelDiags, resolvedQuery.diagnostics, ]); // RFC 0001 FN-7 — resolve each top-level `subagent fn`'s spawned-session // config now that the enclosing frontmatter is parsed: inherit the enclosing // theta's config, then apply the `with { … }` clause's per-key overrides. A // `.thetalib` helper's inheritance resolves against the calling theta at // dispatch (FN-9), so here it carries only its own `with`-clause overrides. const configuredStatements = attachSubagentSessionConfigs(statements, frontmatter); return { frontmatter, body: { statements: configuredStatements, tail: resolvedTail }, diagnostics, }; } /** * Attach a resolved `sessionConfig` to every top-level `subagent fn` (RFC 0001 * FN-7). Non-`fn` statements and ordinary `fn`s pass through unchanged; a * `subagent fn` is re-emitted with its inherit-then-`with`-override config so * the runtime executor reads a self-contained node. */ function attachSubagentSessionConfigs( statements: readonly Stmt[], frontmatter: ParsedFrontmatter | null, ): readonly Stmt[] { return statements.map((stmt) => { if (stmt.kind !== "fn" || stmt.subagent !== true) { return stmt; } return { ...stmt, sessionConfig: resolveSubagentSessionConfig(stmt, frontmatter) }; }); } /** * Resolve a `subagent fn`'s spawned-session config: start from the enclosing * theta's inherited `model` / `tools` / `tool_loop` / `respond_repair` (FN-7 * default), then overwrite each key named in the `with { … }` clause. All five * session-config keys take effect (FN-7): `model` / `tools` / `system` plus the * two loop budgets `tool_loop` / `respond_repair`. A `.thetalib` helper carries * a `null` frontmatter here, so it projects only its own `with`-clause overrides * — its inheritance resolves against the CALLING theta at dispatch (FN-9, * `resolveSubagentSessionConfigAt`). */ function resolveSubagentSessionConfig( fn: FnDecl, frontmatter: ParsedFrontmatter | null, ): SubagentSessionConfig { const config: { model?: string; tools?: readonly string[]; system?: string; toolLoop?: ParsedToolLoop; respondRepair?: ParsedRespondRepair; toolsOverridden?: boolean; } = {}; if (frontmatter?.model !== undefined) { config.model = frontmatter.model; } if (frontmatter?.tools !== undefined) { config.tools = frontmatter.tools; } if (frontmatter?.toolLoop !== undefined) { config.toolLoop = frontmatter.toolLoop; } if (frontmatter?.respondRepair !== undefined) { config.respondRepair = frontmatter.respondRepair; } for (const field of fn.withClause ?? []) { if (field.key === "model") { const v = stringExprValue(field.value); if (v !== undefined) { config.model = v; } } else if (field.key === "tools") { config.tools = toolNameList(field.value); config.toolsOverridden = true; } else if (field.key === "system") { const v = stringExprValue(field.value); if (v !== undefined) { config.system = v; } } else if (field.key === "tool_loop") { const loop = toolLoopValue(field.value); if (loop !== undefined) { config.toolLoop = loop; } } else if (field.key === "respond_repair") { const repair = respondRepairValue(field.value); if (repair !== undefined) { config.respondRepair = repair; } } } return config; } /** * Re-resolve a `subagent fn`'s session config against a DIFFERENT enclosing * frontmatter than the one it was parsed under (RFC 0001 FN-9). A `.thetalib` * helper has no frontmatter of its own, so its `model` / `tools` / * `tool_loop` / `respond_repair` inheritance resolves against the CALLING * theta's frontmatter at dispatch time; its `with { … }` overrides still apply * on top. For an in-file `subagent fn` (parse-time frontmatter already the * enclosing theta's) this is identical to the parse-time resolution. */ export function resolveSubagentSessionConfigAt( fn: FnDecl, callingFrontmatter: ParsedFrontmatter | null, ): SubagentSessionConfig { return resolveSubagentSessionConfig(fn, callingFrontmatter); } /** * The `{ maxRounds }` a `with { tool_loop: { max_rounds: N } }` value expression * denotes (RFC 0001 FN-7), mirroring the frontmatter `tool_loop:` block. A * non-object / absent `max_rounds` yields `undefined` (the inherited value then * stands). */ function toolLoopValue(expr: Expr): ParsedToolLoop | undefined { const maxRounds = objectFieldNumber(expr, "max_rounds"); return maxRounds === undefined ? undefined : { maxRounds }; } /** * The `{ attempts }` a `with { respond_repair: { attempts: N } }` value * expression denotes (RFC 0001 FN-7), mirroring the frontmatter * `respond_repair:` block. A non-object / absent `attempts` yields `undefined`. */ function respondRepairValue(expr: Expr): ParsedRespondRepair | undefined { const attempts = objectFieldNumber(expr, "attempts"); return attempts === undefined ? undefined : { attempts }; } /** The numeric literal value of an object-literal field `name`, else `undefined`. */ function objectFieldNumber(expr: Expr, name: string): number | undefined { if (expr.kind !== "object") { return undefined; } const field = expr.fields.find((f) => f.name === name); if (field === undefined || field.value.kind !== "number") { return undefined; } const parsed = Number(field.value.text); return Number.isFinite(parsed) ? parsed : undefined; } /** The literal string value of a `with`-clause value expression, else `undefined`. */ function stringExprValue(expr: Expr): string | undefined { return expr.kind === "string" ? expr.value : undefined; } /** * The tool-name list a `with { tools: […] }` value expression denotes: each * array element is a bare identifier (a callable name) or a `.theta`/`.thetalib` * path string literal. */ function toolNameList(expr: Expr): readonly string[] { if (expr.kind !== "array") { return []; } const names: string[] = []; for (const el of expr.elements) { if (el.kind === "ident") { names.push(el.name); } else if (el.kind === "string") { names.push(el.value); } } return names; } /** * Map a top-level `.thetalib` statement AST kind to its `ThetaLibTopLevelForm` for the * permitted-form check (imports.md §"`.thetalib` file rules"). `import` / `export` / * `schema` / `enum` / `fn` are the permitted forms; a `let` binding, a bare * query, and any other statement are non-permitted. A `///` doc-comment carries * no executable form and is not checked. */ function thetalibFormOf(stmt: Stmt): ThetaLibTopLevelForm | null { switch (stmt.kind) { case "import": return "import"; case "export": return "export"; case "schema": return "schema"; case "enum": return "enum"; case "fn": return "fn"; case "let": return "let"; case "query": return "query"; case "doc-comment": return null; default: return "statement"; } } /** * Check a `.thetalib` file's top-level forms, emitting * `theta/parse/thetalib-top-level-statement` for every non-permitted top-level form * (imports.md §"`.thetalib` file rules"). A trailing tail expression at the top * level is a bare statement and is likewise non-permitted. */ function checkThetaLibTopLevel(block: Block, file: string): Diagnostic[] { const diagnostics: Diagnostic[] = []; for (const stmt of block.statements) { const form = thetalibFormOf(stmt); if (form === null) { continue; } const diag = checkThetaLibTopLevelForm(form, { file, range: stmt.range }); if (diag !== undefined) { diagnostics.push(diag); } } if (block.tail !== null) { const diag = checkThetaLibTopLevelForm("statement", { file, range: block.tail.range, }); if (diag !== undefined) { diagnostics.push(diag); } } return diagnostics; } /** * Parse a standalone expression `source` into an `Expr`, reusing the same * `parseExpression` entry the body parser drives for a `let` RHS so a caller * (e.g. a `@`...`` template's `${…}` interpolation, expressions.md * §"Supported forms") honours the full expression sublanguage rather than a * dotted-path subset. Returns `null` when the source does not parse as a single * expression. Lex diagnostics are discarded here: a well-formed theta's * interpolation already lexed as part of the whole-file body, and a malformed * one degrades to `null` at the call site (the inline no-op channel keeps this * helper free of shared state — no module-level mutable channel). */ export function parseExpressionSource(source: string): Expr | null { const lex = lexTheta( { path: "", bytes: encodeSource(source) }, { pi: { sendMessage: () => {} }, ui: { notify: () => {} }, emitDiagnostic: () => {}, }, ); const parser = new BodyParser(lex.tokens, "", source); return parser.parseSingleExpression(); } /** * Collect the whole-file named-type set the frontmatter `params:` / `system:` * value-validations resolve a `NamedType` against: body `schema` declarations * (with their object field sources when present), body `enum` declarations, and * the symbols pulled in by body `import` declarations. Supplying the names is * sufficient to decide `theta/parse/unresolved-named-type`; the schema field * sources let the `system:` surface descend `.Ident` steps. */ function collectBodyTypes(statements: readonly Stmt[]): FrontmatterBodyTypes { const schemas = new Map(); const enums = new Set(); const imports = new Set(); const schemaDecls: SchemaDecl[] = []; const enumDecls: EnumDecl[] = []; const importNames: string[] = []; for (const stmt of statements) { if (stmt.kind === "schema") { schemas.set(stmt.name, stmt.fields); schemaDecls.push(stmt); } else if (stmt.kind === "enum") { enums.add(stmt.name); enumDecls.push(stmt); } else if (stmt.kind === "import") { for (const symbol of stmt.symbols) { imports.add(symbol); importNames.push(symbol); } } } // Lower each named type to the JSON-Schema fragment a `params:` `NamedType` // resolves to (BIND-1): schema object bodies and enum wire-value sets lower // concretely; a schema without an object body (alias / discriminated union) // and an imported symbol lower permissively to `{}` — the name still resolves, // so `theta/parse/unresolved-named-type` does not fire, and the `params:` // schema is present (not mis-classified as no-params). const lowered = buildBodyTypeSchemas(schemaDecls, enumDecls); for (const decl of schemaDecls) { if (!lowered.has(decl.name)) { lowered.set(decl.name, {}); } } for (const name of importNames) { if (!lowered.has(name)) { lowered.set(name, {}); } } return { schemas, enums, imports, lowered }; } // -------------------------------------------------------------------------- // Source decoding + frontmatter separation // -------------------------------------------------------------------------- /** Decode validated UTF-8 body bytes (skipping a BOM) and normalise newlines. */ function decodeSource(bytes: Uint8Array): string { const hasBom = bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf; const body = hasBom ? bytes.subarray(3) : bytes; return new TextDecoder("utf-8", { ignoreBOM: true }) .decode(body) .replace(/\r\n?/g, "\n"); } /** Re-encode a (already-normalised) body string for the lexer's byte input. */ function encodeSource(text: string): Uint8Array { return new TextEncoder().encode(text); } /** * Split a normalised source into its optional leading `---` frontmatter block * and the executable body. The frontmatter region is blanked (not removed) in * the returned body so body line numbers stay aligned with the original * source. Returns `frontmatterText: null` when no leading fence is present. */ function splitFrontmatter(text: string): { frontmatterText: string | null; bodyText: string; } { const lines = text.split("\n"); let open = -1; for (let i = 0; i < lines.length; i += 1) { const t = (lines[i] ?? "").trim(); if (t === "") { continue; } open = t === "---" ? i : -1; break; } if (open < 0) { return { frontmatterText: null, bodyText: text }; } let close = -1; for (let i = open + 1; i < lines.length; i += 1) { if ((lines[i] ?? "").trim() === "---") { close = i; break; } } if (close < 0) { // FM-4: an opening `---` with no closing `---` is a malformed, unterminated // frontmatter fence. frontmatter.md delimits the block with a closing // fence; an unclosed block is not a valid frontmatter mapping. Rather than // swallow the whole file as frontmatter and silently register a do-nothing // empty-body theta (dropping the author's query), yield an EMPTY frontmatter // block so `parseFrontmatter` produces `theta/load/missing-mode` and the // theta un-registers with author feedback. The closed diagnostics registry // (docs/reference/diagnostics.md) has no dedicated unterminated-fence code; // missing-mode is the documented "no recognised frontmatter mapping" // surface (see `extractFrontmatterBlock` in frontmatter.ts). return { frontmatterText: "", bodyText: lines.map(() => "").join("\n"), }; } const frontmatterText = lines.slice(open + 1, close).join("\n"); const bodyText = lines.map((l, i) => (i <= close ? "" : l)).join("\n"); return { frontmatterText, bodyText }; } // -------------------------------------------------------------------------- // `///` doc-comment line scan // -------------------------------------------------------------------------- /** * Recover `///` doc-comment runs from the body text (the lexer emits no * comment tokens) and delegate each run's placement to V5c's * `checkDocCommentPlacement` over the following production's leading keyword. */ function scanDocComments( bodyText: string, file: string, ): { nodes: DocComment[]; diagnostics: Diagnostic[] } { const lines = bodyText.split("\n"); const nodes: DocComment[] = []; const diagnostics: Diagnostic[] = []; const docLine = /^[ \t]*\/\/\/(?!\/)(.*)$/; let i = 0; while (i < lines.length) { const first = docLine.exec(lines[i] ?? ""); if (first === null) { i += 1; continue; } const startLine = i + 1; // 1-indexed const content: string[] = []; while (i < lines.length) { const m = docLine.exec(lines[i] ?? ""); if (m === null) { break; } content.push(m[1] ?? ""); i += 1; } const range: SourceRange = { start: { line: startLine, column: 1 }, end: { line: startLine, column: (lines[startLine - 1] ?? "").length + 1 }, }; nodes.push({ kind: "doc-comment", lines: content, range }); // The anchored production is the next non-blank, non-comment line's leading // word. `schema` / `enum` / `fn` are eligible anchors; every other // production (`let`, `import`, `export`, expression / control-flow // statements) is `theta/parse/doc-comment-misplaced`. let production = ""; for (let j = i; j < lines.length; j += 1) { const raw = lines[j] ?? ""; if (raw.trim() === "" || /^[ \t]*\/\//.test(raw)) { continue; } production = /^[ \t]*([A-Za-z_][A-Za-z0-9_]*)/.exec(raw)?.[1] ?? "other"; break; } const anchor = production === "schema" || production === "enum" || production === "fn" ? production : "other"; const diag = checkDocCommentPlacement(anchor, { file, range }); if (diag !== undefined) { diagnostics.push(diag); } } return { nodes, diagnostics }; } /** Merge doc-comment nodes into the statement list, ordered by source line. */ function mergeByLine( statements: readonly Stmt[], docs: readonly DocComment[], ): Stmt[] { const merged: Stmt[] = [...statements, ...docs]; return merged.sort((a, b) => { const al = a.range.start.line; const bl = b.range.start.line; if (al !== bl) { return al - bl; } return a.range.start.column - b.range.start.column; }); } // -------------------------------------------------------------------------- // Recursive-descent body parser // -------------------------------------------------------------------------- /** Compound-assignment leading operators (`+=`, `-=`, …) lexed as two tokens. */ const COMPOUND_OPS: ReadonlySet = new Set(["+", "-", "*", "/", "%"]); /** * The five recognised `subagent fn` `with { … }` session-config keys (RFC 0001 * FN-7; grammar.md `WithKey`). Each mirrors a like-named frontmatter field; a * key outside this set surfaces `theta/load/unknown-frontmatter-field`. */ const WITH_CLAUSE_KEYS: ReadonlySet = new Set([ "system", "model", "tools", "tool_loop", "respond_repair", ]); /** Reserved keywords that can begin an expression (used in ternary-head lookahead). */ const EXPRESSION_KEYWORDS: ReadonlySet = new Set([ "match", "true", "false", "null", "Ok", "Err", "invoke", ]); /** Punctuation that can begin an expression (used in ternary-head lookahead). */ const EXPRESSION_LEAD_PUNCT: ReadonlySet = new Set([ "(", "[", "{", "-", "!", "@", "`", ]); /** Whether a token can begin an expression (a ternary consequent). */ function canStartExpression(t: Token): boolean { switch (t.kind) { case "number": case "string": case "ident": return true; case "keyword": return EXPRESSION_KEYWORDS.has(t.text); case "punct": return EXPRESSION_LEAD_PUNCT.has(t.text); default: return false; } } /** One parsed top-level / block form: its statement node plus tail metadata. */ interface Form { readonly stmt: Stmt; /** The raw `Expr` when the form is an expression form, else `null`. */ readonly expr: Expr | null; /** `true` when the form began at a logical-line start (after a `stmt-sep`). */ readonly lineStart: boolean; } /** * A per-invocation recursive-descent parser over the lexer's continuation-joined * token stream. Holds only per-parse cursor / diagnostic / binding-scope state * (constructor-injected), never module-level mutable state. */ class BodyParser { private pos = 0; /** * When set, `parsePrimary` does NOT treat a leading `{` (bare object literal) * or an `Ident {` (named object literal) as an object-literal expression, so * an `if` / `while` / `for` header's `{` reads as the block opener, not an * object literal. It is cleared inside a bracketed group (`(...)`, `[...]`, * call args, object-field values, match arms) so an object literal nested * inside a condition still parses. */ private suppressBrace = false; /** Declared binding mutability, for the V3b immutable-rebinding delegation. */ private readonly bindings = new Map(); public readonly diagnostics: Diagnostic[] = []; /** Binary-operator precedence, lowest tier first (each left-associative). */ private readonly tiers: readonly (readonly string[])[] = [ ["||"], ["&&"], ["==", "!="], ["<", "<=", ">", ">="], ["+", "-"], ["*", "/", "%"], ]; /** * Tier indices into `tiers` whose operators are non-associative and reject * chaining (equality `== !=` and comparison `< <= > >=`), per * expressions.md §"Operator precedence". */ private readonly nonAssociativeTiers: ReadonlySet = new Set([2, 3]); public constructor( private readonly tokens: readonly Token[], private readonly file: string, /** * The raw (newline-normalised) body source the tokens index into. A * `@`...`` query template is recovered by slicing this verbatim between the * backtick token bounds, so the template preserves the author's exact text * (punctuation, interpolation braces, and internal spacing) rather than a * lossy space-join of the interior tokens. */ private readonly bodyText: string = "", ) {} // --- cursor helpers ----------------------------------------------------- private peek(offset = 0): Token { return this.tokens[this.pos + offset] ?? this.eofToken(); } private eofToken(): Token { const last = this.tokens[this.tokens.length - 1]; const end = last?.range.end ?? { line: 1, column: 1 }; return { kind: "eof", text: "", range: { start: end, end } }; } private advance(): Token { const t = this.peek(); if (t.kind !== "eof") { this.pos += 1; } return t; } private atEnd(): boolean { return this.peek().kind === "eof"; } private isPunct(text: string, offset = 0): boolean { const t = this.peek(offset); return t.kind === "punct" && t.text === text; } private isKeyword(text: string, offset = 0): boolean { const t = this.peek(offset); return t.kind === "keyword" && t.text === text; } // --- body / block ------------------------------------------------------- public parseBody(): Block { return this.parseForms(() => this.atEnd()); } private parseBlock(): Block { // Consumes a `{ ... }` StmtBlock / FnBody. if (this.isPunct("{")) { this.advance(); } const block = this.parseForms(() => this.isPunct("}") || this.atEnd()); if (this.isPunct("}")) { this.advance(); } return block; } /** Parse forms until `isEnd`, promoting a trailing tail `Expr` per grammar. */ private parseForms(isEnd: () => boolean): Block { const forms: Form[] = []; // The postfix error-propagation `?` is a complete-expression terminator that // always closes its statement and never triggers newline continuation // (grammar.md §"Newline continuation" — "The `?` trigger is the ternary head // only"). The lexer, unable to distinguish a postfix `?` from a ternary-head // `?`, swallows the following `stmt-sep`; so a form whose final token is a // postfix `?` forces the NEXT form to start a new logical line, restoring // its `lineStart` (and hence its tail-`Expr` promotion eligibility). let forcedLineStart = false; while (!isEnd()) { let sawSep = forms.length === 0 || forcedLineStart; while (this.peek().kind === "stmt-sep") { this.advance(); sawSep = true; } if (isEnd()) { break; } const before = this.pos; const form = this.parseForm(sawSep); if (form === null) { // No progress possible on this token: it starts no legal statement or // expression form. A stray punctuation token in statement position (a // trailing `;`, a stray non-grammar char) is not part of the grammar // (lexical.md §"Statement terminators": semicolons are not part of the // grammar) — surface a parse error rather than silently dropping it, then // drop it to guarantee termination. if (this.pos === before) { const stray = this.peek(); if (stray.kind === "punct") { this.diagnostics.push({ severity: "error", code: "theta/parse/unsupported-feature", file: this.file, range: stray.range, message: `unsupported syntactic feature: stray '${stray.text}' in statement position`, }); } this.advance(); } continue; } forms.push(form); const lastTok = this.tokens[this.pos - 1]; // A postfix `?` and a block-closing `}` both terminate their statement and // never continue onto the next line: the `stmt-sep` after each is not // surfaced as a form boundary here (the lexer swallows the postfix-`?` // separator; a block-terminated statement leaves the next form with no // consumed `stmt-sep`), so restore `lineStart` for the NEXT form to keep // its tail-`Expr` promotion eligibility. Without the `}` arm, a trailing // expression after an `if`/`while`/`for`/`fn` block // (`fn s(n){ if …{…}\n n + s(n - 1) }`) would lose its FN-5 tail promotion // and its value would be dropped. forcedLineStart = lastTok !== undefined && lastTok.kind === "punct" && (lastTok.text === "?" || lastTok.text === "}"); } // ThetaBody ::= Stmt* Expr? — the final form is promoted to the tail iff it // is a line-start expression form. Its value is the body's final value // (functions.md FN-5: a fn/theta body's value is its tail expression), // including a lone or trailing call/invoke/query — `fn f(n){ g(n) }` MUST // return `g(n)` (FN-5), so a bare-call tail is the final value, not a // discarded action. The V19a-T continuation witness `f(a,\n b)` is about // grouping the multi-line call arguments into ONE form (a lexer concern), // orthogonal to whether that one form's value is the body's tail. const last = forms[forms.length - 1]; if (last !== undefined && last.expr !== null && last.lineStart) { return { statements: forms.slice(0, -1).map((f) => f.stmt), tail: last.expr, }; } return { statements: forms.map((f) => f.stmt), tail: null }; } // --- individual forms --------------------------------------------------- private parseForm(lineStart: boolean): Form | null { const t = this.peek(); // `subagent fn` — `subagent` is a contextual keyword (grammar.md // §"Contextual keywords") recognised only immediately before a `fn`; a // nested occurrence still lowers to a `fn` node so the placement walk fires // `theta/parse/nested-fn` (FN-1/FN-6). Everywhere else `subagent` is an // ordinary identifier and falls through to the ident / expression paths. if (t.kind === "ident" && t.text === "subagent" && this.isKeyword("fn", 1)) { this.advance(); // `subagent` return this.wrap(this.parseFn(true), null, lineStart); } if (t.kind === "keyword") { switch (t.text) { case "let": return this.wrap(this.parseLet(), null, lineStart); case "fn": return this.wrap(this.parseFn(), null, lineStart); case "if": return this.wrap(this.parseIf(), null, lineStart); case "while": return this.wrap(this.parseWhile(), null, lineStart); case "for": return this.wrap(this.parseFor(), null, lineStart); case "break": return this.wrap(this.simpleKeyword("break"), null, lineStart); case "continue": return this.wrap(this.simpleKeyword("continue"), null, lineStart); case "return": return this.wrap(this.parseReturn(), null, lineStart); case "schema": return this.wrap(this.parseSchema(), null, lineStart); case "enum": return this.wrap(this.parseEnum(), null, lineStart); case "import": return this.wrap(this.parseImportExport("import"), null, lineStart); case "export": return this.wrap(this.parseImportExport("export"), null, lineStart); default: break; } } // Statement-form reassignment: `x = e` / `x += e` (ident + assign op). if (t.kind === "ident") { const reassign = this.tryParseReassign(); if (reassign !== null) { return this.wrap(reassign, null, lineStart); } } // Every remaining form is an expression form; its statement wrapper depends // on the expression kind. const expr = this.parseExpression(); if (expr === null) { return null; } // A member / index expression at statement head followed by an assignment // operator is `obj.field = …` / `arr[i] = …` — theta 1.0 mutability is // binding-level only (bindings.md §Mutability is binding-level only). Detect // it here (the AST carries no member/index reassignment form) and consume // the RHS so the assignment does not mis-parse into stray forms. if (expr.kind === "member" || expr.kind === "index") { const isSimple = this.isPunct("=") && !this.isPunct("=", 1); const opTok = this.peek(); const isCompound = opTok.kind === "punct" && COMPOUND_OPS.has(opTok.text) && this.isPunct("=", 1); if (isSimple || isCompound) { this.advance(); // operator (`=`, or the `` of `=`) if (isCompound) { this.advance(); // the `=` of a compound `=` } const diag = checkAssignmentTarget( { kind: expr.kind }, { file: this.file, range: expr.range }, ); if (diag !== undefined) { this.diagnostics.push(diag); } this.parseExpression(); // consume + discard the RHS return this.wrap( { kind: "expr", expr, range: expr.range }, null, lineStart, ); } } // A `par for` in statement position is a discarded-value expression // statement (grammar.md §Blocks): it is NOT promoted to the body tail, so // its value is discarded (`tailExpr = null`). It is recorded as an // `ExprStmt`, so a standalone `par for` reads as an expression statement // rather than a bare tail node whose value would flow on as the body tail. const tailExpr = expr.kind === "par-for" ? null : expr; return this.wrap(this.exprToStmt(expr), tailExpr, lineStart); } private wrap(stmt: Stmt, expr: Expr | null, lineStart: boolean): Form { return { stmt, expr, lineStart }; } private exprToStmt(expr: Expr): Stmt { if (expr.kind === "call") { return { kind: "tool-call", call: expr, range: expr.range }; } if (expr.kind === "invoke") { return { kind: "invoke", invoke: expr, range: expr.range }; } if (expr.kind === "query") { return { kind: "query", query: expr, range: expr.range }; } return { kind: "expr", expr, range: expr.range }; } private simpleKeyword(kind: "break" | "continue"): Stmt { const t = this.advance(); if (kind === "break") { // A value operand on the same logical line (`break expr`) is forbidden in // theta 1.0. Peek (do not consume) so the residual expression still parses // as its own statement; the structural checker reads `hasValue`. const next = this.peek(); const hasValue = next.kind !== "stmt-sep" && next.kind !== "eof" && !(next.kind === "punct" && next.text === "}"); return { kind, hasValue, range: t.range }; } return { kind, range: t.range }; } private parseLet(): Stmt { const kw = this.advance(); // `let` let mutable = false; if (this.isKeyword("mut")) { this.advance(); mutable = true; } const nameTok = this.advance(); const name = nameTok.text; if (mutable && name === "_") { // `_` is a discard binding and cannot be reassigned, so `mut` is // meaningless on it (bindings.md §"Immutable contexts"). this.diagnostics.push({ severity: "error", code: "theta/parse/mut-on-discard", file: this.file, range: nameTok.range, message: "'mut' is not permitted on discard binding '_'", }); } let annotation: string | null = null; if (this.isPunct(":")) { this.advance(); annotation = this.parseType(); } let init: Expr | null = null; if (this.isPunct("=")) { this.advance(); init = this.parseExpression(); } // A `let x: T = @`…`` (or its `?`-propagating form `let x: T = @`…`?`) binds // a typed query: propagate the declared annotation onto the query so the // runtime drives the typed two-phase respond loop and lowers `T` as the // response schema (a bare `@`…`` initialiser carries no `@` // annotation of its own). The `?`-wrapped form is `try(query)`, so the // annotation propagates onto the try's inner query operand. if (init !== null && annotation !== null && annotation.length > 0) { if (init.kind === "query" && init.schema === null) { init = { ...init, schema: annotation }; } else if ( init.kind === "try" && init.operand.kind === "query" && init.operand.schema === null ) { init = { ...init, operand: { ...init.operand, schema: annotation } }; } } this.bindings.set(name, mutable); return { kind: "let", name, mutable, annotation, init, range: spanRange(kw.range, this.prevRange()), }; } private tryParseReassign(): Stmt | null { const nameTok = this.peek(); // `x = e` (simple) or `x = e` (compound, `` + `=` as two tokens). if (this.isPunct("=", 1)) { this.advance(); // name this.advance(); // `=` const value = this.parseExpression(); return this.buildReassign(nameTok, "=", value); } const opTok = this.peek(1); if ( opTok.kind === "punct" && COMPOUND_OPS.has(opTok.text) && this.isPunct("=", 2) ) { this.advance(); // name this.advance(); // op this.advance(); // `=` const value = this.parseExpression(); return this.buildReassign( nameTok, `${opTok.text}=` as ReassignStmt["op"], value, ); } return null; } private buildReassign( nameTok: Token, op: ReassignStmt["op"], value: Expr | null, ): Stmt { const target = nameTok.text; // Delegate the immutable-rebinding check to V3b over the real binding // scope: fire only for a known immutable (`let`, non-`mut`) target; // undeclared targets are another leaf's binding-resolution concern. const known = this.bindings.get(target); if (known === false) { const diag = checkReassignment( { name: target, mutable: false }, { file: this.file, range: nameTok.range }, ); if (diag !== undefined) { this.diagnostics.push(diag); } } return { kind: "reassign", target, op, value: value ?? nullExpr(nameTok.range), range: spanRange(nameTok.range, this.prevRange()), }; } /** * Parse a control-flow header expression (an `if` / `while` condition or a * `for` iterand) with object-literal brace-suppression active so the trailing * `{` opens the block rather than reading as an object literal. */ private parseHeaderExpression(): Expr | null { const save = this.suppressBrace; this.suppressBrace = true; try { const inner = this.parseExpression(); this.consumeTrailingAssignment(); return inner; } finally { this.suppressBrace = save; } } private parseIf(): Stmt { const kw = this.advance(); // `if` const condition = this.parseHeaderExpression() ?? nullExpr(kw.range); const then = this.parseBlock(); let otherwise: IfStmt | Block | null = null; // An `else` may follow across an intervening `stmt-sep`. const save = this.pos; while (this.peek().kind === "stmt-sep") { this.advance(); } if (this.isKeyword("else")) { this.advance(); if (this.isKeyword("if")) { otherwise = this.parseIf() as IfStmt; } else { otherwise = this.parseBlock(); } } else { this.pos = save; } return { kind: "if", condition, then, otherwise, range: spanRange(kw.range, this.prevRange()), }; } private parseWhile(): Stmt { const kw = this.advance(); const condition = this.parseHeaderExpression() ?? nullExpr(kw.range); const body = this.parseBlock(); return { kind: "while", condition, body, range: spanRange(kw.range, this.prevRange()), }; } private parseFor(): Stmt { const kw = this.advance(); if (this.isKeyword("mut")) { // A `mut` modifier on a `for` iteration variable is an always-immutable // context (bindings.md §Immutable contexts). const mutTok = this.advance(); const diag = checkMutModifier( { position: "for-var" }, { file: this.file, range: mutTok.range }, ); if (diag !== undefined) { this.diagnostics.push(diag); } } const variable = this.advance().text; if (this.isKeyword("in")) { this.advance(); } const iterand = this.parseHeaderExpression() ?? nullExpr(kw.range); const body = this.parseBlock(); return { kind: "for", variable, iterand, body, range: spanRange(kw.range, this.prevRange()), }; } private parseFn(subagent = false): Stmt { const kw = this.advance(); const name = this.advance().text; const params: FnParam[] = []; // Grammar: `FnDecl` parameter lists are always parenthesised (`fn f()`, // never `fn f`). A missing `(` after the fn name is a parse error — without // it a bare `fn f x { … }` silently parses `x` as the fn name's trailing // junk and accepts a malformed declaration. if (!this.isPunct("(")) { this.diagnostics.push({ severity: "error", code: "theta/parse/unsupported-feature", file: this.file, range: this.peek().range, message: "unsupported syntactic feature: fn parameter list must be parenthesised", }); } if (this.isPunct("(")) { this.advance(); while (!this.isPunct(")") && !this.atEnd()) { if (this.isKeyword("mut")) { // A `mut` modifier on a function parameter is an always-immutable // context (bindings.md §Immutable contexts). const mutTok = this.advance(); const diag = checkMutModifier( { position: "fn-param" }, { file: this.file, range: mutTok.range }, ); if (diag !== undefined) { this.diagnostics.push(diag); } } const pName = this.advance().text; let pType = ""; if (this.isPunct(":")) { this.advance(); pType = this.parseType(); } params.push({ name: pName, type: pType }); if (this.isPunct(",")) { this.advance(); } } if (this.isPunct(")")) { this.advance(); } } let returnType: string | null = null; if (this.isPunct(":")) { this.advance(); returnType = this.parseType(); } // `WithClause?` — `with` is a contextual keyword (grammar.md §"Contextual // keywords") admitted only here, between a `subagent fn`'s signature and its // body block. It is only meaningful on a `subagent fn`; on an ordinary `fn` // a `with` before the body is left to fall through (it is not consumed). let withClause: WithField[] | null = null; if ( subagent && this.peek().kind === "ident" && this.peek().text === "with" && this.isPunct("{", 1) ) { withClause = this.parseWithClause(); } const body = this.parseBlock(); return { kind: "fn", name, params, returnType, body, subagent, withClause, range: spanRange(kw.range, this.prevRange()), }; } /** * Parse a `subagent fn`'s `with { WithField ("," WithField)* }` session-config * clause (RFC 0001 FN-7; grammar.md `WithClause`). The cursor is on the `with` * identifier. Each `WithField` is `WithKey ":" WithValue`; the five recognised * keys are `system` / `model` / `tools` / `tool_loop` / `respond_repair`, and a * key outside them surfaces the frontmatter forward-compat warning * `theta/load/unknown-frontmatter-field` (FN-7 reuses the frontmatter field's * own diagnostics rather than coining a parallel code). Each value parses as an * ordinary expression against the like-named frontmatter field's shape. */ private parseWithClause(): WithField[] { this.advance(); // `with` const fields: WithField[] = []; if (this.isPunct("{")) { this.advance(); while (!this.isPunct("}") && !this.atEnd()) { const keyTok = this.advance(); const key = keyTok.text; if (this.isPunct(":")) { this.advance(); } const value = this.parseExpression() ?? nullExpr(keyTok.range); if (!WITH_CLAUSE_KEYS.has(key)) { this.diagnostics.push({ severity: "warning", code: "theta/load/unknown-frontmatter-field", file: this.file, range: keyTok.range, message: `unknown 'with' session-config key '${key}'; expected one of system, model, tools, tool_loop, respond_repair`, }); } fields.push({ key, value }); if (this.isPunct(",")) { this.advance(); } } if (this.isPunct("}")) { this.advance(); } } return fields; } private parseReturn(): Stmt { const kw = this.advance(); let operand: Expr | null = null; const next = this.peek(); if ( next.kind !== "stmt-sep" && next.kind !== "eof" && !(next.kind === "punct" && next.text === "}") ) { operand = this.parseExpression(); } return { kind: "return", operand, range: spanRange(kw.range, this.prevRange()), }; } private parseSchema(): Stmt { const kw = this.advance(); const name = this.advance().text; // Retain the object-body field sources (`schema X { field: Type, … }`) so a // typed `@` query can resolve the declared shape and lower it // (QRY-22 / SUBS-1). The `= …` alias and `by … = …` forms carry no leading // `{`, so they capture no field list and fall through to `skipDeclarationShape`. const fields = this.parseSchemaObjectBody(); const range = spanRange(kw.range, this.prevRange()); if (fields === null) { return { kind: "schema", name, range }; } return { kind: "schema", name, fields, range }; } /** * Capture a `schema X { field: Type, … }` object body's field sources. Returns * `null` (and consumes nothing) when the decl is not the leading-`{` object * form (an `= …` alias or `by … = …` discriminated-union), leaving * `skipDeclarationShape` to consume it. A field name is an `ident` / `keyword` * token followed by `:` and a type expression; a body whose first non-sep * token is not a plain `ident: Type` field is skipped as a balanced brace group * and yields `null` (no field list retained). */ private parseSchemaObjectBody(): SchemaFieldSource[] | null { if (!(this.peek().kind === "punct" && this.peek().text === "{")) { return null; } this.advance(); // opening `{` const fields: SchemaFieldSource[] = []; for (;;) { while (this.peek().kind === "stmt-sep") { this.advance(); } if (this.atEnd()) { break; } if (this.isPunct("}")) { this.advance(); break; } const nameTok = this.peek(); const isFieldName = nameTok.kind === "ident" || nameTok.kind === "keyword"; if (!isFieldName) { // Not a plain `ident: Type` field list (a set-of / discriminated shape): // consume the balance of the brace group and retain no field list. this.skipBraceRemainder(); return null; } this.advance(); // An optional `as "WireName"` rename sits between the field identifier and // its type (schemas.md §Wire-name renaming). Capture it so the runtime can // apply outbound wire-name translation on interpolation (QRY-18). let wireName: string | undefined; if ( (this.peek().kind === "ident" || this.peek().kind === "keyword") && this.peek().text === "as" ) { this.advance(); // `as` const wireTok = this.peek(); if (wireTok.kind !== "string") { this.skipBraceRemainder(); return null; } this.advance(); wireName = wireTok.value ?? wireTok.text; } if (!this.isPunct(":")) { this.skipBraceRemainder(); return null; } this.advance(); // `:` const typeSource = this.parseType(true); fields.push({ name: nameTok.text, typeSource, ...(wireName !== undefined ? { wireName } : {}), }); // Grammar (`SchemaShape ::= "{" Field ("," Field)* ","? "}"`): fields are // comma-separated. Because a newline inside the schema brace body is // swallowed as a continuation (no `stmt-sep`), a comma-missing field body // otherwise coalesces two fields into one malformed field with no // diagnostic (silent data-shape corruption). Require the separator: when a // field is directly followed by the start of another field (an // ident/keyword name token) with no intervening comma, surface a parse // error against that boundary token, then continue parsing so the dropped // field is NOT lost. if (this.isPunct(",")) { this.advance(); } else { const boundary = this.peek(); const startsNextField = boundary.kind === "ident" || boundary.kind === "keyword"; if (startsNextField) { this.diagnostics.push({ severity: "error", code: "theta/parse/unsupported-feature", file: this.file, range: boundary.range, message: "unsupported syntactic feature: schema fields must be comma-separated", }); } } } return fields; } /** Consume tokens up to and including the `}` closing the current brace group. */ private skipBraceRemainder(): void { let depth = 1; while (!this.atEnd() && depth > 0) { const t = this.advance(); if (t.kind === "punct" && t.text === "{") { depth += 1; } else if (t.kind === "punct" && t.text === "}") { depth -= 1; } } } private parseEnum(): Stmt { const kw = this.advance(); const name = this.advance().text; const { names, values, variantDecls } = this.parseEnumVariants(); const hasValues = Object.keys(values).length > 0; return { kind: "enum", name, variants: names, ...(hasValues ? { variantValues: values } : {}), variantDecls, range: spanRange(kw.range, this.prevRange()), }; } /** * Capture the variants of an `enum X { A, B = "b", … }` body in source order * so the runtime can register the enum for `Enum.Variant` resolution: the * leading identifier is the variant name, and an explicit `= ` * value (schemas.md §Enum declarations — "Explicit values override that * mapping") is captured as that variant's wire value. A non-string explicit * value is not captured (the name stands as the wire value; the strictness * diagnostic is a separate check). A non-brace enum shape yields no variants. */ private parseEnumVariants(): { readonly names: readonly string[]; readonly values: Readonly>; readonly variantDecls: readonly EnumVariantDecl[]; } { // Advance to the opening `{`; a non-brace enum shape carries no variants. while (!this.atEnd() && !this.isPunct("{")) { if (this.peek().kind === "stmt-sep") { return { names: [], values: {}, variantDecls: [] }; } this.advance(); } if (!this.isPunct("{")) { return { names: [], values: {}, variantDecls: [] }; } this.advance(); // `{` const names: string[] = []; const values: Record = {}; // The full per-variant decls (name + explicit-value kind/text) in source // order, feeding `checkEnumDeclaration`. Non-string explicit values ARE // retained here (unlike `values`) so they can be rejected. const variantDecls: { name: string; value?: { kind: EnumValueKind; text: string }; }[] = []; // The most recently captured variant decl, so a following `= "wire"` binds // to it; cleared at each `,` so an inter-variant `=` cannot mis-bind. let currentName: string | null = null; let currentDecl: { name: string; value?: { kind: EnumValueKind; text: string } } | null = null; let expectName = true; let depth = 1; while (!this.atEnd() && depth > 0) { const t = this.peek(); if (t.kind === "punct" && t.text === "{") { depth += 1; this.advance(); continue; } if (t.kind === "punct" && t.text === "}") { depth -= 1; this.advance(); continue; } if (depth === 1 && expectName && (t.kind === "ident" || t.kind === "keyword")) { names.push(t.text); currentName = t.text; currentDecl = { name: t.text }; variantDecls.push(currentDecl); expectName = false; this.advance(); continue; } if (depth === 1 && currentName !== null && t.kind === "punct" && t.text === "=") { // An explicit `= ` for the current variant. Only a string literal // becomes the wire value; a non-string literal is retained on the // variant decl (kind + text) so `checkEnumDeclaration` can reject it // (schemas.md §Enum declarations — string values only). this.advance(); // `=` const valueTok = this.peek(); const captured = classifyEnumValueToken(valueTok); if (captured !== undefined) { if (currentDecl !== null) { currentDecl.value = captured; } if (captured.kind === "string" && currentName !== null) { values[currentName] = captured.text; } this.advance(); } continue; } if (depth === 1 && t.kind === "punct" && t.text === ",") { currentName = null; currentDecl = null; expectName = true; this.advance(); continue; } // Any other in-variant token: skip; the next comma re-arms name capture. this.advance(); } return { names, values, variantDecls }; } /** Skip a schema/enum shape (`{ ... }` block or `= …` / `by … = …` tail). */ private skipDeclarationShape(): void { // Consume up to the shape's opening `{` (past any `by field =` / `=` head). while (!this.atEnd()) { if (this.isPunct("{")) { this.skipBraces(); return; } const t = this.peek(); if (t.kind === "stmt-sep") { return; // an `=`-form declaration closes at the newline } this.advance(); } } private skipBraces(): void { // Precondition: current token is `{`. let depth = 0; do { const t = this.advance(); if (t.kind === "punct" && t.text === "{") { depth += 1; } else if (t.kind === "punct" && t.text === "}") { depth -= 1; } else if (t.kind === "eof") { return; } } while (depth > 0); } private parseImportExport(kind: "import" | "export"): Stmt { const kw = this.advance(); // Each specifier is `Source` or `Source as Local` (imports.md §"Unknown // imported symbol" / §"Re-exports"): the `as` keyword rebinds the imported // symbol to a local alias. `symbols` carries the LOCAL name (alias when // present) so downstream named-type / reserved-name consumers see the name // actually bound; `specifiers` retains the `{ source, local }` mapping the // import / re-export checks need (source drives unknown-symbol resolution, // local drives name-collision). const specifiers: ImportSpecifier[] = []; const symbols: string[] = []; if (this.isPunct("{")) { this.advance(); while (!this.isPunct("}") && !this.atEnd()) { const t = this.peek(); const isSymbolToken = (t.kind === "ident" || t.kind === "keyword") && t.text !== "as"; if (isSymbolToken) { const source = t.text; const sourceRange = t.range; this.advance(); let local = source; let endRange = sourceRange; // `Source as Local`: the `as` keyword rebinds to the trailing alias. if (this.isKeyword("as")) { this.advance(); // `as` const aliasTok = this.peek(); if ( (aliasTok.kind === "ident" || aliasTok.kind === "keyword") && aliasTok.text !== "as" ) { local = aliasTok.text; endRange = aliasTok.range; this.advance(); } } specifiers.push({ source, local, range: spanRange(sourceRange, endRange), }); symbols.push(local); } else if (t.kind === "punct" && t.text === ",") { this.advance(); } else { this.advance(); } } if (this.isPunct("}")) { this.advance(); } } if (this.isKeyword("from")) { this.advance(); } let path = ""; const pathTok = this.peek(); if (pathTok.kind === "string") { path = pathTok.value ?? pathTok.text; // imports.md §"Path resolution": an `import` / `export … from` path // literal must end in a byte-exact lowercase `.thetalib` and use forward-slash // separators; a `.theta` path (or any non-`.thetalib` variant) is // `theta/parse/import-non-thetalib-extension`. Validate the literal as written // at parse time so a wrong-extension import un-registers the theta (IMP-2). this.diagnostics.push( ...validatePathLiteral( { value: path, range: pathTok.range }, "import", this.file, ), ); this.advance(); } return { kind, path, symbols, specifiers, range: spanRange(kw.range, this.prevRange()), } as ImportDecl | ExportDecl; } /** * Consume a type expression, joining its tokens until a delimiter. When * `stopAtFieldBoundary` is set (schema-object-body field types), the scan also * stops at a depth-0 field boundary: a value-ish token (ident/keyword/string/ * number) that directly follows a completed type atom with no intervening `|` * union operator marks the start of the next `Field`, so the current field's * type does not greedily swallow it. This is what lets a comma-missing schema * body still recover both fields (see `parseSchemaObjectBody`). */ private parseType(stopAtFieldBoundary = false): string { const parts: string[] = []; let depth = 0; // A leading `{` introduces an inline object type (`let x: { a: T, … }`): // consume the balanced brace group verbatim so the annotation carries the // whole object shape rather than terminating at the opening brace. Only a // *leading* brace is treated this way, so a `fn` return type followed by a // `{ body }` block is unaffected. if (this.peek().kind === "punct" && this.peek().text === "{") { let braceDepth = 0; while (!this.atEnd()) { const t = this.peek(); if (t.kind === "stmt-sep") { break; } if (t.kind === "punct" && t.text === "{") { braceDepth += 1; } else if (t.kind === "punct" && t.text === "}") { braceDepth -= 1; } parts.push(t.text); this.advance(); if (braceDepth === 0) { break; } } return parts.join(""); } while (!this.atEnd()) { const t = this.peek(); if (t.kind === "stmt-sep") { break; } if ( depth === 0 && t.kind === "punct" && (t.text === "," || t.text === ")" || t.text === "{" || t.text === "}" || t.text === "=") ) { break; } if (stopAtFieldBoundary && depth === 0 && parts.length > 0) { const isValueTok = t.kind === "ident" || t.kind === "keyword" || t.kind === "string" || t.kind === "number"; const prevText = parts[parts.length - 1]; if (isValueTok && prevText !== "|") { break; } } if (t.kind === "punct" && (t.text === "<" || t.text === "(" || t.text === "[")) { // Track `[` depth too so an inline `enum["a", "b"]` form is captured // whole (its interior comma must not terminate the type source), // reaching `checkInlineEnumForm` for `theta/parse/inline-enum` rather // than truncating the field to `enum["a"` and discarding the field list. depth += 1; } else if (t.kind === "punct" && (t.text === ">" || t.text === ")" || t.text === "]")) { depth -= 1; } parts.push(t.text); this.advance(); } return parts.join(""); } // --- expression sublanguage -------------------------------------------- private parseExpression(): Expr | null { return this.parseTernary(); } /** * Parse the token stream as a single expression — the same `parseExpression` * entry the `let` RHS drives, exposed so a `@`...`` template's `${…}` * interpolation body honours the full expression sublanguage * (expressions.md §"Supported forms"). */ public parseSingleExpression(): Expr | null { return this.parseExpression(); } /** * Whether the `?` at the cursor is a ternary head rather than the postfix * error-propagation `?`. A ternary head's `?` is immediately followed by an * expression-starting token and, at the same bracket depth, a `:` before the * statement terminates; a postfix `?` is followed by a statement boundary, a * closing bracket, or a statement keyword. Distinguishing by the trailing `:` * keeps `foo()?` (postfix, `try`) separate from `c ? a : b` (ternary), even * across the lexer's swallowed continuation newline after a trailing `?`. */ private isTernaryHead(): boolean { if (!canStartExpression(this.peek(1))) { return false; } let depth = 0; for (let i = 1; ; i += 1) { const t = this.peek(i); if (t.kind === "eof" || t.kind === "stmt-sep") { return false; } if (t.kind === "punct") { const x = t.text; if (x === "(" || x === "[" || x === "{") { depth += 1; } else if (x === ")" || x === "]" || x === "}") { if (depth === 0) { return false; } depth -= 1; } else if (x === ":" && depth === 0) { return true; } } } } private parseTernary(): Expr | null { const condition = this.parseBinary(0); if (condition === null) { return null; } if (this.isPunct("?")) { // Distinguish the ternary head from the postfix error-propagation `?`, // which the binary/postfix layer has already consumed onto its operand. const q = this.advance(); const consequent = this.parseTernary() ?? nullExpr(q.range); if (this.isPunct(":")) { this.advance(); } const alternate = this.parseTernary() ?? nullExpr(q.range); return { kind: "ternary", condition, consequent, alternate, range: spanRange(condition.range, alternate.range), }; } return condition; } private parseBinary(tier: number): Expr | null { if (tier >= this.tiers.length) { return this.parseUnary(); } let left = this.parseBinary(tier + 1); if (left === null) { return null; } const ops = this.tiers[tier] ?? []; // Comparison and equality operators are non-associative and do not chain: // `a < b < c` (and `a == b == c`) is `theta/parse/comparison-chaining` // (expressions.md §"Operator precedence"). Every other tier is // left-associative. const nonAssociative = this.nonAssociativeTiers.has(tier); let matched = false; for (;;) { const t = this.peek(); if (t.kind !== "punct" || !ops.includes(t.text)) { break; } if (nonAssociative && matched) { this.diagnostics.push({ severity: "error", code: "theta/parse/comparison-chaining", file: this.file, range: t.range, message: "comparison operators do not chain; use &&", }); break; } this.advance(); const right = this.parseBinary(tier + 1); if (right === null) { break; } matched = true; left = { kind: "binary", op: t.text, left, right, range: spanRange(left.range, right.range), }; } return left; } private parseUnary(): Expr | null { if (this.isPunct("-") || this.isPunct("!")) { const op = this.advance(); const operand = this.parsePostfix(); if (operand === null) { return null; } // Model unary as a binary with a synthetic `null` left so the AST union // stays closed; theta 1.0 tests exercise no unary form directly. return { kind: "binary", op: op.text, left: nullExpr(op.range), right: operand, range: spanRange(op.range, operand.range), }; } return this.parsePostfix(); } private parsePostfix(): Expr | null { let expr = this.parsePrimary(); if (expr === null) { return null; } for (;;) { if (this.isPunct("?")) { // Postfix error-propagation `?` vs ternary head `cond ? a : b`. A `?` // whose consequent is an expression followed (at the same bracket // depth) by a `:` is a ternary head: leave it unconsumed so // `parseTernary` builds the ternary. Otherwise it is the postfix // error-propagation terminator (grammar.md §"Newline continuation" — // "the `?` trigger is the ternary head only"). if (this.isTernaryHead()) { break; } const q = this.advance(); expr = { kind: "try", operand: expr, range: spanRange(expr.range, q.range), }; continue; } if (this.isPunct(".")) { // Member access `target.field` (expressions.md §"Member access"). this.advance(); const nameTok = this.advance(); expr = { kind: "member", target: expr, field: nameTok.text, range: spanRange(expr.range, nameTok.range), }; continue; } if (this.isPunct("[")) { // Index access `target[index]` (expressions.md §"Index access"). The // index sub-expression parses inside the brackets, so a nested object // literal there is not brace-suppressed. this.advance(); const indexExpr: Expr = this.parseBracketedExpression() ?? nullExpr(expr.range); if (this.isPunct("]")) { this.advance(); } expr = { kind: "index", target: expr, index: indexExpr, range: spanRange(expr.range, this.prevRange()), }; continue; } if (this.isPunct("(") && expr.kind === "member") { // Method call `target.method(args)` (expressions.md §"Built-in methods // and properties"): fold the just-produced `member` and its argument // list into a dedicated `method-call` node so the runtime dispatches // the stdlib member instead of reading the bare field value. const args = this.parseArgs(); expr = { kind: "method-call", target: expr.target, method: expr.field, args, range: spanRange(expr.range, this.prevRange()), }; continue; } break; } return expr; } /** * Parse an expression inside a bracketed group (`(...)`, `[...]`, call args, * object-field value, match arm) with object-literal brace-suppression * cleared, so a nested object literal parses even inside a control-flow * header expression. */ private parseBracketedExpression(): Expr | null { const save = this.suppressBrace; this.suppressBrace = false; try { const inner = this.parseExpression(); this.consumeTrailingAssignment(); return inner; } finally { this.suppressBrace = save; } } private parsePrimary(): Expr | null { const t = this.peek(); // `par for` — `par` is a contextual keyword recognised only immediately // before `for` (grammar.md §"Contextual keywords"); everywhere else `par` // is a normal identifier and falls through to the ident path below. if (t.kind === "ident" && t.text === "par" && this.isKeyword("for", 1)) { return this.parseParFor(); } if (t.kind === "number") { this.advance(); return { kind: "number", text: t.text, numericType: t.numericType ?? "integer", range: t.range, }; } if (t.kind === "string") { this.advance(); return { kind: "string", value: t.value ?? t.text, range: t.range }; } if (t.kind === "keyword") { if (t.text === "true" || t.text === "false") { this.advance(); return { kind: "bool", value: t.text === "true", range: t.range }; } if (t.text === "null") { this.advance(); return { kind: "null", range: t.range }; } if (t.text === "invoke") { return this.parseInvoke(); } if (t.text === "match") { return this.parseMatch(); } // `Ok(arg)` / `Err(arg)` Result constructors in value position // (errors-and-results/error-model.md). Only when followed by `(` — a // bare `Ok` / `Err` is not a first-class value, so it falls through to // the keyword-in-value-position `null` path, mirroring the other // reserved keywords that reach here. if ((t.text === "Ok" || t.text === "Err") && this.isPunct("(", 1)) { this.advance(); // `Ok` / `Err` const args = this.parseArgs(); const arg = args[0] ?? nullExpr(t.range); return { kind: "result-ctor", ctor: t.text, arg, range: spanRange(t.range, this.prevRange()), }; } } if (t.kind === "ident") { this.advance(); if (this.isPunct("(")) { const args = this.parseArgs(); return { kind: "call", callee: t.text, args, range: spanRange(t.range, this.prevRange()), }; } // Named object literal / schema constructor `Ident { field: expr, … }` // (grammar.md `NamedObjectLit`), unless brace-suppression is active (a // control-flow header, where the `{` opens the block). if (this.isPunct("{") && !this.suppressBrace) { return this.parseObjectLiteral(t.text, t.range); } return { kind: "ident", name: t.text, range: t.range }; } if (t.kind === "punct") { if (t.text === "(") { this.advance(); const inner = this.parseBracketedExpression(); if (this.isPunct(")")) { this.advance(); } return inner; } if (t.text === "[") { return this.parseArray(); } if (t.text === "@") { return this.parseQuery(); } if (t.text === "`") { // A backtick template with no leading `@` — a QUERY template in value // position. expressions.md §"Not supported" admits query templates only // `@`-prefixed, at statement / `let`-RHS level; a bare backtick used as a // value (a match-arm body, a value-position `let` RHS) is rejected. return this.parseBareTemplate(); } // Bare object literal `{ field: expr, … }` (grammar.md `BareObjectLit`), // unless brace-suppression is active (a control-flow header block opener). if (t.text === "{" && !this.suppressBrace) { return this.parseObjectLiteral(null, t.range); } } return null; } /** * Parse an object-literal / schema-constructor body `{ field: expr, … }` — the * opening `{` is the current token. `typeName` is the constructor name for a * `NamedObjectLit`, or `null` for a `BareObjectLit`. Field values parse inside * the braces, so a nested object literal is not brace-suppressed. A malformed * field is skipped defensively (matching the array / arg recovery), never * silently swallowing the whole literal. */ private parseObjectLiteral(typeName: string | null, startRange: SourceRange): Expr { this.advance(); // `{` const save = this.suppressBrace; this.suppressBrace = false; const fields: ObjectFieldNode[] = []; while (!this.isPunct("}") && !this.atEnd()) { const nameTok = this.peek(); if (nameTok.kind !== "ident" && nameTok.kind !== "string") { // Not a field name: drop the token to guarantee progress. this.advance(); continue; } this.advance(); if (this.isPunct(":")) { this.advance(); } const value = this.parseExpression() ?? nullExpr(nameTok.range); fields.push({ name: nameTok.text, value }); if (this.isPunct(",")) { this.advance(); } } if (this.isPunct("}")) { this.advance(); } this.suppressBrace = save; return { kind: "object", typeName, fields, range: spanRange(startRange, this.prevRange()), }; } /** * Parse a `match { Pattern "=>" ArmBody, … }` expression * (expressions.md §`match` expression). The scrutinee parses with * brace-suppression active so the arms `{` is not read as an object literal. */ private parseMatch(): Expr { const kw = this.advance(); // `match` const scrutinee = this.parseHeaderExpression() ?? nullExpr(kw.range); const arms: MatchArmNode[] = []; if (this.isPunct("{")) { this.advance(); const save = this.suppressBrace; this.suppressBrace = false; while (!this.isPunct("}") && !this.atEnd()) { while (this.peek().kind === "stmt-sep") { this.advance(); } if (this.isPunct("}") || this.atEnd()) { break; } const before = this.pos; const pattern = this.parsePattern(); // A guarded arm `Pattern if cond => …` is not supported in theta 1.0 // (expressions.md §"Pattern grammar"). Consume and discard the guard // condition so the `=>` arrow still parses. if (this.isKeyword("if")) { const ifTok = this.advance(); this.diagnostics.push({ severity: "error", code: "theta/parse/match-guard-not-supported", file: this.file, range: ifTok.range, message: "match guards are not supported in theta 1.0", }); this.parseExpression(); // consume + discard the guard condition } // Consume the `=>` arm arrow (lexed as two punct tokens `=` `>`). if (this.isPunct("=") && this.isPunct(">", 1)) { this.advance(); this.advance(); } // The arm body is an expression, not a bare statement (grammar.md // §"match arm body"). const consumedStmt = this.tryConsumeArmBodyStatement(); const body = consumedStmt ? nullExpr(kw.range) : (this.parseExpression() ?? nullExpr(kw.range)); arms.push({ pattern, body }); if (this.isPunct(",")) { this.advance(); } if (this.pos === before) { // No progress (a malformed arm): drop a token to guarantee termination. this.advance(); } } this.suppressBrace = save; if (this.isPunct("}")) { this.advance(); } } return { kind: "match", scrutinee, arms, range: spanRange(kw.range, this.prevRange()), }; } /** * Parse one `match` pattern (expressions.md §"Pattern grammar (theta 1.0)"): * wildcard `_`, `Ok(p)` / `Err(p)` constructors, a named/bare object pattern * `Ident { field: p, … }`, an array pattern `[p, …]`, a literal * (`"s"` / `42` / `true` / `null`), or an identifier binding. */ /** * If the cursor begins a bare statement in `match`-arm-body position * (a leading `if` / `for` / `while` / `let` / `break` / `continue` / * `return` keyword, or a bare assignment), emit * `theta/parse/statement-in-arm-body`, consume the statement, and return * true; otherwise return false. Arm bodies are expressions; statements are * wrapped in a block expression `{ ... }` (grammar.md §"match arm body"). */ private tryConsumeArmBodyStatement(): boolean { const t = this.peek(); const stmtKeyword = t.kind === "keyword" && (t.text === "if" || t.text === "for" || t.text === "while" || t.text === "let" || t.text === "break" || t.text === "continue" || t.text === "return"); const next = this.peek(1); const assignHead = t.kind === "ident" && ((this.isPunct("=", 1) && !this.isPunct("=", 2)) || (next.kind === "punct" && COMPOUND_OPS.has(next.text) && this.isPunct("=", 2))); if (!stmtKeyword && !assignHead) { return false; } this.diagnostics.push({ severity: "error", code: "theta/parse/statement-in-arm-body", file: this.file, range: t.range, message: "match arm body must be an expression; wrap statements in a block expression { ... }", }); if (stmtKeyword) { switch (t.text) { case "if": this.parseIf(); break; case "while": this.parseWhile(); break; case "for": this.parseFor(); break; case "let": this.parseLet(); break; case "return": this.parseReturn(); break; default: this.simpleKeyword(t.text === "break" ? "break" : "continue"); break; } } else { this.tryParseReassign(); } return true; } /** * If the cursor begins a rest pattern (`...rest`, lexed as three `.` puncts * optionally followed by a binding name), emit * `theta/parse/rest-pattern-not-supported`, consume it, and return true; rest * patterns are not in theta 1.0 (expressions.md §"Pattern grammar"). */ private tryConsumeRestPattern(): boolean { if ( !(this.isPunct(".") && this.isPunct(".", 1) && this.isPunct(".", 2)) ) { return false; } const dotTok = this.peek(); this.advance(); this.advance(); this.advance(); if (this.peek().kind === "ident") { this.advance(); } this.diagnostics.push({ severity: "error", code: "theta/parse/rest-pattern-not-supported", file: this.file, range: dotTok.range, message: "rest patterns are not supported in theta 1.0", }); return true; } /** * Assignment is statement-only; used in expression position it is * `theta/parse/assignment-as-expression` (bindings.md §"Reassignment is a * statement"). If a simple `=` (not `==`) or compound-assign operator trails * the just-parsed value expression, emit the diagnostic and consume the RHS * so the surrounding parse recovers. */ private consumeTrailingAssignment(): void { const simple = this.isPunct("=") && !this.isPunct("=", 1); const opTok = this.peek(); const compound = opTok.kind === "punct" && COMPOUND_OPS.has(opTok.text) && this.isPunct("=", 1); if (!simple && !compound) { return; } this.diagnostics.push({ severity: "error", code: "theta/parse/assignment-as-expression", file: this.file, range: opTok.range, message: "assignment is not an expression", }); if (simple) { this.advance(); // `=` } else { this.advance(); // op this.advance(); // `=` } this.parseExpression(); // consume + discard the RHS } private parsePattern(): PatternNode { if (this.tryConsumeRestPattern()) { return { kind: "wildcard" }; } if (this.isKeyword("mut")) { // A `mut` modifier on a `match` pattern binding is an always-immutable // context (bindings.md §Immutable contexts). const mutTok = this.advance(); const diag = checkMutModifier( { position: "match-bind" }, { file: this.file, range: mutTok.range }, ); if (diag !== undefined) { this.diagnostics.push(diag); } } const t = this.peek(); if (t.kind === "number") { this.advance(); return { kind: "literal", value: Number(t.text) }; } if (t.kind === "string") { this.advance(); return { kind: "literal", value: t.value ?? t.text }; } if (t.kind === "punct" && t.text === "[") { this.advance(); const elements: PatternNode[] = []; while (!this.isPunct("]") && !this.atEnd()) { elements.push(this.parsePattern()); if (this.isPunct(",")) { this.advance(); } } if (this.isPunct("]")) { this.advance(); } return { kind: "array", elements }; } if (t.kind === "keyword" && t.text === "true") { this.advance(); return { kind: "literal", value: true }; } if (t.kind === "keyword" && t.text === "false") { this.advance(); return { kind: "literal", value: false }; } if (t.kind === "keyword" && t.text === "null") { this.advance(); return { kind: "literal", value: null }; } if (t.kind === "ident" || t.kind === "keyword") { this.advance(); // `Ok(p)` / `Err(p)` result constructor patterns. if ((t.text === "Ok" || t.text === "Err") && this.isPunct("(")) { this.advance(); const inner = this.isPunct(")") ? ({ kind: "wildcard" } as PatternNode) : this.parsePattern(); if (this.isPunct(")")) { this.advance(); } return { kind: "constructor", ctor: t.text, inner }; } // `Ident { field: p, … }` object / schema pattern. if (this.isPunct("{")) { this.advance(); const fields: { readonly name: string; readonly pattern: PatternNode }[] = []; while (!this.isPunct("}") && !this.atEnd()) { if (this.tryConsumeRestPattern()) { if (this.isPunct(",")) { this.advance(); } continue; } const nameTok = this.peek(); if (nameTok.kind !== "ident" && nameTok.kind !== "string") { this.advance(); continue; } this.advance(); let fieldPattern: PatternNode; if (this.isPunct(":")) { this.advance(); fieldPattern = this.parsePattern(); } else { // `{ field }` sugars `{ field: field }` (grammar.md §Pattern // grammar): a colon-less field binds the field value to a // same-named identifier, never a wildcard on the next token. fieldPattern = { kind: "identifier", name: nameTok.text }; } fields.push({ name: nameTok.text, pattern: fieldPattern }); if (this.isPunct(",")) { this.advance(); } } if (this.isPunct("}")) { this.advance(); } return { kind: "object", typeName: t.text, fields }; } // A bare `_` wildcard, else an identifier binding pattern. if (t.text === "_") { return { kind: "wildcard" }; } return { kind: "identifier", name: t.text }; } // A bare object pattern `{ field: p, … }`. if (t.kind === "punct" && t.text === "{") { this.advance(); const fields: { readonly name: string; readonly pattern: PatternNode }[] = []; while (!this.isPunct("}") && !this.atEnd()) { if (this.tryConsumeRestPattern()) { if (this.isPunct(",")) { this.advance(); } continue; } const nameTok = this.peek(); if (nameTok.kind !== "ident" && nameTok.kind !== "string") { this.advance(); continue; } this.advance(); let fieldPattern: PatternNode; if (this.isPunct(":")) { this.advance(); fieldPattern = this.parsePattern(); } else { // `{ field }` sugars `{ field: field }` (grammar.md §Pattern // grammar): a colon-less field binds the field value to a // same-named identifier, never a wildcard on the next token. fieldPattern = { kind: "identifier", name: nameTok.text }; } fields.push({ name: nameTok.text, pattern: fieldPattern }); if (this.isPunct(",")) { this.advance(); } } if (this.isPunct("}")) { this.advance(); } return { kind: "object", typeName: null, fields }; } // Unrecognised: consume one token and treat as a wildcard to keep progress. this.advance(); return { kind: "wildcard" }; } /** * Parse a `par for in [max ] ` fan-out expression * (RFC 0003; grammar.md `ParForExpr`). The cursor is on the `par` identifier. * The iterand and the optional `max` operand parse with object-literal * brace-suppression active so the trailing `{` opens the body block rather * than reading as a bare object literal; `max` is a contextual keyword here * (an ordinary identifier lexeme) recognised only between the iterand and the * body. After the body parses, the three body-restriction diagnostics * (`par-query-in-body` / `par-shared-mutation` / `par-break-continue`, * control-flow.md CTRL-4) are emitted over the parsed body. */ private parseParFor(): Expr { const parTok = this.advance(); // `par` this.advance(); // `for` if (this.isKeyword("mut")) { // A `mut` modifier on the loop variable is an always-immutable context // (bindings.md §Immutable contexts), same as plain `for`. const mutTok = this.advance(); const diag = checkMutModifier( { position: "for-var" }, { file: this.file, range: mutTok.range }, ); if (diag !== undefined) { this.diagnostics.push(diag); } } const variable = this.advance().text; if (this.isKeyword("in")) { this.advance(); } // Snapshot the outer mutable bindings before the body's own `let`s are // recorded, so a body reassignment to an outer `let mut` is detectable. const outerMutables = new Set(); for (const [name, mutable] of this.bindings) { if (mutable) { outerMutables.add(name); } } const save = this.suppressBrace; this.suppressBrace = true; let iterand: Expr; let max: Expr | null = null; try { iterand = this.parseExpression() ?? nullExpr(parTok.range); // `MaxClause ::= "max" Expr` — `max` is a contextual keyword (a bare // identifier lexeme) admitted only here, between the iterand and the body. if (this.peek().kind === "ident" && this.peek().text === "max") { this.advance(); // `max` max = this.parseExpression(); } } finally { this.suppressBrace = save; } const body = this.parseBlock(); this.emitParForBodyDiagnostics(body, outerMutables); return { kind: "par-for", variable, iterand, max, body, range: spanRange(parTok.range, this.prevRange()), }; } /** * Emit the CTRL-4 body-restriction diagnostics over a parsed `par for` body: * - an `@`-query against the enclosing conversation → `par-query-in-body`; * - a reassignment to an outer `let mut` binding → `par-shared-mutation`; * - a `break` / `continue` targeting the `par for` → `par-break-continue`. * A nested `par for` emits its own diagnostics during its own parse, so this * walk does not descend into a nested `par-for` body (only its iterand / max, * which evaluate in this body's scope). */ private emitParForBodyDiagnostics( body: Block, outerMutables: ReadonlySet, ): void { const bodyLocals = new Set(); this.scanParForBlock(body, outerMutables, bodyLocals, 0); } private scanParForBlock( block: Block, outerMutables: ReadonlySet, bodyLocals: Set, loopDepth: number, ): void { for (const s of block.statements) { this.scanParForStmt(s, outerMutables, bodyLocals, loopDepth); } if (block.tail !== null) { this.scanParForExpr(block.tail, outerMutables); } } private scanParForStmt( s: Stmt, outerMutables: ReadonlySet, bodyLocals: Set, loopDepth: number, ): void { switch (s.kind) { case "let": if (s.init !== null) { this.scanParForExpr(s.init, outerMutables); } if (s.name !== "_") { bodyLocals.add(s.name); } return; case "reassign": if (outerMutables.has(s.target) && !bodyLocals.has(s.target)) { this.diagnostics.push({ severity: "error", code: "theta/parse/par-shared-mutation", file: this.file, range: s.range, message: `cannot assign to outer binding '${s.target}' from inside a 'par for' body`, }); } this.scanParForExpr(s.value, outerMutables); return; case "break": case "continue": // Legal only when it targets a plain `for` / `while` nested inside the // body; a `break` / `continue` targeting the `par for` itself has no // defined meaning under concurrent scheduling (CTRL-4). if (loopDepth === 0) { this.diagnostics.push({ severity: "error", code: "theta/parse/par-break-continue", file: this.file, range: s.range, message: `'${s.kind}' is not permitted inside a 'par for' body`, }); } return; case "if": this.scanParForExpr(s.condition, outerMutables); this.scanParForBlock(s.then, outerMutables, bodyLocals, loopDepth); if (s.otherwise !== null) { if ("statements" in s.otherwise) { this.scanParForBlock(s.otherwise, outerMutables, bodyLocals, loopDepth); } else { this.scanParForStmt(s.otherwise, outerMutables, bodyLocals, loopDepth); } } return; case "while": this.scanParForExpr(s.condition, outerMutables); this.scanParForBlock(s.body, outerMutables, bodyLocals, loopDepth + 1); return; case "for": this.scanParForExpr(s.iterand, outerMutables); this.scanParForBlock(s.body, outerMutables, bodyLocals, loopDepth + 1); return; case "query": this.diagnostics.push({ severity: "error", code: "theta/parse/par-query-in-body", file: this.file, range: s.range, message: "`@` query against the enclosing conversation is not permitted inside a 'par for' body", }); return; case "tool-call": this.scanParForExpr(s.call, outerMutables); return; case "invoke": this.scanParForExpr(s.invoke, outerMutables); return; case "expr": this.scanParForExpr(s.expr, outerMutables); return; case "return": if (s.operand !== null) { this.scanParForExpr(s.operand, outerMutables); } return; default: // fn / schema / enum / import / export / doc-comment carry no // enclosing-conversation body restriction to check. return; } } private scanParForExpr(e: Expr, outerMutables: ReadonlySet): void { switch (e.kind) { case "query": this.diagnostics.push({ severity: "error", code: "theta/parse/par-query-in-body", file: this.file, range: e.range, message: "`@` query against the enclosing conversation is not permitted inside a 'par for' body", }); return; case "par-for": // A nested `par for` emits its own body diagnostics; its iterand / max // evaluate in THIS body's scope, so scan those but not its body. this.scanParForExpr(e.iterand, outerMutables); if (e.max !== null) { this.scanParForExpr(e.max, outerMutables); } return; case "try": this.scanParForExpr(e.operand, outerMutables); return; case "binary": this.scanParForExpr(e.left, outerMutables); this.scanParForExpr(e.right, outerMutables); return; case "ternary": this.scanParForExpr(e.condition, outerMutables); this.scanParForExpr(e.consequent, outerMutables); this.scanParForExpr(e.alternate, outerMutables); return; case "call": case "invoke": for (const arg of e.args) { this.scanParForExpr(arg, outerMutables); } return; case "member": this.scanParForExpr(e.target, outerMutables); return; case "index": this.scanParForExpr(e.target, outerMutables); this.scanParForExpr(e.index, outerMutables); return; case "method-call": this.scanParForExpr(e.target, outerMutables); for (const arg of e.args) { this.scanParForExpr(arg, outerMutables); } return; case "object": for (const field of e.fields) { this.scanParForExpr(field.value, outerMutables); } return; case "array": for (const el of e.elements) { this.scanParForExpr(el, outerMutables); } return; case "result-ctor": this.scanParForExpr(e.arg, outerMutables); return; case "match": this.scanParForExpr(e.scrutinee, outerMutables); for (const arm of e.arms) { this.scanParForExpr(arm.body, outerMutables); } return; default: // ident / number / string / bool / null — no query / nested par-for. return; } } private parseInvoke(): Expr { const kw = this.advance(); // `invoke` // Capture an optional `` return-type annotation (invocation.md §Typed // return): its text is threaded onto the AST so the runtime can AJV-validate // the callee's returned value against it (the parse-time type check is // separate; the runtime check is the safety net — hard-ceilings ceiling #4). let returnSchema: string | null = null; if (this.isPunct("<")) { this.advance(); // `<` let depth = 1; const parts: string[] = []; while (depth > 0 && !this.atEnd()) { const t = this.peek(); if (t.kind === "punct" && t.text === "<") { depth += 1; } else if (t.kind === "punct" && t.text === ">") { depth -= 1; if (depth === 0) { this.advance(); break; } } parts.push(t.text); this.advance(); } const annotation = parts.join("").trim(); returnSchema = annotation.length > 0 ? annotation : null; } const args = this.parseArgs(); const first = args[0]; const path = first !== undefined && first.kind === "string" ? first.value : ""; // INV-1 / INV-2 (invocation.md §Resolution; lexical.md §"Path literals" / // §"Extension matching"): the callee path is a string literal — validate its // byte-exact-lowercase `.theta` suffix and forward-slash-only rule at parse // time. INV-8: a non-literal (runtime-computed) path is not supported in // theta 1.0, so surface it as a parse error rather than degrading to a silent // empty-path no-op at runtime. if (first !== undefined) { if (first.kind === "string") { this.diagnostics.push( ...validatePathLiteral( { value: first.value, range: first.range }, "invoke", this.file, ), ); } else { this.diagnostics.push({ severity: "error", code: "theta/parse/unsupported-feature", file: this.file, range: first.range, message: "unsupported syntactic feature: dynamic invoke path (runtime-computed)", }); } } return { kind: "invoke", path, returnSchema, args, range: spanRange(kw.range, this.prevRange()), }; } private parseArgs(): Expr[] { const args: Expr[] = []; if (!this.isPunct("(")) { return args; } this.advance(); // `(` const saveArgs = this.suppressBrace; this.suppressBrace = false; while (!this.isPunct(")") && !this.atEnd()) { const arg = this.parseExpression(); if (arg === null) { this.advance(); continue; } args.push(arg); if (this.isPunct(",")) { this.advance(); } } this.suppressBrace = saveArgs; if (this.isPunct(")")) { this.advance(); } return args; } private parseArray(): Expr { const open = this.advance(); // `[` const saveArr = this.suppressBrace; this.suppressBrace = false; const elements: Expr[] = []; while (!this.isPunct("]") && !this.atEnd()) { const el = this.parseExpression(); if (el === null) { this.advance(); continue; } elements.push(el); if (this.isPunct(",")) { this.advance(); } } this.suppressBrace = saveArr; if (this.isPunct("]")) { this.advance(); } return { kind: "array", elements, range: spanRange(open.range, this.prevRange()), }; } /** * Parse (and reject) a backtick template used in value position with no * leading `@`. Query templates are `@`-prefixed and admitted only at * statement / `let`-RHS level (expressions.md §"Not supported"), so a bare * `` `..${..}` `` value is `theta/parse/unsupported-feature`. The whole * template is consumed — up to the matching closing backtick — so a `${…}` * interpolation brace is never re-read as a bare object literal (which would * mis-emit `theta/parse/bare-object-literal`); an inert `null` node keeps * downstream typing stable. */ private parseBareTemplate(): Expr { const open = this.advance(); // opening backtick // Consume the whole template up to its matching closing backtick, tracking // `${…}` interpolation brace depth so a backtick nested inside an // interpolation (`` `a${@`x`}` ``) does not prematurely close the template // and leave trailing tokens to be re-parsed into a spurious secondary // diagnostic. let braceDepth = 0; while (!this.atEnd()) { if (braceDepth === 0 && this.isPunct("`")) { break; } if (this.isPunct("{")) { braceDepth += 1; } else if (this.isPunct("}") && braceDepth > 0) { braceDepth -= 1; } this.advance(); } if (this.isPunct("`")) { this.advance(); // closing backtick } const range = spanRange(open.range, this.prevRange()); this.diagnostics.push({ severity: "error", code: "theta/parse/unsupported-feature", file: this.file, range, message: "unsupported syntactic feature: backtick template in value position (query templates must be @-prefixed)", }); return nullExpr(range); } private parseQuery(): Expr { const at = this.advance(); // `@` let schema: string | null = null; // An optional `@` annotation precedes the backtick template // (query-forms.md QRY-3). The annotation is a type expression between angle // brackets — a named schema (`@`), a primitive (`@`), or a // nested generic (`@>`) — captured verbatim as the annotation. if (this.isPunct("<")) { this.advance(); // `<` const parts: string[] = []; let depth = 1; while (depth > 0 && !this.atEnd()) { if (this.isPunct("<")) { depth += 1; } else if (this.isPunct(">")) { depth -= 1; if (depth === 0) { this.advance(); break; } } parts.push(this.advance().text); } schema = parts.join("").trim(); } else if (!this.isPunct("`")) { // A bare `@Schema` (no angle brackets) annotation. const ann = this.peek(); if (ann.kind === "ident" || ann.kind === "keyword") { schema = ann.text; this.advance(); } } const parts: string[] = []; let openTick: Token | null = null; let closeTick: Token | null = null; if (this.isPunct("`")) { openTick = this.advance(); // opening backtick while (!this.isPunct("`") && !this.atEnd()) { parts.push(this.advance().text); } if (this.isPunct("`")) { closeTick = this.advance(); // closing backtick } } // Recover the verbatim template between the backticks from the raw body // source (the tokens are a lossy, space-joined view — they collapse the // author's spacing and drop interpolation braces). Fall back to the // space-joined tokens only when the raw slice is unavailable (no closing // backtick, or no body source threaded through). const rawTemplate = openTick !== null && closeTick !== null && this.bodyText.length > 0 ? this.bodyText.slice( positionToOffset(this.bodyText, openTick.range.end), positionToOffset(this.bodyText, closeTick.range.start), ) : parts.join(" "); return { kind: "query", schema, template: rawTemplate, range: spanRange(at.range, this.prevRange()), }; } private prevRange(): SourceRange { const prev = this.tokens[this.pos - 1]; return prev?.range ?? this.peek().range; } } /** Build a range spanning from `start`'s start to `end`'s end. */ function spanRange(start: SourceRange, end: SourceRange): SourceRange { return { start: start.start, end: end.end }; } /** * Classify an enum-variant explicit `= ` value token into the * `checkEnumDeclaration` value shape (kind + text). Only single-token literals * (string / number / `true` / `false` / `null`) are recognised; any other token * (e.g. a bare identifier) is left uncaptured. A non-string kind is retained so * the enum-declaration checker can reject it (schemas.md §Enum declarations). */ function classifyEnumValueToken( tok: Token, ): { kind: EnumValueKind; text: string } | undefined { if (tok.kind === "string") { return { kind: "string", text: tok.value ?? tok.text }; } if (tok.kind === "number") { return { kind: tok.numericType ?? "integer", text: tok.text }; } if (tok.kind === "keyword" && (tok.text === "true" || tok.text === "false")) { return { kind: "boolean", text: tok.text }; } if (tok.kind === "keyword" && tok.text === "null") { return { kind: "null", text: tok.text }; } return undefined; } /** * Convert a 1-indexed `{ line, column }` source position into a 0-based * character offset into `text` (newline-normalised to `\n`). Used to slice a * `@`...`` query template verbatim between its backtick token bounds. */ function positionToOffset(text: string, pos: Position): number { let offset = 0; let line = 1; while (line < pos.line && offset < text.length) { if (text[offset] === "\n") { line += 1; } offset += 1; } return offset + (pos.column - 1); } /** A synthetic `null` literal placeholder for a missing operand. */ function nullExpr(range: SourceRange): Expr { return { kind: "null", range }; } // -------------------------------------------------------------------------- // Identifier-resolution parse checker (`theta/parse/unknown-identifier`) // -------------------------------------------------------------------------- /** * Type / value names the theta 1.0 stdlib exposes bare (so they never read as an * unknown identifier). Primitive / generic type names never legally appear in * value position, but folding them in keeps the check false-positive-free if * one is written where the walk sees an identifier. `QueryError` / `Result` are * the error-model names an author may reference. */ const BUILTIN_VALUE_NAMES: ReadonlySet = new Set([ "string", "number", "integer", "boolean", "null", "void", "array", "Result", "QueryError", ]); /** * Derive the presented callable name for one `tools:` entry, mirroring * `callable-set.ts`: a bare Pi-tool name is used verbatim; a `.theta` path * contributes its basename (extension stripped, hyphens → underscores); an * `as ` rename overrides. Used to seed the identifier root scope so a * `(args)` callable call is not flagged as unknown. */ function toolCallableName(entry: string): string { const parts = entry.trim().split(/\s+/).filter((p) => p.length > 0); if (parts.length >= 3 && parts[1] === "as") { return parts[2] ?? ""; } const spec = parts[0] ?? ""; if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec)) { return spec; } const basename = spec.slice(spec.lastIndexOf("/") + 1); const stem = basename.endsWith(".theta") ? basename.slice(0, -".theta".length) : basename; return stem.replace(/-/g, "_"); } /** * Build the whole-file identifier root scope: every name visible everywhere in * the body regardless of source order — hoisted top-level `fn` names, `schema` / * `enum` names, imported / re-exported symbols, `params:` field names, resolved * `tools:` callable names, and the stdlib builtins. Theta-level `let` bindings are * NOT roots (they bind sequentially and are accumulated as the walk descends). */ function collectIdentRoots( statements: readonly Stmt[], frontmatter: ParsedFrontmatter | null, ): Set { const roots = new Set(BUILTIN_VALUE_NAMES); for (const s of statements) { switch (s.kind) { case "fn": case "schema": case "enum": roots.add(s.name); break; case "import": case "export": for (const sym of s.symbols) { roots.add(sym); } break; default: break; } } if (frontmatter !== null) { for (const f of frontmatter.params?.fields ?? []) { roots.add(f.wireName); } for (const entry of frontmatter.tools ?? []) { const name = toolCallableName(entry); if (name.length > 0) { roots.add(name); } } } return roots; } /** Collect every name a `match` pattern binds into `into` (arm-body scope). */ function collectPatternBindings(p: PatternNode, into: Set): void { switch (p.kind) { case "identifier": into.add(p.name); return; case "constructor": collectPatternBindings(p.inner, into); return; case "object": for (const f of p.fields) { collectPatternBindings(f.pattern, into); } return; case "array": for (const el of p.elements) { collectPatternBindings(el, into); } return; default: // wildcard / literal bind nothing. return; } } /** * Emit `theta/parse/unknown-identifier` (expressions.md §"Identifier resolution"; * REQ-EXPR-7) for a bare identifier in call or value position that resolves to * nothing in scope — not a `params:` field, `let` binding, `fn`, imported name, * `schema` / `enum`, resolved `tools:` callable, or builtin. Scope is tracked * block-locally: `let` bindings accumulate in declaration order, nested blocks * inherit a copy, and a `fn` body sees only the whole-file roots plus its own * parameters (theta 1.0 has no closures). Only names the walk actually reaches in * an identifier / call-callee / member-or-method receiver position are checked; * schema-constructor names, member field names, method names, object keys, and * `${…}` template interpolations are not identifier-resolution sites here. */ function checkUnknownIdentifiers( body: Block, roots: ReadonlySet, file: string, ): Diagnostic[] { const out: Diagnostic[] = []; walkIdentBlock(body, new Set(roots), roots, file, out); return out; } function emitUnknownIdentifier( name: string, range: SourceRange, scope: ReadonlySet, file: string, out: Diagnostic[], ): void { if (name.length === 0 || name === "_" || scope.has(name)) { return; } out.push({ severity: "error", code: "theta/parse/unknown-identifier", file, range, message: `unknown identifier '${name}'`, }); } function walkIdentBlock( block: Block, scope: Set, root: ReadonlySet, file: string, out: Diagnostic[], ): void { for (const s of block.statements) { walkIdentStmt(s, scope, root, file, out); } if (block.tail !== null) { walkIdentExpr(block.tail, scope, root, file, out); } } function walkIdentStmt( s: Stmt, scope: Set, root: ReadonlySet, file: string, out: Diagnostic[], ): void { switch (s.kind) { case "let": if (s.init !== null) { walkIdentExpr(s.init, scope, root, file, out); } if (s.name !== "_") { scope.add(s.name); } return; case "reassign": walkIdentExpr(s.value, scope, root, file, out); return; case "if": { walkIdentExpr(s.condition, scope, root, file, out); walkIdentBlock(s.then, new Set(scope), root, file, out); if (s.otherwise !== null) { if ("statements" in s.otherwise) { walkIdentBlock(s.otherwise, new Set(scope), root, file, out); } else { walkIdentStmt(s.otherwise, new Set(scope), root, file, out); } } return; } case "while": walkIdentExpr(s.condition, scope, root, file, out); walkIdentBlock(s.body, new Set(scope), root, file, out); return; case "for": { walkIdentExpr(s.iterand, scope, root, file, out); const inner = new Set(scope); inner.add(s.variable); walkIdentBlock(s.body, inner, root, file, out); return; } case "fn": { // A `fn` body is closure-free: it sees only the whole-file roots plus its // own parameters, NOT the enclosing theta-level `let` bindings. const fnScope = new Set(root); for (const p of s.params) { fnScope.add(p.name); } walkIdentBlock(s.body, fnScope, root, file, out); return; } case "return": if (s.operand !== null) { walkIdentExpr(s.operand, scope, root, file, out); } return; case "query": walkIdentExpr(s.query, scope, root, file, out); return; case "tool-call": walkIdentExpr(s.call, scope, root, file, out); return; case "invoke": walkIdentExpr(s.invoke, scope, root, file, out); return; case "expr": walkIdentExpr(s.expr, scope, root, file, out); return; default: // schema / enum / import / export / break / continue / doc-comment carry // no identifier-resolution sites. return; } } function walkIdentExpr( e: Expr, scope: Set, root: ReadonlySet, file: string, out: Diagnostic[], ): void { switch (e.kind) { case "ident": emitUnknownIdentifier(e.name, e.range, scope, file, out); return; case "call": // The callee is a bare identifier in call position. emitUnknownIdentifier(e.callee, e.range, scope, file, out); for (const arg of e.args) { walkIdentExpr(arg, scope, root, file, out); } return; case "binary": walkIdentExpr(e.left, scope, root, file, out); walkIdentExpr(e.right, scope, root, file, out); return; case "ternary": walkIdentExpr(e.condition, scope, root, file, out); walkIdentExpr(e.consequent, scope, root, file, out); walkIdentExpr(e.alternate, scope, root, file, out); return; case "try": walkIdentExpr(e.operand, scope, root, file, out); return; case "invoke": // The callee path is a string literal, not an identifier. for (const arg of e.args) { walkIdentExpr(arg, scope, root, file, out); } return; case "member": // The receiver is an identifier-resolution site; the `.field` name is not. walkIdentExpr(e.target, scope, root, file, out); return; case "index": walkIdentExpr(e.target, scope, root, file, out); walkIdentExpr(e.index, scope, root, file, out); return; case "method-call": // The receiver is a resolution site; the method name is A2's concern. walkIdentExpr(e.target, scope, root, file, out); for (const arg of e.args) { walkIdentExpr(arg, scope, root, file, out); } return; case "object": // The constructor / object keys are not value-position identifiers. for (const field of e.fields) { walkIdentExpr(field.value, scope, root, file, out); } return; case "array": for (const el of e.elements) { walkIdentExpr(el, scope, root, file, out); } return; case "result-ctor": walkIdentExpr(e.arg, scope, root, file, out); return; case "match": walkIdentExpr(e.scrutinee, scope, root, file, out); for (const arm of e.arms) { const armScope = new Set(scope); collectPatternBindings(arm.pattern, armScope); walkIdentExpr(arm.body, armScope, root, file, out); } return; default: // number / string / bool / null / query — no identifier sites. return; } } // -------------------------------------------------------------------------- // Structural (AST-shape) parse checkers (C2a wiring) // -------------------------------------------------------------------------- /** * The whole-file declaration references a structural check resolves against as * the walk descends: hoisted top-level `fn` names (for `function-as-value`) and * the declared enum-variant sets keyed by enum name (for `unknown-variant`). */ interface StructuralRefs { readonly fnNames: ReadonlySet; readonly enums: ReadonlyMap>; /** * Declared object-schema field names keyed by schema name (the * `schema X { field: T, … }` object form only). Drives the object-construction * checks: a `X { … }` constructor against a known object schema fires * `theta/parse/extra-object-field` for an undeclared field and * `theta/parse/missing-object-field` for an omitted required field. */ readonly schemas: ReadonlyMap; } /** The lexical context a structural check consults as the walk descends. */ interface WalkCtx { /** Whether the current statements sit inside a `for` / `while` body. */ readonly inLoop: boolean; /** Whether the current statements are the theta's top level (for `fn` placement). */ readonly topLevel: boolean; /** Whether the enclosing `fn` is `void`-annotated (for bare `return`). */ readonly voidReturn: boolean; } /** * Run the implemented structural (AST-shape) parse-checkers over the whole-file * body and aggregate their diagnostics. These are shape-level well-formedness * checks that need no type inference: loop-context (`break` / `continue`), `fn` * placement and first-class use, `let` initialiser presence, bare `return`, * unreachable code, empty object schemas, and the position-sensitive * type-grammar checks over declared type sources. (`mut`-context and member / * index assignment are emitted inline by the parser, where the source tokens * are still in hand.) */ function checkStructural(body: Block, file: string): Diagnostic[] { const out: Diagnostic[] = []; // Hoisted top-level `fn` names, so a bare reference to one in value position // is `theta/parse/function-as-value` (functions.md FN-1). const fnNames = new Set(); // Hoisted top-level `enum` declarations, so a `Enum.Variant` member access to // a variant the enum does not declare is `theta/parse/unknown-variant` // (schemas.md §Variant access). const enums = new Map>(); // Declared object-schema field name sets, so an object constructor against a // known object schema can be validated (extra / missing field). const schemas = new Map(); for (const s of body.statements) { if (s.kind === "fn") { fnNames.add(s.name); } else if (s.kind === "enum" && s.variants !== undefined) { enums.set(s.name, new Set(s.variants)); } else if (s.kind === "schema" && s.fields !== undefined) { schemas.set(s.name, s.fields.map((f) => f.name)); } } const refs: StructuralRefs = { fnNames, enums, schemas }; walkStatements( body.statements, { inLoop: false, topLevel: true, voidReturn: false }, refs, file, out, ); if (body.tail !== null) { walkExpr(body.tail, refs, file, out); } return out; } /** Push a checker's optional diagnostic result, dropping `undefined`. */ function pushDiag(out: Diagnostic[], diag: Diagnostic | undefined): void { if (diag !== undefined) { out.push(diag); } } function walkStatements( statements: readonly Stmt[], scope: WalkCtx, refs: StructuralRefs, file: string, out: Diagnostic[], ): void { // RET-3 — the first statement after a `return` in the same block is // unreachable (a warning). let returnedAt = -1; for (let i = 0; i < statements.length; i += 1) { const s = statements[i]; if (s === undefined) { continue; } if (returnedAt >= 0 && i === returnedAt + 1) { pushDiag( out, checkUnreachableCode( { hasCodeAfterReturn: true }, { file, range: s.range }, ), ); } walkStatement(s, scope, refs, file, out); if (s.kind === "return") { returnedAt = i; } } } function walkBlock( block: Block, scope: WalkCtx, refs: StructuralRefs, file: string, out: Diagnostic[], ): void { walkStatements(block.statements, scope, refs, file, out); if (block.tail !== null) { walkExpr(block.tail, refs, file, out); } } function walkStatement( s: Stmt, scope: WalkCtx, refs: StructuralRefs, file: string, out: Diagnostic[], ): void { switch (s.kind) { case "let": { pushDiag( out, checkLetBinding( { name: s.name, mutable: s.mutable, hasInitialiser: s.init !== null }, { file, range: s.range }, ), ); if (s.annotation !== null && s.annotation.length > 0) { out.push( ...parseTypeExpression(s.annotation, "value", { file, range: s.range }), ); } if (s.init !== null) { walkExpr(s.init, refs, file, out); } return; } case "reassign": walkExpr(s.value, refs, file, out); return; case "if": { walkExpr(s.condition, refs, file, out); walkBlock(s.then, { ...scope, topLevel: false }, refs, file, out); if (s.otherwise !== null) { if ("statements" in s.otherwise) { walkBlock(s.otherwise, { ...scope, topLevel: false }, refs, file, out); } else { walkStatement( s.otherwise, { ...scope, topLevel: false }, refs, file, out, ); } } return; } case "while": walkExpr(s.condition, refs, file, out); walkBlock( s.body, { ...scope, inLoop: true, topLevel: false }, refs, file, out, ); return; case "for": walkExpr(s.iterand, refs, file, out); walkBlock( s.body, { ...scope, inLoop: true, topLevel: false }, refs, file, out, ); return; case "break": pushDiag( out, checkBreakStatement( { insideLoop: scope.inLoop, hasValue: s.hasValue ?? false }, { file, range: s.range }, ), ); return; case "continue": pushDiag( out, checkContinueStatement( { insideLoop: scope.inLoop }, { file, range: s.range }, ), ); return; case "fn": { pushDiag( out, checkFnPlacement({ nested: !scope.topLevel }, { file, range: s.range }), ); for (const p of s.params) { if (p.type.length > 0) { out.push( ...parseTypeExpression(p.type, "value", { file, range: s.range }), ); } } if (s.returnType !== null && s.returnType.length > 0) { out.push( ...parseTypeExpression(s.returnType, "return", { file, range: s.range, }), ); } walkBlock( s.body, { inLoop: false, topLevel: false, voidReturn: s.returnType === "void" }, refs, file, out, ); return; } case "return": if (s.operand === null) { pushDiag( out, checkBareReturn( { returnTypeIsVoid: scope.voidReturn }, { file, range: s.range }, ), ); } else { walkExpr(s.operand, refs, file, out); } return; case "query": // QRY-19 (query-escapes-stringification.md#qry-19): a bare `@`...`` in // expression-statement position drops the must-use `Result` without // acknowledgement. A `QueryStmt` is produced only for a NON-tail bare // query — `parseForms` promotes a trailing line-start query to the // body/void tail (the accepted void-tail discard, QRY-20 territory), and // the `?`-propagate / `let _ =`-discard / `let x = …` binding forms parse // to `try` / `let` nodes — so its disposition is always // `bare-expr-statement`, the sole QRY-19 trigger. pushDiag( out, checkDiscardedQueryResult({ isQuery: true, disposition: "bare-expr-statement", file, range: s.range, }), ); walkExpr(s.query, refs, file, out); return; case "tool-call": walkExpr(s.call, refs, file, out); return; case "invoke": walkExpr(s.invoke, refs, file, out); return; case "expr": walkExpr(s.expr, refs, file, out); return; case "schema": { if (s.fields !== undefined) { out.push( ...checkObjectSchema( { name: s.name, fields: s.fields.map((f) => ({ thetaName: f.name, ...(f.wireName !== undefined ? { wireName: f.wireName } : {}), })), }, { file, range: s.range }, ), ); for (const f of s.fields) { // An inline `enum[...]` in a schema field type is `theta/parse/inline-enum` // — `enum` is top-level only (schemas.md §Enum declarations). pushDiag( out, checkInlineEnumForm(f.typeSource, { file, range: s.range }), ); out.push( ...parseTypeExpression(f.typeSource, "schema-feeding", { file, range: s.range, }), ); } } return; } case "enum": { // Enum-declaration well-formedness (schemas.md §Enum declarations): empty // body, non-string explicit values, duplicate variant names. The // `variantDecls` retain non-string explicit values (unlike the runtime // `variantValues`) so they are rejected here. if (s.variantDecls !== undefined) { out.push( ...checkEnumDeclaration( { name: s.name, variants: s.variantDecls }, { file, range: s.range }, ), ); } return; } default: return; } } /** * Validate an object-construction expression (expressions.md §"Object * construction"). A bare `{ field: expr }` (no schema name) in expression * position outside the two documented carve-outs (`params:` defaults; the single * argument of a Pi-tool call) is `theta/parse/bare-object-literal`; the caller * passes `bareAllowed` for the carve-out positions. A named constructor * `Schema { … }` against a declared object schema fires * `theta/parse/extra-object-field` for a field the schema does not declare and * `theta/parse/missing-object-field` for an omitted required field (every * declared field is required — schemas.md; no `field?:` shorthand). */ function checkObjectExpr( e: ObjectExpr, refs: StructuralRefs, file: string, out: Diagnostic[], bareAllowed: boolean, ): void { if (e.typeName === null) { if (!bareAllowed) { out.push({ severity: "error", code: "theta/parse/bare-object-literal", file, range: e.range, message: "bare object literal not permitted in this position; name the schema (Schema { ... })", }); } return; } const declared = refs.schemas.get(e.typeName); if (declared === undefined) { // Not a statically-declared object schema (an alias / union / enum / // imported / builtin name): the field-set check needs the declared shape, so // defer — do not guess. return; } const declaredSet = new Set(declared); const present = e.fields.map((f) => f.name); for (const field of present) { if (!declaredSet.has(field)) { out.push({ severity: "error", code: "theta/parse/extra-object-field", file, range: e.range, message: `extra field '${field}' on schema '${e.typeName}'`, }); } } out.push( ...checkObjectLiteralFields( { name: e.typeName, fields: declared }, present, { file, range: e.range }, ), ); } function walkExpr( e: Expr, refs: StructuralRefs, file: string, out: Diagnostic[], bareObjectAllowed = false, ): void { switch (e.kind) { case "ident": if (refs.fnNames.has(e.name)) { pushDiag( out, checkFunctionReference( { name: e.name, position: "value" }, { file, range: e.range }, ), ); } return; case "binary": walkExpr(e.left, refs, file, out); walkExpr(e.right, refs, file, out); return; case "ternary": walkExpr(e.condition, refs, file, out); walkExpr(e.consequent, refs, file, out); walkExpr(e.alternate, refs, file, out); return; case "try": walkExpr(e.operand, refs, file, out); return; case "call": // Carve-out: a bare `{ … }` as the single argument of a Pi-tool call is // permitted (expressions.md §"Object construction"). A sole bare-object // argument is walked with the bare-object check suppressed; its nested // fields are still validated. for (const arg of e.args) { const soleBareObject = e.args.length === 1 && arg.kind === "object" && arg.typeName === null; walkExpr(arg, refs, file, out, soleBareObject); } return; case "invoke": for (const arg of e.args) { walkExpr(arg, refs, file, out); } return; case "member": { // A `Enum.Variant` member access (target is a bare enum name) to a variant // the enum does not declare is `theta/parse/unknown-variant` at parse time // (schemas.md §Variant access). if (e.target.kind === "ident") { const variants = refs.enums.get(e.target.name); if (variants !== undefined) { pushDiag( out, checkVariantAccess( { enumName: e.target.name, variant: e.field, knownVariants: [...variants], }, { file, range: e.range }, ), ); } } walkExpr(e.target, refs, file, out); return; } case "index": walkExpr(e.target, refs, file, out); walkExpr(e.index, refs, file, out); return; case "object": checkObjectExpr(e, refs, file, out, bareObjectAllowed); for (const field of e.fields) { walkExpr(field.value, refs, file, out); } return; case "match": walkExpr(e.scrutinee, refs, file, out); for (const arm of e.arms) { walkExpr(arm.body, refs, file, out); } return; case "result-ctor": walkExpr(e.arg, refs, file, out); return; case "method-call": walkExpr(e.target, refs, file, out); for (const arg of e.args) { walkExpr(arg, refs, file, out); } return; case "array": for (const el of e.elements) { walkExpr(el, refs, file, out); } return; case "query": // A `@`-query's `${…}` interpolations are captured verbatim, so a `match` // or nested `@`-query inside one is invisible to the whole-document walk // above; re-lex and inspect them here so the forms expressions.md §"Not // supported" forbids inside `${…}` are rejected at load time. checkQueryTemplateInterpolations(e, file, out); return; default: // number / string / bool / null — no nested expressions. return; } } /** * Reject the interpolation forms expressions.md §"Not supported" forbids inside * a `@`-query `${…}` — a nested `match` expression or a nested `@`-query * template — with `theta/parse/unsupported-feature`. Both are admitted only at * statement / `let`-RHS level so template evaluation stays code-only and never * silently fires a model turn. Each `${…}` interpolation source is re-lexed * (`lexQueryTemplate`) and parsed as a full expression (`parseExpressionSource`, * the same entry the render path drives), then its subtree is scanned for a * forbidden node. The whole `@`-query range locates the diagnostic — the * verbatim template carries no per-interpolation token span. */ function checkQueryTemplateInterpolations( e: QueryExpr, file: string, out: Diagnostic[], ): void { for (const part of lexQueryTemplate(e.template).parts) { if (part.kind !== "interp") { continue; } const parsed = parseExpressionSource(part.exprSource); if (parsed === null) { // A malformed interpolation must still not silently smuggle a forbidden // `match` / nested `@`-query past the AST walk (which is unavailable when // the source does not parse). Both are reserved forms — `match` a // keyword, `@` a punct — so a token-level scan cannot false-positive on // string-literal contents; flag it rather than skipping. const tokenForbidden = firstForbiddenInterpolationToken(part.exprSource); if (tokenForbidden !== null) { out.push({ severity: "error", code: "theta/parse/unsupported-feature", file, range: e.range, message: "unsupported syntactic feature: " + tokenForbidden + " inside ${...} interpolation", }); } continue; } const forbidden = firstForbiddenInterpolationForm(parsed); if (forbidden !== null) { out.push({ severity: "error", code: "theta/parse/unsupported-feature", file, range: e.range, message: "unsupported syntactic feature: " + forbidden + " inside ${...} interpolation", }); } } } /** * A forbidden interpolation construct detected at the TOKEN level, for the * malformed-interpolation path where `parseExpressionSource` returns `null` and * the AST walk is unavailable. `match` is a reserved keyword and `@` a punct, so * a token match is unambiguous (never a string-literal false positive). Returns * `"match"` / `"@-query template"` for the first such token, else `null`. */ function firstForbiddenInterpolationToken(source: string): string | null { const lex = lexTheta( { path: "", bytes: encodeSource(source) }, { pi: { sendMessage: () => {} }, ui: { notify: () => {} }, emitDiagnostic: () => {}, }, ); for (const t of lex.tokens) { if (t.kind === "keyword" && t.text === "match") { return "match"; } if (t.kind === "punct" && t.text === "@") { return "@-query template"; } } return null; } /** * The construct name of the first `match` or nested `@`-query node in `e`'s * subtree (`"match"` / `"@-query template"`), or `null` when none is present. * Walks the child expressions so a `match` / `@`-query buried in a larger * interpolation expression (`${1 + match … }`) is still caught. */ function firstForbiddenInterpolationForm(e: Expr): string | null { if (e.kind === "match") { return "match"; } if (e.kind === "query") { return "@-query template"; } for (const child of interpolationChildExprs(e)) { const found = firstForbiddenInterpolationForm(child); if (found !== null) { return found; } } return null; } /** The direct child expressions of `e` (for the interpolation-form scan). */ function interpolationChildExprs(e: Expr): readonly Expr[] { switch (e.kind) { case "binary": return [e.left, e.right]; case "ternary": return [e.condition, e.consequent, e.alternate]; case "try": return [e.operand]; case "call": case "invoke": return e.args; case "member": return [e.target]; case "index": return [e.target, e.index]; case "object": return e.fields.map((f) => f.value); case "match": return [e.scrutinee, ...e.arms.map((arm) => arm.body)]; case "result-ctor": return [e.arg]; case "method-call": return [e.target, ...e.args]; case "array": return e.elements; default: return []; } }