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:
parent
dbde01ccb5
commit
d16df7581a
10 changed files with 1660 additions and 84 deletions
82
compiler/dist/parser.js
vendored
82
compiler/dist/parser.js
vendored
|
|
@ -612,15 +612,18 @@ function parseSource(line) {
|
|||
function parseStateBlockMultiline(lines, startIndex, 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('/*'));
|
||||
// Join lines: use '; ' between complete variable declarations
|
||||
const inner = innerLines.reduce((acc, line, i) => {
|
||||
// State blocks separate fields with commas or newlines/semicolons.
|
||||
// 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;
|
||||
const prevLine = innerLines[i - 1];
|
||||
const prevLine = cleanedLines[i - 1];
|
||||
// Check if this line starts a new variable declaration (name: type)
|
||||
const isNewVar = /^\w+\s*:/.test(line);
|
||||
// Check if prev line ends with a continuation character
|
||||
const prevIsContinuation = /[{(,]\s*$/.test(prevLine);
|
||||
// Check if prev line ends with a continuation character (brace or paren, NOT comma)
|
||||
const prevIsContinuation = /[{(]\s*$/.test(prevLine);
|
||||
if (isNewVar && !prevIsContinuation) {
|
||||
return acc + '; ' + line;
|
||||
}
|
||||
|
|
@ -631,12 +634,15 @@ function parseStateBlockMultiline(lines, startIndex, startBraceCount) {
|
|||
function parseInitBlockMultiline(lines, startIndex, 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 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;
|
||||
const prevLine = innerLines[i - 1];
|
||||
const prevLine = cleanedLines[i - 1];
|
||||
const isNewVar = /^\w+\s*:/.test(line);
|
||||
const prevIsContinuation = /[{(,]\s*$/.test(prevLine);
|
||||
const prevIsContinuation = /[{(]\s*$/.test(prevLine);
|
||||
if (isNewVar && !prevIsContinuation) {
|
||||
return acc + '; ' + line;
|
||||
}
|
||||
|
|
@ -736,7 +742,7 @@ function parseActionBlockMultiline(lines, startIndex, startBraceCount) {
|
|||
const fieldsStr = emitMatch[2];
|
||||
const fields = [];
|
||||
if (fieldsStr) {
|
||||
const fieldPairs = fieldsStr.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const fieldPairs = splitFields(fieldsStr);
|
||||
for (const fp of fieldPairs) {
|
||||
const colonIdx = fp.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
|
|
@ -821,12 +827,16 @@ function parseInterfaceBlockMultiline(lines, startIndex, startBraceCount) {
|
|||
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
|
||||
const methods = [];
|
||||
for (const line of innerLines) {
|
||||
// name(params) -> returnType
|
||||
const match = line.match(/^(\w+)\s*\(([^)]*)\)\s*-\u003e\s*(.+?),?$/);
|
||||
if (match) {
|
||||
const name = match[1];
|
||||
const paramsStr = match[2];
|
||||
const returnTypeStr = match[3].replace(/,$/, '').trim();
|
||||
// name-only: enum-style values (e.g., "Idle", "Idle, Parsing" comma-separated)
|
||||
// Split on comma and take each trimmed token
|
||||
const tokens = line.split(',').map(s => s.trim()).filter(Boolean);
|
||||
for (const token of tokens) {
|
||||
// Check if it’s a name(params) -> returnType method signature
|
||||
const methodMatch = token.match(/^(\w+)\s*\(([^)]*)\)\s*->\s*(.+?),?$/);
|
||||
if (methodMatch) {
|
||||
const name = methodMatch[1];
|
||||
const paramsStr = methodMatch[2];
|
||||
const returnTypeStr = methodMatch[3].replace(/,$/, '').trim();
|
||||
const params = [];
|
||||
if (paramsStr.trim()) {
|
||||
const paramPairs = paramsStr.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
|
@ -839,6 +849,11 @@ function parseInterfaceBlockMultiline(lines, startIndex, startBraceCount) {
|
|||
}
|
||||
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') });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...header, methods };
|
||||
}
|
||||
|
|
@ -1037,4 +1052,41 @@ function parseConstraintBlockMultiline(lines, startIndex, startBraceCount) {
|
|||
}
|
||||
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
|
||||
2
compiler/dist/parser.js.map
vendored
2
compiler/dist/parser.js.map
vendored
File diff suppressed because one or more lines are too long
155
compiler/src/expr/ast.ts
Normal file
155
compiler/src/expr/ast.ts
Normal 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;
|
||||
}
|
||||
5
compiler/src/expr/index.ts
Normal file
5
compiler/src/expr/index.ts
Normal 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
411
compiler/src/expr/parser.ts
Normal 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();
|
||||
}
|
||||
66
compiler/src/expr/stmt-ast.ts
Normal file
66
compiler/src/expr/stmt-ast.ts
Normal 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
|
||||
}
|
||||
221
compiler/src/expr/stmt-parser.ts
Normal file
221
compiler/src/expr/stmt-parser.ts
Normal 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';
|
||||
387
compiler/src/expr/tokenizer.ts
Normal file
387
compiler/src/expr/tokenizer.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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 (enumInterfaces.length === 0) {
|
||||
enumMap.set(stateEnumName.replace(speck.name, ''), stateValues);
|
||||
// Avoid collision: don't add to enumMap if the key matches an existing ident type in state vars
|
||||
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)
|
||||
|
|
@ -180,8 +196,8 @@ function emitSpeck(speck: SpeckNode): string {
|
|||
}).join('\n');
|
||||
return `export interface ${e.name} {\n${fields}\n}`;
|
||||
}),
|
||||
// Implicit events from emit statements
|
||||
...Array.from(implicitEvents.values()).map(e => {
|
||||
// Implicit events from emit statements (skip if already declared explicitly)
|
||||
...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');
|
||||
return `export interface ${e.name} {\n${fields}\n}`;
|
||||
})
|
||||
|
|
@ -204,7 +220,8 @@ function emitSpeck(speck: SpeckNode): string {
|
|||
if (!t) return;
|
||||
if (t.type === 'ident' && 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -262,12 +279,20 @@ function emitSpeck(speck: SpeckNode): string {
|
|||
).join('\n\n');
|
||||
|
||||
// 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
|
||||
const opaqueTypeDecls = opaqueRecordTypes.size > 0
|
||||
? '// 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
|
||||
|
|
@ -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 =
|
||||
`// Auto-generated by speckl-compile from ${speck.name}\n` +
|
||||
`// DO NOT EDIT MANUALLY — regenerate from .speckdl source\n\n` +
|
||||
`${stateEnum}\n\n` +
|
||||
`${eventInterfaces}\n\n` +
|
||||
`${hashUtility}` +
|
||||
`${classBody}`;
|
||||
|
||||
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 {
|
||||
// Collect local parameter names so we DON'T prefix them with this.
|
||||
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
|
||||
.map(p => `${p.name}: ${tsType(p.type, speckName, enumMap)}`)
|
||||
|
|
@ -326,8 +372,16 @@ function emitAction(action: ActionNode, stateVarNames: Set<string>, mapVarNames:
|
|||
break;
|
||||
}
|
||||
case 'let': {
|
||||
const expr = rewriteExpr(stmt.expr, stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues);
|
||||
bodyParts.push(` const ${stmt.name} = ${expr};`);
|
||||
let expr = rewriteExpr(stmt.expr, stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues);
|
||||
// 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;
|
||||
}
|
||||
case 'assign': {
|
||||
|
|
@ -338,7 +392,25 @@ function emitAction(action: ActionNode, stateVarNames: Set<string>, mapVarNames:
|
|||
const mapBracketMatch = target.match(/^(\w+)\[([\s\S]+)\]$/);
|
||||
if (mapBracketMatch && mapVarNames.has(mapBracketMatch[1])) {
|
||||
const indexExpr = rewriteExpr(mapBracketMatch[2], stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues);
|
||||
// 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 {
|
||||
bodyParts.push(` this.${target} = ${expr};`);
|
||||
}
|
||||
|
|
@ -372,9 +444,48 @@ function emitAction(action: ActionNode, stateVarNames: Set<string>, mapVarNames:
|
|||
}
|
||||
}
|
||||
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
|
||||
// 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 {
|
||||
|
|
@ -382,15 +493,30 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, mapVarNames: Set<
|
|||
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
|
||||
let ts = expr
|
||||
.replace(/\bnow\(\)/g, 'Date.now()')
|
||||
ts = ts
|
||||
.replace(/\bhash\(([^,{}]+),\s*([^)]+)\)/g, 'simpleHash($1, $2)')
|
||||
.replace(/\bhash\(([^)]+)\)/g, 'simpleHash($1)')
|
||||
.replace(/\bnow\(\)/g, 'new Date().toISOString()')
|
||||
.replace(/\blength\(([^)]+)\)/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)');
|
||||
|
||||
// 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);
|
||||
// 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));
|
||||
|
|
@ -399,6 +525,9 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, mapVarNames: Set<
|
|||
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
|
||||
// First, protect object literals (those { ... } blocks are object values, not state refs)
|
||||
const objectLiterals: string[] = [];
|
||||
|
|
@ -436,8 +565,11 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, mapVarNames: Set<
|
|||
}).join('')
|
||||
// Undo this. prefix on identifiers after bracket access
|
||||
.replace(/\]\.this\.(\w+)/g, '].$1')
|
||||
// Restore object literals
|
||||
.replace(/__OBJ_LIT_(\d+)__/g, (_, i) => objectLiterals[parseInt(i)]);
|
||||
// Step 2c: Restore string literals AFTER identifier replacement (step 3b)
|
||||
// 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)
|
||||
// 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(/==$/, '==='); // trailing == at end-of-string (no word boundary after)
|
||||
|
||||
|
||||
// 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)")
|
||||
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)")
|
||||
|
|
@ -461,26 +609,61 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, mapVarNames: Set<
|
|||
// Match balanced parens: old(...). The inner expr can contain parens.
|
||||
ts = ts.replace(/old\(([^()]*(?:\([^()]*\)[^()]*)*)\)/g, '$1');
|
||||
|
||||
// 5c: union -> new Set([...a, ...b]) (set union operator)
|
||||
// "pending union {t.id}" -> "new Set([...pending, t.id])"
|
||||
ts = ts.replace(/(\S+)\s+union\s+\{([^}]+)\}/g, 'new Set([...$1, $2])');
|
||||
// 5c: union -> new Set(Array.from(a).concat([b])) (set union operator)
|
||||
// "pending union {t.id}" -> "new Set(Array.from(pending).concat([t.id]))"
|
||||
// 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 \\\\)
|
||||
// "pending \\ {id}" or "pending \ {id}" -> "new Set([...pending].filter(x => x !== id))"
|
||||
// For Maps: "pending \\ {id}" -> "new Map([...pending].filter(([k, v]) => k !== id))"
|
||||
ts = ts.replace(/(\S+)\s+\\\\\s+\{([^}]+)\}/g, (match, collection, key) => {
|
||||
const base = collection.replace(/^this\./, '');
|
||||
// "pending \\ {id}" or "pending \ {id}" -> "new Set(Array.from(pending).filter(x => x !== id))"
|
||||
// For Maps: "pending \\ {id}" -> "new Map(Array.from(pending).filter(([k, v]) => k !== id))"
|
||||
// Also handles __OBJ_LIT_ placeholders (protected object literals)
|
||||
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([...${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\./, '');
|
||||
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)
|
||||
|
|
@ -488,8 +671,9 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, mapVarNames: Set<
|
|||
ts = ts.replace(/\bnot\s+/g, '!');
|
||||
|
||||
// 5d2: and -> &&, or -> || (logical operators)
|
||||
// Avoid matching inside method calls like .or( or .and(
|
||||
ts = ts.replace(/([^.]\s*)\band\b(?!\s*\()/g, '$1&&').replace(/([^.]\s*)\bor\b(?!\s*\()/g, '$1||');
|
||||
ts = ts.replace(/ and /g, ' && ').replace(/ or /g, ' || ');
|
||||
// Handle SpeckDL .or() method — record-type short-circuit or
|
||||
ts = ts.replace(/\.or\(([^)]+)\)/g, ' || $1');
|
||||
|
||||
// 5d3: Strip SpeckDL comments (// ...) before TS processing
|
||||
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
|
||||
// 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)
|
||||
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"
|
||||
ts = ts.replace(/(\S+)\s*===\s*$/g, '$1 === $1');
|
||||
|
||||
// 5f: append(X, Y) -> [...X, Y] (list append)
|
||||
ts = ts.replace(/\bappend\(([^,]+),\s*([^)]+)\)/g, '[...$1, $2]');
|
||||
// 5f: append(X, Y) -> [...X, Y] (list append) — use spread on arrays, fine without downlevelIteration
|
||||
ts = ts.replace(/\bappend\(([^,{}]+),\s*([^)]+)\)/g, '[...$1, $2]');
|
||||
|
||||
// 5g: Map access via bracket -> .get() (e.g. "transfers[id]" -> "transfers.get(id)")
|
||||
// Only applies to state variables that are Map types
|
||||
// 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) {
|
||||
// Match: mapVar[expr] where mapVar is a known Map state variable
|
||||
// Handle both bare mapVar[...] and this.mapVar[...]
|
||||
// The inner expr needs rewriting for this-prefixing
|
||||
const barePattern = new RegExp('\\b(' + mapVar + ')\\[([^\\]]+)\\]', 'g');
|
||||
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);
|
||||
return `${varName}.get(${rewrittenInner})!`;
|
||||
});
|
||||
const thisPattern = new RegExp('\\b(this\\.' + mapVar + ')\\[([^\\]]+)\\]', 'g');
|
||||
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);
|
||||
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
|
||||
ts = ts.replace(/\bif\s+([^{}:]+):\s*([^{};]+)\s+else\s+([^{};\n]+)/g, '($1 ? $2 : $3)');
|
||||
} 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();
|
||||
}
|
||||
|
|
@ -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 === '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') {
|
||||
const fields = t.fields.map((f: any) => {
|
||||
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 === 'Boolean') return 'boolean';
|
||||
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
|
||||
if (enumMap && enumMap.has(rawName)) {
|
||||
return `${speckName}${rawName}`;
|
||||
|
|
|
|||
|
|
@ -855,14 +855,17 @@ function parseSource(line: string): SourceNode {
|
|||
function parseStateBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): StateNode {
|
||||
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('/*'));
|
||||
// Join lines: use '; ' between complete variable declarations
|
||||
const inner = innerLines.reduce((acc, line, i) => {
|
||||
// State blocks separate fields with commas or newlines/semicolons.
|
||||
// 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;
|
||||
const prevLine = innerLines[i - 1];
|
||||
const prevLine = cleanedLines[i - 1];
|
||||
// Check if this line starts a new variable declaration (name: type)
|
||||
const isNewVar = /^\w+\s*:/.test(line);
|
||||
// Check if prev line ends with a continuation character
|
||||
const prevIsContinuation = /[{(,]\s*$/.test(prevLine);
|
||||
// Check if prev line ends with a continuation character (brace or paren, NOT comma)
|
||||
const prevIsContinuation = /[{(]\s*$/.test(prevLine);
|
||||
if (isNewVar && !prevIsContinuation) {
|
||||
return acc + '; ' + line;
|
||||
}
|
||||
|
|
@ -874,11 +877,14 @@ function parseStateBlockMultiline(lines: string[], startIndex: number, startBrac
|
|||
function parseInitBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): InitNode {
|
||||
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 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;
|
||||
const prevLine = innerLines[i - 1];
|
||||
const prevLine = cleanedLines[i - 1];
|
||||
const isNewVar = /^\w+\s*:/.test(line);
|
||||
const prevIsContinuation = /[{(,]\s*$/.test(prevLine);
|
||||
const prevIsContinuation = /[{(]\s*$/.test(prevLine);
|
||||
if (isNewVar && !prevIsContinuation) {
|
||||
return acc + '; ' + line;
|
||||
}
|
||||
|
|
@ -975,7 +981,7 @@ function parseActionBlockMultiline(lines: string[], startIndex: number, startBra
|
|||
const fieldsStr = emitMatch[2];
|
||||
const fields: { name: string; value: string }[] = [];
|
||||
if (fieldsStr) {
|
||||
const fieldPairs = fieldsStr.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const fieldPairs = splitFields(fieldsStr);
|
||||
for (const fp of fieldPairs) {
|
||||
const colonIdx = fp.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
|
|
@ -1060,12 +1066,16 @@ function parseInterfaceBlockMultiline(lines: string[], startIndex: number, start
|
|||
|
||||
const methods: MethodSignature[] = [];
|
||||
for (const line of innerLines) {
|
||||
// name(params) -> returnType
|
||||
const match = line.match(/^(\w+)\s*\(([^)]*)\)\s*-\u003e\s*(.+?),?$/);
|
||||
if (match) {
|
||||
const name = match[1];
|
||||
const paramsStr = match[2];
|
||||
const returnTypeStr = match[3].replace(/,$/, '').trim();
|
||||
// name-only: enum-style values (e.g., "Idle", "Idle, Parsing" comma-separated)
|
||||
// Split on comma and take each trimmed token
|
||||
const tokens = line.split(',').map(s => s.trim()).filter(Boolean);
|
||||
for (const token of tokens) {
|
||||
// Check if it’s a name(params) -> returnType method signature
|
||||
const methodMatch = token.match(/^(\w+)\s*\(([^)]*)\)\s*->\s*(.+?),?$/);
|
||||
if (methodMatch) {
|
||||
const name = methodMatch[1];
|
||||
const paramsStr = methodMatch[2];
|
||||
const returnTypeStr = methodMatch[3].replace(/,$/, '').trim();
|
||||
const params: { name: string; type: TypeExpr }[] = [];
|
||||
if (paramsStr.trim()) {
|
||||
const paramPairs = paramsStr.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
|
@ -1077,9 +1087,12 @@ function parseInterfaceBlockMultiline(lines: string[], startIndex: number, start
|
|||
}
|
||||
}
|
||||
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') });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ...header, methods };
|
||||
}
|
||||
|
||||
|
|
@ -1310,3 +1323,27 @@ function parseConstraintBlockMultiline(lines: string[], startIndex: number, star
|
|||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue