← Commit history

2.17.0: assemble verb (multi-deviceset .lbr assembler for Cloud Libraries)

Colby Knox ·6926bb94b8 ·1mo ago ·parent 34f1818
3 files changed +329−2
VERSION+1−1
@@ -1 +1 @@-2.16.0+2.17.0
src/assemble.rsadded+309
@@ -0,0 +1,309 @@+//! 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());+}
src/main.rs+19−1
@@ -1,3 +1,4 @@+mod assemble; mod deliver; mod server; mod manage;@@ -95,6 +96,20 @@ enum Commands {         /// Path to the .lbr file         path: PathBuf,     },+    /// Assemble many single-part .lbr files into ONE multi-deviceset library.+    /// Packages and symbols dedupe by content, name collisions rename with a+    /// numeric suffix, lint-failing inputs are refused, and the assembled+    /// document must itself pass lint. Prints a JSON summary.+    Assemble {+        /// Output .lbr path+        #[arg(long, short = 'o')]+        out: PathBuf,+        /// Library description (default: Adom Cloud Library)+        #[arg(long)]+        name: Option<String>,+        /// Input part .lbr files+        inputs: Vec<PathBuf>,+    },     /// Validate a .lbr file has complete deviceset (symbol + package + connects)     Validate {         /// Path to the .lbr file@@ -603,7 +618,7 @@ fn generate_kicad_sym(sym: &ParsedSymbol) -> String {  // ── EAGLE .lbr generator ── -fn esc_xml(s: &str) -> String {+pub(crate) fn esc_xml(s: &str) -> String {     s.replace('&', "&amp;")         .replace('<', "&lt;")         .replace('>', "&gt;")@@ -1917,6 +1932,9 @@ fn main() {             }         } +        Commands::Assemble { out, name, inputs } => {+            assemble::run(&out, name.as_deref(), &inputs);+        }         Commands::Validate { path } => {             let content = fs::read_to_string(&path).unwrap_or_else(|e| {                 eprintln!("ERROR: Cannot read {}: {}", path.display(), e);