Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion compiler/src/emit_capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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}`;
Expand Down
5 changes: 5 additions & 0 deletions compiler/src/lexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export enum TokenKind {
MinusEq = "MinusEq",
AppendEq = "AppendEq",
Question = "Question",
NullCoalesce = "NullCoalesce",
Bang = "Bang",

// Literals
Expand Down Expand Up @@ -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) {
Expand Down
13 changes: 12 additions & 1 deletion compiler/src/parse_expr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand All @@ -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)) {
Expand Down
5 changes: 5 additions & 0 deletions compiler/src/parser_base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Expand Down
16 changes: 16 additions & 0 deletions compiler/src/parser_recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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");
Expand Down
33 changes: 33 additions & 0 deletions compiler/src/tests/test_typechecker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>) {
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<uint>]
constraints: [(price_cents ?? 0) > 0]
}
}
`, "T013");

expect("T013: ?? rejected in top-level constraint", `
system NCBadTop {
entity Product {
owns: [price_cents: optional<uint>]
}
constraint nonneg: (Product.price_cents ?? 0) >= 0
}
`, "T013");

// ─── Summary ─────────────────────────────────────────────────────────────────

console.log(`\n═══════════════════════════════════════`);
Expand Down
34 changes: 34 additions & 0 deletions compiler/src/typechecker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
}
Expand Down