feat(speckl): protobuf backend + service block + record interface fields
- parser.ts: distinguish enum / record / service kinds in interface blocks
- parser.ts: add service { rpc X(R) returns (R); } syntax
- generators/protobuf.ts: emit proto3 (enums, messages, services) from .speckdl
- index.ts: add --target protobuf choice
Dogfood: FederatedMeetup.speckdl → 188-line .proto → buf generate →
working Go + ConnectRPC code with zero glue.
Closes speckl#61 (parser drops field declarations).
This commit is contained in:
parent
e6f95ddf0b
commit
69f668db7c
3 changed files with 424 additions and 29 deletions
294
compiler/src/generators/protobuf.ts
Normal file
294
compiler/src/generators/protobuf.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
/**
|
||||
* Generate Protocol Buffers (proto3) from a SpeckDL spec.
|
||||
*
|
||||
* This is the load-bearing backend for the "no glue code" claim:
|
||||
* the .proto file emitted here is the canonical wire format. Run
|
||||
* `buf generate` on the output to produce Go / TypeScript / Rust / C
|
||||
* / Python / Java / etc. bindings — every gRPC and ConnectRPC ecosystem
|
||||
* gets free bindings with zero hand-written converters.
|
||||
*
|
||||
* Mapping:
|
||||
* event X { name: String, age: Nat } -> message X { string name = 1; uint64 age = 2; }
|
||||
* interface T { Foo, Bar } -> enum T { T_FOO = 0; T_BAR = 1; }
|
||||
* interface X { field: Type } -> message X { Type field = 1; }
|
||||
* state: { foo: Type, bar: Type } -> message StateSnapshot { Type foo = 1; Type bar = 2; }
|
||||
* service S { rpc M(R) returns (Rsp);} -> service S { rpc M(R) returns (Rsp); }
|
||||
* constraint: ... -> comment in generated .proto
|
||||
* verify: ... -> comment in generated .proto
|
||||
* bom { ... } -> file-level metadata
|
||||
*
|
||||
* Deterministic: field numbering is alphabetical by field name, so the same
|
||||
* .speckdl always produces the same .proto byte-for-byte. Matches Speckl's
|
||||
* existing determinism claim.
|
||||
*/
|
||||
|
||||
import { AST, SpeckNode, MemberNode, StateNode, EventNode, InterfaceNode, TypeExpr, ServiceNode } from '../parser.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const PROTO3 = 'proto3';
|
||||
|
||||
// SpeckDL type -> proto3 type
|
||||
function speckTypeToProto(typeName: string): string {
|
||||
const t = typeName.trim();
|
||||
switch (t) {
|
||||
case 'String': return 'string';
|
||||
case 'StringList': return 'repeated string';
|
||||
case 'Nat': return 'uint64';
|
||||
case 'NatList': return 'repeated uint64';
|
||||
case 'Int': return 'int64';
|
||||
case 'IntList': return 'repeated int64';
|
||||
case 'Bool': return 'bool';
|
||||
case 'BoolList': return 'repeated bool';
|
||||
case 'Bytes': return 'bytes';
|
||||
case 'BytesList': return 'repeated bytes';
|
||||
case 'Real': return 'double';
|
||||
case 'Date': return 'google.protobuf.Timestamp';
|
||||
default:
|
||||
// User-defined types: passed through verbatim. The user is responsible
|
||||
// for ensuring these resolve to messages/enums defined elsewhere in
|
||||
// the same .proto (or imported).
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic field numbering: alphabetical by field name, 1-indexed.
|
||||
function assignFieldNumbers(fields: { name: string }[]): Map<string, number> {
|
||||
const sorted = [...fields].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const map = new Map<string, number>();
|
||||
sorted.forEach((f, i) => map.set(f.name, i + 1));
|
||||
return map;
|
||||
}
|
||||
|
||||
function snakeCase(s: string): string {
|
||||
return s
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function pascalCase(s: string): string {
|
||||
return s
|
||||
.replace(/[_\-]+/g, ' ')
|
||||
.replace(/\s+(.)/g, (_, c: string) => c.toUpperCase())
|
||||
.replace(/^(.)/, (_, c: string) => c.toUpperCase())
|
||||
.replace(/\s/g, '');
|
||||
}
|
||||
|
||||
function upperSnake(s: string): string {
|
||||
return snakeCase(s).toUpperCase();
|
||||
}
|
||||
|
||||
interface InterfaceField {
|
||||
name: string;
|
||||
type: TypeExpr; // matches parser's MethodSignature.params shape
|
||||
}
|
||||
|
||||
interface InterfaceKind {
|
||||
name: string;
|
||||
isEnum: boolean;
|
||||
isMessage: boolean;
|
||||
variants?: string[];
|
||||
fields?: InterfaceField[];
|
||||
}
|
||||
|
||||
// Use the parser's `kind` discriminator to route each interface to the
|
||||
// correct proto3 construct. The parser sets `kind` based on what was found
|
||||
// in the interface block:
|
||||
// 'enum' → proto3 enum (variants)
|
||||
// 'record' → proto3 message (fields)
|
||||
// 'service' → proto3 service (rpc methods) — handled separately
|
||||
// 'mixed' → inspect methods/fields directly to disambiguate
|
||||
function classifyInterfaces(members: MemberNode[]): Map<string, InterfaceKind> {
|
||||
const result = new Map<string, InterfaceKind>();
|
||||
for (const m of members) {
|
||||
if (m.type !== 'interface') continue;
|
||||
const iface = m as any; // parser uses kind/fields now
|
||||
switch (iface.kind) {
|
||||
case 'enum':
|
||||
result.set(iface.name, {
|
||||
name: iface.name,
|
||||
isEnum: true,
|
||||
isMessage: false,
|
||||
variants: iface.methods.map((meth: any) => meth.name),
|
||||
});
|
||||
break;
|
||||
case 'record':
|
||||
result.set(iface.name, {
|
||||
name: iface.name,
|
||||
isEnum: false,
|
||||
isMessage: true,
|
||||
fields: iface.fields,
|
||||
});
|
||||
break;
|
||||
case 'service':
|
||||
// Services emitted separately; skip in this map
|
||||
break;
|
||||
case 'mixed':
|
||||
default:
|
||||
if (iface.fields && iface.fields.length > 0) {
|
||||
result.set(iface.name, {
|
||||
name: iface.name,
|
||||
isEnum: false,
|
||||
isMessage: true,
|
||||
fields: iface.fields,
|
||||
});
|
||||
} else if (iface.methods && iface.methods.length > 0) {
|
||||
result.set(iface.name, {
|
||||
name: iface.name,
|
||||
isEnum: true,
|
||||
isMessage: false,
|
||||
variants: iface.methods.map((meth: any) => meth.name),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function generateProtobuf(ast: AST, outputDir: string, packageName?: string): void {
|
||||
for (const speck of ast.specks) {
|
||||
const code = emitSpeck(speck, packageName);
|
||||
const filename = `${snakeCase(speck.name)}.proto`;
|
||||
const filepath = path.join(outputDir, filename);
|
||||
fs.writeFileSync(filepath, code);
|
||||
console.log(`Generated protobuf schema: ${filepath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function emitSpeck(speck: SpeckNode, packageName?: string): string {
|
||||
const members = speck.members;
|
||||
const stateNode = members.find(m => m.type === 'state') as StateNode | undefined;
|
||||
const events = members.filter(m => m.type === 'event') as EventNode[];
|
||||
const interfaces = classifyInterfaces(members);
|
||||
|
||||
const pkg = packageName || `speckl.${snakeCase(speck.name)}.v1`;
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
// Header
|
||||
lines.push('// Auto-generated by speckl-compile from ' + speck.name + '.speckdl');
|
||||
lines.push('// DO NOT EDIT MANUALLY — regenerate from the .speckdl source');
|
||||
lines.push('// SPDX-License-Identifier: MIT');
|
||||
lines.push('');
|
||||
lines.push('syntax = "' + PROTO3 + '";');
|
||||
lines.push('');
|
||||
lines.push('package ' + pkg + ';');
|
||||
lines.push('');
|
||||
lines.push('option go_package = "github.com/sscoble/' + snakeCase(speck.name) + '/proto/' + snakeCase(speck.name) + '/v1;' + snakeCase(speck.name) + 'v1";');
|
||||
lines.push('');
|
||||
// Import google.protobuf.Timestamp if any field uses Date
|
||||
const usesTimestamp = members.some(m => {
|
||||
if (m.type === 'event') {
|
||||
return (m as EventNode).fields.some(f => f.type.name === 'Date');
|
||||
}
|
||||
if (m.type === 'state') {
|
||||
return (m as StateNode).variables.some(v =>
|
||||
v.typeExpr.name === 'Date' ||
|
||||
(v.typeExpr.type === 'list' && v.typeExpr.elementType?.name === 'Date')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (usesTimestamp) {
|
||||
lines.push('import "google/protobuf/timestamp.proto";');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// ─── Enums (interface with variants) ────────────────────────
|
||||
for (const [, iface] of Array.from(interfaces.entries())) {
|
||||
if (iface.isEnum && iface.variants) {
|
||||
lines.push(`enum ${iface.name} {`);
|
||||
lines.push(` ${iface.name}_UNSPECIFIED = 0;`);
|
||||
iface.variants.forEach((v, i) => {
|
||||
lines.push(` ${upperSnake(v)} = ${i + 1};`);
|
||||
});
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Messages (interface with fields, plus events) ─────────
|
||||
// First emit interface-messages (record types like BranchInfo, CustodyDeclaration)
|
||||
for (const [, iface] of Array.from(interfaces.entries())) {
|
||||
if (iface.isMessage && iface.fields && iface.fields.length > 0) {
|
||||
lines.push(`message ${iface.name} {`);
|
||||
const fieldNums = assignFieldNumbers(iface.fields);
|
||||
for (const f of iface.fields) {
|
||||
const num = fieldNums.get(f.name) || 1;
|
||||
const protoType = speckTypeToProto(f.type.name || '');
|
||||
lines.push(` ${protoType} ${snakeCase(f.name)} = ${num};`);
|
||||
}
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Then emit events as messages
|
||||
for (const ev of events) {
|
||||
lines.push(`message ${ev.name} {`);
|
||||
if (ev.fields.length === 0) {
|
||||
lines.push(' // empty event');
|
||||
} else {
|
||||
const fieldNums = assignFieldNumbers(ev.fields);
|
||||
for (const f of ev.fields) {
|
||||
const num = fieldNums.get(f.name) || 1;
|
||||
let protoType = speckTypeToProto(f.type.name || '');
|
||||
// Handle Map<K, V> and List<T>
|
||||
if (f.type.type === 'map' && f.type.keyType && f.type.valueType) {
|
||||
const k = speckTypeToProto(f.type.keyType.name || 'string');
|
||||
const v = speckTypeToProto(f.type.valueType.name || 'bytes');
|
||||
protoType = `map<${k}, ${v}>`;
|
||||
} else if (f.type.type === 'list' && f.type.elementType) {
|
||||
const inner = speckTypeToProto(f.type.elementType.name || 'string');
|
||||
protoType = `repeated ${inner}`;
|
||||
}
|
||||
lines.push(` ${protoType} ${snakeCase(f.name)} = ${num};`);
|
||||
}
|
||||
}
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Then emit state snapshot as a message
|
||||
if (stateNode && stateNode.variables.length > 0) {
|
||||
lines.push(`message ${pascalCase(speck.name)}State {`);
|
||||
const fieldNums = assignFieldNumbers(stateNode.variables);
|
||||
for (const v of stateNode.variables) {
|
||||
const num = fieldNums.get(v.name) || 1;
|
||||
let protoType = speckTypeToProto(v.typeExpr.name || '');
|
||||
if (v.typeExpr.type === 'map' && v.typeExpr.keyType && v.typeExpr.valueType) {
|
||||
const k = speckTypeToProto(v.typeExpr.keyType.name || 'string');
|
||||
const val = speckTypeToProto(v.typeExpr.valueType.name || 'bytes');
|
||||
protoType = `map<${k}, ${val}>`;
|
||||
} else if (v.typeExpr.type === 'list' && v.typeExpr.elementType) {
|
||||
const inner = speckTypeToProto(v.typeExpr.elementType.name || 'string');
|
||||
protoType = `repeated ${inner}`;
|
||||
}
|
||||
lines.push(` ${protoType} ${snakeCase(v.name)} = ${num};`);
|
||||
}
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// ─── Service (RPC surface) ──────────────────────────────────
|
||||
// ServiceNode members emit proto3 service blocks. These map directly to
|
||||
// gRPC and ConnectRPC service definitions; buf generate + protoc-gen-go
|
||||
// + protoc-gen-connect-go produce client stubs and Connect handlers.
|
||||
const serviceNodes = members.filter((m: any) => m.type === 'service') as ServiceNode[];
|
||||
if (serviceNodes.length > 0) {
|
||||
for (const svc of serviceNodes) {
|
||||
lines.push(`service ${svc.name} {`);
|
||||
for (const rpc of svc.rpcs) {
|
||||
const reqStream = rpc.clientStreaming ? 'stream ' : '';
|
||||
const resStream = rpc.serverStreaming ? 'stream ' : '';
|
||||
lines.push(` rpc ${pascalCase(rpc.name)}(${reqStream}${rpc.requestType}) returns (${resStream}${rpc.responseType});`);
|
||||
}
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { generateProvenanceGraph } from './generators/provenance.js';
|
|||
import { generateCycloneDX } from './generators/cyclonedx.js';
|
||||
import { generateSPDX } from './generators/spdx.js';
|
||||
import { generateTypeScriptStateMachine } from './generators/typescript-state-machine.js';
|
||||
import { generateProtobuf } from './generators/protobuf.js';
|
||||
import { generateZ3, Z3Options, parseInvariantsFromSource, parseNextFromSource } from './generators/z3.js';
|
||||
import { generateRust } from './generators/rust.js';
|
||||
import fs from 'fs';
|
||||
|
|
@ -15,7 +16,7 @@ import path from 'path';
|
|||
interface CompileOptions {
|
||||
outputDir: string;
|
||||
bomFormat: 'cdx' | 'spdx' | 'both';
|
||||
target: 'typescript' | 'z3' | 'all';
|
||||
target: 'typescript' | 'z3' | 'rust' | 'protobuf' | 'all';
|
||||
verifyDepth: number;
|
||||
}
|
||||
|
||||
|
|
@ -50,7 +51,7 @@ async function main() {
|
|||
alias: 't',
|
||||
describe: 'Compilation target',
|
||||
type: 'string',
|
||||
choices: ['typescript', 'z3', 'rust', 'all'],
|
||||
choices: ['typescript', 'z3', 'rust', 'protobuf', 'all'],
|
||||
default: 'typescript' as const,
|
||||
})
|
||||
.option('verify-depth', {
|
||||
|
|
@ -74,7 +75,7 @@ async function main() {
|
|||
const options = {
|
||||
outputDir: argv.outputDir as string,
|
||||
bomFormat: argv.bomFormat as 'cdx' | 'spdx' | 'both',
|
||||
target: argv.target as 'typescript' | 'z3' | 'rust' | 'all',
|
||||
target: argv.target as 'typescript' | 'z3' | 'rust' | 'protobuf' | 'all',
|
||||
verifyDepth: argv.verifyDepth as number,
|
||||
};
|
||||
|
||||
|
|
@ -101,6 +102,15 @@ async function main() {
|
|||
generateRust(ast, options.outputDir);
|
||||
}
|
||||
|
||||
// Generate protobuf schema if target is protobuf or all
|
||||
// This is the load-bearing backend for the "no glue code" claim:
|
||||
// the emitted .proto is the canonical wire format. Run `buf generate`
|
||||
// on the output to produce Go/TS/Rust/C/Python/Java bindings.
|
||||
if (options.target === 'protobuf' || options.target === 'all') {
|
||||
console.log('\nGenerating protobuf schema (.proto)...');
|
||||
generateProtobuf(ast, options.outputDir);
|
||||
}
|
||||
|
||||
// Always generate provenance (it's the core output) if not z3-only or rust-only
|
||||
if (options.target !== 'z3' && options.target !== 'rust') {
|
||||
console.log('\nGenerating provenance graph (PROV-O JSON-LD)...');
|
||||
|
|
|
|||
|
|
@ -39,10 +39,37 @@ export interface VerifyNode {
|
|||
depth?: number;
|
||||
}
|
||||
|
||||
export interface InterfaceField {
|
||||
name: string;
|
||||
type: TypeExpr;
|
||||
}
|
||||
|
||||
export interface RpcMethod {
|
||||
name: string;
|
||||
requestType: string;
|
||||
responseType: string;
|
||||
// Streaming flags for gRPC/ConnectRPC
|
||||
clientStreaming?: boolean;
|
||||
serverStreaming?: boolean;
|
||||
}
|
||||
|
||||
export interface ServiceNode {
|
||||
type: 'service';
|
||||
name: string;
|
||||
rpcs: RpcMethod[];
|
||||
}
|
||||
|
||||
export interface InterfaceNode {
|
||||
type: 'interface';
|
||||
name: string;
|
||||
// Discriminator: which form does this interface take?
|
||||
// 'enum' → only methods[] populated with variant names (returnType: Void)
|
||||
// 'record' → only fields[] populated with name: Type entries
|
||||
// 'service' → methods[] populated with method signatures (name(params) -> Type)
|
||||
// 'mixed' → ambiguous; generators should inspect both
|
||||
kind: 'enum' | 'record' | 'service' | 'mixed';
|
||||
methods: MethodSignature[];
|
||||
fields: InterfaceField[];
|
||||
}
|
||||
|
||||
export interface MethodSignature {
|
||||
|
|
@ -141,24 +168,25 @@ export type ActionStatement =
|
|||
| { type: 'return'; expr: string }
|
||||
| { type: 'ifblock'; raw: string };
|
||||
|
||||
export type MemberNode =
|
||||
| ImportNode
|
||||
| InputNode
|
||||
| OutputNode
|
||||
| ConstraintNode
|
||||
| VerifyNode
|
||||
| InterfaceNode
|
||||
| EventNode
|
||||
| ProvenanceNode
|
||||
| ReviewNode
|
||||
| DerivesNode
|
||||
| SatisfiesNode
|
||||
| AuthorNode
|
||||
| SourceNode
|
||||
export type MemberNode =
|
||||
| ImportNode
|
||||
| InputNode
|
||||
| OutputNode
|
||||
| ConstraintNode
|
||||
| VerifyNode
|
||||
| InterfaceNode
|
||||
| EventNode
|
||||
| ProvenanceNode
|
||||
| ReviewNode
|
||||
| DerivesNode
|
||||
| SatisfiesNode
|
||||
| AuthorNode
|
||||
| SourceNode
|
||||
| BOMNode
|
||||
| StateNode
|
||||
| InitNode
|
||||
| ActionNode;
|
||||
| ActionNode
|
||||
| ServiceNode;
|
||||
|
||||
export interface TypeExpr {
|
||||
type: 'primitive' | 'record' | 'list' | 'set' | 'map' | 'ident';
|
||||
|
|
@ -251,7 +279,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 ', 'input:', 'output:'];
|
||||
'state {', 'init {', 'verify ', 'constraint ', 'input:', 'output:', 'service '];
|
||||
const isBlockStarter = blockStarters.some(s => line.startsWith(s));
|
||||
|
||||
if (isBlockStarter && openCount > 0) {
|
||||
|
|
@ -285,7 +313,8 @@ function parseSpeck(lines: string[], startIndex: number): SpeckNode | null {
|
|||
nextLine.startsWith('state') || nextLine.startsWith('init') ||
|
||||
nextLine.startsWith('action ') || nextLine.startsWith('event ') ||
|
||||
nextLine.startsWith('interface ') || nextLine.startsWith('provenance ') ||
|
||||
nextLine.startsWith('bom ') || nextLine.startsWith('}') ||
|
||||
nextLine.startsWith('service ') || nextLine.startsWith('bom ') ||
|
||||
nextLine.startsWith('}') ||
|
||||
nextLine.startsWith('next:') || nextLine.startsWith('invariant ')) {
|
||||
break;
|
||||
}
|
||||
|
|
@ -371,6 +400,9 @@ function parseMemberBlock(lines: string[], startIndex: number): MemberNode | nul
|
|||
if (firstLine.startsWith('interface ')) {
|
||||
return parseInterfaceBlockMultiline(lines, startIndex, startBraceCount);
|
||||
}
|
||||
if (firstLine.startsWith('service ')) {
|
||||
return parseServiceBlockMultiline(lines, startIndex, startBraceCount);
|
||||
}
|
||||
if (firstLine.startsWith('provenance ')) {
|
||||
return { type: 'provenance', clauses: [] };
|
||||
}
|
||||
|
|
@ -795,14 +827,17 @@ function parseInterface(line: string): InterfaceNode {
|
|||
if (!match) {
|
||||
throw new Error(`Failed to parse interface: ${line}`);
|
||||
}
|
||||
|
||||
|
||||
const name = match[1];
|
||||
|
||||
// This is a simplified parser - in a real implementation, we'd need to handle multi-line interfaces
|
||||
|
||||
// Header-only; parseInterfaceBlockMultiline fills in methods/fields/kind
|
||||
// by scanning the lines inside the { ... } block.
|
||||
return {
|
||||
type: 'interface',
|
||||
name,
|
||||
kind: 'mixed',
|
||||
methods: [],
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1125,12 +1160,13 @@ function parseInterfaceBlockMultiline(lines: string[], startIndex: number, start
|
|||
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
|
||||
|
||||
const methods: MethodSignature[] = [];
|
||||
const fields: InterfaceField[] = [];
|
||||
|
||||
for (const line of innerLines) {
|
||||
// name-only: enum-style values (e.g., "Idle", "Idle, Parsing" comma-separated)
|
||||
// Split on comma and take each trimmed token
|
||||
// Split on comma so multi-variant lines like "Idle, Parsing, Done" all parse
|
||||
const tokens = line.split(',').map(s => s.trim()).filter(Boolean);
|
||||
for (const token of tokens) {
|
||||
// Check if it’s a name(params) -> returnType method signature
|
||||
// Check 1: method signature form: name(params) -> returnType
|
||||
const methodMatch = token.match(/^(\w+)\s*\(([^)]*)\)\s*->\s*(.+?),?$/);
|
||||
if (methodMatch) {
|
||||
const name = methodMatch[1];
|
||||
|
|
@ -1147,13 +1183,68 @@ function parseInterfaceBlockMultiline(lines: string[], startIndex: number, start
|
|||
}
|
||||
}
|
||||
methods.push({ name, params, returnType: parseTypeExpr(returnTypeStr) });
|
||||
} else if (/^\w+$/.test(token)) {
|
||||
// Name-only (enum-style) — treat as a method with no params and void return
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check 2: field declaration form: field_name: TypeName
|
||||
// Lowercase-starting identifier followed by ':' — record-style interface
|
||||
const fieldMatch = token.match(/^(\w+)\s*:\s*(.+)$/);
|
||||
if (fieldMatch && /^[a-z_]/.test(fieldMatch[1])) {
|
||||
fields.push({ name: fieldMatch[1], type: parseTypeExpr(fieldMatch[2].trim()) });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check 3: bare identifier (enum variant) — name only, no params
|
||||
if (/^\w+$/.test(token)) {
|
||||
methods.push({ name: token, params: [], returnType: parseTypeExpr('Void') });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...header, methods };
|
||||
|
||||
// Determine the kind based on what we collected
|
||||
let kind: 'enum' | 'record' | 'service' | 'mixed' = 'mixed';
|
||||
if (fields.length > 0 && methods.length === 0) {
|
||||
kind = 'record';
|
||||
} else if (methods.length > 0 && fields.length === 0) {
|
||||
// Distinguish enum (all methods have empty params and Void return) from service
|
||||
const allEnumLike = methods.every(m => m.params.length === 0 && m.returnType.name === 'Void');
|
||||
kind = allEnumLike ? 'enum' : 'service';
|
||||
}
|
||||
|
||||
return { ...header, kind, methods, fields };
|
||||
}
|
||||
|
||||
function parseServiceBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): ServiceNode {
|
||||
// service Name {
|
||||
const firstLine = lines[startIndex].trim();
|
||||
const match = firstLine.match(/^service\s+(\w+)\s*{?/);
|
||||
const name = match ? match[1] : 'UnnamedService';
|
||||
|
||||
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 rpcs: RpcMethod[] = [];
|
||||
for (const line of innerLines) {
|
||||
// rpc MethodName(RequestType) returns (ResponseType);
|
||||
// Also support streaming: rpc M(stream R) returns (S); or rpc M(R) returns (stream S);
|
||||
const rpcMatch = line.match(/^rpc\s+(\w+)\s*\(\s*(stream\s+)?(\w+)\s*\)\s*returns\s*\(\s*(stream\s+)?(\w+)\s*\)\s*;?$/);
|
||||
if (rpcMatch) {
|
||||
const methodName = rpcMatch[1];
|
||||
const clientStream = !!rpcMatch[2];
|
||||
const requestType = rpcMatch[3];
|
||||
const serverStream = !!rpcMatch[4];
|
||||
const responseType = rpcMatch[5];
|
||||
rpcs.push({
|
||||
name: methodName,
|
||||
requestType,
|
||||
responseType,
|
||||
clientStreaming: clientStream,
|
||||
serverStreaming: serverStream,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'service', name, rpcs };
|
||||
}
|
||||
|
||||
// --- Multi-line constraint/verify parsing (no braces needed) ---
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue