Choreographic DSL
Overview
The parser translates a layout-sensitive DSL into the internal AST (Choreography and Protocol). The DSL is direct style. Statements are newline separated. Indentation defines blocks.
Empty blocks must use {}. The DSL does not use an explicit end keyword. A protocol ends when its block ends.
The parser module is located in rust/choreography/src/compiler/parser/. It provides an implementation of the choreography DSL parser using Pest plus a layout preprocessor.
DSL Syntax
#![allow(unused)]
fn main() {
choreography!(r#"
protocol PingPong =
roles Alice, Bob
Alice -> Bob : Ping
Bob -> Alice : Pong
"#);
}
This form passes the DSL as a string literal.
#![allow(unused)]
fn main() {
choreography! {
protocol PingPong = {
roles Alice, Bob;
Alice -> Bob : Ping;
Bob -> Alice : Pong;
}
}
}
This form passes the DSL as normal macro tokens. Semicolons are treated as statement separators. Composite operators are normalized for DSL parsing.
Namespaces
Namespaces are expressed via a module declaration (rather than attributes).
#![allow(unused)]
fn main() {
let protocol = r#"
module threshold_ceremony exposing (ThresholdProtocol)
protocol ThresholdProtocol =
roles Coordinator, Signers[*]
Coordinator -> Signers[*] : Request
"#;
}
Multiple modules can coexist in separate files. Inside the choreography! macro you typically omit the module header. String-based parsing supports module headers.
The parser recognizes import declarations for completeness. Import resolution is not currently applied during compilation.
Supported Constructs
1) Send Statement
#![allow(unused)]
fn main() {
Role1 -> Role2 : MessageName
Role1 -> Role2 : MessageWithPayload { data : String, count : Int }
}
The send statement transfers a message from one role to another.
2) Broadcast Statement
#![allow(unused)]
fn main() {
Leader ->* : Announcement
}
Broadcast sends a message to all other roles.
3) Choice Statement (explicit decider)
#![allow(unused)]
fn main() {
case choose Client of
Buy when (balance > price) ->
Client -> Server : Purchase
Cancel ->
Client -> Server : Cancel
}
The deciding role (Client) selects a branch. Guards are optional.
Guard expressions are parsed through a typed predicate IR and must be boolean-like. Valid examples include ready, balance > price, and is_open().
Alias syntax (sugar):
#![allow(unused)]
fn main() {
choice at Client
Buy ->
Client -> Server : Purchase
Cancel ->
Client -> Server : Cancel
}
This is equivalent to the case choose form. It is a lighter syntax for the same choice structure.
4) Loop Statement
#![allow(unused)]
fn main() {
loop decide by Client
Client -> Server : Request
Server -> Client : Response
}
This loop continues while the deciding role chooses to continue. The decide by form is
desugared to a choice+recursion pattern at parse time. The first message in the loop body
serves as the “continue” signal - when the deciding role sends it, the loop continues.
A synthetic Done message is added as the termination signal.
The example above desugars to:
#![allow(unused)]
fn main() {
rec RoleDecidesLoop
choice at Client
Request ->
Client -> Server : Request
Server -> Client : Response
continue RoleDecidesLoop
Done ->
Client -> Server : Done
}
Requirement: The first statement in a loop decide by Role body must be a send from
the deciding role. This ensures the decision to continue is fused with the first message,
saving a round trip compared to sending a separate “continue” signal.
This desugaring converts the RoleDecides loop into standard multiparty session type
constructs (choice + recursion), enabling formal verification in Lean and compatibility
with standard MPST projection algorithms.
#![allow(unused)]
fn main() {
loop repeat 5
A -> B : Ping
B -> A : Pong
}
This loop repeats a fixed number of times. The compiler records the iteration count in the AST.
#![allow(unused)]
fn main() {
loop while "has_more_data"
A -> B : Data
}
This loop parses the string content through the same typed predicate IR used by guards. The parser rejects non-boolean predicates such as "count + 1" before building the AST.
#![allow(unused)]
fn main() {
loop forever
A -> B : Tick
}
This loop has no exit condition. Use it for persistent background protocols.
5) Parallel Statement (branch adjacency)
Parallel composition is expressed by adjacent branch blocks at the same indentation level.
#![allow(unused)]
fn main() {
branch
A -> B : Msg1
B -> A : Ack
branch
C -> D : Msg2
}
A solitary branch is a parse error. Use {} for an empty branch if needed.
6) Recursion and Calls
#![allow(unused)]
fn main() {
rec Loop
A -> B : Tick
continue Loop
}
This defines a recursive label Loop and uses continue Loop to jump back, modeling unbounded recursion.
#![allow(unused)]
fn main() {
call Handshake
}
This calls another protocol that is in scope. The call target must be defined in the same file or where block.
The continue keyword is for recursive back-references within a rec block. The call keyword is for invoking sub-protocols defined in where blocks.
7) Protocol Composition (where block)
Define and reuse local protocol fragments inside a where block.
#![allow(unused)]
fn main() {
protocol Main =
roles A, B, C
call Handshake
call DataTransfer
A -> C : Done
where
protocol Handshake =
roles A, B
A -> B : Hello
B -> A : Hi
protocol DataTransfer =
roles A, B
A -> B : Data
B -> A : Ack
}
Local protocols can call each other and can be used within choices, loops, and branches.
8) Message Types and Payloads
Message types support generic parameters. Payloads may be written with {} or ().
#![allow(unused)]
fn main() {
A -> B : Request<String>
B -> A : Response<i32>
A -> B : Data<String, i32, bool>
A -> B : Msg { data : String, count : Int }
B -> A : Result(i : Int, ok : Bool)
}
This section shows generic parameters and payload shapes. Both {} and () forms are accepted.
9) Dynamic Role Count Support
Dynamic role counts are supported via wildcard and symbolic parameters.
#![allow(unused)]
fn main() {
protocol ThresholdProtocol =
roles Coordinator, Signers[*]
Coordinator -> Signers[*] : Request
Signers[0..threshold] -> Coordinator : Response
}
This example uses a wildcard role family. It also uses a symbolic bound in the role index.
#![allow(unused)]
fn main() {
protocol ConsensusProtocol =
roles Leader, Followers[N]
Leader -> Followers[*] : Proposal
Followers[i] -> Leader : Vote
}
This example mixes a named count with index variables. It enables parameterized protocols.
#![allow(unused)]
fn main() {
protocol PartialBroadcast =
roles Broadcaster, Receivers[*]
Broadcaster -> Receivers[0..count] : Message
Receivers[0..threshold] -> Broadcaster : Ack
}
This example shows bounded ranges for role indices. It models partial broadcasts and threshold acknowledgments.
10) Timing Patterns
Timing patterns provide constructs for building time-aware protocols. All patterns desugar to standard MPST constructs (choice, recursion) and remain verifiable in Lean.
Timed Choice
A timed choice races an operation against a timeout deadline:
#![allow(unused)]
fn main() {
protocol TimedRequest =
roles Client, Server
Client -> Server : Request
timed_choice at Client(5s)
OnTime ->
Server -> Client : Response
TimedOut ->
Client -> Server : Cancel
}
This desugars to a standard Choice with a TimedChoice { duration } annotation. The timeout is enforced at runtime by the effect interpreter.
Durations support: ms (milliseconds), s (seconds), m (minutes), h (hours).
Heartbeat Pattern
The heartbeat pattern models connection liveness detection:
#![allow(unused)]
fn main() {
protocol Liveness =
roles Alice, Bob
heartbeat Alice -> Bob every 1s on_missing(3) {
Bob -> Alice : Disconnect
} body {
Alice -> Bob : Ping
Bob -> Alice : Pong
}
}
This desugars to a recursive choice:
#![allow(unused)]
fn main() {
rec HeartbeatLoop
Alice -> Bob : Heartbeat
choice at Bob
Alive ->
// body
Alice -> Bob : Ping
Bob -> Alice : Pong
continue HeartbeatLoop
Dead ->
// on_missing
Bob -> Alice : Disconnect
}
The on_missing(3) parameter indicates how many missed heartbeats before declaring the sender dead. The runtime uses this for timeout calculations.
Runtime Timeout Annotation
The @runtime_timeout annotation provides transport-level timeout hints:
#![allow(unused)]
fn main() {
protocol TimedOps =
roles Client, Server
@runtime_timeout(5s) Client -> Server : Request
Server -> Client : Response
}
Unlike timed_choice, this annotation does not change the session type. It is a hint to the transport layer and is not verified in Lean. Use it for operational timeouts when you do not want the protocol to branch on timeout.
11) Proof Bundles and VM-Core Statements
proof_bundle declarations define capability sets. protocol ... requires ... selects the bundles required by a protocol.
#![allow(unused)]
fn main() {
proof_bundle DelegationBase version "1.0.0" issuer "did:example:issuer" constraint "fresh_nonce" requires [delegation, guard_tokens]
proof_bundle KnowledgeBase requires [knowledge_flow]
protocol TransferFlow requires DelegationBase, KnowledgeBase =
roles A, B
acquire guard as token
transfer endpoint to B with bundle DelegationBase
delegate endpoint to B with bundle DelegationBase
tag obligation as obligation_tag
check obligation for B into witness
release guard using token
A -> B : Commit
}
The parser stores bundle declarations and required bundle names in typed choreography metadata. Bundle records include optional version, issuer, and repeated constraint fields.
Validation fails on duplicate bundles, missing required bundles, or missing capability coverage for VM-core statements. If requires is omitted and bundle coverage is unambiguous, the parser infers required bundles from VM-core capability demand.
VM-core statements lower to Protocol::Extension nodes with annotations. The annotation keys are vm_core_op, required_capability, and vm_core_operands.
#![allow(unused)]
fn main() {
protocol SpeculativeFlow requires SpecBundle =
roles A, B
fork ghost0
A -> B : Try
join
}
This lowering preserves statement order and continuation structure. Projection skips extension-local behavior for now and continues projecting the remaining protocol.
12) First-Class Combinators
The DSL includes first-class combinators for common patterns.
#![allow(unused)]
fn main() {
protocol Combinators =
roles A, B
handshake A <-> B : Hello
quorum_collect A -> B min 2 : Vote
retry 3 {
A -> B : Ping
}
}
handshake lowers to a two-message exchange (Hello and HelloAck). retry lowers to a bounded loop. quorum_collect lowers to a protocol extension node with combinator annotations.
13) Role Sets and Topologies
Role sets and topologies are declared at the top level and stored as typed metadata.
#![allow(unused)]
fn main() {
role_set Signers = Alice, Bob, Carol
role_set Quorum = subset(Signers, 0..2)
cluster LocalCluster = Signers, Quorum
ring RingNet = Alice, Bob, Carol
mesh FullMesh = Alice, Bob, Carol
}
These declarations do not change core protocol semantics. They provide structured topology context for tooling and simulation setup.
14) Lowering Diagnostics
Use choreo-fmt --explain-lowering to inspect canonical lowering output.
choreo-fmt --explain-lowering protocol.choreo
The report includes proof-bundle metadata, inferred requirements, lowered protocol shape, and lint suggestions.
15) String-based Protocol Definition
#![allow(unused)]
fn main() {
use telltale_choreography::compiler::parser::parse_choreography_str;
let protocol = r#"
protocol PingPong =
roles Alice, Bob
Alice -> Bob : Ping
Bob -> Alice : Pong
"#;
let choreography = parse_choreography_str(protocol)?;
}
This parses a string into a Choreography AST. It is the entry point for runtime parsing.
Namespaced protocols are expressed with a module header.
#![allow(unused)]
fn main() {
let protocol = r#"
module secure_messaging exposing (EncryptedProtocol)
protocol EncryptedProtocol =
roles Sender, Receiver
Sender -> Receiver : SecureMessage
"#;
let choreography = parse_choreography_str(protocol)?;
}
The parser builds the AST for projection, validation, and code generation.
Implementation Details
Parser Stack
- Layout preprocessor converts indentation into explicit block delimiters.
- Pest grammar parses the canonical brace based syntax.
- Parser module constructs the AST and runs validation.
Parse Pipeline
- Preprocess layout (indentation ->
{}/()). - Parse with Pest grammar.
- Build statements and normalize branch adjacency to
Parallel. - Resolve
callreferences and lower VM-core statements toProtocol::Extension. - Build
Choreographyand attach typed proof-bundle metadata. - Run semantic checks with
choreography.validate()when required by your integration.
API
Primary Functions
parse_choreography_str parses a DSL string into a Choreography AST.
#![allow(unused)]
fn main() {
use telltale_choreography::compiler::parser::parse_choreography_str;
let choreo = parse_choreography_str(r#"
protocol Example =
roles A, B
A -> B : Hello
"#)?;
}
This example parses a DSL string into a Choreography. It uses parse_choreography_str directly.
parse_choreography_file parses a DSL file from disk.
#![allow(unused)]
fn main() {
use std::path::Path;
use telltale_choreography::compiler::parser::parse_choreography_file;
let choreo = parse_choreography_file(Path::new("protocol.choreo"))?;
}
This example parses a file path into a Choreography. The file must contain a valid DSL definition.
parse_dsl is an alias for parse_choreography_str.
Error Handling
#![allow(unused)]
fn main() {
match parse_choreography_str(input) {
Ok(choreo) => { /* use choreography */ }
Err(ParseError::UndefinedRole { role, .. }) => {
eprintln!("Role '{}' used but not declared", role);
}
Err(ParseError::DuplicateRole { role, .. }) => {
eprintln!("Role '{}' declared multiple times", role);
}
Err(ParseError::Syntax { span, message }) => {
eprintln!("Syntax error at {}:{}: {}", span.line, span.column, message);
}
Err(e) => {
eprintln!("Parse error: {}", e);
}
}
}
This pattern matches common parse errors. It formats diagnostics with the reported context.
Grammar Details
Tokens and Keywords
- Identifiers:
[a-zA-Z][a-zA-Z0-9_]* - Integers:
[0-9]+ - Strings:
"..."(used inloop while) - Keywords:
protocol,roles,case,choose,of,choice,at,loop,decide,by,repeat,while,forever,branch,rec,call,where,module,import,exposing,proof_bundle,requires,acquire,release,fork,join,abort,transfer,delegate,tag,check,using,into,version,issuer,constraint,handshake,retry,quorum_collect,min,role_set,subset,cluster,ring,mesh - Operators:
->,->*,:,{},(),,,|
Comments
Single-line comments use --. Multi-line comments use {- ... -}.
-- This is a single-line comment
{- This is a
multi-line comment -}
These comments are ignored by the parser. They use the same syntax as Haskell and PureScript.
Whitespace and Layout
Indentation defines blocks. Use {} to force an empty block or to opt out of layout. Parenthesized blocks must be non-empty.
Validation
The parser validates role declarations and branch adjacency. choreography.validate() also validates proof-bundle declarations, required bundle references, and VM-core capability coverage.
Error Messages
Example undefined role error:
Undefined role 'Charlie'
--> input:5:14
|
5 | Alice -> Charlie : Hello
^^^^^^^
This error indicates a role that was not declared in roles. The location points to the undefined identifier.
Example duplicate role error:
Duplicate role declaration 'Alice'
--> input:3:33
|
3 | roles Alice, Bob, Charlie, Alice
^^^^^
This error indicates that a role name appears more than once. The location points to the duplicate entry.
Examples
Simple Two-Party Protocol
#![allow(unused)]
fn main() {
protocol PingPong =
roles Alice, Bob
Alice -> Bob : Ping
Bob -> Alice : Pong
}
This example shows a simple two role protocol. It uses a single send and reply.
Protocol with Choice
#![allow(unused)]
fn main() {
protocol Negotiation =
roles Buyer, Seller
Buyer -> Seller : Offer
case choose Seller of
Accept ->
Seller -> Buyer : Accept
Reject ->
Seller -> Buyer : Reject
}
This example shows an explicit choice decided by Seller. Each branch starts with a send from the deciding role.
Complex E-Commerce Protocol
#![allow(unused)]
fn main() {
protocol ECommerce =
roles Buyer, Seller, Shipper
Buyer -> Seller : Inquiry
Seller -> Buyer : Quote
case choose Buyer of
Order ->
Buyer -> Seller : Order
Seller -> Shipper : ShipRequest
Shipper -> Buyer : Tracking
loop decide by Buyer
Buyer -> Shipper : StatusCheck
Shipper -> Buyer : StatusUpdate
Shipper -> Buyer : Delivered
Buyer -> Seller : Confirmation
Cancel ->
Buyer -> Seller : Cancel
}
This example combines choice and looping. It models a longer interaction with a buyer controlled loop.
Parallel Example
#![allow(unused)]
fn main() {
protocol ParallelDemo =
roles A, B, C, D
branch
A -> B : Msg1
branch
C -> D : Msg2
}
This example uses adjacent branch blocks. Each branch defines a parallel sub protocol.
Integration
With Projection
#![allow(unused)]
fn main() {
use telltale_choreography::compiler::{parser, projection};
let choreo = parser::parse_choreography_str(input)?;
for role in &choreo.roles {
let local_type = projection::project(&choreo, role)?;
println!("Local type for {}: {:?}", role.name, local_type);
}
}
This projects the global protocol to a local type for each role. The result can be used for type driven code generation.
With Code Generation
#![allow(unused)]
fn main() {
use telltale_choreography::compiler::{parser, projection, codegen};
let choreo = parser::parse_choreography_str(input)?;
let mut local_types = Vec::new();
for role in &choreo.roles {
let local_type = projection::project(&choreo, role)?;
local_types.push((role.clone(), local_type));
}
let code = codegen::generate_choreography_code(
&choreo.name.to_string(),
&choreo.roles,
&local_types,
);
}
This generates Rust code for the choreography. The generated code includes session types and role APIs.
Testing
Run parser tests with:
cargo test --package telltale-choreography parser
This runs the parser test suite for the choreography crate. It exercises grammar and layout handling.