Skip to content

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:

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

rust
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:

rust
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:

FieldWhat it provides
module.packetsPacket definitions with fields, items, checksum plans
module.framesTagged union definitions with variant scopes
module.capsulesTLV containers with header + payload variants
module.varintsVarInt definitions (prefix-match and continuation-bit)
module.constsNamed constants
module.enumsEnum/flags definitions
module.state_machinesState machine definitions

Each CodecField has:

  • strategy — how to parse/serialize (Primitive, VarInt, BytesLength, Array, BitGroup, etc.)
  • wire_type — the wire-level type
  • endianness — byte order
  • is_optional / condition — conditional field info
  • bytes_spec / array_spec / bitgroup_member — strategy-specific details
  • checksum_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:

toml
[workspace]
members = [
    # ... existing members ...
    "crates/wirespec-backend-xxx",
]

Add the dependency and register the factory in the CLI binary:

rust
// 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:

rust
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:

rust
let mut sink = MemorySink::new();
backend.lower_and_emit(&codec, &ctx, &mut sink).unwrap();
assert_eq!(sink.artifacts.len(), 1);

Run tests with:

bash
cargo test -p wirespec-backend-xxx

What You Do NOT Need to Modify

  • wirespec-syntax — parser / AST
  • wirespec-sema — semantic analysis
  • wirespec-layout — layout IR
  • wirespec-codec — codec IR
  • wirespec-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 CodecModule and 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 proper Cargo.toml
  • [ ] Backend and BackendDyn traits implemented
  • [ ] All field strategies handled (or explicit error for unsupported ones)
  • [ ] BackendFactory registered in the CLI binary
  • [ ] wirespec compile input.wspec -t xxx works for all example files
  • [ ] Tests added in crates/wirespec-backend-xxx/tests/
  • [ ] Generated code compiles with zero warnings under the target toolchain
  • [ ] cargo test --workspace passes