fix(parser): parse multi-line input/output blocks as record types (speckl#57)
Multi-line `input: { ... }` and `output: { ... }` blocks fell through
to parseMember which returned `{ type: 'ident', name: '{' }` instead of
a record with fields. Broke RetryHandler.speck and similar real specks.
Fix: add `input:`/`output:` to blockStarters, dispatch to new
parseInputOutputBlock() that brace-counts across newlines, strips
line comments, and delegates to parseTypeExpr's record branch.
CoS-verified-live-close (cycle 126 pattern): subagent hit max_iterations
on the brace-count edge case. CoS applied focused fix + 2 new tests.
39/39 vitest pass.
Test evidence:
- RetryHandler.speck: input typeExpr.type='record' 4 fields
- 37 prior tests + 2 new = 39 passing
This commit is contained in:
parent
d5eea7e7c7
commit
e6f95ddf0b
2 changed files with 116 additions and 2 deletions
|
|
@ -100,6 +100,60 @@ speck Second {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should parse input with multi-line record type (speckl#57)', () => {
|
||||||
|
const content = `speck Test {
|
||||||
|
input: {
|
||||||
|
operation: Operation,
|
||||||
|
maxRetries: Nat,
|
||||||
|
initialDelay: Real,
|
||||||
|
maxDelay: Real
|
||||||
|
}
|
||||||
|
output: {
|
||||||
|
success: Bool,
|
||||||
|
finalResult: String,
|
||||||
|
attemptCount: Nat,
|
||||||
|
totalDelay: Real
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
const ast = parseSpeckContent(content);
|
||||||
|
const input = ast.specks[0].members[0];
|
||||||
|
const output = ast.specks[0].members[1];
|
||||||
|
expect(input.type).toBe('input');
|
||||||
|
expect(output.type).toBe('output');
|
||||||
|
if (input.type === 'input') {
|
||||||
|
expect(input.typeExpr.type).toBe('record');
|
||||||
|
expect(input.typeExpr.fields).toHaveLength(4);
|
||||||
|
expect(input.typeExpr.fields![0].name).toBe('operation');
|
||||||
|
expect(input.typeExpr.fields![3].name).toBe('maxDelay');
|
||||||
|
}
|
||||||
|
if (output.type === 'output') {
|
||||||
|
expect(output.typeExpr.type).toBe('record');
|
||||||
|
expect(output.typeExpr.fields).toHaveLength(4);
|
||||||
|
expect(output.typeExpr.fields![0].name).toBe('success');
|
||||||
|
expect(output.typeExpr.fields![3].name).toBe('totalDelay');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse input with multi-line record type, comments inside (speckl#57)', () => {
|
||||||
|
// Edge case: line comments inside the block
|
||||||
|
const content = `speck Test {
|
||||||
|
input: {
|
||||||
|
// the operation to retry
|
||||||
|
operation: Operation,
|
||||||
|
// max number of retry attempts
|
||||||
|
retries: Nat
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
const ast = parseSpeckContent(content);
|
||||||
|
const input = ast.specks[0].members[0];
|
||||||
|
if (input.type === 'input') {
|
||||||
|
expect(input.typeExpr.type).toBe('record');
|
||||||
|
expect(input.typeExpr.fields).toHaveLength(2);
|
||||||
|
expect(input.typeExpr.fields![0].name).toBe('operation');
|
||||||
|
expect(input.typeExpr.fields![1].name).toBe('retries');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('should parse input with list type', () => {
|
it('should parse input with list type', () => {
|
||||||
const content = `speck Test {
|
const content = `speck Test {
|
||||||
input: [Nat]
|
input: [Nat]
|
||||||
|
|
|
||||||
|
|
@ -251,7 +251,7 @@ function parseSpeck(lines: string[], startIndex: number): SpeckNode | null {
|
||||||
|
|
||||||
// Check for block-opening members (those that open braces on the same line)
|
// Check for block-opening members (those that open braces on the same line)
|
||||||
const blockStarters = ['state:', 'init:', 'action ', 'event ', 'provenance ', 'bom ', 'interface ',
|
const blockStarters = ['state:', 'init:', 'action ', 'event ', 'provenance ', 'bom ', 'interface ',
|
||||||
'state {', 'init {', 'verify ', 'constraint '];
|
'state {', 'init {', 'verify ', 'constraint ', 'input:', 'output:'];
|
||||||
const isBlockStarter = blockStarters.some(s => line.startsWith(s));
|
const isBlockStarter = blockStarters.some(s => line.startsWith(s));
|
||||||
|
|
||||||
if (isBlockStarter && openCount > 0) {
|
if (isBlockStarter && openCount > 0) {
|
||||||
|
|
@ -383,10 +383,70 @@ function parseMemberBlock(lines: string[], startIndex: number): MemberNode | nul
|
||||||
if (firstLine.startsWith('constraint ')) {
|
if (firstLine.startsWith('constraint ')) {
|
||||||
return parseConstraintBlockMultiline(lines, startIndex, startBraceCount);
|
return parseConstraintBlockMultiline(lines, startIndex, startBraceCount);
|
||||||
}
|
}
|
||||||
|
if (firstLine.startsWith('input:') || firstLine.startsWith('output:')) {
|
||||||
|
return parseInputOutputBlock(lines, startIndex, startBraceCount);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a multi-line `input: { ... }` or `output: { ... }` block.
|
||||||
|
*
|
||||||
|
* The first line opens with `input: {` or `output: {` and the matching `}`
|
||||||
|
* may be many lines later (with field declarations on intermediate lines).
|
||||||
|
* We collect all lines up to the matching `}` (brace-counted) and join the
|
||||||
|
* inner content into a single record string that `parseTypeExpr` already
|
||||||
|
* knows how to handle.
|
||||||
|
*/
|
||||||
|
function parseInputOutputBlock(
|
||||||
|
lines: string[],
|
||||||
|
startIndex: number,
|
||||||
|
startBraceCount: number,
|
||||||
|
): MemberNode | null {
|
||||||
|
const header = lines[startIndex].trim();
|
||||||
|
const isInput = header.startsWith('input:');
|
||||||
|
const kind: 'input' | 'output' = isInput ? 'input' : 'output';
|
||||||
|
|
||||||
|
// Collect inner content lines, brace-counting across the block.
|
||||||
|
// startBraceCount is the brace count on the header line (1 for `input: {`).
|
||||||
|
// We need to find the line where braceCount returns to 0 (i.e. the closing `}`).
|
||||||
|
const innerLines: string[] = [];
|
||||||
|
let braceCount = 0;
|
||||||
|
let i = startIndex;
|
||||||
|
// First pass: extract inner content from header line (everything after the first `{`).
|
||||||
|
const headerLine = lines[startIndex].trim();
|
||||||
|
const headerNoComment = headerLine.replace(/\/\/.*$/, '').trim();
|
||||||
|
const openIdx = headerNoComment.indexOf('{');
|
||||||
|
if (openIdx >= 0) {
|
||||||
|
const after = headerNoComment.substring(openIdx + 1).trim();
|
||||||
|
if (after) innerLines.push(after);
|
||||||
|
}
|
||||||
|
braceCount = 1; // we're inside one `{` from the header
|
||||||
|
i = startIndex + 1;
|
||||||
|
while (i < lines.length && braceCount > 0) {
|
||||||
|
const raw = lines[i];
|
||||||
|
const stripped = raw.trim();
|
||||||
|
// Strip line comments (//...) — they cannot be inside a type expression.
|
||||||
|
const noComment = stripped.replace(/\/\/.*$/, '').trim();
|
||||||
|
for (const ch of noComment) {
|
||||||
|
if (ch === '{') braceCount++;
|
||||||
|
else if (ch === '}') braceCount--;
|
||||||
|
}
|
||||||
|
if (braceCount > 0 && noComment) {
|
||||||
|
// Inside the block — keep content (skip the closing `}` line itself).
|
||||||
|
innerLines.push(noComment);
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join inner content with newlines; `parseTypeExpr`'s record branch
|
||||||
|
// splits on commas and matches `name: type` pairs.
|
||||||
|
const recordExpr = '{ ' + innerLines.join(' ') + ' }';
|
||||||
|
const typeExpr = parseTypeExpr(recordExpr);
|
||||||
|
return { type: kind, typeExpr };
|
||||||
|
}
|
||||||
|
|
||||||
function parseMember(line: string): MemberNode | null {
|
function parseMember(line: string): MemberNode | null {
|
||||||
// input:
|
// input:
|
||||||
if (line.startsWith('input:')) {
|
if (line.startsWith('input:')) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue