master
Colby Knox 2.17.0: assemble verb (multi-deviceset .lbr assembler for Cloud Libraries) 6926bb9 1mo ago
//! Multi-deviceset .lbr assembler (Cloud Libraries phase 2).
//!
//! `adom-lbr assemble -o lib.lbr <part.lbr...>` merges the <packages>,
//! <symbols>, and <devicesets> of many single-part libraries into one
//! document: packages and symbols dedupe by CONTENT (identical bytes modulo
//! the name attribute collapse to one entry, references retargeted), name
//! collisions across different content rename with a numeric suffix, and any
//! input that fails lint is refused up front. The assembled document must
//! itself pass lint or the command fails.

use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process;

use crate::{lint_lbr, LintResult};

/// One named entry (a <package>, <symbol>, or <deviceset> block).
struct Entry {
    name: String,
    xml: String,
}

/// Inner text of `<tag>...</tag>` (first opening, last closing).
fn section_inner<'a>(content: &'a str, tag: &str) -> Option<&'a str> {
    let open = format!("<{}>", tag);
    let close = format!("</{}>", tag);
    let start = content.find(&open)? + open.len();
    let end = content.rfind(&close)?;
    if end < start {
        return None;
    }
    Some(&content[start..end])
}

/// Split a section into its top-level named entries. The EAGLE library
/// sections never nest an entry inside another entry of the same tag, so
/// scanning open/close pairs is sound.
fn split_entries(section: &str, tag: &str) -> Vec<Entry> {
    let mut out = Vec::new();
    let open_a = format!("<{} ", tag);
    let open_b = format!("<{}>", tag);
    let close = format!("</{}>", tag);
    let mut pos = 0;
    while pos < section.len() {
        let a = section[pos..].find(&open_a).map(|i| i + pos);
        let b = section[pos..].find(&open_b).map(|i| i + pos);
        let start = match (a, b) {
            (Some(x), Some(y)) => x.min(y),
            (Some(x), None) => x,
            (None, Some(y)) => y,
            (None, None) => break,
        };
        let end = match section[start..].find(&close) {
            Some(i) => start + i + close.len(),
            None => break,
        };
        let xml = section[start..end].to_string();
        let name = attr_value(&xml, "name").unwrap_or_default();
        out.push(Entry { name, xml });
        pos = end;
    }
    out
}

/// First `attr="value"` in the first tag of a block.
fn attr_value(xml: &str, attr: &str) -> Option<String> {
    let tag_end = xml.find('>')?;
    let head = &xml[..tag_end];
    let needle = format!("{}=\"", attr);
    let i = head.find(&needle)? + needle.len();
    let j = head[i..].find('"')? + i;
    Some(head[i..j].to_string())
}

/// Content key for dedup: the block with its name attribute and all
/// whitespace runs normalized, so formatting differences do not defeat it.
fn content_key(xml: &str, name: &str) -> String {
    let named = format!("name=\"{}\"", name);
    let stripped = xml.replacen(&named, "name=\"\"", 1);
    stripped.split_whitespace().collect::<Vec<_>>().join(" ")
}

/// Replace `attr="old"` with `attr="new"` everywhere in a block.
fn rewrite_attr(xml: &str, attr: &str, old: &str, new: &str) -> String {
    xml.replace(
        &format!("{}=\"{}\"", attr, old),
        &format!("{}=\"{}\"", attr, new),
    )
}

/// Self-closing <layer .../> lines from a document, keyed by layer number.
fn collect_layers(content: &str, seen: &mut HashMap<String, ()>, out: &mut Vec<String>) {
    let mut pos = 0;
    while let Some(i) = content[pos..].find("<layer ") {
        let start = pos + i;
        let end = match content[start..].find("/>") {
            Some(j) => start + j + 2,
            None => break,
        };
        let xml = &content[start..end];
        if let Some(num) = attr_value(xml, "number") {
            if seen.insert(num, ()).is_none() {
                out.push(xml.to_string());
            }
        }
        pos = end;
    }
}

struct Pool {
    entries: Vec<Entry>,
    by_key: HashMap<String, String>, // content key -> canonical name
    names: HashMap<String, ()>,      // taken names
}

impl Pool {
    fn new() -> Self {
        Pool { entries: Vec::new(), by_key: HashMap::new(), names: HashMap::new() }
    }
    /// Add an entry, deduping by content and resolving name collisions.
    /// Returns the name the entry ended up under, and whether it was deduped.
    fn add(&mut self, e: Entry, dedup_by_content: bool) -> (String, bool) {
        let key = content_key(&e.xml, &e.name);
        if dedup_by_content {
            if let Some(existing) = self.by_key.get(&key) {
                return (existing.clone(), true);
            }
        }
        let mut name = e.name.clone();
        let mut n = 2;
        while self.names.contains_key(&name) {
            name = format!("{}_{}", e.name, n);
            n += 1;
        }
        let xml = if name != e.name {
            e.xml.replacen(
                &format!("name=\"{}\"", e.name),
                &format!("name=\"{}\"", name),
                1,
            )
        } else {
            e.xml
        };
        self.names.insert(name.clone(), ());
        self.by_key.insert(key, name.clone());
        self.entries.push(Entry { name: name.clone(), xml });
        (name, false)
    }
}

pub fn run(out: &Path, name: Option<&str>, inputs: &[PathBuf]) {
    if inputs.is_empty() {
        eprintln!("ERROR: No input .lbr files given");
        eprintln!("Hint: adom-lbr assemble -o lib.lbr part1.lbr part2.lbr ...");
        process::exit(1);
    }

    let mut packages = Pool::new();
    let mut symbols = Pool::new();
    let mut devicesets = Pool::new();
    let mut layer_seen: HashMap<String, ()> = HashMap::new();
    let mut layers: Vec<String> = Vec::new();
    let mut renamed: Vec<serde_json::Value> = Vec::new();
    let mut packages_deduped = 0usize;
    let mut symbols_deduped = 0usize;

    for input in inputs {
        let content = fs::read_to_string(input).unwrap_or_else(|e| {
            eprintln!("ERROR: Cannot read {}: {}", input.display(), e);
            process::exit(1);
        });
        // Refuse lint-failing inputs: the assembled library only ships clean.
        let lint = lint_lbr(&content);
        if !lint.passed {
            eprintln!(
                "ERROR: {} fails lint ({}); refusing to assemble it",
                input.display(),
                lint.errors
                    .first()
                    .map(|e| e.code.clone())
                    .unwrap_or_else(|| "error".into())
            );
            for err in &lint.errors {
                eprintln!("  [{}] {}", err.code, err.message);
            }
            process::exit(2);
        }

        collect_layers(&content, &mut layer_seen, &mut layers);

        let pkg_entries = section_inner(&content, "packages")
            .map(|s| split_entries(s, "package"))
            .unwrap_or_default();
        let sym_entries = section_inner(&content, "symbols")
            .map(|s| split_entries(s, "symbol"))
            .unwrap_or_default();
        let ds_entries = section_inner(&content, "devicesets")
            .map(|s| split_entries(s, "deviceset"))
            .unwrap_or_default();

        // Per-input rename maps, applied to this input's devicesets only
        // (each part file is self-contained).
        let mut pkg_map: HashMap<String, String> = HashMap::new();
        let mut sym_map: HashMap<String, String> = HashMap::new();

        for e in pkg_entries {
            let old = e.name.clone();
            let (new_name, deduped) = packages.add(e, true);
            if deduped {
                packages_deduped += 1;
            }
            if new_name != old {
                if !deduped {
                    renamed.push(serde_json::json!({"kind": "package", "from": old, "to": new_name, "file": input.display().to_string()}));
                }
                pkg_map.insert(old, new_name);
            }
        }
        for e in sym_entries {
            let old = e.name.clone();
            let (new_name, deduped) = symbols.add(e, true);
            if deduped {
                symbols_deduped += 1;
            }
            if new_name != old {
                if !deduped {
                    renamed.push(serde_json::json!({"kind": "symbol", "from": old, "to": new_name, "file": input.display().to_string()}));
                }
                sym_map.insert(old, new_name);
            }
        }
        for e in ds_entries {
            let mut xml = e.xml;
            for (old, new) in &sym_map {
                xml = rewrite_attr(&xml, "symbol", old, new);
            }
            for (old, new) in &pkg_map {
                xml = rewrite_attr(&xml, "package", old, new);
            }
            let old = e.name.clone();
            let (new_name, _) = devicesets.add(Entry { name: e.name, xml }, false);
            if new_name != old {
                renamed.push(serde_json::json!({"kind": "deviceset", "from": old, "to": new_name, "file": input.display().to_string()}));
            }
        }
    }

    let description = name.unwrap_or("Adom Cloud Library");
    let mut xml = String::with_capacity(64 * 1024);
    xml.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
    xml.push_str("<!DOCTYPE eagle SYSTEM \"eagle.dtd\">\n");
    xml.push_str("<eagle version=\"9.6.2\">\n<drawing>\n");
    xml.push_str("<settings><setting alwaysvectorfont=\"no\"/></settings>\n");
    xml.push_str("<grid distance=\"0.1\" unitdist=\"inch\" unit=\"inch\" style=\"lines\" multiple=\"1\" display=\"no\" altdistance=\"0.01\" altunitdist=\"inch\" altunit=\"inch\"/>\n");
    xml.push_str("<layers>\n");
    for l in &layers {
        xml.push_str(l);
        xml.push('\n');
    }
    xml.push_str("</layers>\n");
    xml.push_str("<library>\n");
    xml.push_str(&format!("<description>{}</description>\n", crate::esc_xml(description)));
    xml.push_str("<packages>\n");
    for e in &packages.entries {
        xml.push_str(&e.xml);
        xml.push('\n');
    }
    xml.push_str("</packages>\n<symbols>\n");
    for e in &symbols.entries {
        xml.push_str(&e.xml);
        xml.push('\n');
    }
    xml.push_str("</symbols>\n<devicesets>\n");
    for e in &devicesets.entries {
        xml.push_str(&e.xml);
        xml.push('\n');
    }
    xml.push_str("</devicesets>\n</library>\n</drawing>\n</eagle>\n");

    // The assembled document is held to the same gate as its inputs.
    let lint: LintResult = lint_lbr(&xml);
    if !lint.passed {
        eprintln!("ERROR: assembled library fails lint");
        for err in &lint.errors {
            eprintln!("  [{}] {}", err.code, err.message);
        }
        process::exit(2);
    }

    if let Err(e) = fs::write(out, &xml) {
        eprintln!("ERROR: Cannot write {}: {}", out.display(), e);
        process::exit(1);
    }

    let summary = serde_json::json!({
        "passed": true,
        "out": out.display().to_string(),
        "parts": inputs.len(),
        "packages": packages.entries.len(),
        "symbols": symbols.entries.len(),
        "devicesets": devicesets.entries.len(),
        "packages_deduped": packages_deduped,
        "symbols_deduped": symbols_deduped,
        "renamed": renamed,
        "lint_warnings": lint.warnings.len(),
    });
    println!("{}", serde_json::to_string_pretty(&summary).unwrap());
}