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:
parent
adc5b75bff
commit
0e42961525
2 changed files with 165 additions and 4 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue