feat(speckl): oneof syntax for proto wire-format discriminated unions (cycle 57)

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.
This commit is contained in:
Scott Scoble 2026-06-28 21:49:26 -07:00
parent adc5b75bff
commit 0e42961525
2 changed files with 165 additions and 4 deletions

View file

@ -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);
});
});
});

View file

@ -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();