diff --git a/compiler/src/emit_capability.ts b/compiler/src/emit_capability.ts index a72e1eb..0539db1 100644 --- a/compiler/src/emit_capability.ts +++ b/compiler/src/emit_capability.ts @@ -41,7 +41,7 @@ function parseExprStr(s: string): Expr { } // Binary operators (check in precedence order, right-to-left to handle left-assoc) - const binOps = [" or ", " and ", " == ", " != ", " >= ", " <= ", " > ", " < ", " in ", " contains ", " + ", " - ", " * ", " / "]; + const binOps = [" ?? ", " or ", " and ", " == ", " != ", " >= ", " <= ", " > ", " < ", " in ", " contains ", " + ", " - ", " * ", " / "]; for (const op of binOps) { const idx = findBinOp(s, op); if (idx !== -1) { @@ -198,6 +198,7 @@ function exprToTsInner(expr: Expr): string { case "or": return `(${l} || ${r})`; case "in": return `[${r}].flat().includes(${l})`; case "contains": return `${l}?.includes(${r})`; + case "??": return `(${l} ?? ${r})`; case ">": case "<": case ">=": case "<=": case "+": case "-": case "*": case "/": return `${l} ${expr.op} ${r}`; diff --git a/compiler/src/lexer.ts b/compiler/src/lexer.ts index 54f0b92..6994710 100644 --- a/compiler/src/lexer.ts +++ b/compiler/src/lexer.ts @@ -42,6 +42,7 @@ export enum TokenKind { MinusEq = "MinusEq", AppendEq = "AppendEq", Question = "Question", + NullCoalesce = "NullCoalesce", Bang = "Bang", // Literals @@ -704,6 +705,10 @@ export class Lexer { this.advance(); this.advance(); return { kind: TokenKind.DotDot, value: "..", loc }; } + if (ch === "?" && this.peekAt(1) === "?") { + this.advance(); this.advance(); + return { kind: TokenKind.NullCoalesce, value: "??", loc }; + } // Single-character operators switch (ch) { diff --git a/compiler/src/parse_expr.ts b/compiler/src/parse_expr.ts index 80d8ccb..36c4fda 100644 --- a/compiler/src/parse_expr.ts +++ b/compiler/src/parse_expr.ts @@ -7,7 +7,7 @@ import { TokenStream, ParseError } from "./parser_base"; import * as AST from "./ast"; export function parseExpr(s: TokenStream): AST.ExprNode { - return parseLogicalOr(s); + return parseNullCoalesce(s); } export function parseExprList(s: TokenStream): AST.ExprNode[] { @@ -19,6 +19,17 @@ export function parseExprList(s: TokenStream): AST.ExprNode[] { return exprs; } +function parseNullCoalesce(s: TokenStream): AST.ExprNode { + let left = parseLogicalOr(s); + while (s.check(TokenKind.NullCoalesce)) { + const loc = s.peek().loc; + s.advance(); + const right = parseLogicalOr(s); + left = { kind: "BinaryExpr", loc, op: "??", left, right }; + } + return left; +} + function parseLogicalOr(s: TokenStream): AST.ExprNode { let left = parseLogicalAnd(s); while (s.check(TokenKind.KwOr)) { diff --git a/compiler/src/parser_base.ts b/compiler/src/parser_base.ts index 5bd9099..3e5870c 100644 --- a/compiler/src/parser_base.ts +++ b/compiler/src/parser_base.ts @@ -38,6 +38,11 @@ export class TokenStream { this.loopGuard = 0; } + /** Current position in the token stream (used to detect lack of progress during error recovery) */ + position(): number { + return this.pos; + } + peek(offset: number = 0): Token { return this.tokens[this.pos + offset] ?? this.tokens[this.tokens.length - 1]; } diff --git a/compiler/src/parser_recovery.ts b/compiler/src/parser_recovery.ts index 4c6f8e9..00e6a8b 100644 --- a/compiler/src/parser_recovery.ts +++ b/compiler/src/parser_recovery.ts @@ -57,6 +57,7 @@ export class RecoveringParser { const systems: AST.SystemDeclNode[] = []; while (!this.s.check(TokenKind.EOF)) { + const before = this.s.position(); try { systems.push(this.parseSystemDecl()); } catch (e) { @@ -67,6 +68,11 @@ export class RecoveringParser { throw e; } } + // Guarantee forward progress: if neither parsing nor synchronization + // consumed a token, skip one to avoid an infinite recovery loop. + if (this.s.position() === before && !this.s.check(TokenKind.EOF)) { + this.s.advance(); + } } if (systems.length === 0 && this.errors.length === 0) { @@ -107,6 +113,7 @@ export class RecoveringParser { const declarations: AST.DeclarationNode[] = []; while (!this.s.check(TokenKind.RBrace) && !this.s.check(TokenKind.EOF)) { + const before = this.s.position(); try { declarations.push(this.parseDeclaration()); } catch (e) { @@ -117,6 +124,15 @@ export class RecoveringParser { throw e; } } + // Guarantee forward progress to avoid an infinite recovery loop when + // synchronization lands on a token the body loop cannot consume. + if ( + this.s.position() === before && + !this.s.check(TokenKind.RBrace) && + !this.s.check(TokenKind.EOF) + ) { + this.s.advance(); + } } this.s.expect(TokenKind.RBrace, "system body close"); diff --git a/compiler/src/tests/test_typechecker.ts b/compiler/src/tests/test_typechecker.ts index 43bc423..0768f4d 100644 --- a/compiler/src/tests/test_typechecker.ts +++ b/compiler/src/tests/test_typechecker.ts @@ -125,6 +125,39 @@ system BadEmit { } `, "T011"); +// ─── Null-coalescing operator (??) ─────────────────────────────────────────── + +expect("?? allowed in capability effects", ` +system NCOk { + entity Product { + owns: [title: string] + } + capability update_product(p: Product, title: optional) { + requires: [p.title != ""] + effects: [p.title = title ?? p.title] + sync: eventual + } +} +`, null); + +expect("T013: ?? rejected in entity constraint", ` +system NCBadEntity { + entity Product { + owns: [price_cents: optional] + constraints: [(price_cents ?? 0) > 0] + } +} +`, "T013"); + +expect("T013: ?? rejected in top-level constraint", ` +system NCBadTop { + entity Product { + owns: [price_cents: optional] + } + constraint nonneg: (Product.price_cents ?? 0) >= 0 +} +`, "T013"); + // ─── Summary ───────────────────────────────────────────────────────────────── console.log(`\n═══════════════════════════════════════`); diff --git a/compiler/src/typechecker.ts b/compiler/src/typechecker.ts index 52288d1..50a8e8f 100644 --- a/compiler/src/typechecker.ts +++ b/compiler/src/typechecker.ts @@ -325,6 +325,9 @@ export class TypeChecker { if (entitySym) { const ctx = new TypeContext(entitySym.type.fields, this.symbols); for (const constraint of decl.constraints) { + if (this.exprUsesNullCoalesce(constraint)) { + this.addError("T013", "The '??' operator is not allowed in entity constraints (constraints compile to SQL)", constraint.loc); + } const ctype = this.inferExprType(constraint, ctx); if (ctype && ctype.tag !== "primitive") { this.addError("T005", `Constraint expression must type to bool, got ${typeToString(ctype)}`, constraint.loc); @@ -522,12 +525,36 @@ export class TypeChecker { private checkConstraint(decl: AST.ConstraintDeclNode) { // Top-level constraints are checked in a global context const globalCtx = new TypeContext(new Map(), this.symbols); + if (this.exprUsesNullCoalesce(decl.expr)) { + this.addError("T013", `The '??' operator is not allowed in constraint '${decl.name}' (constraints compile to SQL)`, decl.loc); + } const ctype = this.inferExprType(decl.expr, globalCtx); if (ctype && !this.isBoolish(ctype)) { this.addError("T005", `Top-level constraint '${decl.name}' must type to bool`, decl.loc); } } + /** Recursively detect the null-coalescing operator anywhere in an expression. */ + private exprUsesNullCoalesce(expr: AST.ExprNode): boolean { + switch (expr.kind) { + case "BinaryExpr": + return expr.op === "??" || this.exprUsesNullCoalesce(expr.left) || this.exprUsesNullCoalesce(expr.right); + case "UnaryExpr": + return this.exprUsesNullCoalesce(expr.operand); + case "CallExpr": + return expr.args.some(a => this.exprUsesNullCoalesce(a)); + case "TernaryExpr": + return this.exprUsesNullCoalesce(expr.condition) || this.exprUsesNullCoalesce(expr.consequent) || this.exprUsesNullCoalesce(expr.alternate); + case "Literal": + if (expr.type === "list" && Array.isArray(expr.value)) { + return (expr.value as AST.ExprNode[]).some(e => this.exprUsesNullCoalesce(e)); + } + return false; + default: + return false; + } + } + // ─── Expression Type Inference ───────────────────────────────────────────── private inferExprType(expr: AST.ExprNode, ctx: TypeContext): CVType | null { @@ -651,6 +678,13 @@ export class TypeChecker { case "-": return prim("int"); // subtraction may produce negative + // Null-coalescing: `a ?? b` yields the unwrapped (non-optional) type + case "??": { + const unwrap = (t: CVType | null): CVType | null => + t && t.tag === "generic" && t.name === "optional" ? t.args[0] : t; + return unwrap(left) ?? unwrap(right) ?? prim("json"); + } + default: return prim("bool"); }