vexil-codegen-rust 0.4.3

Rust code generation backend for the Vexil schema compiler
Documentation
//! Rust code generation backend for the Vexil schema compiler.
//!
//! Implements the [`CodegenBackend`](vexil_lang::codegen::CodegenBackend) trait,
//! generating Rust structs with `Pack`/`Unpack` implementations for wire encoding.
//!
//! # Usage
//!
//! ```ignore
//! let result = vexil_lang::compile(source);
//! let code = vexil_codegen_rust::generate(&result.compiled.unwrap());
//! ```

pub mod annotations;
pub mod backend;
pub mod boxing;
pub mod config;
pub mod delta;
pub mod emit;
pub mod enum_gen;
pub mod flags;
pub mod message;
pub mod newtype;
pub mod types;
pub mod union_gen;

pub use backend::RustBackend;

use std::collections::HashMap;

use vexil_lang::ir::{CompiledSchema, ResolvedType, TypeDef, TypeId};

#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum CodegenError {
    #[error("unresolved type {type_id:?} referenced by {referenced_by}")]
    UnresolvedType {
        type_id: TypeId,
        referenced_by: String,
    },
}

/// Generate Rust code for a compiled schema (no cross-file imports).
pub fn generate(compiled: &CompiledSchema) -> Result<String, CodegenError> {
    generate_with_imports(compiled, None)
}

/// Generate Rust code for a compiled schema, with optional cross-file import `use` statements.
///
/// `import_paths` maps TypeIds of imported types to their Rust module paths
/// (e.g., `"crate::foo::bar::types::Baz"` for a type `Baz` from namespace `foo.bar.types`).
pub(crate) fn generate_with_imports(
    compiled: &CompiledSchema,
    import_paths: Option<&HashMap<TypeId, String>>,
) -> Result<String, CodegenError> {
    // Step 1: Detect recursive types that need Box<T> wrapping.
    let needs_box = boxing::detect_boxing(compiled);

    // Step 2: Create the code writer.
    let mut w = emit::CodeWriter::new();

    // Step 3: Emit header.
    w.line("// Code generated by vexilc. DO NOT EDIT.");
    let ns = compiled.namespace.join(".");
    w.line(&format!("// Source: {ns}"));

    // Step 4: Emit blank line.
    w.blank();

    // Step 5: Check if any type uses Map -> emit BTreeMap import.
    if schema_uses_map(compiled) {
        w.line("use std::collections::BTreeMap;");
    }
    w.line("use vexil_runtime::*;");

    // Cross-file use statements.
    if let Some(paths) = import_paths {
        let mut unique_paths: Vec<&String> = paths.values().collect();
        unique_paths.sort();
        unique_paths.dedup();
        for path in unique_paths {
            w.line(&format!("use {path};"));
        }
    }

    // Step 7: Emit blank line.
    w.blank();

    // Emit SCHEMA_HASH (always present — every schema has a hash).
    let hash = vexil_lang::canonical::schema_hash(compiled);
    let hash_str = hash
        .iter()
        .map(|b| format!("0x{b:02x}"))
        .collect::<Vec<_>>()
        .join(", ");
    w.line(&format!("pub const SCHEMA_HASH: [u8; 32] = [{hash_str}];"));

    // Emit SCHEMA_VERSION if present (after SCHEMA_HASH per spec §8.2).
    if let Some(ref version) = compiled.annotations.version {
        w.line(&format!("pub const SCHEMA_VERSION: &str = \"{version}\";"));
    }

    // Step 9: For each declared type, emit the appropriate code.
    for &type_id in &compiled.declarations {
        let typedef =
            compiled
                .registry
                .get(type_id)
                .ok_or_else(|| CodegenError::UnresolvedType {
                    type_id,
                    referenced_by: "declarations".to_string(),
                })?;

        // Emit blank line + section separator comment.
        w.blank();
        let type_name = type_name_of(typedef);
        w.line(&format!("// \u{2500}\u{2500} {type_name} \u{2500}\u{2500}"));

        // Dispatch to the appropriate emitter.
        match typedef {
            TypeDef::Message(msg) => {
                message::emit_message(&mut w, msg, &compiled.registry, &needs_box, type_id);
                delta::emit_delta(&mut w, msg, &compiled.registry);
            }
            TypeDef::Enum(en) => {
                enum_gen::emit_enum(&mut w, en, &compiled.registry);
            }
            TypeDef::Flags(fl) => {
                flags::emit_flags(&mut w, fl, &compiled.registry);
            }
            TypeDef::Union(un) => {
                union_gen::emit_union(&mut w, un, &compiled.registry, &needs_box, type_id);
            }
            TypeDef::Newtype(nt) => {
                newtype::emit_newtype(&mut w, nt, &compiled.registry, &needs_box);
            }
            TypeDef::Config(cfg) => {
                config::emit_config(&mut w, cfg, &compiled.registry, &needs_box);
            }
            _ => {} // non_exhaustive guard
        }
    }

    Ok(w.finish())
}

/// Generate a `mod.rs` file that re-exports child modules.
pub fn generate_mod_file(module_names: &[&str]) -> String {
    let mut w = emit::CodeWriter::new();
    w.line("// Code generated by vexilc. DO NOT EDIT.");
    w.blank();
    for name in module_names {
        w.line(&format!("pub mod {name};"));
    }
    w.finish()
}

/// Returns the name of a TypeDef for use in section separator comments.
pub(crate) fn type_name_of(typedef: &TypeDef) -> &str {
    match typedef {
        TypeDef::Message(m) => m.name.as_str(),
        TypeDef::Enum(e) => e.name.as_str(),
        TypeDef::Flags(f) => f.name.as_str(),
        TypeDef::Union(u) => u.name.as_str(),
        TypeDef::Newtype(n) => n.name.as_str(),
        TypeDef::Config(c) => c.name.as_str(),
        _ => "Unknown", // non_exhaustive guard
    }
}

/// Returns true if any declaration in the schema uses a Map type (recursively).
fn schema_uses_map(compiled: &CompiledSchema) -> bool {
    for &type_id in &compiled.declarations {
        if let Some(typedef) = compiled.registry.get(type_id) {
            if typedef_uses_map(typedef) {
                return true;
            }
        }
    }
    false
}

fn typedef_uses_map(typedef: &TypeDef) -> bool {
    match typedef {
        TypeDef::Message(msg) => msg
            .fields
            .iter()
            .any(|f| resolved_type_uses_map(&f.resolved_type)),
        TypeDef::Union(un) => un.variants.iter().any(|v| {
            v.fields
                .iter()
                .any(|f| resolved_type_uses_map(&f.resolved_type))
        }),
        TypeDef::Config(cfg) => cfg
            .fields
            .iter()
            .any(|f| resolved_type_uses_map(&f.resolved_type)),
        TypeDef::Newtype(nt) => resolved_type_uses_map(&nt.inner_type),
        _ => false,
    }
}

fn resolved_type_uses_map(ty: &ResolvedType) -> bool {
    match ty {
        ResolvedType::Map(_, _) => true,
        ResolvedType::Optional(inner) => resolved_type_uses_map(inner),
        ResolvedType::Array(inner) => resolved_type_uses_map(inner),
        ResolvedType::Result(ok, err) => resolved_type_uses_map(ok) || resolved_type_uses_map(err),
        _ => false,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn generate_with_no_imports_matches_generate() {
        let result = vexil_lang::compile("namespace test.gen\nmessage Foo { x @0 : u32 }");
        let compiled = result.compiled.unwrap();
        let without = generate(&compiled).unwrap();
        let with = generate_with_imports(&compiled, None).unwrap();
        assert_eq!(without, with);
    }

    #[test]
    fn generate_with_imports_emits_use_statements() {
        let result = vexil_lang::compile("namespace test.gen\nmessage Foo { x @0 : u32 }");
        let compiled = result.compiled.unwrap();
        let foo_id = compiled.declarations[0];
        let mut paths = HashMap::new();
        paths.insert(foo_id, "crate::other::module::Bar".to_string());
        let code = generate_with_imports(&compiled, Some(&paths)).unwrap();
        assert!(code.contains("use crate::other::module::Bar;"));
    }

    #[test]
    fn generate_mod_file_produces_correct_output() {
        let output = generate_mod_file(&["foo", "bar"]);
        assert!(output.contains("// Code generated by vexilc. DO NOT EDIT."));
        assert!(output.contains("pub mod foo;"));
        assert!(output.contains("pub mod bar;"));
    }

    #[test]
    fn rust_backend_generate_matches_free_function() {
        use vexil_lang::codegen::CodegenBackend;
        let result = vexil_lang::compile("namespace test.backend\nmessage Foo { x @0 : u32 }");
        let compiled = result.compiled.unwrap();
        let free_fn = generate(&compiled).unwrap();
        let backend = crate::backend::RustBackend;
        let trait_fn = backend.generate(&compiled).unwrap();
        assert_eq!(free_fn, trait_fn);
    }

    #[test]
    fn generate_project_emits_cross_file_use_statements() {
        use vexil_lang::codegen::CodegenBackend;
        use vexil_lang::resolve::InMemoryLoader;
        let root_src = "namespace proj.root\nimport proj.dep\nmessage Foo { d @0 : Bar }";
        let dep_src = "namespace proj.dep\nmessage Bar { y @0 : string }";
        let mut loader = InMemoryLoader::new();
        loader.schemas.insert("proj.dep".into(), dep_src.into());
        let root_path = std::path::PathBuf::from("proj/root.vexil");
        let result = vexil_lang::compile_project(root_src, &root_path, &loader).unwrap();
        let backend = crate::backend::RustBackend;
        let files = backend.generate_project(&result).unwrap();
        // Find root.rs — it should contain a `use crate::proj::dep::Bar;` statement
        let root_code = files
            .iter()
            .find(|(p, _)| p.to_string_lossy().contains("root.rs"))
            .map(|(_, code)| code.as_str())
            .expect("missing root.rs");
        assert!(
            root_code.contains("use crate::proj::dep::Bar;"),
            "root.rs should contain cross-file use statement for Bar, got:\n{root_code}"
        );
    }

    #[test]
    fn rust_backend_generate_project_produces_files() {
        use vexil_lang::codegen::CodegenBackend;
        use vexil_lang::resolve::InMemoryLoader;
        let root_src = "namespace proj.root\nimport proj.dep\nmessage Foo { x @0 : u32 }";
        let dep_src = "namespace proj.dep\nmessage Bar { y @0 : string }";
        let mut loader = InMemoryLoader::new();
        loader.schemas.insert("proj.dep".into(), dep_src.into());
        let root_path = std::path::PathBuf::from("proj/root.vexil");
        let result = vexil_lang::compile_project(root_src, &root_path, &loader).unwrap();
        let backend = crate::backend::RustBackend;
        let files = backend.generate_project(&result).unwrap();
        // Should have schema files + mod.rs files
        assert!(
            files.len() >= 2,
            "expected at least 2 files, got {}",
            files.len()
        );
        // Should contain a .rs file for each namespace
        let has_root = files
            .keys()
            .any(|p| p.to_string_lossy().contains("root.rs"));
        let has_dep = files.keys().any(|p| p.to_string_lossy().contains("dep.rs"));
        assert!(
            has_root,
            "missing root.rs in output: {:?}",
            files.keys().collect::<Vec<_>>()
        );
        assert!(
            has_dep,
            "missing dep.rs in output: {:?}",
            files.keys().collect::<Vec<_>>()
        );
    }
}