ifblock-fix: real if/else codegen + parser require-continuation fix

- typescript-state-machine.ts: replace TODO stub at L557-559 with real
  if/else TypeScript codegen from SpeckDL ifblock AST nodes
- parser.ts: fix require-continuation loop at L1213 that was consuming
  'if (...)' lines as require args; added nextLine.startsWith('if ')
  to the stop conditions so ifblocks parse correctly
- examples/ConditionalToggle.speck: new test fixture exercising if/else
- compiler.test.ts: new describe block for ifblock code generation
  (asserts real 'if (' + brace block + 'else', no TODO stub)

Tests: 71/71 green (53 parser + 18 compiler incl. new ifblock test).
This commit is contained in:
tef-engine 2026-07-19 01:28:00 -07:00
parent 857fa6ceb4
commit 9e4ac1b120
12 changed files with 254 additions and 9 deletions

View file

@ -1 +1 @@
{"version":"2.1.9","results":[[":__tests__/parser.test.ts",{"duration":55.17420900000002,"failed":false}],[":src/__tests__/compiler.test.ts",{"duration":3350.1431570000004,"failed":false}]]}
{"version":"2.1.9","results":[[":__tests__/parser.test.ts",{"duration":58.32899300000008,"failed":false}],[":src/__tests__/compiler.test.ts",{"duration":7758.09763,"failed":false}]]}

View file

@ -74,6 +74,56 @@ describe('Compiler E2E', () => {
expect(spdx['@context']).toBeDefined();
expect(spdx.spdx).toBeDefined();
});
it('should generate protobuf schema', () => {
// Compile RetryHandler.speck with --target protobuf and assert the
// generated .proto contains the expected protobuf constructs.
const specPath = path.join(__dirname, '..', '..', '..', 'examples', 'RetryHandler.speck');
const protoOutDir = path.join(testOutputDir, 'proto-smoke');
if (!fs.existsSync(protoOutDir)) {
fs.mkdirSync(protoOutDir, { recursive: true });
}
execSync(
`npx tsx src/index.ts ${specPath} -t protobuf -o ${protoOutDir}`,
{ cwd: COMPILER_DIR, stdio: 'pipe' },
);
const protoFiles = fs.readdirSync(protoOutDir).filter(f => f.endsWith('.proto'));
expect(protoFiles.length).toBeGreaterThanOrEqual(1);
const protoContent = fs.readFileSync(path.join(protoOutDir, protoFiles[0]), 'utf-8');
expect(protoContent).toContain('syntax = "proto3";');
expect(protoContent).toMatch(/\bmessage\s+\w+/);
// RetryHandler has events with fields — assert at least one field line
expect(protoContent).toMatch(/\w+\s+\w+\s*=\s*\d+;/);
});
});
describe('ifblock code generation', () => {
it('should emit real TypeScript if/else from a SpeckDL ifblock', () => {
// Compile ConditionalToggle.speck (has an if/else action body) and assert
// the generated TS contains a real `if (` with a brace block, not the old
// TODO stub comment.
const specPath = path.join(__dirname, '..', '..', '..', 'examples', 'ConditionalToggle.speck');
const outDir = path.join(__dirname, 'ifblock-smoke');
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
execSync(
`npx tsx src/index.ts ${specPath} -t typescript -o ${outDir}`,
{ cwd: COMPILER_DIR, stdio: 'pipe' },
);
const tsFile = path.join(outDir, 'ConditionalToggle.ts');
expect(fs.existsSync(tsFile)).toBe(true);
const tsContent = fs.readFileSync(tsFile, 'utf-8');
// Should contain a real if statement
expect(tsContent).toMatch(/\bif\s*\(/);
// Should contain a brace block body
expect(tsContent).toContain('{ ');
expect(tsContent).toContain(' }');
// Should NOT contain the old TODO stub
expect(tsContent).not.toContain('TODO: SpeckDL conditional block');
// Should contain an else clause (our spec has if/else)
expect(tsContent).toMatch(/\belse\b/);
});
});
// ---------------------------------------------------------------------------

View file

@ -0,0 +1,21 @@
{
"@context": {
"prov": "http://www.w3.org/ns/prov#",
"speckl": "https://speckl.scoble.me/ns/v0.2#",
"rdfs": "http://www.w3.org/2000/01/rdf-schema#"
},
"@graph": [
{
"@type": "prov:Entity",
"@id": "speck:ConditionalToggle",
"prov:qualifiedGeneration": {
"@type": "prov:Generation",
"prov:activity": {
"@type": "prov:Activity",
"prov:startTime": "2026-07-19T08:31:19.208Z"
}
},
"rdfs:label": "ConditionalToggle"
}
]
}

View file

@ -0,0 +1,15 @@
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"components": [],
"metadata": {
"tools": [],
"component": {
"type": "application",
"name": "ConditionalToggle",
"version": "0.2.0",
"properties": []
}
},
"annotations": []
}

View file

@ -0,0 +1,26 @@
{
"@context": [
"https://spdx.github.io/spdx-v3/spdx-context.jsonld",
{
"speckl": "https://speckl.scoble.me/ns/v0.2#"
}
],
"@type": "spdx:Document",
"spdx": {
"@id": "spdx:ConditionalToggle",
"@type": "spdx:Snippet",
"name": "ConditionalToggle",
"spdxVersion": "3.0.1",
"dataLicense": "CC0-1.0",
"creationInfo": {
"created": "2026-07-19T08:31:19.232Z",
"createdBy": [
"Organization: Speckl",
"tool: speckl-compile-0.2.0"
]
}
},
"builtBy": [],
"relationships": [],
"elements": []
}

View file

@ -0,0 +1,33 @@
// Auto-generated by speckl-compile from ConditionalToggle
// DO NOT EDIT MANUALLY — regenerate from .speckdl source
export const ConditionalToggleState = {
Open: 'open',
Closed: 'closed',
} as const;
export type ConditionalToggleState = typeof ConditionalToggleState[keyof typeof ConditionalToggleState];
export class ConditionalToggleMachine {
isOn!: boolean;
count!: number;
decisionLog: any[] = [];
constructor() {
// No init block defined
}
Toggle() {
const xxx: any = {} as any; // FIXME: parser dropped let/forall
if (!(true)) throw new Error('Guard failed: true');
if (this.isOn) { this.isOn = false } else { this.isOn = true }
this.count = this.count + 1;
return this.isOn;
}
}

View file

@ -555,7 +555,78 @@ function emitAction(action: ActionNode, stateVarNames: Set<string>, mapVarNames:
break;
}
case 'ifblock': {
bodyParts.push(` // TODO: SpeckDL conditional block (if/else) - manual translation required`);
// The parser captures the raw text of if/else blocks as stmt.raw
// (see parser.ts:~1267). The raw text looks like:
// "if (cond) { thenBody } else { elseBody }"
// or "if (cond) { body }" when there is no else clause.
// We split on 'else', rewrite the condition and bodies via rewriteExpr,
// and emit a real TypeScript if/else statement.
const raw = stmt.raw as string;
// Extract the condition (text between 'if (' and the matching ')').
// The raw text is a single-line join, so the first ')' after 'if ('
// closes the condition — but the condition itself may contain nested
// parens. Use a balanced-paren scan from the first 'if'.
const ifIdx = raw.indexOf('if');
const condStart = raw.indexOf('(', ifIdx);
let condEnd = -1;
let depth = 0;
for (let k = condStart; k < raw.length; k++) {
if (raw[k] === '(') depth++;
else if (raw[k] === ')') { depth--; if (depth === 0) { condEnd = k; break; } }
}
const condRaw = condStart >= 0 && condEnd > condStart ? raw.slice(condStart + 1, condEnd) : 'true';
const cond = rewriteExpr(condRaw, stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues);
// The remainder after the condition's closing paren holds the bodies.
const rest = condEnd > 0 ? raw.slice(condEnd + 1) : '';
// Split on 'else' (word-boundary) to separate then/else branches.
const elseIdx = rest.search(/\belse\b/);
let thenBody = '';
let elseBody = '';
if (elseIdx >= 0) {
thenBody = rest.slice(0, elseIdx).trim();
elseBody = rest.slice(elseIdx + 4).trim();
} else {
thenBody = rest.trim();
}
// Strip surrounding braces from bodies so we can rewrite the inner
// statements, then re-wrap in { ... }.
const stripBraces = (s: string) => {
const m = s.match(/^\s*\{([\s\S]*)\}\s*$/);
return m ? m[1].trim() : s;
};
// A body may contain multiple statements separated by ';' or newlines.
// Each statement may be a SpeckDL assignment (target := expr) or a bare
// expression. We rewrite each one:
// - assignment: this.target = rewriteExpr(expr) (or target = ... if local)
// - bare expr: rewriteExpr(expr)
const rewriteBody = (body: string) => {
if (!body) return '';
const inner = stripBraces(body);
// Split on ';' or newlines (the raw text is a single-line join, so
// statements may be separated by ';'). Keep empty parts out.
const parts = inner.split(/[;\n]/).map(s => s.trim()).filter(Boolean);
const rewritten = parts.map(part => {
const assignMatch = part.match(/^(\w+(?:\[.*?\])?)\s*:=\s*(.+)$/);
if (assignMatch) {
const target = cleanName(assignMatch[1]);
const rhs = rewriteExpr(assignMatch[2], stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues);
if (localNames.has(target)) {
return `${target} = ${rhs}`;
}
return `this.${target} = ${rhs}`;
}
return rewriteExpr(part, stateVarNames, mapVarNames, localNames, stateEnumName, knownStateValues);
});
return rewritten.join('; ');
};
const thenRewritten = rewriteBody(thenBody);
const elseRewritten = rewriteBody(elseBody);
let stmtStr = ` if (${cond}) { ${thenRewritten} }`;
if (elseRewritten) {
stmtStr += ` else { ${elseRewritten} }`;
}
bodyParts.push(stmtStr);
break;
}
case 'return': {

View file

@ -1210,7 +1210,7 @@ function parseActionBlockMultiline(lines: string[], startIndex: number, startBra
if (nextLine.startsWith('precondition:') || nextLine.startsWith('postcondition:') ||
nextLine.startsWith('require ') || nextLine.startsWith('return ') ||
nextLine.startsWith('emit ') || nextLine.startsWith('let ') ||
nextLine.includes(':=')) {
nextLine.startsWith('if ') || nextLine.includes(':=')) {
break;
}
i++;

View file

@ -12,7 +12,7 @@
"@type": "prov:Generation",
"prov:activity": {
"@type": "prov:Activity",
"prov:startTime": "2026-06-30T01:54:05.948Z"
"prov:startTime": "2026-07-19T08:31:16.455Z"
}
},
"rdfs:label": "RetryHandler",

View file

@ -38,7 +38,7 @@
"name": "Scott Scoble",
"email": "scott@scoble.me"
},
"timestamp": "2026-06-30T01:54:05.951Z"
"timestamp": "2026-07-19T08:31:16.458Z"
},
{
"subject": "speck:RetryHandler",
@ -53,7 +53,7 @@
"name": "Speckl Compiler",
"email": "compiler@speckl.scoble.me"
},
"timestamp": "2026-06-30T01:54:05.951Z"
"timestamp": "2026-07-19T08:31:16.458Z"
},
{
"subject": "speck:RetryHandler",
@ -68,7 +68,7 @@
"name": "Speckl Compiler",
"email": "compiler@speckl.scoble.me"
},
"timestamp": "2026-06-30T01:54:05.951Z"
"timestamp": "2026-07-19T08:31:16.458Z"
}
]
}

View file

@ -13,7 +13,7 @@
"spdxVersion": "3.0.1",
"dataLicense": "CC0-1.0",
"creationInfo": {
"created": "2026-06-30T01:54:05.952Z",
"created": "2026-07-19T08:31:16.459Z",
"createdBy": [
"Organization: Speckl",
"tool: speckl-compile-0.2.0"
@ -27,7 +27,7 @@
"@type": "spdx:Tool",
"name": "speckl-compile"
},
"timestamp": "2026-06-30T01:54:05.952Z",
"timestamp": "2026-07-19T08:31:16.459Z",
"comment": "Review type: manual"
}
]

View file

@ -0,0 +1,29 @@
speck ConditionalToggle {
state {
isOn: Bool
count: Nat
}
init {
isOn == false
count == 0
}
invariant SwitchConsistent {
isOn in {true, false}
}
action Toggle {
require true
if (isOn) {
isOn := false
} else {
isOn := true
}
count := count + 1
return isOn
}
next: Toggle
}