From e6f95ddf0ba857697ca96df6e83d8b23f3e8584a Mon Sep 17 00:00:00 2001 From: Scott Scoble Date: Tue, 9 Jun 2026 13:53:02 -0700 Subject: [PATCH] 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 --- compiler/__tests__/parser.test.ts | 54 ++++++++++++++++++++++++++ compiler/src/parser.ts | 64 ++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/compiler/__tests__/parser.test.ts b/compiler/__tests__/parser.test.ts index e16b091..77045e0 100644 --- a/compiler/__tests__/parser.test.ts +++ b/compiler/__tests__/parser.test.ts @@ -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', () => { const content = `speck Test { input: [Nat] diff --git a/compiler/src/parser.ts b/compiler/src/parser.ts index 8a26e13..28cacf3 100644 --- a/compiler/src/parser.ts +++ b/compiler/src/parser.ts @@ -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) 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)); if (isBlockStarter && openCount > 0) { @@ -383,10 +383,70 @@ function parseMemberBlock(lines: string[], startIndex: number): MemberNode | nul if (firstLine.startsWith('constraint ')) { return parseConstraintBlockMultiline(lines, startIndex, startBraceCount); } - + if (firstLine.startsWith('input:') || firstLine.startsWith('output:')) { + return parseInputOutputBlock(lines, startIndex, startBraceCount); + } + 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 { // input: if (line.startsWith('input:')) {