How to Add a New Backend
This guide walks through adding a new code generation backend to wirespec. The C and Rust backends serve as reference implementations. The same steps apply to any future target language (e.g., Go, Swift, Zig).
Prerequisites
Read Architecture and IR Pipeline first. The key takeaway: backends consume CodecModule, not AST. All name resolution and type checking is already done by the time your backend runs.
Architecture Overview
.wspec source
→ wirespec-syntax (parse) → AST
→ wirespec-sema (analyze) → Semantic IR
→ wirespec-layout (lower) → Layout IR
→ wirespec-codec (lower) → Codec IR ← your backend consumes this
→ wirespec-backend-XXX (lower) → target code (.go, .swift, etc.)Step 1: Create a New Crate
Create crates/wirespec-backend-xxx/ with a Cargo.toml:
[package]
name = "wirespec-backend-xxx"
version.workspace = true
edition.workspace = true
[dependencies]
wirespec-backend-api = { path = "../wirespec-backend-api" }
wirespec-codec = { path = "../wirespec-codec" }
wirespec-sema = { path = "../wirespec-sema" } # for Endianness, SemanticVarInt, etc.Step 2: Implement the Backend Trait
use wirespec_backend_api::*;
use wirespec_codec::CodecModule;
pub const TARGET_XXX: TargetId = TargetId("xxx");
pub struct XxxBackendOptions {
// target-specific options
}
impl Default for XxxBackendOptions {
fn default() -> Self { Self {} }
}
pub struct XxxBackend;
impl Backend for XxxBackend {
type LoweredModule = XxxLoweredModule;
fn id(&self) -> TargetId { TARGET_XXX }
fn lower(
&self,
module: &CodecModule,
ctx: &BackendContext,
) -> Result<Self::LoweredModule, BackendError> {
// Downcast target options
let _opts = ctx.target_options.downcast_ref::<XxxBackendOptions>()
.ok_or_else(|| BackendError::UnsupportedOption {
target: TARGET_XXX,
option: "target_options".into(),
reason: "expected XxxBackendOptions".into(),
})?;
// Generate code from CodecModule
let source = emit_source(module, &ctx.module_prefix);
Ok(XxxLoweredModule { source, prefix: ctx.module_prefix.clone() })
}
fn emit(
&self,
lowered: &Self::LoweredModule,
sink: &mut dyn ArtifactSink,
) -> Result<BackendOutput, BackendError> {
sink.write(Artifact {
target: TARGET_XXX,
kind: ArtifactKind("xxx-source"),
module_name: lowered.prefix.clone(),
module_prefix: lowered.prefix.clone(),
relative_path: format!("{}.xxx", lowered.prefix).into(),
contents: lowered.source.as_bytes().to_vec(),
})?;
Ok(BackendOutput {
target: TARGET_XXX,
artifacts: vec![ArtifactMeta {
kind: ArtifactKind("xxx-source"),
relative_path: format!("{}.xxx", lowered.prefix).into(),
byte_len: lowered.source.len(),
}],
})
}
}
// Required for registry dispatch
impl BackendDyn for XxxBackend {
fn id(&self) -> TargetId { TARGET_XXX }
fn lower_and_emit(
&self, module: &CodecModule, ctx: &BackendContext, sink: &mut dyn ArtifactSink,
) -> Result<BackendOutput, BackendError> {
let lowered = Backend::lower(self, module, ctx)?;
Backend::emit(self, &lowered, sink)
}
}
pub struct XxxLoweredModule {
pub source: String,
pub prefix: String,
}Step 3: Implement Checksum Bindings (Optional)
If your target supports checksums:
use wirespec_backend_api::*;
pub struct XxxChecksumBindings;
impl ChecksumBindingProvider for XxxChecksumBindings {
fn binding_for(&self, algorithm: &str) -> Result<ChecksumBackendBinding, BackendError> {
match algorithm {
"internet" => Ok(ChecksumBackendBinding {
verify_symbol: Some("xxx_internet_checksum_verify".into()),
compute_symbol: "xxx_internet_checksum_compute".into(),
compute_style: ComputeStyle::PatchInPlace,
}),
_ => Err(BackendError::MissingChecksumBinding {
target: TARGET_XXX,
algorithm: algorithm.to_string(),
}),
}
}
}Step 4: Understand CodecModule
Your backend consumes CodecModule which contains everything needed:
| Field | What it provides |
|---|---|
module.packets | Packet definitions with fields, items, checksum plans |
module.frames | Tagged union definitions with variant scopes |
module.capsules | TLV containers with header + payload variants |
module.varints | VarInt definitions (prefix-match and continuation-bit) |
module.consts | Named constants |
module.enums | Enum/flags definitions |
module.state_machines | State machine definitions |
Each CodecField has:
strategy— how to parse/serialize (Primitive, VarInt, BytesLength, Array, BitGroup, etc.)wire_type— the wire-level typeendianness— byte orderis_optional/condition— conditional field infobytes_spec/array_spec/bitgroup_member— strategy-specific detailschecksum_algorithm— if this field has a checksum annotation
See crates/wirespec-codec/src/ir.rs for the complete CodecModule schema.
Step 5: Register with the CLI
Add your crate to the workspace Cargo.toml:
[workspace]
members = [
# ... existing members ...
"crates/wirespec-backend-xxx",
]Add the dependency and register the factory in the CLI binary:
// crates/wirespec-driver/src/bin/wirespec.rs
struct XxxBackendFactory;
impl BackendFactory for XxxBackendFactory {
fn id(&self) -> TargetId { wirespec_backend_xxx::TARGET_XXX }
fn create(&self) -> Box<dyn BackendDyn> { Box::new(wirespec_backend_xxx::XxxBackend) }
fn default_options(&self) -> Box<dyn std::any::Any + Send + Sync> {
Box::new(wirespec_backend_xxx::XxxBackendOptions::default())
}
}
fn build_registry() -> BackendRegistry {
let mut reg = BackendRegistry::new();
reg.register(Box::new(CBackendFactory));
reg.register(Box::new(RustBackendFactory));
reg.register(Box::new(XxxBackendFactory)); // ← add this line
reg
}Then wirespec compile input.wspec -t xxx works.
Step 6: Add Tests
Test your code generation output:
fn generate_xxx(src: &str) -> String {
let ast = wirespec_syntax::parse(src).unwrap();
let sem = wirespec_sema::analyze(
&ast,
wirespec_sema::ComplianceProfile::default(),
&Default::default(),
).unwrap();
let layout = wirespec_layout::lower(&sem).unwrap();
let codec = wirespec_codec::lower(&layout).unwrap();
let backend = XxxBackend;
let ctx = BackendContext {
module_name: "test".into(),
module_prefix: "test".into(),
source_prefixes: Default::default(),
compliance_profile: "phase2_extended_current".into(),
common_options: CommonOptions::default(),
target_options: Box::new(XxxBackendOptions::default()),
checksum_bindings: Arc::new(XxxChecksumBindings),
is_entry_module: true,
};
let lowered = Backend::lower(&backend, &codec, &ctx).unwrap();
lowered.source
}
#[test]
fn codegen_simple_packet() {
let src = generate_xxx("packet P { x: u8, y: u16 }");
assert!(src.contains(/* expected pattern */));
}Use MemorySink for artifact tests:
let mut sink = MemorySink::new();
backend.lower_and_emit(&codec, &ctx, &mut sink).unwrap();
assert_eq!(sink.artifacts.len(), 1);Run tests with:
cargo test -p wirespec-backend-xxxWhat You Do NOT Need to Modify
wirespec-syntax— parser / ASTwirespec-sema— semantic analysiswirespec-layout— layout IRwirespec-codec— codec IRwirespec-backend-api— backend traits (TargetId, ArtifactKind, etc. are all open)wirespec-driver— driver library (only the CLI binary needs the factory registration)
What a Correct Backend Must Not Do
- Do not access AST nodes. Only
CodecModuleand below. - Do not re-implement name resolution. If a name is not in the IR, it was not exported or imported correctly. Fix the upstream crate.
- Do not allocate heap memory in generated code. Only stack and caller-provided buffers are allowed.
- Do not modify IR types. IR crates are shared across all backends. Backend-specific concerns belong in your backend crate.
- Generated code must compile with zero warnings.
Reference Backends
crates/wirespec-backend-c/— C backend (header + source split, bitgroup shift/mask, checksum verify/compute)crates/wirespec-backend-rust/— Rust backend (single .rs file, lifetime tracking, Rust enums for frames)
Checklist
Before opening a PR for a new backend:
- [ ]
crates/wirespec-backend-xxx/created with properCargo.toml - [ ]
BackendandBackendDyntraits implemented - [ ] All field strategies handled (or explicit error for unsupported ones)
- [ ]
BackendFactoryregistered in the CLI binary - [ ]
wirespec compile input.wspec -t xxxworks for all example files - [ ] Tests added in
crates/wirespec-backend-xxx/tests/ - [ ] Generated code compiles with zero warnings under the target toolchain
- [ ]
cargo test --workspacepasses