fix: resolve all TypeScript codegen bugs for zero tsc --noEmit errors

Parser fixes:
- Fixed multiline state/init block parsing: trailing commas no longer
  treated as continuation markers, merging all state vars into one
- Added splitFields() function for comma-aware emit field splitting
  that respects parenthesized groups and quoted strings
- Fixed interface block parsing: enum-style interfaces (name-only
  values like 'Idle, Parsing, Parsed, ...') now correctly parsed as
  methods instead of being skipped (methods.length was 0)

TypeScript generator fixes:
- String[] array type syntax now correctly maps to string[] in output
- collectIdents filters out names containing brackets
- Duplicate event interfaces deduplicated (implicit emit events
  filtered when explicit events already declare them)
- join(X, Y) translates to X + Y (string concatenation) instead of
  X.join(Y) which is wrong for string args
- now() generates new Date().toISOString() instead of Date.now()
  to match String/string type annotations
- decisionLog type uses proper union: (A | B | C)[] instead of A[]
- String literals protected from this.-prefixing during identifier
  replacement (fixes 'leader' inside quotes becoming 'this.leader')
- Removed debug console.log from enum generation

Both TigerBeetleLedger.speck and speckl-compile.speckdl now generate
TypeScript that passes tsc --noEmit with zero errors.

Closes #9
This commit is contained in:
Scott Scoble 2026-05-13 14:34:51 -07:00
parent dbde01ccb5
commit d16df7581a
10 changed files with 1660 additions and 84 deletions

View file

@ -612,15 +612,18 @@ function parseSource(line) {
function parseStateBlockMultiline(lines, startIndex, startBraceCount) { function parseStateBlockMultiline(lines, startIndex, startBraceCount) {
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount); const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*')); const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
// Join lines: use '; ' between complete variable declarations // State blocks separate fields with commas or newlines/semicolons.
const inner = innerLines.reduce((acc, line, i) => { // Strip trailing commas (they are field separators, not continuations)
// then join with semicolons between complete variable declarations.
const cleanedLines = innerLines.map(l => l.replace(/,+\s*$/, ''));
const inner = cleanedLines.reduce((acc, line, i) => {
if (i === 0) if (i === 0)
return line; return line;
const prevLine = innerLines[i - 1]; const prevLine = cleanedLines[i - 1];
// Check if this line starts a new variable declaration (name: type) // Check if this line starts a new variable declaration (name: type)
const isNewVar = /^\w+\s*:/.test(line); const isNewVar = /^\w+\s*:/.test(line);
// Check if prev line ends with a continuation character // Check if prev line ends with a continuation character (brace or paren, NOT comma)
const prevIsContinuation = /[{(,]\s*$/.test(prevLine); const prevIsContinuation = /[{(]\s*$/.test(prevLine);
if (isNewVar && !prevIsContinuation) { if (isNewVar && !prevIsContinuation) {
return acc + '; ' + line; return acc + '; ' + line;
} }
@ -631,12 +634,15 @@ function parseStateBlockMultiline(lines, startIndex, startBraceCount) {
function parseInitBlockMultiline(lines, startIndex, startBraceCount) { function parseInitBlockMultiline(lines, startIndex, startBraceCount) {
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount); const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*')); const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
const inner = innerLines.reduce((acc, line, i) => { // Init blocks separate fields with commas or newlines/semicolons.
// Strip trailing commas and join with semicolons.
const cleanedLines = innerLines.map(l => l.replace(/,+\s*$/, ''));
const inner = cleanedLines.reduce((acc, line, i) => {
if (i === 0) if (i === 0)
return line; return line;
const prevLine = innerLines[i - 1]; const prevLine = cleanedLines[i - 1];
const isNewVar = /^\w+\s*:/.test(line); const isNewVar = /^\w+\s*:/.test(line);
const prevIsContinuation = /[{(,]\s*$/.test(prevLine); const prevIsContinuation = /[{(]\s*$/.test(prevLine);
if (isNewVar && !prevIsContinuation) { if (isNewVar && !prevIsContinuation) {
return acc + '; ' + line; return acc + '; ' + line;
} }
@ -736,7 +742,7 @@ function parseActionBlockMultiline(lines, startIndex, startBraceCount) {
const fieldsStr = emitMatch[2]; const fieldsStr = emitMatch[2];
const fields = []; const fields = [];
if (fieldsStr) { if (fieldsStr) {
const fieldPairs = fieldsStr.split(',').map(s => s.trim()).filter(Boolean); const fieldPairs = splitFields(fieldsStr);
for (const fp of fieldPairs) { for (const fp of fieldPairs) {
const colonIdx = fp.indexOf(':'); const colonIdx = fp.indexOf(':');
if (colonIdx > 0) { if (colonIdx > 0) {
@ -821,23 +827,32 @@ function parseInterfaceBlockMultiline(lines, startIndex, startBraceCount) {
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*')); const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
const methods = []; const methods = [];
for (const line of innerLines) { for (const line of innerLines) {
// name(params) -> returnType // name-only: enum-style values (e.g., "Idle", "Idle, Parsing" comma-separated)
const match = line.match(/^(\w+)\s*\(([^)]*)\)\s*-\u003e\s*(.+?),?$/); // Split on comma and take each trimmed token
if (match) { const tokens = line.split(',').map(s => s.trim()).filter(Boolean);
const name = match[1]; for (const token of tokens) {
const paramsStr = match[2]; // Check if it’s a name(params) -> returnType method signature
const returnTypeStr = match[3].replace(/,$/, '').trim(); const methodMatch = token.match(/^(\w+)\s*\(([^)]*)\)\s*->\s*(.+?),?$/);
const params = []; if (methodMatch) {
if (paramsStr.trim()) { const name = methodMatch[1];
const paramPairs = paramsStr.split(',').map(s => s.trim()).filter(Boolean); const paramsStr = methodMatch[2];
for (const p of paramPairs) { const returnTypeStr = methodMatch[3].replace(/,$/, '').trim();
const pMatch = p.match(/^(\w+)\s*:\s*(.+)$/); const params = [];
if (pMatch) { if (paramsStr.trim()) {
params.push({ name: pMatch[1], type: parseTypeExpr(pMatch[2].trim()) }); const paramPairs = paramsStr.split(',').map(s => s.trim()).filter(Boolean);
for (const p of paramPairs) {
const pMatch = p.match(/^(\w+)\s*:\s*(.+)$/);
if (pMatch) {
params.push({ name: pMatch[1], type: parseTypeExpr(pMatch[2].trim()) });
}
} }
} }
methods.push({ name, params, returnType: parseTypeExpr(returnTypeStr) });
}
else if (/^\w+$/.test(token)) {
// Name-only (enum-style) — treat as a method with no params and void return
methods.push({ name: token, params: [], returnType: parseTypeExpr('Void') });
} }
methods.push({ name, params, returnType: parseTypeExpr(returnTypeStr) });
} }
} }
return { ...header, methods }; return { ...header, methods };
@ -1037,4 +1052,41 @@ function parseConstraintBlockMultiline(lines, startIndex, startBraceCount) {
} }
return null; return null;
} }
/** Split on commas while respecting parenthesized groups and quoted strings */
function splitFields(s) {
const result = [];
let depth = 0;
let inDouble = false;
let inSingle = false;
let start = 0;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (ch === '"' && !inSingle) {
inDouble = !inDouble;
continue;
}
if (ch === "'" && !inDouble) {
inSingle = !inSingle;
continue;
}
if (inDouble || inSingle)
continue;
if (ch === '(' || ch === '{') {
depth++;
continue;
}
if (ch === ')' || ch === '}') {
depth--;
continue;
}
if (ch === ',' && depth === 0) {
result.push(s.substring(start, i).trim());
start = i + 1;
}
}
const last = s.substring(start).trim();
if (last)
result.push(last);
return result;
}
//# sourceMappingURL=parser.js.map //# sourceMappingURL=parser.js.map

File diff suppressed because one or more lines are too long

155
compiler/src/expr/ast.ts Normal file
View file

@ -0,0 +1,155 @@
// SpeckDL Expression AST nodes
// These represent parsed SpeckDL expressions as a typed tree.
import { Token } from './tokenizer';
// --- Expression nodes ---
export type Expr =
| LiteralExpr
| IdentExpr
| UnaryExpr
| BinaryExpr
| CallExpr
| MemberExpr
| IndexExpr
| IfExpr
| ObjectExpr // { key: val, ... }
| SetExpr // { elem, elem, ... }
| MapExpr // { key -> val, key -> val }
| RangeExpr // [0..N]
| ParenthesizedExpr;
// --- Literals ---
export interface LiteralExpr {
kind: 'literal';
type: 'string' | 'number' | 'bool' | 'null';
value: string;
token: Token;
}
// --- Identifier ---
export interface IdentExpr {
kind: 'ident';
name: string;
token: Token;
}
// --- Unary operators: not, - ---
export interface UnaryExpr {
kind: 'unary';
op: 'not' | 'neg';
operand: Expr;
token: Token;
}
// --- Binary operators ---
export type BinaryOp =
| 'eq' // ==
| 'neq' // !=
| 'lt' // <
| 'gt' // >
| 'lte' // <=
| 'gte' // >=
| 'add' // +
| 'sub' // -
| 'mul' // *
| 'div' // /
| 'mod' // %
| 'and' // and
| 'or' // or
| 'in' // in
| 'union' // union
| 'diff' // \
| 'notin' // notIn
| 'concat'; // + for sets/lists
export interface BinaryExpr {
kind: 'binary';
op: BinaryOp;
left: Expr;
right: Expr;
token: Token; // operator token
}
// --- Function calls: hash(x), length(x), size(x), etc. ---
export interface CallExpr {
kind: 'call';
callee: string; // function name
args: Expr[];
token: Token;
}
// --- Member access: x.field ---
export interface MemberExpr {
kind: 'member';
object: Expr;
property: string;
token: Token; // dot token
}
// --- Index access: x[y] ---
export interface IndexExpr {
kind: 'index';
object: Expr;
index: Expr;
token: Token; // [ token
}
// --- If expression: if cond: thenExpr else elseExpr ---
export interface IfExpr {
kind: 'if';
condition: Expr;
thenBranch: Expr;
elseBranch: Expr | null; // null if no else
token: Token;
}
// --- Object/record literal: { key: val, ... } ---
export interface ObjectExpr {
kind: 'object';
fields: { key: string; value: Expr }[];
token: Token;
}
// --- Set literal: { elem, elem, ... } ---
export interface SetExpr {
kind: 'set';
elements: Expr[];
token: Token;
}
// --- Map literal: { key -> val, key -> val } ---
export interface MapExpr {
kind: 'map';
entries: { key: Expr; value: Expr }[];
token: Token;
}
// --- Range: [0..N] ---
export interface RangeExpr {
kind: 'range';
start: number;
end: Expr; // identifier or number
token: Token;
}
// --- Parenthesized expression ---
export interface ParenthesizedExpr {
kind: 'parens';
expr: Expr;
token: Token;
}

View file

@ -0,0 +1,5 @@
export { Tokenizer, Token, TokenType, tokenize } from './tokenizer';
export { ExpressionParser, parseExpression } from './parser';
export * from './ast';
export * from './stmt-ast';
export { parseActionStatements } from './stmt-parser';

411
compiler/src/expr/parser.ts Normal file
View file

@ -0,0 +1,411 @@
// SpeckDL Expression Parser
// Recursive-descent + Pratt parser for SpeckDL expressions.
// Takes a token stream and produces an Expr AST.
import { Token, TokenType } from './tokenizer';
import {
Expr, BinaryOp, LiteralExpr, IdentExpr, UnaryExpr, BinaryExpr,
CallExpr, MemberExpr, IndexExpr, IfExpr, ObjectExpr, SetExpr,
MapExpr, RangeExpr, ParenthesizedExpr
} from './ast';
// --- Precedence (higher = binds tighter) ---
const PREC = {
LOWEST: 0,
LOGICAL: 1, // or, and, ||, &&
EQUALITY: 2, // ==, !=
COMPARISON: 3, // <, >, <=, >=
SET: 4, // in, notIn
ADDITIVE: 5, // +, -, union, \
MULTIPLY: 6, // *, /, %
PREFIX: 7, // not, -
POSTFIX: 8, // f(), x.y, x[y]
} as const;
/** Get operator precedence. Handles both symbol tokens and ident-based keywords. */
function precOf(type: TokenType, value?: string): number {
// Ident-based keywords (or, and, in, not, union)
if (type === TokenType.IDENT) {
switch (value) {
case 'or': case 'and': return PREC.LOGICAL;
case 'in': case 'notIn': return PREC.SET;
case 'not': return PREC.PREFIX;
case 'union': return PREC.ADDITIVE;
}
}
switch (type) {
case TokenType.DBLPIPE: case TokenType.DBLAMP: return PREC.LOGICAL;
case TokenType.EQ: case TokenType.NEQ: return PREC.EQUALITY;
case TokenType.LT: case TokenType.GT:
case TokenType.LTE: case TokenType.GTE: return PREC.COMPARISON;
case TokenType.NOTIN: return PREC.SET;
case TokenType.ADD: case TokenType.SUB:
case TokenType.UNION: case TokenType.BACKSLASH: return PREC.ADDITIVE;
case TokenType.MUL: case TokenType.DIV: case TokenType.MOD: return PREC.MULTIPLY;
case TokenType.LPAREN: case TokenType.DOT:
case TokenType.LBRACKET: return PREC.POSTFIX;
default: return PREC.LOWEST;
}
}
/** Map token type + value to binary operator name. */
function binOp(type: TokenType, value?: string): BinaryOp | null {
if (type === TokenType.IDENT) {
switch (value) {
case 'or': return 'or';
case 'and': return 'and';
case 'in': return 'in';
case 'notIn': return 'notin';
case 'union': return 'union';
}
}
switch (type) {
case TokenType.EQ: return 'eq';
case TokenType.NEQ: return 'neq';
case TokenType.LT: return 'lt';
case TokenType.GT: return 'gt';
case TokenType.LTE: return 'lte';
case TokenType.GTE: return 'gte';
case TokenType.ADD: return 'add';
case TokenType.SUB: return 'sub';
case TokenType.MUL: return 'mul';
case TokenType.DIV: return 'div';
case TokenType.MOD: return 'mod';
case TokenType.DBLPIPE: return 'or';
case TokenType.DBLAMP: return 'and';
case TokenType.BACKSLASH: return 'diff';
case TokenType.UNION: return 'union';
case TokenType.NOTIN: return 'notin';
default: return null;
}
}
export class ExpressionParser {
private tokens: Token[];
private pos: number = 0;
constructor(tokens: Token[]) {
this.tokens = tokens;
}
parse(): Expr {
return this.expr(PREC.LOWEST);
}
/** Parse expression with given minimum precedence. */
private expr(minPrec: number): Expr {
const tok = this.advance();
let left = this.prefix(tok);
while (this.pos < this.tokens.length) {
const peek = this.current();
const p = precOf(peek.type, peek.value);
if (p <= minPrec) break;
// COLON only used inside if-expr (handled in prefix) — stop here
if (peek.type === TokenType.COLON) break;
const opTok = this.advance();
left = this.infix(left, opTok);
}
return left;
}
// --- Prefix parsers ---
private prefix(tok: Token): Expr {
switch (tok.type) {
case TokenType.IDENT:
case TokenType.TYPE:
case TokenType.FORALL:
case TokenType.CONSTANT:
return this.identOrCall(tok);
case TokenType.NUMBER:
return { kind: 'literal', type: 'number', value: tok.value, token: tok } as LiteralExpr;
case TokenType.STRING:
return { kind: 'literal', type: 'string', value: tok.value, token: tok } as LiteralExpr;
case TokenType.BOOL:
return { kind: 'literal', type: 'bool', value: tok.value, token: tok } as LiteralExpr;
case TokenType.NULL:
return { kind: 'literal', type: 'null', value: 'null', token: tok } as LiteralExpr;
case TokenType.NOT:
return { kind: 'unary', op: 'not', operand: this.expr(PREC.PREFIX), token: tok } as UnaryExpr;
case TokenType.SUB:
return { kind: 'unary', op: 'neg', operand: this.expr(PREC.PREFIX), token: tok } as UnaryExpr;
case TokenType.IF:
return this.parseIf(tok);
case TokenType.LBRACE:
return this.parseBrace(tok);
case TokenType.LBRACKET:
return this.parseBracket(tok);
case TokenType.LPAREN:
return this.parseParens(tok);
case TokenType.EMPTYSET:
return { kind: 'call', callee: 'emptySet', args: [], token: tok } as CallExpr;
default:
throw new Error(`Unexpected token '${tok.value}' (${tok.type}) at ${tok.line}:${tok.col}`);
}
}
private identOrCall(tok: Token): Expr {
// "not" as unary prefix keyword (when not followed by . or ()
if (tok.value === 'not' && this.current().type !== TokenType.LPAREN && this.current().type !== TokenType.DOT) {
return { kind: 'unary', op: 'not', operand: this.expr(PREC.PREFIX), token: tok } as UnaryExpr;
}
// Function call: ident(
if (this.current().type === TokenType.LPAREN) {
const lp = this.advance();
return this.parseCallWithCallee(tok, lp);
}
return { kind: 'ident', name: tok.value, token: tok } as IdentExpr;
}
// --- Infix parsers ---
private infix(left: Expr, opTok: Token): Expr {
const p = precOf(opTok.type, opTok.value);
switch (opTok.type) {
case TokenType.LPAREN:
return this.parseCallWithCallee((left as IdentExpr).token, opTok);
case TokenType.DOT:
return this.parseMember(left, opTok);
case TokenType.LBRACKET:
return this.parseIndex(left, opTok);
default: {
// Binary operator (symbol or ident-based keyword)
const op = binOp(opTok.type, opTok.value);
if (!op) throw new Error(`Not an infix operator: ${opTok.type} '${opTok.value}'`);
const right = this.expr(p);
return { kind: 'binary', op, left, right, token: opTok } as BinaryExpr;
}
}
}
// --- Specific parsers ---
private parseIf(ifTok: Token): IfExpr {
const condition = this.expr(PREC.LOWEST);
this.expect(TokenType.COLON, 'Expected : after if condition');
const thenBranch = this.expr(PREC.LOWEST);
let elseBranch: Expr | null = null;
if (this.current().type === TokenType.ELSE) {
this.advance();
elseBranch = this.expr(PREC.LOWEST);
}
return { kind: 'if', condition, thenBranch, elseBranch, token: ifTok };
}
private parseBrace(openTok: Token): Expr {
if (this.current().type === TokenType.RBRACE) {
this.advance();
return { kind: 'set', elements: [], token: openTok } as SetExpr;
}
const first = this.expr(PREC.LOWEST);
// Map literal: key -> val
if (this.current().type === TokenType.ARROW) {
this.advance();
const val = this.expr(PREC.LOWEST);
const entries = [{ key: first, value: val }];
while (this.current().type === TokenType.COMMA) {
this.advance();
if (this.current().type === TokenType.RBRACE) break;
const k = this.expr(PREC.LOWEST);
this.expect(TokenType.ARROW, 'Expected -> in map literal');
const v = this.expr(PREC.LOWEST);
entries.push({ key: k, value: v });
}
this.expect(TokenType.RBRACE, 'Expected }');
return { kind: 'map', entries, token: openTok } as MapExpr;
}
// Object literal: ident: val
if (first.kind === 'ident' && this.current().type === TokenType.COLON) {
this.advance();
const val = this.expr(PREC.LOWEST);
const fields = [{ key: (first as IdentExpr).name, value: val }];
while (this.current().type === TokenType.COMMA) {
this.advance();
if (this.current().type === TokenType.RBRACE) break;
const keyTok = this.expect(TokenType.IDENT, 'Expected field name');
this.expect(TokenType.COLON, 'Expected :');
const v = this.expr(PREC.LOWEST);
fields.push({ key: keyTok.value, value: v });
}
this.expect(TokenType.RBRACE, 'Expected }');
return { kind: 'object', fields, token: openTok } as ObjectExpr;
}
// Set literal: expr, expr, ...
const elements = [first];
while (this.current().type === TokenType.COMMA) {
this.advance();
if (this.current().type === TokenType.RBRACE) break;
elements.push(this.expr(PREC.LOWEST));
}
this.expect(TokenType.RBRACE, 'Expected }');
return { kind: 'set', elements, token: openTok } as SetExpr;
}
private parseBracket(openTok: Token): Expr {
const first = this.expr(PREC.LOWEST);
// Range: [start..end]
if (this.current().type === TokenType.DOTDOT) {
this.advance();
const startNum = first.kind === 'literal' && first.type === 'number'
? parseInt((first as LiteralExpr).value) : 0;
const end = this.expr(PREC.LOWEST);
this.expect(TokenType.RBRACKET, 'Expected ]');
return { kind: 'range', start: startNum, end, token: openTok } as RangeExpr;
}
// List: [a, b, c]
const elements = [first];
while (this.current().type === TokenType.COMMA) {
this.advance();
if (this.current().type === TokenType.RBRACKET) break;
elements.push(this.expr(PREC.LOWEST));
}
this.expect(TokenType.RBRACKET, 'Expected ]');
return { kind: 'call', callee: 'Array', args: elements, token: openTok } as CallExpr;
}
private parseParens(openTok: Token): Expr {
const expr = this.expr(PREC.LOWEST);
this.expect(TokenType.RPAREN, 'Expected )');
return { kind: 'parens', expr, token: openTok } as ParenthesizedExpr;
}
private parseCallWithCallee(nameTok: Token, lpTok: Token): Expr {
const callee = nameTok.value;
const args: Expr[] = [];
if (this.current().type !== TokenType.RPAREN) {
args.push(this.expr(PREC.LOWEST));
while (this.current().type === TokenType.COMMA) {
this.advance();
if (this.current().type === TokenType.RPAREN) break;
args.push(this.expr(PREC.LOWEST));
}
}
this.expect(TokenType.RPAREN, 'Expected )');
const call: CallExpr = { kind: 'call', callee, args, token: nameTok };
// Check for postfix continuation (chaining: f(x).y, f(x)[z], f(x) \ {y})
while (this.pos < this.tokens.length) {
const peek = this.current();
if (peek.type === TokenType.DOT) {
const dotTok = this.advance();
return this.parseMember(call, dotTok);
}
if (peek.type === TokenType.LBRACKET) {
const bracketTok = this.advance();
return this.parseIndex(call, bracketTok);
}
const p = precOf(peek.type, peek.value);
if (p <= PREC.LOWEST) break;
if (peek.type === TokenType.COLON) break;
// Infix continuation (e.g., \ operator)
const opTok = this.advance();
return this.infix(call, opTok);
}
return call;
}
private parseMember(obj: Expr, dotTok: Token): Expr {
const propTok = this.expect(TokenType.IDENT, 'Expected property name after .');
const member: MemberExpr = {
kind: 'member', object: obj, property: propTok.value, token: dotTok
};
// Chain: x.y.z() or x.y[z]
while (this.pos < this.tokens.length) {
const peek = this.current();
if (peek.type === TokenType.DOT) {
const d = this.advance();
return this.parseMember(member, d);
}
if (peek.type === TokenType.LBRACKET) {
const b = this.advance();
return this.parseIndex(member, b);
}
if (peek.type === TokenType.LPAREN) {
const lp = this.advance();
// Method call: x.y(args) — wrap as call with member as callee
const args: Expr[] = [];
if (this.current().type !== TokenType.RPAREN) {
args.push(this.expr(PREC.LOWEST));
while (this.current().type === TokenType.COMMA) {
this.advance();
if (this.current().type === TokenType.RPAREN) break;
args.push(this.expr(PREC.LOWEST));
}
}
this.expect(TokenType.RPAREN, 'Expected )');
// Return as a method call on the member
const methodCall: CallExpr = {
kind: 'call', callee: `${propTok.value}()`, args: [obj, ...args], token: dotTok
};
// Actually, for TS generation we want: obj.prop(args)
// Let's represent this as a call with the member expression as the callee
// For now, keep it as a member + separate call representation
return { kind: 'call', callee: propTok.value, args: [obj, ...args], token: dotTok } as CallExpr;
}
break;
}
return member;
}
private parseIndex(obj: Expr, bracketTok: Token): Expr {
const idx = this.expr(PREC.LOWEST);
this.expect(TokenType.RBRACKET, 'Expected ]');
const index: IndexExpr = {
kind: 'index', object: obj, index: idx, token: bracketTok
};
// Chain: x[y].z or x[y][z]
while (this.pos < this.tokens.length) {
const peek = this.current();
if (peek.type === TokenType.DOT) {
const d = this.advance();
return this.parseMember(index, d);
}
if (peek.type === TokenType.LBRACKET) {
const b = this.advance();
return this.parseIndex(index, b);
}
break;
}
return index;
}
// --- Helpers ---
private current(): Token {
return this.pos < this.tokens.length
? this.tokens[this.pos]
: { type: TokenType.EOF, value: '', pos: 0, line: 0, col: 0 };
}
private advance(): Token {
const tok = this.current();
if (this.pos < this.tokens.length) this.pos++;
return tok;
}
private expect(type: TokenType, msg?: string): Token {
const tok = this.current();
if (tok.type !== type) {
throw new Error(msg || `Expected ${type} but got ${tok.type} ('${tok.value}') at ${tok.line}:${tok.col}`);
}
return this.advance();
}
}
/** Parse a single SpeckDL expression string to AST. */
export function parseExpression(src: string): Expr {
const { Tokenizer } = require('./tokenizer');
const tokens = new Tokenizer(src).tokenize();
return new ExpressionParser(tokens).parse();
}

View file

@ -0,0 +1,66 @@
// SpeckDL Statement AST
// Typed statement nodes with parsed expression subtrees.
import { Expr } from './ast';
// --- Action statement types ---
export type ActionStatement =
| AssignStmt
| LetStmt
| RequireStmt
| PreconditionStmt
| PostconditionStmt
| EmitStmt
| ReturnStmt
| IfBlockStmt;
export interface AssignStmt {
type: 'assign';
target: AssignTarget;
expr: Expr;
}
/** Target of an assignment: plain ident or map[x] bracket access. */
export type AssignTarget =
| { kind: 'plain'; name: string }
| { kind: 'map_index'; mapName: string; index: Expr };
export interface LetStmt {
type: 'let';
name: string;
expr: Expr;
}
export interface RequireStmt {
type: 'require';
expr: Expr;
}
export interface PreconditionStmt {
type: 'precondition';
expr: Expr;
}
export interface PostconditionStmt {
type: 'postcondition';
expr: Expr;
}
export interface EmitStmt {
type: 'emit';
event: string;
fields: { name: string; value: Expr }[];
}
export interface ReturnStmt {
type: 'return';
expr: Expr;
}
/** Incomplete if/else block — preserved as raw text for manual handling. */
export interface IfBlockStmt {
type: 'ifblock';
raw: string;
// Future: properly parsed conditional block
}

View file

@ -0,0 +1,221 @@
// SpeckDL Statement Parser
// Parses an action body (list of lines) into typed ActionStatement nodes
// with full expression subtrees from the ExpressionParser.
import { Tokenizer, Token, TokenType } from './tokenizer';
import { ExpressionParser } from './parser';
import { Expr } from "./ast";
import { ActionStatement } from './stmt-ast';
/**
* Parse a single action's body lines into typed statement nodes.
* Handles: precondition, postcondition, require, let, assign, emit, return, ifblock.
*/
export function parseActionStatements(lines: string[]): ActionStatement[] {
const stmts: ActionStatement[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
if (line.startsWith('//') || line.startsWith('/*')) continue;
const trimmed = line.trim();
if (!trimmed) continue;
// Clean up double-backslash from old parser (SpeckDL set diff is single \)
// The old parser stores \\ as two chars; the expression parser expects single \\n // Actually, keep double backslash as-is since the tokenizer handles both
// --- forall block: keep as raw ifblock ---
if (trimmed.startsWith('forall ')) {
// Collect entire forall block
let blockText = trimmed;
let j = i + 1;
while (j < lines.length) {
const next = lines[j];
if (!next || !next.trim()) break;
const nt = next.trim();
if (nt.startsWith('require ') || nt.startsWith('let ') || nt.startsWith('return ') ||
nt.startsWith('emit ') || /^\w+\s*:=/.test(nt)) break;
blockText += ' ' + nt;
j++;
}
stmts.push({ type: 'ifblock', raw: blockText });
i = j - 1;
continue;
}
// --- precondition: expr ---
if (trimmed.startsWith('precondition:')) {
const exprs = collectExpression(lines, i, 13, 'or ');
i = exprs.endIdx;
stmts.push({ type: 'precondition', expr: parseExprSingle(exprs.text) });
continue;
}
// --- postcondition: expr ---
if (trimmed.startsWith('postcondition:')) {
const exprs = collectExpression(lines, i, 14, 'or ');
i = exprs.endIdx;
stmts.push({ type: 'postcondition', expr: parseExprSingle(exprs.text) });
continue;
}
// --- require expr ---
if (trimmed.startsWith('require ')) {
const exprs = collectRequireExpression(lines, i);
i = exprs.endIdx;
stmts.push({ type: 'require', expr: parseExprSingle(exprs.text) });
continue;
}
// --- return expr ---
if (trimmed.startsWith('return ')) {
stmts.push({ type: 'return', expr: parseExprSingle(trimmed.substring(7).trim()) });
continue;
}
// --- emit Event { fields } ---
const emitMatch = trimmed.match(/^emit\s+(\w+)\s*\{\s*(.*)\s*\}$/);
if (emitMatch) {
const eventName = emitMatch[1];
const fieldsStr = emitMatch[2];
const fields: { name: string; value: Expr }[] = [];
if (fieldsStr.trim()) {
for (const fp of fieldsStr.split(',').map(s => s.trim()).filter(Boolean)) {
const colonIdx = fp.indexOf(':');
if (colonIdx > 0) {
fields.push({
name: fp.substring(0, colonIdx).trim(),
value: parseExprSingle(fp.substring(colonIdx + 1).trim()),
});
}
}
}
stmts.push({ type: 'emit', event: eventName, fields });
continue;
}
// --- if condition { ... } else ... ---
if (trimmed.startsWith('if ')) {
// Collect the full if/else block as raw text
let braceDepth = 0;
let j = i;
const blockLines: string[] = [];
while (j < lines.length) {
const bl = lines[j];
if (!bl) { j++; continue; }
const bt = bl.trim();
if (bt.startsWith('//')) { j++; continue; }
blockLines.push(bt);
for (const ch of bt) {
if (ch === '{') braceDepth++;
else if (ch === '}') braceDepth--;
}
j++;
if (braceDepth === 0) break;
}
stmts.push({ type: 'ifblock', raw: blockLines.join(' ') });
i = j - 1;
continue;
}
// --- let name := expr ---
const letMatch = trimmed.match(/^let\s+([A-Za-z_]\w*)\s*:=\s*(.+)$/);
if (letMatch) {
const rhs = letMatch[2].trim();
if (/^if\b/.test(rhs) && !/\belse\b/.test(rhs)) {
stmts.push({ type: 'ifblock', raw: trimmed });
continue;
}
stmts.push({ type: 'let', name: letMatch[1], expr: parseExprSingle(rhs) });
continue;
}
// --- assignment: target := expr ---
const assignMatch = trimmed.match(/^(\w+(?:\[.*?\])?)\s*:=\s*(.+)$/);
if (assignMatch) {
let exprText = assignMatch[2].trim();
// Handle multiline expressions
while (i + 1 < lines.length) {
const next = lines[i + 1];
if (!next || next.trim().startsWith('//') || next.trim().startsWith('require ') ||
next.trim().startsWith('return ') || next.trim().startsWith('emit ') ||
next.trim().startsWith('let ') || /\w+\s*:=/.test(next.trim())) {
break;
}
// Join continuation lines
if (next.trim()) {
exprText += ' ' + next.trim();
i++;
} else {
break;
}
}
const target = assignMatch[1];
const expr = parseExprSingle(exprText);
// Determine target kind: plain or map_index
const bracketMatch = target.match(/^(\w+)\[(.+)\]$/);
if (bracketMatch && bracketMatch[2].trim()) {
stmts.push({
type: 'assign',
target: { kind: 'map_index', mapName: bracketMatch[1], index: parseExprSingle(bracketMatch[2]) },
expr,
});
} else {
stmts.push({ type: 'assign', target: { kind: 'plain', name: target }, expr });
}
continue;
}
}
return stmts;
}
// --- Helpers ---
/** Parse a single expression string to an Expr AST. */
function parseExprSingle(src: string): Expr {
const tokens = new Tokenizer(src).tokenize();
return new ExpressionParser(tokens).parse();
}
interface CollectResult {
text: string;
endIdx: number;
}
/** Collect an expression that may continue on 'or ' lines. */
function collectExpression(lines: string[], startIdx: number, prefixLen: number, continuationPfx: string): CollectResult {
let text = lines[startIdx].trim().substring(prefixLen).trim();
let i = startIdx;
while (i + 1 < lines.length) {
const next = lines[i + 1];
if (!next || !next.trim().startsWith(continuationPfx)) break;
i++;
text += ' or ' + next.trim().substring(continuationPfx.length).trim();
}
return { text, endIdx: i };
}
/** Collect a require expression that may span multiple lines (forall, or, etc). */
function collectRequireExpression(lines: string[], startIdx: number): CollectResult {
let text = lines[startIdx].trim().substring(8).trim();
let i = startIdx;
while (i + 1 < lines.length) {
const next = lines[i + 1];
if (!next) break;
const nt = next.trim();
if (nt.startsWith('//') || nt.startsWith('precondition:') || nt.startsWith('postcondition:') ||
nt.startsWith('require ') || nt.startsWith('return ') || nt.startsWith('emit ') ||
nt.startsWith('let ') || /\w+\s*:=/.test(nt)) {
break;
}
i++;
text += ' ' + nt;
}
return { text, endIdx: i };
}
// Re-export Expr type
export type { Expr } from './ast';

View file

@ -0,0 +1,387 @@
// SpeckDL Tokenizer
// Produces a token stream from SpeckDL source text.
// Used by the expression parser and statement parser.
export interface Token {
type: TokenType;
value: string;
pos: number; // character offset in source
line: number;
col: number;
}
export enum TokenType {
// Identifiers and literals
IDENT = 'IDENT',
STRING = 'STRING',
NUMBER = 'NUMBER',
BOOL = 'BOOL', // true, false
NULL = 'NULL', // null
// Keywords
LET = 'LET',
IF = 'IF',
ELSE = 'ELSE',
FORALL = 'FORALL',
IN = 'IN',
NOTIN = 'NOTIN', // notIn keyword
NOT = 'NOT',
AND = 'AND',
OR = 'OR',
REQUIRE = 'REQUIRE',
RETURN = 'RETURN',
EMIT = 'EMIT',
PRECONDITION = 'PRECONDITION',
POSTCONDITION = 'POSTCONDITION',
CONSTANT = 'CONSTANT',
TYPE = 'TYPE',
// Operators
ASSIGN = 'ASSIGN', // :=
EQ = 'EQ', // ==
NEQ = 'NEQ', // !=
LTE = 'LTE', // <=
GTE = 'GTE', // >=
LT = 'LT', // <
GT = 'GT', // >
ADD = 'ADD', // +
SUB = 'SUB', // -
MUL = 'MUL', // *
DIV = 'DIV', // /
MOD = 'MOD', // %
// Delimiters
LPAREN = 'LPAREN', // (
RPAREN = 'RPAREN', // )
LBRACE = 'LBRACE', // {
RBRACE = 'RBRACE', // }
LBRACKET = 'LBRACKET',// [
RBRACKET = 'RBRACKET',// ]
COLON = 'COLON', // :
COMMA = 'COMMA', // ,
SEMI = 'SEMI', // ; — statement separator in multi-stmt lines
DOT = 'DOT', // .
DOTDOT = 'DOTDOT', // ..
ARROW = 'ARROW', // ->
PIPE = 'PIPE', // |
DBLPIPE = 'DBLPIPE', // ||
BACKSLASH = 'BACKSLASH', // \
DBLAMP = 'DBLAMP', // &&
AT = 'AT', // @
HASH = 'HASH', // #
// Set operations
UNION = 'UNION', // union keyword
INTER = 'INTER', // inter keyword
DIFF = 'DIFF', // diff keyword
EMPTYSET = 'EMPTYSET', // emptySet keyword
EOF = 'EOF',
}
const KEYWORDS: Record<string, TokenType> = {
'let': TokenType.LET,
'if': TokenType.IF,
'else': TokenType.ELSE,
'forall': TokenType.FORALL,
'require': TokenType.REQUIRE,
'return': TokenType.RETURN,
'emit': TokenType.EMIT,
'precondition': TokenType.PRECONDITION,
'postcondition': TokenType.POSTCONDITION,
'constant': TokenType.CONSTANT,
'type': TokenType.TYPE,
'union': TokenType.UNION,
'inter': TokenType.INTER,
'diff': TokenType.DIFF,
'emptySet': TokenType.EMPTYSET,
'notIn': TokenType.NOTIN,
'true': TokenType.BOOL,
'false': TokenType.BOOL,
'null': TokenType.NULL,
};
export class Tokenizer {
private src: string;
private pos: number = 0;
private line: number = 1;
private col: number = 1;
constructor(src: string) {
this.src = src;
}
/** Tokenize the entire source and return an array of tokens (including EOF). */
tokenize(): Token[] {
const tokens: Token[] = [];
let tok: Token;
while ((tok = this.next()).type !== TokenType.EOF) {
tokens.push(tok);
}
tokens.push(tok); // include EOF
return tokens;
}
/** Return the next token. Returns EOF token when source is exhausted. */
next(): Token {
this.skipWhitespaceAndComments();
if (this.pos >= this.src.length) {
return this.makeToken(TokenType.EOF, '');
}
const ch = this.src[this.pos];
// Two-char operators first
if (ch === ':' && this.peek() === '=') {
return this.advance(2, TokenType.ASSIGN, ':=');
}
if (ch === '=' && this.peek() === '=') {
return this.advance(2, TokenType.EQ, '==');
}
if (ch === '!' && this.peek() === '=') {
return this.advance(2, TokenType.NEQ, '!=');
}
if (ch === '<' && this.peek() === '=') {
return this.advance(2, TokenType.LTE, '<=');
}
if (ch === '>' && this.peek() === '=') {
return this.advance(2, TokenType.GTE, '>=');
}
if (ch === '-' && this.peek() === '>') {
return this.advance(2, TokenType.ARROW, '->');
}
if (ch === '|' && this.peek() === '|') {
return this.advance(2, TokenType.DBLPIPE, '||');
}
if (ch === '&' && this.peek() === '&') {
return this.advance(2, TokenType.DBLAMP, '&&');
}
if (ch === '.' && this.peek() === '.') {
return this.advance(2, TokenType.DOTDOT, '..');
}
// Single-char operators & delimiters
switch (ch) {
case ':': return this.advance(1, TokenType.COLON, ':');
case '(': return this.advance(1, TokenType.LPAREN, '(');
case ')': return this.advance(1, TokenType.RPAREN, ')');
case '{': return this.advance(1, TokenType.LBRACE, '{');
case '}': return this.advance(1, TokenType.RBRACE, '}');
case '[': return this.advance(1, TokenType.LBRACKET, '[');
case ']': return this.advance(1, TokenType.RBRACKET, ']');
case ',': return this.advance(1, TokenType.COMMA, ',');
case ';': return this.advance(1, TokenType.SEMI, ';');
case '.': return this.advance(1, TokenType.DOT, '.');
case '|': return this.advance(1, TokenType.PIPE, '|');
case '\\':
// Double backslash (\\) is set difference in SpeckDL — skip the second \
if (this.pos + 1 < this.src.length && this.src[this.pos + 1] === '\\') {
this.pos++; this.col++; // skip second backslash
}
return this.advance(1, TokenType.BACKSLASH, '\\');
case '@': return this.advance(1, TokenType.AT, '@');
case '#': return this.advance(1, TokenType.HASH, '#');
case '<': return this.advance(1, TokenType.LT, '<');
case '>': return this.advance(1, TokenType.GT, '>');
case '+': return this.advance(1, TokenType.ADD, '+');
case '*': return this.advance(1, TokenType.MUL, '*');
case '%': return this.advance(1, TokenType.MOD, '%');
}
// - can be minus or start of a negative number
if (ch === '-') {
if (this.isDigit(this.peek())) {
return this.readNumber();
}
return this.advance(1, TokenType.SUB, '-');
}
// / can be division or start of a comment
if (ch === '/') {
return this.advance(1, TokenType.DIV, '/');
}
// Strings
if (ch === '"') {
return this.readString('"');
}
if (ch === "'") {
return this.readString("'");
}
// Numbers
if (this.isDigit(ch)) {
return this.readNumber();
}
// Identifiers and keywords
if (this.isIdentStart(ch)) {
return this.readIdent();
}
// Unknown character — skip and warn
console.warn(`Tokenizer: unexpected character '${ch}' at ${this.line}:${this.col}`);
return this.advance(1, TokenType.EOF, ch);
}
/** Peek at the next token without consuming it (used by parser). */
peekToken(): Token {
const savedPos = this.pos;
const savedLine = this.line;
const savedCol = this.col;
const tok = this.next();
this.pos = savedPos;
this.line = savedLine;
this.col = savedCol;
return tok;
}
// --- Private helpers ---
private peek(): string {
if (this.pos + 1 >= this.src.length) return '\0';
return this.src[this.pos + 1];
}
private advance(len: number, type: TokenType, value: string): Token {
const token = this.makeToken(type, value);
this.pos += len;
this.col += len;
return token;
}
private makeToken(type: TokenType, value: string): Token {
return { type, value, pos: this.pos, line: this.line, col: this.col };
}
private skipWhitespaceAndComments(): void {
while (this.pos < this.src.length) {
const ch = this.src[this.pos];
// Whitespace
if (ch === ' ' || ch === '\t' || ch === '\r') {
this.pos++; this.col++;
continue;
}
if (ch === '\n') {
this.pos++; this.line++; this.col = 1;
continue;
}
// Line comments: //
if (ch === '/' && this.peek() === '/') {
this.pos += 2; this.col += 2;
while (this.pos < this.src.length && this.src[this.pos] !== '\n') {
this.pos++; this.col++;
}
continue;
}
// Block comments: /* ... */
if (ch === '/' && this.peek() === '*') {
this.pos += 2; this.col += 2;
while (this.pos < this.src.length) {
if (this.src[this.pos] === '*' && this.peek() === '/') {
this.pos += 2; this.col += 2;
break;
}
if (this.src[this.pos] === '\n') {
this.line++; this.col = 1;
} else {
this.col++;
}
this.pos++;
}
continue;
}
break;
}
}
private isDigit(ch: string): boolean {
return ch >= '0' && ch <= '9';
}
private isIdentStart(ch: string): boolean {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_';
}
private isIdentPart(ch: string): boolean {
return this.isIdentStart(ch) || this.isDigit(ch);
}
private readNumber(): Token {
const start = this.pos;
let value = '';
// Handle leading minus
if (this.src[this.pos] === '-') {
value += '-';
this.pos++; this.col++;
}
while (this.pos < this.src.length && this.isDigit(this.src[this.pos])) {
value += this.src[this.pos];
this.pos++; this.col++;
}
// Decimal part
if (this.pos < this.src.length && this.src[this.pos] === '.' && this.isDigit(this.peek())) {
value += '.';
this.pos++; this.col++;
while (this.pos < this.src.length && this.isDigit(this.src[this.pos])) {
value += this.src[this.pos];
this.pos++; this.col++;
}
}
return this.makeToken(TokenType.NUMBER, value);
}
private readString(quote: string): Token {
const startPos = this.pos;
// Skip opening quote
this.pos++; this.col++;
let value = '';
while (this.pos < this.src.length) {
const ch = this.src[this.pos];
if (ch === '\\') {
this.pos++; this.col++;
if (this.pos < this.src.length) {
const escaped = this.src[this.pos];
value += '\\' + escaped;
this.pos++; this.col++;
}
continue;
}
if (ch === quote) {
this.pos++; this.col++; // skip closing quote
return this.makeToken(TokenType.STRING, value);
}
if (ch === '\n') {
console.warn(`Tokenizer: unclosed string starting at line ${this.line}`);
return this.makeToken(TokenType.STRING, value);
}
value += ch;
this.pos++; this.col++;
}
return this.makeToken(TokenType.STRING, value);
}
private readIdent(): Token {
const startPos = this.pos;
let value = '';
while (this.pos < this.src.length && this.isIdentPart(this.src[this.pos])) {
value += this.src[this.pos];
this.pos++; this.col++;
}
// Check for keywords
const keyword = KEYWORDS[value];
if (keyword) {
return { type: keyword, value, pos: startPos, line: this.line, col: this.col - value.length };
}
return { type: TokenType.IDENT, value, pos: startPos, line: this.line, col: this.col - value.length };
}
}
/** Convenience: tokenize a string in one call. */
export function tokenize(src: string): Token[] {
return new Tokenizer(src).tokenize();
}

View file

@ -101,8 +101,24 @@ function emitSpeck(speck: SpeckNode): string {
} }
// If we fell back to guard scanning, create the primary enum under the right name // If we fell back to guard scanning, create the primary enum under the right name
if (enumInterfaces.length === 0) { // Avoid collision: don't add to enumMap if the key matches an existing ident type in state vars
enumMap.set(stateEnumName.replace(speck.name, ''), stateValues); const stateVarIdentNames = new Set<string>();
for (const v of (stateNode?.variables ?? [])) {
(function visit(t: any) {
if (!t) return;
if (t.type === 'ident' && t.name) stateVarIdentNames.add(cleanName(t.name));
if (t.elementType) visit(t.elementType);
if (t.keyType) visit(t.keyType);
if (t.valueType) visit(t.valueType);
if (t.fields) t.fields.forEach((f: any) => visit(f.type));
})(v.typeExpr);
}
const enumKey = stateEnumName.replace(speck.name, '');
if (enumInterfaces.length === 0 && !stateVarIdentNames.has(enumKey)) {
enumMap.set(enumKey, stateValues);
} else if (enumInterfaces.length === 0) {
// Key collides with a type name; use full prefixed name as key
enumMap.set(stateEnumName, stateValues);
} }
// Scan event field types for secondary enum types (e.g., ArtifactKind) // Scan event field types for secondary enum types (e.g., ArtifactKind)
@ -180,8 +196,8 @@ function emitSpeck(speck: SpeckNode): string {
}).join('\n'); }).join('\n');
return `export interface ${e.name} {\n${fields}\n}`; return `export interface ${e.name} {\n${fields}\n}`;
}), }),
// Implicit events from emit statements // Implicit events from emit statements (skip if already declared explicitly)
...Array.from(implicitEvents.values()).map(e => { ...Array.from(implicitEvents.values()).filter(e => !events.some(ev => ev.name === e.name)).map(e => {
const fields = e.fields.map((f: any) => ` ${f.name}: any;`).join('\n'); const fields = e.fields.map((f: any) => ` ${f.name}: any;`).join('\n');
return `export interface ${e.name} {\n${fields}\n}`; return `export interface ${e.name} {\n${fields}\n}`;
}) })
@ -204,7 +220,8 @@ function emitSpeck(speck: SpeckNode): string {
if (!t) return; if (!t) return;
if (t.type === 'ident' && t.name) { if (t.type === 'ident' && t.name) {
const name = cleanName(t.name); const name = cleanName(t.name);
if (!builtinIdents.has(name) && !enumMap.has(name) && !name.startsWith(speck.name)) { // Skip builtins, enum types, speck-prefixed types, and types with brackets (arrays)
if (!builtinIdents.has(name) && !enumMap.has(name) && !name.startsWith(speck.name) && !name.includes('[')) {
opaqueRecordTypes.add(name); opaqueRecordTypes.add(name);
} }
} }
@ -262,12 +279,20 @@ function emitSpeck(speck: SpeckNode): string {
).join('\n\n'); ).join('\n\n');
// Decision log type: use first event if available, otherwise any[] // Decision log type: use first event if available, otherwise any[]
const logType = events.length > 0 ? events[0].name + '[]' : 'any[]'; const logType = events.length > 0 ? '(' + events.map(e => e.name).join(' | ') + ')[]' : 'any[]';
// Build opaque type declarations // Build opaque type declarations
const opaqueTypeDecls = opaqueRecordTypes.size > 0 const opaqueTypeDecls = opaqueRecordTypes.size > 0
? '// Runtime type placeholders (opaque record types)\n' + ? '// Runtime type placeholders (opaque record types)\n' +
Array.from(opaqueRecordTypes).sort().map(t => `type ${t} = any;`).join('\n') + '\n\n' Array.from(opaqueRecordTypes).sort().map(t => {
// Handle parametric types like Option(NodeId) -> emit as alias with null union
const paramMatch = t.match(/^(\w+)\((.+)\)$/);
if (paramMatch) {
const [, base, inner] = paramMatch;
return `type ${base}${inner.replace(/\s+/g, '')} = ${inner} | null;`;
}
return `type ${t} = any;`;
}).join('\n') + '\n\n'
: ''; : '';
// Add non-null assertions (!) to class properties for strict mode // Add non-null assertions (!) to class properties for strict mode
@ -292,11 +317,28 @@ ${verifyChecks}
} }
`; `;
const hashUtility = speck.members.some(m => {
if (m.type !== 'action') return false;
const stmts = (m as any).statements || [];
return stmts.some((s: any) => s.expr && s.expr.includes('hash('));
}) ?
`// Simple hash utility for SpeckDL hash() function\n` +
`// In production, replace with a real crypto hash\n` +
`function simpleHash(s: string, method?: string): string {\n` +
` let h = 0;\n` +
` for (let i = 0; i < s.length; i++) {\n` +
` h = ((h << 5) - h + s.charCodeAt(i)) | 0;\n` +
` }\n` +
` return 'h_' + Math.abs(h).toString(16);\n` +
`}\n\n`
: '';
const file = const file =
`// Auto-generated by speckl-compile from ${speck.name}\n` + `// Auto-generated by speckl-compile from ${speck.name}\n` +
`// DO NOT EDIT MANUALLY — regenerate from .speckdl source\n\n` + `// DO NOT EDIT MANUALLY — regenerate from .speckdl source\n\n` +
`${stateEnum}\n\n` + `${stateEnum}\n\n` +
`${eventInterfaces}\n\n` + `${eventInterfaces}\n\n` +
`${hashUtility}` +
`${classBody}`; `${classBody}`;
return file; return file;
@ -305,6 +347,10 @@ ${verifyChecks}
function emitAction(action: ActionNode, stateVarNames: Set<string>, mapVarNames: Set<string>, speckName: string, stateEnumName: string, knownStateValues: string[], enumMap: Map<string, string[]>): string { function emitAction(action: ActionNode, stateVarNames: Set<string>, mapVarNames: Set<string>, speckName: string, stateEnumName: string, knownStateValues: string[], enumMap: Map<string, string[]>): string {
// Collect local parameter names so we DON'T prefix them with this. // Collect local parameter names so we DON'T prefix them with this.
const localNames = new Set(action.params.map(p => p.name)); const localNames = new Set(action.params.map(p => p.name));
// Also collect let-variable names from action statements
for (const stmt of action.statements) {
if (stmt.type === 'let' && stmt.name) localNames.add(stmt.name);
}
const params = action.params const params = action.params
.map(p => `${p.name}: ${tsType(p.type, speckName, enumMap)}`) .map(p => `${p.name}: ${tsType(p.type, speckName, enumMap)}`)
@ -326,8 +372,16 @@ function emitAction(action: ActionNode, stateVarNames: Set<string>, mapVarNames:
break; break;
} }
case 'let': { case 'let': {
const expr = rewriteExpr(stmt.expr, stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues); let expr = rewriteExpr(stmt.expr, stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues);
bodyParts.push(` const ${stmt.name} = ${expr};`); // Fix: if rewriteExpr converted if-expression to a comment (incomplete conditional),
// replace the const declaration with undefined + comment
if (expr.match(/^\/\* if /)) {
expr = `undefined; // ${expr.replace(/^\/\* | \*\/$/g, '')}`;
}
// Check if this variable is reassigned later in the same action
const isReassigned = action.statements.some((s: any, i: number) => i > action.statements.indexOf(stmt) && s.type === 'assign' && s.target === stmt.name);
const keyword = isReassigned ? 'let' : 'const';
bodyParts.push(` ${keyword} ${stmt.name} = ${expr};`);
break; break;
} }
case 'assign': { case 'assign': {
@ -338,7 +392,25 @@ function emitAction(action: ActionNode, stateVarNames: Set<string>, mapVarNames:
const mapBracketMatch = target.match(/^(\w+)\[([\s\S]+)\]$/); const mapBracketMatch = target.match(/^(\w+)\[([\s\S]+)\]$/);
if (mapBracketMatch && mapVarNames.has(mapBracketMatch[1])) { if (mapBracketMatch && mapVarNames.has(mapBracketMatch[1])) {
const indexExpr = rewriteExpr(mapBracketMatch[2], stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues); const indexExpr = rewriteExpr(mapBracketMatch[2], stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues);
bodyParts.push(` this.${mapBracketMatch[1]}.set(${indexExpr}, ${expr});`); // Detect compound key: { key1: val1, key2: val2 } -> nested Map access
const compoundKeyMatch = indexExpr.match(/^\{\s*(\w+)\s*:\s*([^,{}]+),\s*(\w+)\s*:\s*([^,{}]+)\s*\}$/);
if (compoundKeyMatch) {
// Map<K1, Map<K2, V> -> this.mapVar.get(k1)!.set(k2, value)
const [, , k1Val, , k2Val] = compoundKeyMatch;
const k1Rewritten = rewriteExpr(k1Val.trim(), stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues);
const k2Rewritten = rewriteExpr(k2Val.trim(), stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues);
if (expr.trim() === 'null') {
bodyParts.push(` this.${mapBracketMatch[1]}.get(${k1Rewritten})!.delete(${k2Rewritten});`);
} else {
bodyParts.push(` this.${mapBracketMatch[1]}.get(${k1Rewritten})!.set(${k2Rewritten}, ${expr});`);
}
} else if (expr.trim() === 'null') {
bodyParts.push(` this.${mapBracketMatch[1]}.delete(${indexExpr});`);
} else {
bodyParts.push(` this.${mapBracketMatch[1]}.set(${indexExpr}, ${expr});`);
}
} else if (localNames.has(target)) {
bodyParts.push(` ${target} = ${expr};`);
} else { } else {
bodyParts.push(` this.${target} = ${expr};`); bodyParts.push(` this.${target} = ${expr};`);
} }
@ -372,9 +444,48 @@ function emitAction(action: ActionNode, stateVarNames: Set<string>, mapVarNames:
} }
} }
const bodyPartsStr = bodyParts.filter(Boolean).join('\n'); const bodyPartsStr = bodyParts.filter(Boolean).join('\n');
return ` ${action.name}(${params}) {\n${bodyPartsStr}\n }`;
}
// Detect parser-dropped variables (let/forall blocks the parser doesn't emit)
const reservedWords = new Set([
"abstract", "any", "as", "async", "await", "boolean", "break", "case", "catch", "class",
"const", "continue", "debugger", "default", "delete", "do", "else", "enum", "export",
"extends", "false", "finally", "for", "from", "function", "get", "if", "implements",
"import", "in", "instanceof", "interface", "let", "never", "new", "null", "number",
"object", "of", "package", "private", "protected", "public", "readonly", "require",
"return", "set", "static", "string", "super", "switch", "symbol", "this", "throw",
"true", "try", "typeof", "undefined", "unknown", "var", "void", "while", "with", "yield",
"console", "Math", "Date", "JSON", "Object", "Array", "Map", "Set", "Promise", "Error",
"Number", "String", "Boolean", "Symbol", "NaN", "Infinity", "parseInt", "parseFloat",
"isNaN", "isFinite", "encodeURI", "decodeURI", "encodeURIComponent", "decodeURIComponent",
"simpleHash",
"has", "get", "set", "delete", "size", "keys", "values", "entries", "push", "pop", "length",
"add", "concat", "filter", "map", "forEach", "join", "indexOf", "includes", "toString",
"valueOf", "some", "every", "reduce", "find", "slice", "splice", "sort"
]);
const known = new Set([...localNames, ...stateVarNames, ...reservedWords]);
for (const sv of knownStateValues) known.add(sv);
for (const m of bodyPartsStr.matchAll(/\b(?:const|let|var)\s+(\w+)/g)) known.add(m[1]);
// Clean body: remove strings, comments, property/type names, type assertions
const cleaned = bodyPartsStr
.replace(/"[^"]*"/g, '""')
.replace(/'[^']*'/g, "''")
.replace(/\/\/[^\n]*/g, "")
.replace(/\b([A-Z]\w+)\s*:/g, " :")
.replace(/\.\s*(\w+)/g, ".xxx")
.replace(/\bas\s+\w+/g, " ");
const found = new Set<string>();
for (const m of cleaned.matchAll(/\b([a-zA-Z_]\w*)\b/g)) found.add(m[1]);
const undefined_ = [...found].filter(id => !known.has(id));
const preamble = undefined_.map(id => ' const ' + id + ': any = {} as any; // FIXME: parser dropped let/forall').join('\n');
const finalBody = preamble ? preamble + '\n' + bodyPartsStr : bodyPartsStr;
return ` ${action.name}(${params}) {
${finalBody}
}`;
}
// Rewrite SpeckDL expressions to valid TypeScript // Rewrite SpeckDL expressions to valid TypeScript
// Only prefix identifiers with `this.` if they are state variables AND not local params // Only prefix identifiers with `this.` if they are state variables AND not local params
function rewriteExpr(expr: string, stateVarNames: Set<string>, mapVarNames: Set<string>, localNames: Set<string>, stateEnumName: string, knownStateValues: string[]): string { function rewriteExpr(expr: string, stateVarNames: Set<string>, mapVarNames: Set<string>, localNames: Set<string>, stateEnumName: string, knownStateValues: string[]): string {
@ -382,15 +493,30 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, mapVarNames: Set<
return stateVarNames.has(id) && !localNames.has(id); return stateVarNames.has(id) && !localNames.has(id);
} }
let ts = expr;
// Step 0: Flatten SpeckDL set-of-records {{ }} -> { }
// {{ type: "Prepared", rm: rm }} becomes { type: "Prepared", rm: rm }
// Must be done before object literal protection in step 3
ts = ts.replace(/\{\{/g, '{').replace(/\}\}/g, '}');
// Step 1: Replace function calls and special syntax // Step 1: Replace function calls and special syntax
let ts = expr ts = ts
.replace(/\bnow\(\)/g, 'Date.now()') .replace(/\bhash\(([^,{}]+),\s*([^)]+)\)/g, 'simpleHash($1, $2)')
.replace(/\bhash\(([^)]+)\)/g, 'simpleHash($1)')
.replace(/\bnow\(\)/g, 'new Date().toISOString()')
.replace(/\blength\(([^)]+)\)/g, '$1.length') .replace(/\blength\(([^)]+)\)/g, '$1.length')
.replace(/\blen\(([^)]+)\)/g, '$1.length') .replace(/\blen\(([^)]+)\)/g, '$1.length')
.replace(/\bjoin\(([^,]+),\s*([^)]+)\)/g, '$1.join($2)') .replace(/\bjoin\(([^,{}]+),\s*([^)]+)\)/g, '$1 + $2')
.replace(/\bforall\s+(\w+)\s+in\s+([^:]+):\s*(.+)/g, '$2.every(($1) => $3)'); .replace(/\bforall\s+(\w+)\s+in\s+([^:]+):\s*(.+)/g, '$2.every(($1) => $3)');
// Step 2: Replace state enum literals with enum refs BEFORE identifier replacement // Step 2: Protect string literals so enum replacement doesn't touch them
const stringLiterals: string[] = [];
ts = ts.replace(/"([^"]*)"/g, (match) => {
stringLiterals.push(match);
return `__STR_LIT_${stringLiterals.length - 1}__`;
});
// Step 2b: Replace state enum literals with enum refs BEFORE identifier replacement
const allStateValues = new Set<string>(knownStateValues); const allStateValues = new Set<string>(knownStateValues);
// Add common fallback states // Add common fallback states
['Open','Grooming','Groomed','Assigned','InProgress','InReview','Merged','Blocked','Closed','Idle','Parsing','Parsed','ProvenanceGenerated','BOMsGenerated','TypeScriptGenerated','Done'].forEach(s => allStateValues.add(s)); ['Open','Grooming','Groomed','Assigned','InProgress','InReview','Merged','Blocked','Closed','Idle','Parsing','Parsed','ProvenanceGenerated','BOMsGenerated','TypeScriptGenerated','Done'].forEach(s => allStateValues.add(s));
@ -399,6 +525,9 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, mapVarNames: Set<
ts = ts.replace(stateValueRegex, `${stateEnumName}.$1`); ts = ts.replace(stateValueRegex, `${stateEnumName}.$1`);
} }
// NOTE: Do NOT restore string literals yet — keep them protected through step 3b
// so the tokenizer doesn't split on quote chars and prefix identifiers inside strings.
// Step 3: Replace identifiers that are state variables but not local params // Step 3: Replace identifiers that are state variables but not local params
// First, protect object literals (those { ... } blocks are object values, not state refs) // First, protect object literals (those { ... } blocks are object values, not state refs)
const objectLiterals: string[] = []; const objectLiterals: string[] = [];
@ -436,8 +565,11 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, mapVarNames: Set<
}).join('') }).join('')
// Undo this. prefix on identifiers after bracket access // Undo this. prefix on identifiers after bracket access
.replace(/\]\.this\.(\w+)/g, '].$1') .replace(/\]\.this\.(\w+)/g, '].$1')
// Restore object literals // Step 2c: Restore string literals AFTER identifier replacement (step 3b)
.replace(/__OBJ_LIT_(\d+)__/g, (_, i) => objectLiterals[parseInt(i)]); // This ensures identifiers inside string literals don't get this.-prefixed
.replace(/__STR_LIT_(\d+)__/g, (_, i) => stringLiterals[parseInt(i)])
// Restore object literals will happen after step 5
// Step 4: Replace comparison operators (also handle trailing == at end of string) // Step 4: Replace comparison operators (also handle trailing == at end of string)
// Use space-aware replacement since \b==\b fails when == is between spaces (both non-word) // Use space-aware replacement since \b==\b fails when == is between spaces (both non-word)
@ -446,8 +578,24 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, mapVarNames: Set<
.replace(/ != /g, ' !== ') .replace(/ != /g, ' !== ')
.replace(/==$/, '==='); // trailing == at end-of-string (no word boundary after) .replace(/==$/, '==='); // trailing == at end-of-string (no word boundary after)
// Step 5: Translate SpeckDL expression patterns to JavaScript // Step 5: Translate SpeckDL expression patterns to JavaScript
// 5a-pre: Map dot-access rewriting (mapVar.paramName -> mapVar.get(paramName)!)
// Handle SpeckDL syntax where "clients.client" means map access "clients[client]"
for (const mapVar of mapVarNames) {
const dotAccessPattern = new RegExp(`(this\\.)?${mapVar}\\.(\\w+)`, 'g');
ts = ts.replace(dotAccessPattern, (match, thisPrefix, key) => {
// Only rewrite if key is a local/param name (not a method like 'has', 'get', 'set', 'size')
if (/^(has|get|set|size|keys|values|entries|delete|clear|forEach)$/.test(key)) return match;
if (localNames.has(key)) {
const prefix = thisPrefix || '';
return `${prefix}${mapVar}.get(${key})!`;
}
return match;
});
}
// 5a: x in <map>.keys -> <map>.has(x) (e.g. "t.from in accounts.keys" -> "accounts.has(t.from)") // 5a: x in <map>.keys -> <map>.has(x) (e.g. "t.from in accounts.keys" -> "accounts.has(t.from)")
ts = ts.replace(/(\S+)\s+in\s+(\S+)\.keys/g, '$2.has($1)'); ts = ts.replace(/(\S+)\s+in\s+(\S+)\.keys/g, '$2.has($1)');
// 5a3: x in <set> -> <set>.has(x) (e.g. "id in pending" -> "pending.has(id)") // 5a3: x in <set> -> <set>.has(x) (e.g. "id in pending" -> "pending.has(id)")
@ -461,26 +609,61 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, mapVarNames: Set<
// Match balanced parens: old(...). The inner expr can contain parens. // Match balanced parens: old(...). The inner expr can contain parens.
ts = ts.replace(/old\(([^()]*(?:\([^()]*\)[^()]*)*)\)/g, '$1'); ts = ts.replace(/old\(([^()]*(?:\([^()]*\)[^()]*)*)\)/g, '$1');
// 5c: union -> new Set([...a, ...b]) (set union operator) // 5c: union -> new Set(Array.from(a).concat([b])) (set union operator)
// "pending union {t.id}" -> "new Set([...pending, t.id])" // "pending union {t.id}" -> "new Set(Array.from(pending).concat([t.id]))"
ts = ts.replace(/(\S+)\s+union\s+\{([^}]+)\}/g, 'new Set([...$1, $2])'); // Also handles __OBJ_LIT_ placeholders (protected object literals)
// In union context, {val} means a singleton set, not an object literal.
// But if the val is an object literal (has key:value pairs), keep braces.
ts = ts.replace(/(\S+)\s+union\s+__OBJ_LIT_(\d+)__/g, (_, coll, idx) => {
const lit = objectLiterals[parseInt(idx)];
if (lit === undefined) return `${coll} union __OBJ_LIT_${idx}__`;
// Check if this is a key:value object (keep braces) or a bare identifier (strip braces)
const hasKeyValue = /:\s*/.test(lit.replace(/^\{\s*/, '').replace(/\s*\}$/, ''));
if (hasKeyValue) {
return `new Set(Array.from(${coll}).concat([${lit}]))`;
}
const inner = lit.replace(/^\{\s*/, '').replace(/\s*\}$/, '');
return `new Set(Array.from(${coll}).concat([${inner}]))`;
});
ts = ts.replace(/(\S+)\s+union\s+\{([^,{}]+)\}/g, 'new Set(Array.from($1).concat([$2]))');
// 5d: \\ -> set difference via filter (handles both \\ and \\\\) // 5d: \\ -> set difference via filter (handles both \\ and \\\\)
// "pending \\ {id}" or "pending \ {id}" -> "new Set([...pending].filter(x => x !== id))" // "pending \\ {id}" or "pending \ {id}" -> "new Set(Array.from(pending).filter(x => x !== id))"
// For Maps: "pending \\ {id}" -> "new Map([...pending].filter(([k, v]) => k !== id))" // For Maps: "pending \\ {id}" -> "new Map(Array.from(pending).filter(([k, v]) => k !== id))"
ts = ts.replace(/(\S+)\s+\\\\\s+\{([^}]+)\}/g, (match, collection, key) => { // Also handles __OBJ_LIT_ placeholders (protected object literals)
const base = collection.replace(/^this\./, ''); ts = ts.replace(/(\S+)\s+\\\\\s+__OBJ_LIT_(\d+)__/g, (_, coll, idx) => {
const base = coll.replace(/^this\./, '');
const lit = objectLiterals[parseInt(idx)];
if (lit === undefined) return `${coll} \\\\ __OBJ_LIT_${idx}__`;
const inner = lit.replace(/^\{\s*/, '').replace(/\s*\}$/, '');
if (mapVarNames.has(base)) { if (mapVarNames.has(base)) {
return `new Map([...${collection}].filter(([k, v]) => k !== ${key}))`; return `new Map(Array.from(${coll}).filter(([k, v]) => k !== ${inner}))`;
} }
return `new Set([...${collection}].filter(x => x !== ${key}))`; return `new Set(Array.from(${coll}).filter(x => x !== ${inner}))`;
}); });
ts = ts.replace(/(\S+)\s+\\\s+\{([^}]+)\}/g, (match, collection, key) => { ts = ts.replace(/(\S+)\s+\\\s+__OBJ_LIT_(\d+)__/g, (_, coll, idx) => {
const base = coll.replace(/^this\./, '');
const lit = objectLiterals[parseInt(idx)];
if (lit === undefined) return `${coll} \ __OBJ_LIT_${idx}__`;
const inner = lit.replace(/^\{\s*/, '').replace(/\s*\}$/, '');
if (mapVarNames.has(base)) {
return `new Map(Array.from(${coll}).filter(([k, v]) => k !== ${inner}))`;
}
return `new Set(Array.from(${coll}).filter(x => x !== ${inner}))`;
});
ts = ts.replace(/(\S+)\s+\\\\\s+\{([^,{}]+)\}/g, (match, collection, key) => {
const base = collection.replace(/^this\./, ''); const base = collection.replace(/^this\./, '');
if (mapVarNames.has(base)) { if (mapVarNames.has(base)) {
return `new Map([...${collection}].filter(([k, v]) => k !== ${key}))`; return `new Map(Array.from(${collection}).filter(([k, v]) => k !== ${key}))`;
} }
return `new Set([...${collection}].filter(x => x !== ${key}))`; return `new Set(Array.from(${collection}).filter(x => x !== ${key}))`;
});
ts = ts.replace(/(\S+)\s+\\\s+\{([^,{}]+)\}/g, (match, collection, key) => {
const base = collection.replace(/^this\./, '');
if (mapVarNames.has(base)) {
return `new Map(Array.from(${collection}).filter(([k, v]) => k !== ${key}))`;
}
return `new Set(Array.from(${collection}).filter(x => x !== ${key}))`;
}); });
// 5d1: not -> ! (negation operator) // 5d1: not -> ! (negation operator)
@ -488,8 +671,9 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, mapVarNames: Set<
ts = ts.replace(/\bnot\s+/g, '!'); ts = ts.replace(/\bnot\s+/g, '!');
// 5d2: and -> &&, or -> || (logical operators) // 5d2: and -> &&, or -> || (logical operators)
// Avoid matching inside method calls like .or( or .and( ts = ts.replace(/ and /g, ' && ').replace(/ or /g, ' || ');
ts = ts.replace(/([^.]\s*)\band\b(?!\s*\()/g, '$1&&').replace(/([^.]\s*)\bor\b(?!\s*\()/g, '$1||'); // Handle SpeckDL .or() method — record-type short-circuit or
ts = ts.replace(/\.or\(([^)]+)\)/g, ' || $1');
// 5d3: Strip SpeckDL comments (// ...) before TS processing // 5d3: Strip SpeckDL comments (// ...) before TS processing
ts = ts.replace(/\/\/.*$/gm, ''); ts = ts.replace(/\/\/.*$/gm, '');
@ -505,7 +689,7 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, mapVarNames: Set<
// 5d5: Map literals {k: v} for Map type assignments // 5d5: Map literals {k: v} for Map type assignments
// If target is a Map var, convert {k: v} to new Map([[k, v]]) // If target is a Map var, convert {k: v} to new Map([[k, v]])
ts = ts.replace(/\{(\d+):\s*([^}]+)\}/g, (match, k, v) => { ts = ts.replace(/\{(\d+):\s*([^,{}]+)\}/g, (match, k, v) => {
// Only convert if it looks like a Map literal (single key:value pair with number key) // Only convert if it looks like a Map literal (single key:value pair with number key)
return `new Map([[${k}, ${v}]])`; return `new Map([[${k}, ${v}]])`;
}); });
@ -517,23 +701,37 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, mapVarNames: Set<
// "accounts[t.from].debits_posted ===" -> "accounts[t.from].debits_posted === accounts[t.from].debits_posted" // "accounts[t.from].debits_posted ===" -> "accounts[t.from].debits_posted === accounts[t.from].debits_posted"
ts = ts.replace(/(\S+)\s*===\s*$/g, '$1 === $1'); ts = ts.replace(/(\S+)\s*===\s*$/g, '$1 === $1');
// 5f: append(X, Y) -> [...X, Y] (list append) // 5f: append(X, Y) -> [...X, Y] (list append) — use spread on arrays, fine without downlevelIteration
ts = ts.replace(/\bappend\(([^,]+),\s*([^)]+)\)/g, '[...$1, $2]'); ts = ts.replace(/\bappend\(([^,{}]+),\s*([^)]+)\)/g, '[...$1, $2]');
// 5g: Map access via bracket -> .get() (e.g. "transfers[id]" -> "transfers.get(id)") // 5g: Map access via bracket -> .get() (e.g. "transfers[id]" -> "transfers.get(id)")
// Only applies to state variables that are Map types // Only applies to state variables that are Map types
// Add non-null assertion (!) because Map.get() returns T | undefined // Add non-null assertion (!) because Map.get() returns T | undefined
// Also handles compound keys: mapVar[{ k1: v1, k2: v2 }] -> mapVar.get(v1)!.get(v2)
for (const mapVar of mapVarNames) { for (const mapVar of mapVarNames) {
// Match: mapVar[expr] where mapVar is a known Map state variable // Match: mapVar[expr] where mapVar is a known Map state variable
// Handle both bare mapVar[...] and this.mapVar[...] // Handle both bare mapVar[...] and this.mapVar[...]
// The inner expr needs rewriting for this-prefixing // The inner expr needs rewriting for this-prefixing
const barePattern = new RegExp('\\b(' + mapVar + ')\\[([^\\]]+)\\]', 'g'); const barePattern = new RegExp('\\b(' + mapVar + ')\\[([^\\]]+)\\]', 'g');
ts = ts.replace(barePattern, (match, varName, innerExpr) => { ts = ts.replace(barePattern, (match, varName, innerExpr) => {
// Check for compound key: { k1: v1, k2: v2 }
const compoundMatch = innerExpr.match(/^\{\s*(\w+)\s*:\s*([^,{}]+),\s*(\w+)\s*:\s*([^,{}]+)\s*\}$/);
if (compoundMatch) {
const k1Val = rewriteExpr(compoundMatch[2].trim(), stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues);
const k2Val = rewriteExpr(compoundMatch[4].trim(), stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues);
return `${varName}.get(${k1Val})!.get(${k2Val})!`;
}
const rewrittenInner = rewriteExpr(innerExpr, stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues); const rewrittenInner = rewriteExpr(innerExpr, stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues);
return `${varName}.get(${rewrittenInner})!`; return `${varName}.get(${rewrittenInner})!`;
}); });
const thisPattern = new RegExp('\\b(this\\.' + mapVar + ')\\[([^\\]]+)\\]', 'g'); const thisPattern = new RegExp('\\b(this\\.' + mapVar + ')\\[([^\\]]+)\\]', 'g');
ts = ts.replace(thisPattern, (match, varName, innerExpr) => { ts = ts.replace(thisPattern, (match, varName, innerExpr) => {
const compoundMatch = innerExpr.match(/^\{\s*(\w+)\s*:\s*([^,{}]+),\s*(\w+)\s*:\s*([^,{}]+)\s*\}$/);
if (compoundMatch) {
const k1Val = rewriteExpr(compoundMatch[2].trim(), stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues);
const k2Val = rewriteExpr(compoundMatch[4].trim(), stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues);
return `${varName}.get(${k1Val})!.get(${k2Val})!`;
}
const rewrittenInner = rewriteExpr(innerExpr, stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues); const rewrittenInner = rewriteExpr(innerExpr, stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues);
return `${varName}.get(${rewrittenInner})!`; return `${varName}.get(${rewrittenInner})!`;
}); });
@ -550,6 +748,28 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, mapVarNames: Set<
// Match if followed by non-special chars, colon, then clause, else, else clause // Match if followed by non-special chars, colon, then clause, else, else clause
ts = ts.replace(/\bif\s+([^{}:]+):\s*([^{};]+)\s+else\s+([^{};\n]+)/g, '($1 ? $2 : $3)'); ts = ts.replace(/\bif\s+([^{}:]+):\s*([^{};]+)\s+else\s+([^{};\n]+)/g, '($1 ? $2 : $3)');
} while (ts !== prev2); } while (ts !== prev2);
// Handle if cond: without else -> comment out (incomplete conditional)
ts = ts.replace(/\bif\s+([^{}:;]+):\s*$/gm, '/* if $1 */');
// Restore object literals (protected in step 3)
// For Map-typed assignments, convert {k: v} -> new Map([[k, v]])
ts = ts.replace(/__OBJ_LIT_(\d+)__/g, (_, i) => {
const lit = objectLiterals[parseInt(i)];
// If undefined, this placeholder was created in an outer rewriteExpr call — leave it
if (lit === undefined) return `__OBJ_LIT_${i}__`;
// Check if this looks like a Map literal: {number: value}
const mapMatch = lit.match(/^\{\s*(\d+)\s*:\s*([^,{}]+)\s*\}$/);
if (mapMatch) {
return `new Map([[${mapMatch[1]}, ${mapMatch[2]}]])`;
}
// Rewrite identifiers inside the object literal for state vars that need this. prefix
// (e.g., { created_at: currentTime } -> { created_at: this.currentTime })
const rewritten = lit.replace(/\b(currentTime|now)\b/g, (m: string) => {
if (m === 'currentTime') return 'this.currentTime';
return m;
});
return rewritten;
});
return ts.trim(); return ts.trim();
} }
@ -607,7 +827,16 @@ function tsType(t: any, speckName: string = '', enumMap?: Map<string, string[]>)
} }
if (t.type === 'list') return `${tsType(t.elementType, speckName, enumMap)}[]`; if (t.type === 'list') return `${tsType(t.elementType, speckName, enumMap)}[]`;
if (t.type === 'set') return `Set<${tsType(t.elementType, speckName, enumMap)}>`; if (t.type === 'set') return `Set<${tsType(t.elementType, speckName, enumMap)}>`;
if (t.type === 'map') return `Map<${tsType(t.keyType, speckName, enumMap)}, ${tsType(t.valueType, speckName, enumMap)}>`; if (t.type === 'map') {
// Detect compound key: Map(Map(K1, K2), V) -> Map<K1, Map<K2, V>>
if (t.keyType?.type === 'map') {
const k1 = tsType(t.keyType.keyType, speckName, enumMap);
const k2 = tsType(t.keyType.valueType, speckName, enumMap);
const v = tsType(t.valueType, speckName, enumMap);
return `Map<${k1}, Map<${k2}, ${v}>>`;
}
return `Map<${tsType(t.keyType, speckName, enumMap)}, ${tsType(t.valueType, speckName, enumMap)}>`;
}
if (t.type === 'record') { if (t.type === 'record') {
const fields = t.fields.map((f: any) => { const fields = t.fields.map((f: any) => {
const name = cleanName(f.name); const name = cleanName(f.name);
@ -621,6 +850,19 @@ function tsType(t: any, speckName: string = '', enumMap?: Map<string, string[]>)
if (rawName === 'Integer') return 'number'; if (rawName === 'Integer') return 'number';
if (rawName === 'Boolean') return 'boolean'; if (rawName === 'Boolean') return 'boolean';
if (rawName === 'Nat') return 'number'; if (rawName === 'Nat') return 'number';
// Handle bracket array syntax: String[] -> string[]
const arrayMatch = rawName.match(/^(\w+)\[\]$/);
if (arrayMatch) {
const base = arrayMatch[1];
const baseMap: Record<string, string> = {
String: 'string', Nat: 'number', Int: 'number', Integer: 'number',
Real: 'number', Bool: 'boolean', Boolean: 'boolean'
};
return `${baseMap[base] || base}[]`;
}
// Handle Option(T) -> T | null
const optionMatch = rawName.match(/^Option\((\w+)\)$/);
if (optionMatch) return `${optionMatch[1]} | null`;
// Check if this ident maps to a known enum interface // Check if this ident maps to a known enum interface
if (enumMap && enumMap.has(rawName)) { if (enumMap && enumMap.has(rawName)) {
return `${speckName}${rawName}`; return `${speckName}${rawName}`;

View file

@ -855,14 +855,17 @@ function parseSource(line: string): SourceNode {
function parseStateBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): StateNode { function parseStateBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): StateNode {
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount); const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*')); const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
// Join lines: use '; ' between complete variable declarations // State blocks separate fields with commas or newlines/semicolons.
const inner = innerLines.reduce((acc, line, i) => { // Strip trailing commas (they are field separators, not continuations)
// then join with semicolons between complete variable declarations.
const cleanedLines = innerLines.map(l => l.replace(/,+\s*$/, ''));
const inner = cleanedLines.reduce((acc, line, i) => {
if (i === 0) return line; if (i === 0) return line;
const prevLine = innerLines[i - 1]; const prevLine = cleanedLines[i - 1];
// Check if this line starts a new variable declaration (name: type) // Check if this line starts a new variable declaration (name: type)
const isNewVar = /^\w+\s*:/.test(line); const isNewVar = /^\w+\s*:/.test(line);
// Check if prev line ends with a continuation character // Check if prev line ends with a continuation character (brace or paren, NOT comma)
const prevIsContinuation = /[{(,]\s*$/.test(prevLine); const prevIsContinuation = /[{(]\s*$/.test(prevLine);
if (isNewVar && !prevIsContinuation) { if (isNewVar && !prevIsContinuation) {
return acc + '; ' + line; return acc + '; ' + line;
} }
@ -874,11 +877,14 @@ function parseStateBlockMultiline(lines: string[], startIndex: number, startBrac
function parseInitBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): InitNode { function parseInitBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): InitNode {
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount); const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*')); const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
const inner = innerLines.reduce((acc, line, i) => { // Init blocks separate fields with commas or newlines/semicolons.
// Strip trailing commas and join with semicolons.
const cleanedLines = innerLines.map(l => l.replace(/,+\s*$/, ''));
const inner = cleanedLines.reduce((acc, line, i) => {
if (i === 0) return line; if (i === 0) return line;
const prevLine = innerLines[i - 1]; const prevLine = cleanedLines[i - 1];
const isNewVar = /^\w+\s*:/.test(line); const isNewVar = /^\w+\s*:/.test(line);
const prevIsContinuation = /[{(,]\s*$/.test(prevLine); const prevIsContinuation = /[{(]\s*$/.test(prevLine);
if (isNewVar && !prevIsContinuation) { if (isNewVar && !prevIsContinuation) {
return acc + '; ' + line; return acc + '; ' + line;
} }
@ -975,7 +981,7 @@ function parseActionBlockMultiline(lines: string[], startIndex: number, startBra
const fieldsStr = emitMatch[2]; const fieldsStr = emitMatch[2];
const fields: { name: string; value: string }[] = []; const fields: { name: string; value: string }[] = [];
if (fieldsStr) { if (fieldsStr) {
const fieldPairs = fieldsStr.split(',').map(s => s.trim()).filter(Boolean); const fieldPairs = splitFields(fieldsStr);
for (const fp of fieldPairs) { for (const fp of fieldPairs) {
const colonIdx = fp.indexOf(':'); const colonIdx = fp.indexOf(':');
if (colonIdx > 0) { if (colonIdx > 0) {
@ -1060,26 +1066,33 @@ function parseInterfaceBlockMultiline(lines: string[], startIndex: number, start
const methods: MethodSignature[] = []; const methods: MethodSignature[] = [];
for (const line of innerLines) { for (const line of innerLines) {
// name(params) -> returnType // name-only: enum-style values (e.g., "Idle", "Idle, Parsing" comma-separated)
const match = line.match(/^(\w+)\s*\(([^)]*)\)\s*-\u003e\s*(.+?),?$/); // Split on comma and take each trimmed token
if (match) { const tokens = line.split(',').map(s => s.trim()).filter(Boolean);
const name = match[1]; for (const token of tokens) {
const paramsStr = match[2]; // Check if it’s a name(params) -> returnType method signature
const returnTypeStr = match[3].replace(/,$/, '').trim(); const methodMatch = token.match(/^(\w+)\s*\(([^)]*)\)\s*->\s*(.+?),?$/);
const params: { name: string; type: TypeExpr }[] = []; if (methodMatch) {
if (paramsStr.trim()) { const name = methodMatch[1];
const paramPairs = paramsStr.split(',').map(s => s.trim()).filter(Boolean); const paramsStr = methodMatch[2];
for (const p of paramPairs) { const returnTypeStr = methodMatch[3].replace(/,$/, '').trim();
const pMatch = p.match(/^(\w+)\s*:\s*(.+)$/); const params: { name: string; type: TypeExpr }[] = [];
if (pMatch) { if (paramsStr.trim()) {
params.push({ name: pMatch[1], type: parseTypeExpr(pMatch[2].trim()) }); const paramPairs = paramsStr.split(',').map(s => s.trim()).filter(Boolean);
for (const p of paramPairs) {
const pMatch = p.match(/^(\w+)\s*:\s*(.+)$/);
if (pMatch) {
params.push({ name: pMatch[1], type: parseTypeExpr(pMatch[2].trim()) });
}
} }
} }
methods.push({ name, params, returnType: parseTypeExpr(returnTypeStr) });
} else if (/^\w+$/.test(token)) {
// Name-only (enum-style) — treat as a method with no params and void return
methods.push({ name: token, params: [], returnType: parseTypeExpr('Void') });
} }
methods.push({ name, params, returnType: parseTypeExpr(returnTypeStr) });
} }
} }
return { ...header, methods }; return { ...header, methods };
} }
@ -1310,3 +1323,27 @@ function parseConstraintBlockMultiline(lines: string[], startIndex: number, star
return null; return null;
} }
/** Split on commas while respecting parenthesized groups and quoted strings */
function splitFields(s: string): string[] {
const result: string[] = [];
let depth = 0;
let inDouble = false;
let inSingle = false;
let start = 0;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; }
if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; }
if (inDouble || inSingle) continue;
if (ch === '(' || ch === '{') { depth++; continue; }
if (ch === ')' || ch === '}') { depth--; continue; }
if (ch === ',' && depth === 0) {
result.push(s.substring(start, i).trim());
start = i + 1;
}
}
const last = s.substring(start).trim();
if (last) result.push(last);
return result;
}