rigless 0.1.0

Virtual hardware-in-the-loop runtime: run unmodified firmware on an emulated MCU with every peripheral, chip model and unit of virtual time in Rust, and a CPU core of its own that translates the firmware's instructions to host code
Documentation
// Copyright (c) 2026 Rigless Labs.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! A vendor's CMSIS-SVD file as the register tables an SoC is built from.
//!
//! For each register layout it emits a `Register` enum to match on, a constant per field, and
//! the [`RegisterSpec`](crate::soc::RegisterSpec) table the runtime serves reads and writes
//! from. `generated.rs` names every instance at its address. [`generate`] gives the tables of
//! one SVD as strings and [`write_module`] writes a whole SoC directory, which is what
//! `rigless svd generate` runs. Nothing here knows which processor family the SoC belongs to.

mod check;
mod emit;
mod model;

use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

use sha2::{Digest, Sha256};

/// Why an SVD could not become a module.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    /// The file is not an SVD the parser accepts.
    #[error("not a readable SVD")]
    Svd(#[source] anyhow::Error),
    /// The SVD describes no CPU.
    #[error("the SVD describes no CPU")]
    NoCpu,
    /// A peripheral has no address block, so its size on the bus is unknown.
    #[error("{instance} has no address block")]
    NoAddressBlock {
        /// The peripheral.
        instance: String,
    },
    /// A register has a width the bus cannot carry.
    #[error("{register} is {bits} bits wide, and a register is 8, 16, 32, or 64")]
    Width {
        /// The register, as `PERIPHERAL.REGISTER`.
        register: String,
        /// The width the SVD gives.
        bits: u32,
    },
    /// A field extends past its register.
    #[error("{register}.{field} ends at bit {end} of a {bits}-bit register")]
    FieldOutOfRange {
        /// The register, as `PERIPHERAL.REGISTER`.
        register: String,
        /// The field.
        field: String,
        /// One past the field's last bit.
        end: u32,
        /// The register's width.
        bits: u32,
    },
    /// Two registers of one layout share bytes and neither is declared an alternate.
    #[error("registers {lower} and {upper} overlap")]
    OverlappingRegisters {
        /// The register with the lower offset, as `PERIPHERAL.REGISTER`.
        lower: String,
        /// The register that starts inside it.
        upper: String,
    },
    /// Two instances share addresses.
    #[error("instances {lower} and {upper} overlap")]
    OverlappingInstances {
        /// The instance with the lower base.
        lower: String,
        /// The instance that starts inside it.
        upper: String,
    },
    /// Two names in one scope fold to the same Rust identifier.
    #[error("{first} and {second} become the same identifier")]
    NameCollision {
        /// The first name.
        first: String,
        /// The second name.
        second: String,
    },
    /// A peripheral's `derivedFrom` chain comes back to a peripheral it already passed, so it has no
    /// layout to take.
    #[error("{instance} derives from itself, through its derivedFrom chain")]
    DerivationCycle {
        /// The peripheral.
        instance: String,
    },
    /// A value does not fit the integer the tables hold it in.
    #[error("{what} of {element} is {value}, more than the tables hold")]
    TooLarge {
        /// The SVD element.
        element: String,
        /// Which value.
        what: &'static str,
        /// The value the SVD gives.
        value: u64,
    },
    /// An SoC directory holds no SVD file or more than one.
    #[error("{} must hold exactly one .svd file, found {found}", directory.display())]
    SvdCount {
        /// The directory.
        directory: PathBuf,
        /// How many were found.
        found: usize,
    },
    /// A file could not be read or written.
    #[error("cannot access {}", path.display())]
    Io {
        /// The file.
        path: PathBuf,
        /// What the operating system said.
        #[source]
        source: io::Error,
    },
}

/// The files of a generated tree, keyed by path under the SoC directory.
///
/// `generated.rs` declares the modules and the instances, and `generated/<layout>.rs` holds one
/// register layout each.
pub type GeneratedFiles = BTreeMap<String, String>;

/// Generates the tables for an SVD.
///
/// `source_name` is the file name recorded in each file's provenance line beside the SHA-256 of `svd_xml`.
/// Every file also carries the copyright and license lines of the comments before the SVD's root
/// element, since each is derived from the vendor's file.
pub fn generate(svd_xml: &str, source_name: &str) -> Result<GeneratedFiles, Error> {
    let device = svd_parser::parse(svd_xml).map_err(Error::Svd)?;
    let model = model::from_device(&device)?;
    check::check(&model)?;
    let digest = format!("{:x}", Sha256::digest(svd_xml.as_bytes()));
    Ok(emit::emit(&model, source_name, &digest, &notice(svd_xml)))
}

/// The lines of the comments before the root element that start `Copyright` or
/// `SPDX-License-Identifier:`, which is where a vendor states whose the file is and its license.
// ceiling: reads leading comments only, not CMSIS-SVD's `licenseText`; upgrade when a vendor's
// file states its license there.
fn notice(svd_xml: &str) -> Vec<String> {
    let mut rest = svd_xml.trim_start_matches('\u{feff}');
    let mut lines = Vec::new();
    loop {
        rest = rest.trim_start();
        let (inside, tail) = if let Some(after) = rest.strip_prefix("<?") {
            match after.split_once("?>") {
                Some((_, tail)) => ("", tail),
                None => break,
            }
        } else if let Some(after) = rest.strip_prefix("<!--") {
            match after.split_once("-->") {
                Some(split) => split,
                None => break,
            }
        } else {
            break;
        };
        lines.extend(
            inside
                .lines()
                .map(str::trim)
                .filter(|line| {
                    line.to_ascii_lowercase().starts_with("copyright")
                        || line.starts_with("SPDX-License-Identifier:")
                })
                .map(|line| line.chars().filter(|c| !c.is_control()).collect()),
        );
        rest = tail;
    }
    lines
}

/// Regenerates an SoC's tables from the one SVD file in its directory.
///
/// Writes `generated.rs` and `generated/` beside the SVD in `soc_directory`, replacing what is there.
pub fn write_module(soc_directory: &Path) -> Result<(), Error> {
    let svd_path = svd_file_in(soc_directory)?;
    let svd_xml = fs::read_to_string(&svd_path).map_err(|source| Error::Io {
        path: svd_path.clone(),
        source,
    })?;
    let source_name = svd_path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or_default();
    let files = generate(&svd_xml, source_name)?;
    let tables = soc_directory.join("generated");
    if tables.exists() {
        fs::remove_dir_all(&tables).map_err(|source| Error::Io {
            path: tables.clone(),
            source,
        })?;
    }
    fs::create_dir_all(&tables).map_err(|source| Error::Io {
        path: tables.clone(),
        source,
    })?;
    for (relative, contents) in &files {
        let path = soc_directory.join(relative);
        fs::write(&path, contents).map_err(|source| Error::Io { path, source })?;
    }
    Ok(())
}

fn svd_file_in(directory: &Path) -> Result<PathBuf, Error> {
    let entries = fs::read_dir(directory).map_err(|source| Error::Io {
        path: directory.to_path_buf(),
        source,
    })?;
    let mut svd_files = Vec::new();
    for entry in entries {
        let path = entry
            .map_err(|source| Error::Io {
                path: directory.to_path_buf(),
                source,
            })?
            .path();
        if path
            .extension()
            .is_some_and(|extension| extension.eq_ignore_ascii_case("svd"))
        {
            svd_files.push(path);
        }
    }
    svd_files.sort();
    match svd_files.as_slice() {
        [only] => Ok(only.clone()),
        found => Err(Error::SvdCount {
            directory: directory.to_path_buf(),
            found: found.len(),
        }),
    }
}