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:
parent
857fa6ceb4
commit
9e4ac1b120
12 changed files with 254 additions and 9 deletions
2
compiler/node_modules/.vite/vitest/results.json
generated
vendored
2
compiler/node_modules/.vite/vitest/results.json
generated
vendored
|
|
@ -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}]]}
|
||||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"components": [],
|
||||
"metadata": {
|
||||
"tools": [],
|
||||
"component": {
|
||||
"type": "application",
|
||||
"name": "ConditionalToggle",
|
||||
"version": "0.2.0",
|
||||
"properties": []
|
||||
}
|
||||
},
|
||||
"annotations": []
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
33
compiler/src/__tests__/ifblock-smoke/ConditionalToggle.ts
Normal file
33
compiler/src/__tests__/ifblock-smoke/ConditionalToggle.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
29
examples/ConditionalToggle.speck
Normal file
29
examples/ConditionalToggle.speck
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue