cadmpeg-step 0.1.4

Serialize cadmpeg IR documents as ISO 10303-21 STEP AP214 files.
Documentation
// SPDX-License-Identifier: Apache-2.0
//! Encodes Part 21 DATA instances.
//!
//! The emitter allocates instance names, formats scalar values, counts entity
//! types, and interns repeated points and directions.

use std::collections::BTreeMap;
use std::collections::HashMap;

/// A STEP instance name such as `#42`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Ref(pub u64);

impl std::fmt::Display for Ref {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "#{}", self.0)
    }
}

/// Accumulates DATA instances in allocation order and counts their entity types.
pub struct Emitter {
    next: u64,
    lines: Vec<String>,
    counts: BTreeMap<String, usize>,
    /// Leaf instances keyed by encoded type and parameters.
    interned: HashMap<String, Ref>,
}

impl Emitter {
    pub fn new() -> Self {
        Emitter {
            next: 1,
            lines: Vec::new(),
            counts: BTreeMap::new(),
            interned: HashMap::new(),
        }
    }

    /// Append `#id = TYPE(params);` and return the allocated reference.
    ///
    /// `type_` is also the entity-count key. Complex instances use their leading
    /// keyword as the key.
    pub fn emit(&mut self, type_: &str, params: &str) -> Ref {
        let id = self.next;
        self.next += 1;
        self.lines.push(format!("#{id} = {type_}({params});"));
        *self.counts.entry(type_.to_string()).or_insert(0) += 1;
        Ref(id)
    }

    /// Append a preformatted entity or complex-instance body.
    ///
    /// `tally` supplies its entity-count key.
    pub fn emit_raw(&mut self, tally: &str, body: &str) -> Ref {
        let id = self.next;
        self.next += 1;
        self.lines.push(format!("#{id} = {body};"));
        *self.counts.entry(tally.to_string()).or_insert(0) += 1;
        Ref(id)
    }

    /// Emit a value-like leaf or reuse an identical encoded instance.
    pub fn emit_interned(&mut self, type_: &str, params: &str) -> Ref {
        let key = format!("{type_}|{params}");
        if let Some(r) = self.interned.get(&key) {
            return *r;
        }
        let r = self.emit(type_, params);
        self.interned.insert(key, r);
        r
    }

    pub fn counts(&self) -> &BTreeMap<String, usize> {
        &self.counts
    }

    pub fn total(&self) -> usize {
        self.lines.len()
    }

    /// Consume the emitter and return one encoded DATA instance per element.
    pub fn into_lines(self) -> Vec<String> {
        self.lines
    }
}

/// Format an `f64` as a Part 21 real literal.
///
/// The result always contains a decimal point, including scientific notation.
/// Non-finite inputs become `0.`.
pub fn real(v: f64) -> String {
    if !v.is_finite() {
        return "0.".to_string();
    }
    // Shortest round-tripping decimal, then normalize to Part 21 lexical rules.
    let mut s = format!("{v}");
    if let Some(e_pos) = s.find(['e', 'E']) {
        // Scientific: ensure the mantissa has a decimal point and use uppercase E.
        let (mantissa, exp) = s.split_at(e_pos);
        let exp = &exp[1..];
        let mantissa = if mantissa.contains('.') {
            mantissa.to_string()
        } else {
            format!("{mantissa}.")
        };
        let exp = if let Some(rest) = exp.strip_prefix('-') {
            format!("-{rest}")
        } else {
            exp.strip_prefix('+').unwrap_or(exp).to_string()
        };
        return format!("{mantissa}E{exp}");
    }
    if !s.contains('.') {
        s.push('.');
    }
    s
}

/// Encode a Rust string as a Part 21 single-quoted string literal.
///
/// Apostrophes are doubled. Non-ASCII and control characters use
/// `\X2\..\X0\` UTF-16 hexadecimal notation, keeping the encoded file 7-bit.
pub fn string(s: &str) -> String {
    use std::fmt::Write as _;

    let mut out = String::with_capacity(s.len() + 2);
    out.push('\'');
    for ch in s.chars() {
        if ch == '\'' {
            out.push_str("''");
        } else if ch.is_ascii() && !ch.is_ascii_control() {
            out.push(ch);
        } else {
            // Extended encoding: 4 hex digits per UTF-16 code unit, wrapped in
            // a single \X2\...\X0\ run.
            out.push_str("\\X2\\");
            let mut buf = [0u16; 2];
            for cu in ch.encode_utf16(&mut buf) {
                let _ = write!(out, "{cu:04X}");
            }
            out.push_str("\\X0\\");
        }
    }
    out.push('\'');
    out
}

/// Join instance references into a Part 21 aggregate such as `(#1,#2,#3)`.
pub fn refs(items: &[Ref]) -> String {
    let mut out = String::from("(");
    for (i, r) in items.iter().enumerate() {
        if i > 0 {
            out.push(',');
        }
        out.push_str(&r.to_string());
    }
    out.push(')');
    out
}