Fix multiline type/object parsing, Map inline records, set-diff for Maps

- Add braceDepth tracking to Map/Set type parsing for inline records
- Fix multiline state block joining to preserve type expressions
- Fix multiline object literal parsing in assignments
- Add smart set-difference for Maps vs Sets
- Fix bare notIn for Sets
- Add non-null assertion (!) to Map.get() calls
- Add implicit event interface generation from emit statements
- Protect object literals from this. prefix injection
This commit is contained in:
Scott Scoble 2026-05-11 18:42:59 -07:00
parent 8b520284cb
commit a392163630
4 changed files with 201 additions and 36 deletions

View file

@ -141,6 +141,25 @@ function findBlockEnd(lines, startIndex, startBraceCount) {
} }
return i; return i;
} }
// Check if an expression has balanced braces, parens, and brackets
function isExpressionComplete(expr) {
let braces = 0, parens = 0, brackets = 0;
for (const char of expr) {
if (char === '{')
braces++;
else if (char === '}')
braces--;
else if (char === '(')
parens++;
else if (char === ')')
parens--;
else if (char === '[')
brackets++;
else if (char === ']')
brackets--;
}
return braces === 0 && parens === 0 && brackets === 0;
}
function parseMemberBlock(lines, startIndex) { function parseMemberBlock(lines, startIndex) {
const firstLine = lines[startIndex].trim(); const firstLine = lines[startIndex].trim();
const startBraceCount = (firstLine.match(/{/g) || []).length; const startBraceCount = (firstLine.match(/{/g) || []).length;
@ -398,15 +417,20 @@ function parseTypeExpr(expr) {
// Map<K, V> // Map<K, V>
if (expr.startsWith('Map<') && expr.endsWith('>')) { if (expr.startsWith('Map<') && expr.endsWith('>')) {
const inner = expr.substring(4, expr.length - 1); const inner = expr.substring(4, expr.length - 1);
// Find the first-level comma (not inside nested generics) // Find the first-level comma (not inside nested generics or record literals)
let depth = 0; let depth = 0;
let braceDepth = 0;
let commaIdx = -1; let commaIdx = -1;
for (let i = 0; i < inner.length; i++) { for (let i = 0; i < inner.length; i++) {
if (inner[i] === '<') if (inner[i] === '<' || inner[i] === '(')
depth++; depth++;
else if (inner[i] === '>') else if (inner[i] === '>' || inner[i] === ')')
depth--; depth--;
else if (inner[i] === ',' && depth === 0) { else if (inner[i] === '{')
braceDepth++;
else if (inner[i] === '}')
braceDepth--;
else if (inner[i] === ',' && depth === 0 && braceDepth === 0) {
commaIdx = i; commaIdx = i;
break; break;
} }
@ -433,13 +457,18 @@ function parseTypeExpr(expr) {
if (expr.startsWith('Map(') && expr.endsWith(')')) { if (expr.startsWith('Map(') && expr.endsWith(')')) {
const inner = expr.substring(4, expr.length - 1); const inner = expr.substring(4, expr.length - 1);
let depth = 0; let depth = 0;
let braceDepth = 0;
let commaIdx = -1; let commaIdx = -1;
for (let i = 0; i < inner.length; i++) { for (let i = 0; i < inner.length; i++) {
if (inner[i] === '(' || inner[i] === '<') if (inner[i] === '(' || inner[i] === '<')
depth++; depth++;
else if (inner[i] === ')' || inner[i] === '>') else if (inner[i] === ')' || inner[i] === '>')
depth--; depth--;
else if (inner[i] === ',' && depth === 0) { else if (inner[i] === '{')
braceDepth++;
else if (inner[i] === '}')
braceDepth--;
else if (inner[i] === ',' && depth === 0 && braceDepth === 0) {
commaIdx = i; commaIdx = i;
break; break;
} }
@ -583,13 +612,36 @@ 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('/*'));
const inner = innerLines.join('; '); // Join lines: use '; ' between complete variable declarations
const inner = innerLines.reduce((acc, line, i) => {
if (i === 0)
return line;
const prevLine = innerLines[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);
if (isNewVar && !prevIsContinuation) {
return acc + '; ' + line;
}
return acc + ' ' + line;
}, '');
return parseStateBlock(inner); return parseStateBlock(inner);
} }
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.join('; '); const inner = innerLines.reduce((acc, line, i) => {
if (i === 0)
return line;
const prevLine = innerLines[i - 1];
const isNewVar = /^\w+\s*:/.test(line);
const prevIsContinuation = /[{(,]\s*$/.test(prevLine);
if (isNewVar && !prevIsContinuation) {
return acc + '; ' + line;
}
return acc + ' ' + line;
}, '');
return parseInitBlock(inner); return parseInitBlock(inner);
} }
function parseActionBlockMultiline(lines, startIndex, startBraceCount) { function parseActionBlockMultiline(lines, startIndex, startBraceCount) {
@ -675,10 +727,17 @@ function parseActionBlockMultiline(lines, startIndex, startBraceCount) {
statements.push({ type: 'let', name: letMatch[1], expr: letMatch[2].trim() }); statements.push({ type: 'let', name: letMatch[1], expr: letMatch[2].trim() });
continue; continue;
} }
// assignment: target := expr // assignment: target := expr (may span multiple lines)
const assignMatch = stmt.match(/^(\w+(?:\[.*?\])?)\s*:=\s*(.+)$/); const assignMatch = stmt.match(/^(\w+(?:\[.*?\])?)\s*:=\s*(.+)$/);
if (assignMatch) { if (assignMatch) {
statements.push({ type: 'assign', target: assignMatch[1], expr: assignMatch[2].trim() }); let target = assignMatch[1];
let expr = assignMatch[2].trim();
// Handle multiline expressions: join subsequent lines until braces/parens are balanced
while (i + 1 < bodyLines.length && !isExpressionComplete(expr)) {
i++;
expr += ' ' + bodyLines[i];
}
statements.push({ type: 'assign', target, expr });
} }
} }
return { ...header, statements }; return { ...header, statements };

File diff suppressed because one or more lines are too long

View file

@ -38,6 +38,19 @@ function emitSpeck(speck: SpeckNode): string {
const initNode = members.find(m => m.type === 'init') as InitNode | undefined; const initNode = members.find(m => m.type === 'init') as InitNode | undefined;
const actions = members.filter(m => m.type === 'action') as ActionNode[]; const actions = members.filter(m => m.type === 'action') as ActionNode[];
const events = members.filter(m => m.type === 'event') as EventNode[]; const events = members.filter(m => m.type === 'event') as EventNode[];
// Collect implicit event types from emit statements
const implicitEvents = new Map<string, { name: string; fields: { name: string; value: string }[] }>();
for (const action of actions) {
for (const stmt of action.statements) {
if (stmt.type === 'emit') {
const existing = implicitEvents.get(stmt.event);
if (!existing) {
implicitEvents.set(stmt.event, { name: stmt.event, fields: stmt.fields });
}
}
}
}
const constraints = members.filter(m => m.type === 'constraint') as ConstraintNode[]; const constraints = members.filter(m => m.type === 'constraint') as ConstraintNode[];
const verifies = members.filter(m => m.type === 'verify') as VerifyNode[]; const verifies = members.filter(m => m.type === 'verify') as VerifyNode[];
@ -158,13 +171,21 @@ function emitSpeck(speck: SpeckNode): string {
} }
const stateEnum = allEnums.join('\n\n'); const stateEnum = allEnums.join('\n\n');
const eventInterfaces = events.map(e => { const eventInterfaces = [
const fields = e.fields.map(f => { // Explicit event declarations
const name = cleanName(f.name); ...events.map(e => {
return ` ${name}: ${tsType(f.type, speck.name, enumMap)};`; const fields = e.fields.map(f => {
}).join('\n'); const name = cleanName(f.name);
return `export interface ${e.name} {\n${fields}\n}`; return ` ${name}: ${tsType(f.type, speck.name, enumMap)};`;
}).join('\n\n'); }).join('\n');
return `export interface ${e.name} {\n${fields}\n}`;
}),
// Implicit events from emit statements
...Array.from(implicitEvents.values()).map(e => {
const fields = e.fields.map((f: any) => ` ${f.name}: any;`).join('\n');
return `export interface ${e.name} {\n${fields}\n}`;
})
].join('\n\n');
const className = `${speck.name}Machine`; const className = `${speck.name}Machine`;
const stateVars = (stateNode?.variables ?? []).map(v => ({ const stateVars = (stateNode?.variables ?? []).map(v => ({
@ -310,10 +331,26 @@ function emitAction(action: ActionNode, stateVarNames: Set<string>, mapVarNames:
.map(s => { .map(s => {
const target = cleanName(s.target); const target = cleanName(s.target);
const expr = rewriteExpr(s.expr, stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues); const expr = rewriteExpr(s.expr, stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues);
// Map assignment: map[key] = val -> map.set(key, val)
const mapBracketMatch = target.match(/^(\w+)\[([^\]]+)\]$/);
if (mapBracketMatch && mapVarNames.has(mapBracketMatch[1])) {
const varName = mapBracketMatch[1];
const key = mapBracketMatch[2];
return ` this.${varName}.set(${key}, ${expr});`;
}
return ` this.${target} = ${expr};`; return ` this.${target} = ${expr};`;
}) })
.join('\n'); .join('\n');
// Handle let statements: let x := expr -> const x = expr;
const lets = action.statements
.filter(s => s.type === 'let')
.map(s => {
const expr = rewriteExpr(s.expr, stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues);
return ` const ${s.name} = ${expr};`;
})
.join('\n');
const emits = action.statements const emits = action.statements
.filter(s => s.type === 'emit') .filter(s => s.type === 'emit')
.map(s => { .map(s => {
@ -339,9 +376,9 @@ function emitAction(action: ActionNode, stateVarNames: Set<string>, mapVarNames:
}) })
.join('\n'); .join('\n');
// Preconditions + requires first (guards), then mutations (assign + emit), then postconditions // Preconditions + requires first (guards), then mutations (let + assign + emit), then postconditions
const guards = [preconditions, requires].filter(Boolean).join('\n'); const guards = [preconditions, requires].filter(Boolean).join('\n');
const mutations = [assigns, emits].filter(Boolean).join('\n'); const mutations = [lets, assigns, emits].filter(Boolean).join('\n');
const bodyParts = [guards, mutations, postconditions].filter(Boolean).join('\n'); const bodyParts = [guards, mutations, postconditions].filter(Boolean).join('\n');
return ` ${action.name}(${params}) {\n${bodyParts}\n }`; return ` ${action.name}(${params}) {\n${bodyParts}\n }`;
} }
@ -371,10 +408,10 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, mapVarNames: Set<
} }
// 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 identifiers inside object literals (e.g. { ...t, pending: false }) // First, protect object literals (those { ... } blocks are object values, not state refs)
// Those identifiers are object keys, not state variable refs
const objectLiterals: string[] = []; const objectLiterals: string[] = [];
ts = ts.replace(/\{[^}]*\.\.\.[^}]*\}/g, (match) => { // Match { ... } including nested braces (simplistic: match balanced braces)
ts = ts.replace(/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g, (match) => {
objectLiterals.push(match); objectLiterals.push(match);
return `__OBJ_LIT_${objectLiterals.length - 1}__`; return `__OBJ_LIT_${objectLiterals.length - 1}__`;
}); });
@ -404,6 +441,12 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, mapVarNames: Set<
// 5a: notIn -> !<map>.has(<key>) (e.g. "t.id notIn transfers.keys" -> "!transfers.has(t.id)") // 5a: notIn -> !<map>.has(<key>) (e.g. "t.id notIn transfers.keys" -> "!transfers.has(t.id)")
ts = ts.replace(/(\S+)\s+notIn\s+(\S+)\.keys/g, '!$2.has($1)'); ts = ts.replace(/(\S+)\s+notIn\s+(\S+)\.keys/g, '!$2.has($1)');
// 5a1: notIn for Sets (no .keys suffix), e.g. "acctId notIn accounts" -> "!accounts.has(acctId)"
ts = ts.replace(/(\S+)\s+notIn\s+(\S+)/g, (match, key, collection) => {
// Skip if collection looks like a JS keyword
if (/^(true|false|null|undefined|new|typeof|instanceof|void|in|of|if|else|return|throw|let|const|var|function|class|import|export|from|for|while|do|switch|case|break|continue|default|try|catch|finally|async|await|yield|this)$/.test(collection)) return match;
return `!${collection}.has(${key})`;
});
// 5a2: x in <map>.keys -> <map>.has(x) (e.g. "t.from in accounts.keys" -> "accounts.has(t.from)") // 5a2: 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)")
@ -421,9 +464,23 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, mapVarNames: Set<
// "pending union {t.id}" -> "new Set([...pending, t.id])" // "pending union {t.id}" -> "new Set([...pending, t.id])"
ts = ts.replace(/(\S+)\s+union\s+\{([^}]+)\}/g, 'new Set([...$1, $2])'); ts = ts.replace(/(\S+)\s+union\s+\{([^}]+)\}/g, 'new Set([...$1, $2])');
// 5d: \\ -> set difference via filter // 5d: \\ -> set difference via filter (handles both \\ and \\\\)
// "pending \\ {id}" -> "new Set([...pending].filter(x => x !== id))" // "pending \\ {id}" or "pending \ {id}" -> "new Set([...pending].filter(x => x !== id))"
ts = ts.replace(/(\S+)\s+\\\\\s+\{([^}]+)\}/g, 'new Set([...$1].filter(x => x !== $2))'); // 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\./, '');
if (mapVarNames.has(base)) {
return `new Map([...${collection}].filter(([k, v]) => k !== ${key}))`;
}
return `new Set([...${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([...${collection}].filter(([k, v]) => k !== ${key}))`;
}
return `new Set([...${collection}].filter(x => x !== ${key}))`;
});
// 5e: Clean up trailing empty comparisons (===) — expression has no RHS after old() removal // 5e: Clean up trailing empty comparisons (===) — expression has no RHS after old() removal
// "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"
@ -434,13 +491,14 @@ function rewriteExpr(expr: string, stateVarNames: Set<string>, mapVarNames: Set<
// 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
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[...]
const barePattern = new RegExp('\\b(' + mapVar + ')\\[([^\\]]+)\\]', 'g'); const barePattern = new RegExp('\\b(' + mapVar + ')\\[([^\\]]+)\\]', 'g');
ts = ts.replace(barePattern, '$1.get($2)'); ts = ts.replace(barePattern, '$1.get($2)!');
const thisPattern = new RegExp('\\b(this\\.' + mapVar + ')\\[([^\\]]+)\\]', 'g'); const thisPattern = new RegExp('\\b(this\\.' + mapVar + ')\\[([^\\]]+)\\]', 'g');
ts = ts.replace(thisPattern, '$1.get($2)'); ts = ts.replace(thisPattern, '$1.get($2)!');
} }
// 5h: .size() -> .size (Set/Map size property, not a method in JS) // 5h: .size() -> .size (Set/Map size property, not a method in JS)
ts = ts.replace(/\.size\(\)/g, '.size'); ts = ts.replace(/\.size\(\)/g, '.size');

View file

@ -331,6 +331,20 @@ function findBlockEnd(lines: string[], startIndex: number, startBraceCount: numb
return i; return i;
} }
// Check if an expression has balanced braces, parens, and brackets
function isExpressionComplete(expr: string): boolean {
let braces = 0, parens = 0, brackets = 0;
for (const char of expr) {
if (char === '{') braces++;
else if (char === '}') braces--;
else if (char === '(') parens++;
else if (char === ')') parens--;
else if (char === '[') brackets++;
else if (char === ']') brackets--;
}
return braces === 0 && parens === 0 && brackets === 0;
}
function parseMemberBlock(lines: string[], startIndex: number): MemberNode | null { function parseMemberBlock(lines: string[], startIndex: number): MemberNode | null {
const firstLine = lines[startIndex].trim(); const firstLine = lines[startIndex].trim();
const startBraceCount = (firstLine.match(/{/g) || []).length; const startBraceCount = (firstLine.match(/{/g) || []).length;
@ -624,13 +638,16 @@ function parseTypeExpr(expr: string): TypeExpr {
// Map<K, V> // Map<K, V>
if (expr.startsWith('Map<') && expr.endsWith('>')) { if (expr.startsWith('Map<') && expr.endsWith('>')) {
const inner = expr.substring(4, expr.length - 1); const inner = expr.substring(4, expr.length - 1);
// Find the first-level comma (not inside nested generics) // Find the first-level comma (not inside nested generics or record literals)
let depth = 0; let depth = 0;
let braceDepth = 0;
let commaIdx = -1; let commaIdx = -1;
for (let i = 0; i < inner.length; i++) { for (let i = 0; i < inner.length; i++) {
if (inner[i] === '<') depth++; if (inner[i] === '<' || inner[i] === '(') depth++;
else if (inner[i] === '>') depth--; else if (inner[i] === '>' || inner[i] === ')') depth--;
else if (inner[i] === ',' && depth === 0) { else if (inner[i] === '{') braceDepth++;
else if (inner[i] === '}') braceDepth--;
else if (inner[i] === ',' && depth === 0 && braceDepth === 0) {
commaIdx = i; commaIdx = i;
break; break;
} }
@ -660,11 +677,14 @@ function parseTypeExpr(expr: string): TypeExpr {
if (expr.startsWith('Map(') && expr.endsWith(')')) { if (expr.startsWith('Map(') && expr.endsWith(')')) {
const inner = expr.substring(4, expr.length - 1); const inner = expr.substring(4, expr.length - 1);
let depth = 0; let depth = 0;
let braceDepth = 0;
let commaIdx = -1; let commaIdx = -1;
for (let i = 0; i < inner.length; i++) { for (let i = 0; i < inner.length; i++) {
if (inner[i] === '(' || inner[i] === '<') depth++; if (inner[i] === '(' || inner[i] === '<') depth++;
else if (inner[i] === ')' || inner[i] === '>') depth--; else if (inner[i] === ')' || inner[i] === '>') depth--;
else if (inner[i] === ',' && depth === 0) { else if (inner[i] === '{') braceDepth++;
else if (inner[i] === '}') braceDepth--;
else if (inner[i] === ',' && depth === 0 && braceDepth === 0) {
commaIdx = i; commaIdx = i;
break; break;
} }
@ -834,14 +854,35 @@ 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('/*'));
const inner = innerLines.join('; '); // Join lines: use '; ' between complete variable declarations
const inner = innerLines.reduce((acc, line, i) => {
if (i === 0) return line;
const prevLine = innerLines[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);
if (isNewVar && !prevIsContinuation) {
return acc + '; ' + line;
}
return acc + ' ' + line;
}, '');
return parseStateBlock(inner); return parseStateBlock(inner);
} }
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.join('; '); const inner = innerLines.reduce((acc, line, i) => {
if (i === 0) return line;
const prevLine = innerLines[i - 1];
const isNewVar = /^\w+\s*:/.test(line);
const prevIsContinuation = /[{(,]\s*$/.test(prevLine);
if (isNewVar && !prevIsContinuation) {
return acc + '; ' + line;
}
return acc + ' ' + line;
}, '');
return parseInitBlock(inner); return parseInitBlock(inner);
} }
@ -929,10 +970,17 @@ function parseActionBlockMultiline(lines: string[], startIndex: number, startBra
statements.push({ type: 'let', name: letMatch[1], expr: letMatch[2].trim() }); statements.push({ type: 'let', name: letMatch[1], expr: letMatch[2].trim() });
continue; continue;
} }
// assignment: target := expr // assignment: target := expr (may span multiple lines)
const assignMatch = stmt.match(/^(\w+(?:\[.*?\])?)\s*:=\s*(.+)$/); const assignMatch = stmt.match(/^(\w+(?:\[.*?\])?)\s*:=\s*(.+)$/);
if (assignMatch) { if (assignMatch) {
statements.push({ type: 'assign', target: assignMatch[1], expr: assignMatch[2].trim() }); let target = assignMatch[1];
let expr = assignMatch[2].trim();
// Handle multiline expressions: join subsequent lines until braces/parens are balanced
while (i + 1 < bodyLines.length && !isExpressionComplete(expr)) {
i++;
expr += ' ' + bodyLines[i];
}
statements.push({ type: 'assign', target, expr });
} }
} }