DSL Extensions
This document explains how to extend the choreography DSL with runtime effects and syntax extensions.
Overview
The extension system has two parts. Runtime extensions add type-safe effects that can be inserted into Program sequences. Syntax extensions add new grammar rules and statement parsers to the DSL.
Runtime effect extensions are projected to roles during compilation and dispatched by interpret_extensible at runtime.
Syntax extensions are registered through the parser and grammar composition APIs.
The repository contains two extension registries with the same name:
effects::registry::ExtensionRegistry<E, R>for runtime extension effect handlers.extensions::ExtensionRegistryfor DSL grammar and parser extensions.
Simulator Integration for Extensions
Extension projects often need protocol-machine-level regression tests in addition to parser tests.
Use telltale-simulator harness APIs to run projected local types under scenario middleware.
This keeps extension validation aligned with protocol-machine effect contracts.
#![allow(unused)]
fn main() {
let harness = SimulationHarness::new(&DirectAdapter::new(&handler));
let result = harness.run(&spec)?;
assert_contracts(&result, &ContractCheckConfig::default())?;
}
This pattern makes extension runtime checks reusable across projects. See Simulation Overview for harness config files and preset constructors.
Runtime Effect Extensions
ExtensionEffect Trait
Extensions implement ExtensionEffect and specify which roles participate.
#![allow(unused)]
fn main() {
pub trait ExtensionEffect<R: RoleId>: Send + Sync + Debug {
fn type_id(&self) -> TypeId;
fn type_name(&self) -> &'static str;
fn participating_roles(&self) -> Vec<R> { vec![] }
fn as_any(&self) -> &dyn Any;
fn as_any_mut(&mut self) -> &mut dyn Any;
fn clone_box(&self) -> Box<dyn ExtensionEffect<R>>;
}
}
The default participating_roles implementation returns an empty vector, which makes the extension global. A non-empty vector limits the extension to specific roles.
Defining an Extension
#![allow(unused)]
fn main() {
#[derive(Clone, Debug)]
pub struct ValidateCapability<R> {
pub role: R,
pub capability: String,
}
impl<R: RoleId> ExtensionEffect<R> for ValidateCapability<R> {
fn type_id(&self) -> TypeId {
TypeId::of::<Self>()
}
fn type_name(&self) -> &'static str {
"ValidateCapability"
}
fn participating_roles(&self) -> Vec<R> {
vec![self.role]
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn clone_box(&self) -> Box<dyn ExtensionEffect<R>> {
Box::new(self.clone())
}
}
}
This extension only appears in the projection for role. Use an empty vector to make it global.
Extension Registry
Handlers register extension logic in an ExtensionRegistry.
#![allow(unused)]
fn main() {
let mut registry = ExtensionRegistry::new();
registry.register::<ValidateCapability<Role>, _>(|_ep, ext| {
Box::pin(async move {
let validate = ext
.as_any()
.downcast_ref::<ValidateCapability<Role>>()
.ok_or_else(|| ExtensionError::TypeMismatch {
expected: "ValidateCapability",
actual: ext.type_name(),
})?;
tracing::info!(cap = %validate.capability, "checked capability");
Ok(())
})
})?;
}
The register method returns an error on duplicate handlers. Use ExtensionRegistry::merge to compose registries across modules.
Error Handling
Extension errors surface as ExtensionError.
#![allow(unused)]
fn main() {
pub enum ExtensionError {
UnknownExtension { type_name: &'static str, type_id: TypeId },
HandlerNotRegistered { type_name: &'static str },
ExecutionFailed { type_name: &'static str, error: String },
TypeMismatch { expected: &'static str, actual: &'static str },
DuplicateHandler { type_name: &'static str },
MergeConflict { type_name: &'static str },
}
}
Handlers should register all required extensions before interpretation. This keeps failures at startup instead of in the middle of protocol execution.
Interpreter Integration
Use interpret_extensible with handlers that implement ExtensibleHandler.
#![allow(unused)]
fn main() {
let mut handler = MyHandler::new();
let mut endpoint = ();
let result = interpret_extensible(&mut handler, &mut endpoint, program).await?;
}
The non-extensible interpret function does not dispatch extensions. That
runtime is now an internal choreography-layer surface, not the primary public
entrypoint for application code.
Using Extensions in Programs
Extensions should be introduced from the DSL/parser side so they remain part of
the validated semantic surface. Do not teach or depend on manual
Program::ext construction as the public modeling path.
Syntax Extensions
GrammarExtension Trait
Grammar extensions provide Pest rules and a list of statement rules they handle.
#![allow(unused)]
fn main() {
pub trait GrammarExtension: Send + Sync + Debug {
fn grammar_rules(&self) -> &'static str;
fn statement_rules(&self) -> Vec<&'static str>;
fn priority(&self) -> u32 { 100 }
fn extension_id(&self) -> &'static str;
}
}
The priority value resolves conflicts when multiple extensions define the same rule. Higher priority wins and conflicts are recorded by the registry.
StatementParser and ProtocolExtension
Statement parsers translate matched rules into protocol extensions.
#![allow(unused)]
fn main() {
pub trait StatementParser: Send + Sync + Debug {
fn can_parse(&self, rule_name: &str) -> bool;
fn supported_rules(&self) -> Vec<String>;
fn parse_statement(
&self,
rule_name: &str,
content: &str,
context: &ParseContext,
) -> Result<Box<dyn ProtocolExtension>, ParseError>;
}
}
ProtocolExtension instances participate in validation, projection, and code generation.
#![allow(unused)]
fn main() {
pub trait ProtocolExtension: Send + Sync + Debug {
fn type_name(&self) -> &'static str;
fn mentions_role(&self, role: &Role) -> bool;
fn validate(&self, roles: &[Role]) -> Result<(), ExtensionValidationError>;
fn project(&self, role: &Role, context: &ProjectionContext) -> Result<LocalType, ProjectionError>;
fn generate_code(&self, context: &CodegenContext) -> proc_macro2::TokenStream;
fn as_any(&self) -> &dyn Any;
fn as_any_mut(&mut self) -> &mut dyn Any;
fn type_id(&self) -> TypeId;
}
}
Use these traits to attach new DSL constructs to projection and codegen.
ExtensionRegistry for Syntax
The ExtensionRegistry stores grammar extensions and statement parsers.
#![allow(unused)]
fn main() {
let mut registry = ExtensionRegistry::new();
registry.register_grammar(MyGrammarExtension)?;
registry.register_parser(MyStatementParser, "my_parser".to_string());
}
The registry tracks rule conflicts and supports dependency checks. Use get_detailed_conflicts for human-readable conflict reports.
GrammarComposer
GrammarComposer combines the base grammar with registered extension rules and caches the result.
#![allow(unused)]
fn main() {
let mut composer = GrammarComposer::new();
composer.register_extension(MyGrammarExtension)?;
let grammar = composer.compose()?;
}
The cache avoids recomposing the grammar when the extension set has not changed.
ExtensionParser
ExtensionParser wires the grammar composer into the parsing pipeline.
#![allow(unused)]
fn main() {
let mut parser = ExtensionParser::new();
parser.register_extension(MyGrammarExtension, MyStatementParser)?;
let choreography = parser.parse_with_extensions(source)?;
}
The parse_with_extensions method now composes the grammar metadata and routes
registered statement rules through the shared extension registry. Supported
extension statements are parsed deterministically into Protocol::Extension
nodes instead of falling back to the standard parser unchanged.
Extension Discovery
The extensions::discovery module provides metadata and dependency management.
#![allow(unused)]
fn main() {
let mut discovery = ExtensionDiscovery::new();
discovery.add_search_path("./extensions");
let registry = discovery.create_registry(&["timeout".to_string()])?;
}
Discovery assembles an ExtensionRegistry in dependency order and performs basic validation.
Complete Workflow Example
This section shows an end-to-end workflow for building and running a runtime extension.
Step 1: Add Dependencies
Add the choreography crate and an async runtime.
[dependencies]
telltale-runtime = "11.3.0"
tokio = { version = "1", features = ["full"] }
Use a path dependency only for local workspace development. External projects should pin to a release version.
Step 2: Define Roles and Messages
Define roles, labels, and messages for your protocol.
#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use telltale_runtime::{LabelId, RoleId, RoleName};
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
enum Role { Client, Server }
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
enum Label { Ok }
impl LabelId for Label {
fn as_str(&self) -> &'static str {
match self { Label::Ok => "ok" }
}
fn from_str(label: &str) -> Option<Self> {
match label { "ok" => Some(Label::Ok), _ => None }
}
}
impl RoleId for Role {
type Label = Label;
fn role_name(&self) -> RoleName {
match self {
Role::Client => RoleName::from_static("Client"),
Role::Server => RoleName::from_static("Server"),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
enum Message { Ping, Pong }
}
Roles implement RoleId. Labels implement LabelId. Messages are serializable.
Step 3: Build an Extensible Handler
Register extension handlers and implement ExtensibleHandler.
#![allow(unused)]
fn main() {
struct DomainHandler {
registry: ExtensionRegistry<(), Role>,
}
impl DomainHandler {
fn new() -> Self {
let mut registry = ExtensionRegistry::new();
registry
.register::<ValidateCapability<Role>, _>(|_ep, ext| {
Box::pin(async move {
let validate = ext
.as_any()
.downcast_ref::<ValidateCapability<Role>>()
.ok_or(ExtensionError::TypeMismatch {
expected: "ValidateCapability",
actual: ext.type_name(),
})?;
tracing::info!(cap = %validate.capability, "validated");
Ok(())
})
})
.expect("register extension");
Self { registry }
}
}
}
ExtensionRegistry stores handlers for each extension type. The interpret_extensible entry point uses this registry during execution.
Step 4: Build and Run a Program
Extensions remain available in the low-level choreography runtime via
interpret_extensible, but that path is internal. The public modeling path is
to parse/validate extension-bearing DSL and lower it through the canonical
semantic surface rather than hand-assembling extension programs.
Testing
Unit tests can validate registry setup and projection behavior.
#![allow(unused)]
fn main() {
#[test]
fn test_registry() {
let handler = DomainHandler::new();
assert!(handler
.extension_registry()
.is_registered::<ValidateCapability<Role>>());
}
}
Use integration tests to check end-to-end protocol behavior with interpret_extensible.
Built-In Extensions
The extensions::timeout module contains a sample grammar extension, parser, and protocol extension. It is intended as a reference implementation and currently uses simplified parsing logic.