From 0e42961525e2f5ea6e1e919c65fbe189b2a64323 Mon Sep 17 00:00:00 2001 From: Scott Scoble Date: Sun, 28 Jun 2026 21:49:26 -0700 Subject: [PATCH] feat(speckl): oneof syntax for proto wire-format discriminated unions (cycle 57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 'oneof Name { field: Type, ... }' as a top-level member of a speck block, alongside interface, event, service, etc. The AST node is OneofNode with a variants array of { fieldName, typeName }. The parser supports multi-line blocks, comments, and trailing commas / semicolons. Generator integration is a follow-up cycle (proto/TS). Motivation: federated-meetup's Transition message needs a oneof for the 15+ payload variants (create_group, add_steward, ...). Without oneof syntax, the consumer Go code has to hand-roll 'Transition_AddHostPeer' wrapper messages for each event type. With oneof, the generated .proto emits a real proto3 oneof and the generated Go bindings have a single 'isXXX()' discriminator API that matches the consumer's expectations. Tests: 4 new parser tests covering happy path, trailing punctuation, comments, and coexistence with other member types. All 43 tests (39 baseline + 4 new) pass; tsc build clean. Reference: spec-compiler-dogfood skill, references/speckl-federated- meetup-protobuf-migration.md — the prior build break was caused by proto message-shape mismatches between the generated .proto and the consumer Go code. oneof syntax is the first of 5 compiler extensions needed to make Speckl-generated proto bindings a drop-in for the handwritten proto/federated_meetup/v1/{state,rpc}.proto. --- compiler/__tests__/parser.test.ts | 68 ++++++++++++++++++++ compiler/src/parser.ts | 101 ++++++++++++++++++++++++++++-- 2 files changed, 165 insertions(+), 4 deletions(-) diff --git a/compiler/__tests__/parser.test.ts b/compiler/__tests__/parser.test.ts index 77045e0..375341c 100644 --- a/compiler/__tests__/parser.test.ts +++ b/compiler/__tests__/parser.test.ts @@ -578,4 +578,72 @@ speck Second { } }); }); + + // Cycle 57: oneof syntax for proto wire-format discriminated unions. + describe('oneof blocks (cycle 57)', () => { + it('should parse a minimal oneof with two variants', () => { + const content = `speck One { + oneof Payload { + create_group: CreateGroup + add_steward: AddSteward + } +}`; + const ast = parseSpeckContent(content); + const oneof = ast.specks[0].members.find(m => m.type === 'oneof'); + expect(oneof).toBeDefined(); + if (oneof?.type === 'oneof') { + expect(oneof.name).toBe('Payload'); + expect(oneof.variants).toEqual([ + { fieldName: 'create_group', typeName: 'CreateGroup' }, + { fieldName: 'add_steward', typeName: 'AddSteward' }, + ]); + } + }); + + it('should tolerate trailing commas / semicolons (proto-style)', () => { + const content = `speck One { + oneof Payload { + create_group: CreateGroup, + add_steward: AddSteward; + } +}`; + const ast = parseSpeckContent(content); + const oneof = ast.specks[0].members.find(m => m.type === 'oneof'); + if (oneof?.type === 'oneof') { + expect(oneof.variants).toHaveLength(2); + } + }); + + it('should skip comments inside oneof blocks', () => { + const content = `speck One { + oneof Payload { + // bootstrap variant + create_group: CreateGroup + /* member action */ + add_member: AddMember + } +}`; + const ast = parseSpeckContent(content); + const oneof = ast.specks[0].members.find(m => m.type === 'oneof'); + if (oneof?.type === 'oneof') { + expect(oneof.variants.map(v => v.fieldName)).toEqual(['create_group', 'add_member']); + } + }); + + it('should coexist with interface and event blocks', () => { + const content = `speck Mix { + event CreateGroup { name: String } + interface AddSteward { new_steward: String } + oneof Payload { + create_group: CreateGroup + add_steward: AddSteward + } +}`; + const ast = parseSpeckContent(content); + const members = ast.specks[0].members; + expect(members.filter(m => m.type === 'event')).toHaveLength(1); + expect(members.filter(m => m.type === 'interface')).toHaveLength(1); + expect(members.filter(m => m.type === 'oneof')).toHaveLength(1); + }); + }); }); diff --git a/compiler/src/parser.ts b/compiler/src/parser.ts index 4845f09..863dc2c 100644 --- a/compiler/src/parser.ts +++ b/compiler/src/parser.ts @@ -6,6 +6,16 @@ export interface SpeckNode { type: 'speck'; name: string; members: MemberNode[]; + // Optional metadata from top-of-file directives (version:, author:, license:, proto_package:, go_package:). + // Undefined when not specified in the source. + version?: string; + author?: string; + license?: string; + // Protobuf-specific overrides. If unset, generator uses sensible defaults. + // proto_package: e.g. "federated_meetup.v1" + // go_package: e.g. "github.com/sscoble/federated-meetup/proto/federated_meetup/v1;federatedmeetupv1" + protoPackage?: string; + goPackage?: string; } export interface ImportNode { @@ -84,6 +94,22 @@ export interface EventNode { fields: { name: string; type: TypeExpr }[]; } +// oneof: discriminated-union variant for proto wire-format compatibility. +// Cycle 57 — emits `oneof payload { TypeName field_name = N; ... }` when +// compiled to .proto. Variants reference top-level event/interface types; +// field_name is the snake_case wire name (lowerCamelCase convention from +// TypeScript). +export interface OneofNode { + type: 'oneof'; + name: string; + variants: OneofVariant[]; +} + +export interface OneofVariant { + fieldName: string; // wire name, e.g. "create_group" + typeName: string; // referenced type, e.g. "CreateGroup" +} + export interface ProvenanceNode { type: 'provenance'; clauses: ProvenanceClause[]; @@ -176,6 +202,7 @@ export type MemberNode = | VerifyNode | InterfaceNode | EventNode + | OneofNode | ProvenanceNode | ReviewNode | DerivesNode @@ -273,13 +300,32 @@ function parseSpeck(lines: string[], startIndex: number): SpeckNode | null { continue; } + // ── Top-of-file protobuf metadata directives ──────────────── + // These attach to the SpeckNode and are NOT emitted as members. + // Format: `key: value` (string values may be quoted or bare). + // `author:`, `version:`, `license:` continue to be parsed as members + // (AuthorNode, etc.) for backward compatibility with the existing + // PROV-O / CycloneDX generators. + const metaMatch = line.match(/^(proto_package|go_package)\s*:\s*(.+)$/); + if (metaMatch) { + // Strip surrounding quotes if present + const value = metaMatch[2].trim().replace(/^['"]|['"]$/g, ''); + if (metaMatch[1] === 'proto_package') { + (parseSpeck as any)._protoPackage = value; + } else if (metaMatch[1] === 'go_package') { + (parseSpeck as any)._goPackage = value; + } + i++; + continue; + } + // Count braces on this line first const openCount = (rawLine.match(/{/g) || []).length; const closeCount = (rawLine.match(/}/g) || []).length; // 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 ', 'input:', 'output:', 'service ']; + 'state {', 'init {', 'verify ', 'constraint ', 'input:', 'output:', 'service ', 'oneof ']; const isBlockStarter = blockStarters.some(s => line.startsWith(s)); if (isBlockStarter && openCount > 0) { @@ -339,7 +385,9 @@ function parseSpeck(lines: string[], startIndex: number): SpeckNode | null { i++; } - return { type: 'speck', name, members }; + return { type: 'speck', name, members, + protoPackage: (parseSpeck as any)._protoPackage, + goPackage: (parseSpeck as any)._goPackage }; } function findBlockEnd(lines: string[], startIndex: number, startBraceCount: number): number { @@ -400,6 +448,9 @@ function parseMemberBlock(lines: string[], startIndex: number): MemberNode | nul if (firstLine.startsWith('interface ')) { return parseInterfaceBlockMultiline(lines, startIndex, startBraceCount); } + if (firstLine.startsWith('oneof ')) { + return parseOneofBlockMultiline(lines, startIndex, startBraceCount); + } if (firstLine.startsWith('service ')) { return parseServiceBlockMultiline(lines, startIndex, startBraceCount); } @@ -525,6 +576,11 @@ function parseMember(line: string): MemberNode | null { if (line.startsWith('event ')) { return parseEvent(line); } + + // oneof: + if (line.startsWith('oneof ')) { + return parseOneof(line); + } // provenance { if (line.startsWith('provenance ')) { @@ -847,9 +903,9 @@ function parseEvent(line: string): EventNode { if (!match) { throw new Error(`Failed to parse event: ${line}`); } - + const name = match[1]; - + return { type: 'event', name, @@ -857,6 +913,20 @@ function parseEvent(line: string): EventNode { }; } +// Cycle 57: parse `oneof Name {` (header only; parseOneofBlockMultiline fills variants). +function parseOneof(line: string): OneofNode { + // oneof Name { + const match = line.match(/^oneof\s+(\w+)\s*{/); + if (!match) { + throw new Error(`Failed to parse oneof: ${line}`); + } + return { + type: 'oneof', + name: match[1], + variants: [], + }; +} + function parseProvenanceBlock(lines: string[], line: string): ProvenanceNode { // This is a simplified parser - in a real implementation, we'd parse the full block return { @@ -1214,6 +1284,29 @@ function parseInterfaceBlockMultiline(lines: string[], startIndex: number, start return { ...header, kind, methods, fields }; } +// Cycle 57: parse a `oneof Name { ... }` block. Each non-empty, non-comment +// inner line is `field_name: TypeName`. The wire field_name follows +// lowerCamelCase convention (TypeScript/Connect) and is emitted to .proto +// as snake_case by the generator. +function parseOneofBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): OneofNode { + const firstLine = lines[startIndex].trim(); + const header = parseOneof(firstLine); + 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 variants: OneofVariant[] = []; + for (const line of innerLines) { + // Strip trailing comma or semicolon (proto-style "create_group: CreateGroup," is valid). + const trimmed = line.replace(/[,;]\s*$/, ''); + const m = trimmed.match(/^(\w+)\s*:\s*(\w+)\s*$/); + if (m) { + variants.push({ fieldName: m[1], typeName: m[2] }); + } + } + + return { ...header, variants }; +} + function parseServiceBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): ServiceNode { // service Name { const firstLine = lines[startIndex].trim();