master
Colby Knox Merge PR #307: kicad-lane: 1.0.64, live adds through KiCad's protobuf API, #126/#129, 17 standard core verbs a8fbf9f 14d ago
//! Library housekeeping the EDA Bridge Standard names in its core tier: rename a footprint's
//! text (for copy, rename and cross-library copy of `.kicad_mod` files), clone and rename a symbol
//! in a `.kicad_sym`, set a symbol's parameters and their visibility, and read a symbol's drawing.
//! Every edit goes through the lossless document model, so the rest of the file stays
//! byte-identical, and the result is checked like the other library writers.

use std::collections::BTreeMap;

use serde_json::{json, Map, Value};

use crate::schematic::{parse_node, pretty, quote, Document, Node};

fn symbols(doc: &Document) -> Vec<(usize, String)> {
    doc.root.children().iter().enumerate().filter(|(_, c)| c.head() == Some("symbol")).filter_map(|(i, c)| c.arg(0).map(|n| (i, n))).collect()
}

fn library(text: &str) -> Result<Document, String> {
    let doc = Document::parse(text)?;
    if doc.root.head() != Some("kicad_symbol_lib") {
        return Err("not a symbol library (the top-level list is not kicad_symbol_lib)".into());
    }
    Ok(doc)
}

fn find(doc: &Document, name: &str) -> Result<usize, String> {
    let syms = symbols(doc);
    syms.iter().find(|(_, n)| n == name).map(|(i, _)| *i).ok_or_else(|| {
        let names: Vec<&str> = syms.iter().map(|(_, n)| n.as_str()).take(40).collect();
        format!("no symbol {name} in the library (it has: {})", names.join(", "))
    })
}

fn checked(doc: &Document, expect: &[&str]) -> Result<String, String> {
    let out = doc.to_text();
    crate::lint::check_symbol_lib_structure(&out)?;
    let listed = crate::libraries::list_symbols_in_text(&out);
    for n in expect {
        if !listed.iter().any(|l| l == n) {
            return Err(format!("{n} does not read back from the edited library"));
        }
    }
    Ok(out)
}

/// A `.kicad_mod`'s text with its footprint named `to` (the name after `(footprint`).
pub fn rename_footprint_text(text: &str, to: &str) -> Result<String, String> {
    let mut doc = Document::parse(text)?;
    if doc.root.head() != Some("footprint") {
        return Err("not a footprint file (the top-level list is not footprint)".into());
    }
    doc.root.set_arg(0, Node::atom(quote(to)));
    Ok(doc.to_text())
}

/// Rename a symbol node and its unit sub-symbols (`<name>_<unit>_<style>`).
fn rename_node(n: &mut Node, from: &str, to: &str) {
    n.set_arg(0, Node::atom(quote(to)));
    if let Some(kids) = n.children_mut() {
        for sub in kids.iter_mut().filter(|c| c.head() == Some("symbol")) {
            if let Some(s) = sub.arg(0) {
                if let Some(rest) = s.strip_prefix(&format!("{from}_")) {
                    sub.set_arg(0, Node::atom(quote(&format!("{to}_{rest}"))));
                }
            }
        }
    }
}

/// A copy of symbol `from` named `to`, every unit and body style included, placed after it.
pub fn clone_symbol(text: &str, from: &str, to: &str) -> Result<String, String> {
    let mut doc = library(text)?;
    let i = find(&doc, from)?;
    if symbols(&doc).iter().any(|(_, n)| n == to) {
        return Err(format!("symbol {to} already exists in the library"));
    }
    let kids = doc.root.children_mut().ok_or("empty library")?;
    let mut copy = kids[i].clone();
    rename_node(&mut copy, from, to);
    kids.insert(i + 1, copy);
    checked(&doc, &[from, to])
}

/// Rename symbol `from` to `to`, and every symbol that extends it with it.
pub fn rename_symbol(text: &str, from: &str, to: &str) -> Result<(String, Vec<String>), String> {
    let mut doc = library(text)?;
    let i = find(&doc, from)?;
    if symbols(&doc).iter().any(|(_, n)| n == to) {
        return Err(format!("symbol {to} already exists in the library"));
    }
    let kids = doc.root.children_mut().ok_or("empty library")?;
    rename_node(&mut kids[i], from, to);
    let mut children = Vec::new();
    for k in kids.iter_mut().filter(|c| c.head() == Some("symbol")) {
        if k.child_value("extends").as_deref() == Some(from) {
            if let Some(e) = k.child_mut("extends") {
                e.set_arg(0, Node::atom(quote(to)));
            }
            children.extend(k.arg(0));
        }
    }
    Ok((checked(&doc, &[to])?, children))
}

/// A new hidden property block, KiCad 10's layout.
fn new_property(name: &str, value: &str) -> Result<Node, String> {
    let mut n = parse_node(&format!(
        "(property {} {} (at 0 0 0) (show_name no) (do_not_autoplace no) (hide yes) (effects (font (size 1.27 1.27))))",
        quote(name),
        quote(value)
    ))?;
    pretty(&mut n, 2);
    Ok(n)
}

/// Set (or add, hidden) properties on symbol `name`. Returns the text and per property what
/// happened: "set", "added" or "unchanged".
pub fn set_params(text: &str, name: &str, params: &BTreeMap<String, String>) -> Result<(String, Map<String, Value>), String> {
    let mut doc = library(text)?;
    let i = find(&doc, name)?;
    let sym = &mut doc.root.children_mut().ok_or("empty library")?[i];
    let mut what = Map::new();
    for (k, v) in params {
        let kids = sym.children_mut().ok_or("empty symbol")?;
        match kids.iter_mut().find(|c| c.head() == Some("property") && c.arg(0).as_deref() == Some(k.as_str())) {
            Some(p) => {
                let same = p.arg(1).as_deref() == Some(v.as_str());
                if !same {
                    p.set_arg(1, Node::atom(quote(v)));
                }
                what.insert(k.clone(), json!(if same { "unchanged" } else { "set" }));
            }
            None => {
                let mut node = new_property(k, v)?;
                let at = kids.iter().rposition(|c| c.head() == Some("property")).map(|j| j + 1).unwrap_or(1);
                node.lead = kids.get(at.min(kids.len() - 1)).map(|c| c.lead.clone()).unwrap_or_else(|| "\n\t\t".into());
                kids.insert(at, node);
                what.insert(k.clone(), json!("added"));
            }
        }
    }
    Ok((checked(&doc, &[name])?, what))
}

/// Show or hide property `param` of symbol `name`. Returns the text and whether it changed.
pub fn set_visibility(text: &str, name: &str, param: &str, visible: bool) -> Result<(String, bool), String> {
    let mut doc = library(text)?;
    let i = find(&doc, name)?;
    let sym = &mut doc.root.children_mut().ok_or("empty library")?[i];
    let kids = sym.children_mut().ok_or("empty symbol")?;
    let p = kids.iter_mut().find(|c| c.head() == Some("property") && c.arg(0).as_deref() == Some(param)).ok_or_else(|| format!("{name} has no property {param}"))?;
    // KiCad 10 keeps (hide yes) on the property; older files keep it inside (effects ...)
    let hidden_now = p.child_value("hide").as_deref() == Some("yes") || p.child("effects").map(|e| e.children().iter().any(|c| c.raw() == Some("hide") || (c.head() == Some("hide") && c.arg(0).as_deref() != Some("no")))).unwrap_or(false);
    if hidden_now != visible {
        return Ok((text.to_string(), false));
    }
    let pk = p.children_mut().ok_or("empty property")?;
    pk.retain(|c| c.head() != Some("hide"));
    if let Some(e) = pk.iter_mut().find(|c| c.head() == Some("effects")) {
        if let Some(ek) = e.children_mut() {
            ek.retain(|c| c.raw() != Some("hide") && c.head() != Some("hide"));
        }
    }
    if !visible {
        let at = pk.iter().position(|c| c.head() == Some("effects")).unwrap_or(pk.len());
        let mut h = parse_node("(hide yes)")?;
        h.lead = pk.get(at).map(|c| c.lead.clone()).unwrap_or_else(|| "\n\t\t\t".into());
        pk.insert(at, h);
    }
    Ok((checked(&doc, &[name])?, true))
}

fn bbox(points: &[(f64, f64)]) -> Option<[f64; 4]> {
    let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
    for (x, y) in points {
        x0 = x0.min(*x);
        y0 = y0.min(*y);
        x1 = x1.max(*x);
        y1 = y1.max(*y);
    }
    (x0 <= x1).then_some([x0, y0, x1, y1])
}

/// A symbol's drawing: primitives counted by kind, the body rectangle, the polylines' bounding box,
/// units and body styles, and the footprint link and filters. Read-only.
pub fn symbol_prims(text: &str, name: &str) -> Result<Value, String> {
    let doc = library(text)?;
    find(&doc, name)?;
    let flat = crate::schematic::flatten_lib_symbol(text, name)?;
    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
    let mut rects: Vec<[f64; 4]> = Vec::new();
    let mut poly_pts: Vec<(f64, f64)> = Vec::new();
    let (mut units, mut styles) = (std::collections::BTreeSet::new(), std::collections::BTreeSet::new());
    for sub in flat.children().iter().filter(|c| c.head() == Some("symbol")) {
        let sname = sub.arg(0).unwrap_or_default();
        let mut it = sname.rsplitn(3, '_');
        styles.insert(it.next().and_then(|s| s.parse::<u32>().ok()).unwrap_or(1));
        let unit = it.next().and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
        if unit > 0 {
            units.insert(unit);
        }
        for p in sub.children().iter().skip(1) {
            let Some(h) = p.head() else { continue };
            *counts.entry(h.to_string()).or_default() += 1;
            match h {
                "rectangle" => {
                    if let (Some(s), Some(e)) = (p.child("start"), p.child("end")) {
                        if let (Some(a), Some(b), Some(c), Some(d)) = (s.num(0), s.num(1), e.num(0), e.num(1)) {
                            rects.push([a.min(c), b.min(d), a.max(c), b.max(d)]);
                        }
                    }
                }
                "polyline" => {
                    if let Some(pts) = p.child("pts") {
                        poly_pts.extend(pts.children().iter().filter(|c| c.head() == Some("xy")).filter_map(|c| Some((c.num(0)?, c.num(1)?))));
                    }
                }
                _ => {}
            }
        }
    }
    let body = rects.iter().copied().max_by(|a, b| ((a[2] - a[0]) * (a[3] - a[1])).total_cmp(&((b[2] - b[0]) * (b[3] - b[1]))));
    let prop = |k: &str| flat.children().iter().find(|c| c.head() == Some("property") && c.arg(0).as_deref() == Some(k)).and_then(|c| c.arg(1));
    let filters: Vec<String> = flat.child("fp_filters").map(|f| f.children().iter().skip(1).filter_map(|c| c.value()).collect()).unwrap_or_default();
    Ok(json!({
        "symbol": name,
        "counts": counts,
        "pins": counts.get("pin").copied().unwrap_or(0),
        "bodyRectangle": body,
        "polylineBox": bbox(&poly_pts),
        "units": units.len().max(1),
        "bodyStyles": styles.len().max(1),
        "footprint": prop("Footprint"),
        "footprintFilters": filters,
        "extends": doc.root.children().iter().find(|c| c.head() == Some("symbol") && c.arg(0).as_deref() == Some(name)).and_then(|c| c.child_value("extends")),
    }))
}

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

    fn lib() -> String {
        std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sch-edit/MiniLib.kicad_sym")).unwrap()
    }

    #[test]
    fn clone_and_rename_keep_units_and_the_rest_of_the_file() {
        let t = lib();
        let first = crate::libraries::list_symbols_in_text(&t)[0].clone();
        let c = clone_symbol(&t, &first, "R_copy").unwrap();
        assert!(c.contains("(symbol \"R_copy\"") && c.contains(&format!("(symbol \"{first}\"")));
        assert!(c.contains("\"R_copy_0_1\"") || c.contains("\"R_copy_1_1\""), "unit sub-symbols renamed");
        assert!(clone_symbol(&c, &first, "R_copy").is_err(), "an existing name is refused");
        let (r, _) = rename_symbol(&c, "R_copy", "R_new").unwrap();
        assert!(!r.contains("\"R_copy") && r.contains("(symbol \"R_new\""));
        assert_eq!(r, c.replace("\"R_copy", "\"R_new"), "only the names changed");
    }

    #[test]
    fn params_set_add_and_hide() {
        let t = lib();
        let first = crate::libraries::list_symbols_in_text(&t)[0].clone();
        let mut p = BTreeMap::new();
        p.insert("Footprint".to_string(), "Resistor_SMD:R_0603_1608Metric".to_string());
        p.insert("MPN".to_string(), "RC0603FR-0710KL".to_string());
        let (t2, what) = set_params(&t, &first, &p).unwrap();
        assert_eq!(what["Footprint"], "set");
        assert_eq!(what["MPN"], "added");
        let info = crate::symedit::symbol_info(&t2, &first).unwrap();
        assert_eq!(info["fields"]["MPN"], "RC0603FR-0710KL");
        let (t3, changed) = set_visibility(&t2, &first, "MPN", true).unwrap();
        assert!(changed);
        let (_, again) = set_visibility(&t3, &first, "MPN", true).unwrap();
        assert!(!again, "already shown");
        let (t4, _) = set_visibility(&t3, &first, "MPN", false).unwrap();
        assert!(t4.contains("(property \"MPN\""));
        let prims = symbol_prims(&t4, &first).unwrap();
        assert!(prims["pins"].as_u64().unwrap() >= 2, "{prims}");
    }

    #[test]
    fn a_footprint_file_takes_a_new_name() {
        let t = "(footprint \"Old\"\n\t(version 20241229)\n\t(layer \"F.Cu\")\n)\n";
        let r = rename_footprint_text(t, "New name").unwrap();
        assert!(r.starts_with("(footprint \"New name\"\n\t(version"));
    }
}