master
Colby Knox Merge PR #358: kicad-lane: 1.0.91, cleanup_libs, build_symbol_modes, add_symbol_art (#29) e1fa2ce 11d ago
//! The standard's cleanup_libs for KiCad: a library's parts that no given project uses, and parts that repeat
//! another part under a different name. A plan by default; removing takes the names to remove.
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;

use serde_json::{json, Value};

use crate::schematic::Document;

/// Every `Lib:Name` a project's schematic sheets place (`lib_id`) and its boards use (`footprint`), from the files in
/// the project's folder.
pub fn project_uses(project_dir: &Path) -> BTreeSet<String> {
    let mut out = BTreeSet::new();
    for e in std::fs::read_dir(project_dir).into_iter().flatten().flatten() {
        let p = e.path();
        let ext = p.extension().map(|x| x.to_string_lossy().into_owned()).unwrap_or_default();
        let key = match ext.as_str() { "kicad_sch" => "(lib_id \"", "kicad_pcb" => "(footprint \"", _ => continue };
        let Ok(t) = std::fs::read_to_string(&p) else { continue };
        let mut rest = t.as_str();
        while let Some(i) = rest.find(key) {
            let after = &rest[i + key.len()..];
            if let Some(q) = after.find('"') {
                out.insert(after[..q].to_string());
            }
            rest = &after[after.find('"').unwrap_or(0) + 1..];
        }
    }
    out
}

/// A symbol's text with its own name taken out, so two symbols that differ only in name compare equal.
fn symbol_shape(lib_text: &str, name: &str) -> Option<String> {
    let flat = crate::schematic::flatten_lib_symbol(lib_text, name).ok()?;
    let mut t = flat.to_text();
    for n in [format!("\"{name}\""), format!("\"{name}_")] {
        t = t.replace(&n, "\"@_");
    }
    // the Value field usually repeats the name
    Some(t.split_whitespace().collect::<Vec<_>>().join(" "))
}

/// A footprint's text without its name, uuids and timestamps.
fn footprint_shape(text: &str, name: &str) -> String {
    let t = text.replacen(&format!("(footprint \"{name}\""), "(footprint \"@\"", 1).replace(&format!("\"{name}\""), "\"@\"");
    t.lines().filter(|l| !l.trim_start().starts_with("(uuid") && !l.trim_start().starts_with("(tedit") && !l.trim_start().starts_with("(generator")).map(str::trim).collect::<Vec<_>>().join(" ")
}

/// The plan for one symbol library: `uses` are the projects' `Lib:Name` ids (None: no project given, so nothing can be
/// called unused), `nick` the library's nickname in those ids.
pub fn plan_symbols(lib_text: &str, nick: &str, uses: Option<&BTreeSet<String>>) -> Result<Value, String> {
    let doc = Document::parse(lib_text)?;
    let syms: Vec<(String, Option<String>)> = doc.root.children().iter().filter(|c| c.head() == Some("symbol"))
        .filter_map(|c| Some((c.arg(0)?, c.child_value("extends")))).collect();
    let extended: BTreeSet<&str> = syms.iter().filter_map(|(_, e)| e.as_deref()).collect();
    let unused: Vec<&str> = match uses {
        Some(u) => syms.iter().map(|(n, _)| n.as_str()).filter(|n| !u.contains(&format!("{nick}:{n}")) && !extended.contains(n)).collect(),
        None => Vec::new(),
    };
    Ok(json!({"kind": "symbols", "parts": syms.len(), "unused": unused, "duplicates": groups(syms.iter().filter_map(|(n, _)| symbol_shape(lib_text, n).map(|s| (n.clone(), s))))}))
}

/// The plan for one footprint library (`files`: name and .kicad_mod text).
pub fn plan_footprints(files: &[(String, String)], nick: &str, uses: Option<&BTreeSet<String>>) -> Value {
    let unused: Vec<&str> = match uses {
        Some(u) => files.iter().map(|(n, _)| n.as_str()).filter(|n| !u.contains(&format!("{nick}:{n}"))).collect(),
        None => Vec::new(),
    };
    json!({"kind": "footprints", "parts": files.len(), "unused": unused, "duplicates": groups(files.iter().map(|(n, t)| (n.clone(), footprint_shape(t, n))))})
}

/// Names whose shapes are identical, in groups of two or more (first name first).
fn groups(items: impl Iterator<Item = (String, String)>) -> Vec<Vec<String>> {
    let mut by: BTreeMap<String, Vec<String>> = BTreeMap::new();
    for (n, s) in items {
        by.entry(s).or_default().push(n);
    }
    let mut g: Vec<Vec<String>> = by.into_values().filter(|v| v.len() > 1).collect();
    g.sort();
    g
}

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

    fn sym(name: &str, body: &str) -> String {
        format!("\t(symbol \"{name}\"\n\t\t(property \"Reference\" \"R\"\n\t\t\t(at 0 0 0)\n\t\t)\n\t\t(property \"Value\" \"{name}\"\n\t\t\t(at 0 0 0)\n\t\t)\n\t\t(symbol \"{name}_0_1\"\n\t\t\t{body}\n\t\t)\n\t)\n")
    }

    #[test]
    fn unused_and_duplicate_symbols_are_found_and_extended_ones_kept() {
        let rect = "(rectangle (start -1 1) (end 1 -1))";
        let lib = format!("(kicad_symbol_lib\n\t(version 20241209)\n{}{}{}\t(symbol \"R_alias\"\n\t\t(extends \"R_base\")\n\t)\n)\n", sym("R_base", rect), sym("R_copy", rect), sym("C_other", "(circle (center 0 0) (radius 1))"));
        let uses: BTreeSet<String> = ["Mine:R_alias".to_string()].into();
        let p = plan_symbols(&lib, "Mine", Some(&uses)).unwrap();
        assert_eq!(p["unused"], json!(["R_copy", "C_other"]), "{p}");
        assert_eq!(p["duplicates"], json!([["R_base", "R_copy"]]));
        let none = plan_symbols(&lib, "Mine", None).unwrap();
        assert_eq!(none["unused"], json!([]));
    }

    #[test]
    fn duplicate_footprints_ignore_names_and_uuids() {
        let fp = |n: &str, u: &str| format!("(footprint \"{n}\"\n\t(uuid \"{u}\")\n\t(pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\"))\n)\n");
        let files = vec![("A".to_string(), fp("A", "1")), ("B".to_string(), fp("B", "2")), ("C".to_string(), fp("C", "3").replace("(size 1 1)", "(size 2 2)"))];
        let uses: BTreeSet<String> = ["Lib:A".to_string()].into();
        let p = plan_footprints(&files, "Lib", Some(&uses));
        assert_eq!(p["duplicates"], json!([["A", "B"]]));
        assert_eq!(p["unused"], json!(["B", "C"]));
    }
}