master
Colby Knox Merge PR #399: 1.0.100: update_pcb sets every reference (closes #138); the verbs ruled on standard #29 85f32a2 8d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640
//! Update PCB from Schematic, on the file: compare the schematic's netlist with the board
//! (missing, extra and changed footprints, changed values, pad nets) and, on a CLOSED
//! board, apply the plan: new footprints from the libraries in a row below the board,
//! values and links updated, pad nets assigned, extras marked (deleted only on request).
//!
//! Footprints are matched by reference, the way KiCad's "Re-link footprints to schematic
//! symbols based on their reference designators" option does; the symbol uuid becomes the
//! footprint's `(path ...)` so KiCad's own F8 matches them by uuid afterwards.

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

use serde_json::{json, Value};

use crate::libraries::{self, LibCtx, LibTable};
use crate::netlist::{Component, Netlist};
use crate::pcb::{self, Board, NetFormat, RoutingError};
use crate::placement;
use crate::route_tools::top_level_spans;
use crate::schematic::new_uuid;

type R<T> = Result<T, RoutingError>;

/// The marker property an extra footprint gets on apply.
pub const EXTRA_MARK: &str = "Adom_NotInSchematic";

fn q(s: &str) -> String {
    serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""))
}

/// `(path ...)` for a netlist component: its sheet path plus its symbol uuid.
pub fn symbol_path(c: &Component) -> Option<String> {
    if c.tstamp.is_empty() {
        return None;
    }
    let mut sheet = if c.sheet_tstamps.is_empty() { "/".to_string() } else { c.sheet_tstamps.clone() };
    if !sheet.ends_with('/') {
        sheet.push('/');
    }
    Some(format!("{sheet}{}", c.tstamp))
}

// ---------------------------------------------------------------------------
// Compare
// ---------------------------------------------------------------------------

fn footprint_spans(text: &str) -> Vec<(usize, usize)> {
    top_level_spans(text).into_iter().filter(|(s, _)| text[*s..].starts_with("(footprint")).collect()
}

/// The span of the board footprint with this reference.
pub fn footprint_span(text: &str, reference: &str) -> Option<(usize, usize)> {
    let needle = format!("(property \"Reference\" {}", q(reference));
    let legacy = format!("(fp_text reference {}", q(reference));
    footprint_spans(text).into_iter().find(|(s, e)| text[*s..*e].contains(&needle) || text[*s..*e].contains(&legacy))
}

fn board_only(text: &str, reference: &str) -> bool {
    footprint_span(text, reference).map(|(s, e)| text[s..e].lines().any(|l| l.trim_start().starts_with("(attr") && l.contains("board_only"))).unwrap_or(false)
}

/// The difference between the netlist and the board, as the plan reports it.
pub fn compare(board: &Board, text: &str, nl: &Netlist) -> Value {
    let on_board: HashMap<&str, &pcb::Footprint> = board.footprints.iter().map(|f| (f.reference.as_str(), f)).collect();
    let mut missing = Vec::new();
    let mut no_footprint = Vec::new();
    let mut values = Vec::new();
    let mut fps = Vec::new();
    let mut links = Vec::new();
    for c in &nl.components {
        match on_board.get(c.reference.as_str()) {
            None if c.footprint.is_empty() => no_footprint.push(json!(c.reference)),
            None => missing.push(json!({"ref": c.reference, "value": c.value, "footprint": c.footprint})),
            Some(f) => {
                if f.value != c.value {
                    values.push(json!({"ref": c.reference, "from": f.value, "to": c.value}));
                }
                if !c.footprint.is_empty() && f.footprint != c.footprint {
                    fps.push(json!({"ref": c.reference, "from": f.footprint, "to": c.footprint}));
                }
                if let Some(p) = symbol_path(c) {
                    if f.path.as_deref() != Some(p.as_str()) {
                        links.push(json!({"ref": c.reference, "from": f.path, "to": p}));
                    }
                }
            }
        }
    }
    let in_netlist: Vec<&str> = nl.components.iter().map(|c| c.reference.as_str()).collect();
    let mut extra = Vec::new();
    let mut kept = Vec::new();
    for f in &board.footprints {
        if in_netlist.contains(&f.reference.as_str()) {
            continue;
        }
        if board_only(text, &f.reference) {
            kept.push(json!(f.reference));
        } else {
            extra.push(json!({"ref": f.reference, "value": f.value, "footprint": f.footprint}));
        }
    }
    let pad_nets = pad_net_changes(board, nl);
    let total = missing.len() + extra.len() + values.len() + fps.len() + pad_nets.len() + links.len();
    json!({
        "inSync": total == 0, "changeCount": total,
        "missingFootprints": missing, "extraFootprints": extra, "boardOnlyFootprints": kept,
        "changedValues": values, "changedFootprints": fps, "padNetChanges": pad_nets, "relinks": links,
        "componentsWithoutFootprint": no_footprint,
        "counts": {"schematicComponents": nl.components.len(), "boardFootprints": board.footprints.len(), "schematicNets": nl.nets.len()},
    })
}

fn expected_nets(nl: &Netlist) -> HashMap<(String, String), String> {
    let mut m = HashMap::new();
    for n in &nl.nets {
        for node in &n.nodes {
            m.insert((node.reference.clone(), node.pin.clone()), n.name.clone());
        }
    }
    m
}

/// Every pad of a footprint the netlist knows whose net differs from the netlist's.
pub fn pad_net_changes(board: &Board, nl: &Netlist) -> Vec<Value> {
    let want = expected_nets(nl);
    let known: Vec<&str> = nl.components.iter().map(|c| c.reference.as_str()).collect();
    let mut out = Vec::new();
    for f in &board.footprints {
        if !known.contains(&f.reference.as_str()) {
            continue;
        }
        let mut seen = Vec::new();
        for p in &f.pads {
            if p.number.is_empty() || seen.contains(&p.number) {
                continue;
            }
            seen.push(p.number.clone());
            let have = p.net_name.clone().unwrap_or_default();
            let to = want.get(&(f.reference.clone(), p.number.clone())).cloned().unwrap_or_default();
            // KiCad names an unconnected pin's net "unconnected-(...)": a pad with no net is the same thing.
            // The board keeps a net name escaped (a label VPP/MCLR is /VPP{slash}MCLR), the netlist does not.
            let have_plain = unescape_net(&have);
            let same = have_plain == to || (have.is_empty() && to.starts_with("unconnected-")) || (to.is_empty() && have.starts_with("unconnected-"));
            if !same {
                out.push(json!({"pad": format!("{}.{}", f.reference, p.number), "from": have, "to": to}));
            }
        }
    }
    out
}

/// A net name as the board file escapes it, read back plain: KiCad writes characters that mean
/// something in a net path or the file format as {slash}, {backslash}, {lt}, {gt}, {colon},
/// {dblquote}, {space}, {tab}, {return}, {brace}.
pub fn unescape_net(name: &str) -> String {
    if !name.contains('{') {
        return name.to_string();
    }
    [("{slash}", "/"), ("{backslash}", "\\"), ("{lt}", "<"), ("{gt}", ">"), ("{colon}", ":"), ("{dblquote}", "\""), ("{space}", " "), ("{tab}", "\t"), ("{return}", "\n"), ("{brace}", "{")]
        .iter().fold(name.to_string(), |s, (from, to)| s.replace(from, to))
}

// ---------------------------------------------------------------------------
// Text edits inside a footprint block
// ---------------------------------------------------------------------------

/// Spans of the direct children of the list at `span`.
pub fn child_spans(text: &str, span: (usize, usize)) -> Vec<(usize, usize)> {
    let inner = &text[span.0..span.1];
    top_level_spans(inner).into_iter().map(|(a, b)| (a + span.0, b + span.0)).collect()
}

/// One s-expression token at `i` (quoted, escapes respected, or bare): its end.
fn token_end(block: &str, i: usize) -> usize {
    let b = block.as_bytes();
    if b.get(i) == Some(&b'"') {
        let mut j = i + 1;
        while j < b.len() {
            match b[j] {
                b'\\' => j += 2,
                b'"' => return j + 1,
                _ => j += 1,
            }
        }
        return b.len();
    }
    let mut j = i;
    while j < b.len() && !b[j].is_ascii_whitespace() && b[j] != b'(' && b[j] != b')' {
        j += 1;
    }
    j
}

fn skip_space(block: &str, mut i: usize) -> usize {
    let b = block.as_bytes();
    while i < b.len() && b[i].is_ascii_whitespace() {
        i += 1;
    }
    i
}

/// Where `open` (a list head such as `(property`) starts a list, however it is spaced: the positions after it.
fn list_heads<'a>(block: &'a str, open: &'a str) -> impl Iterator<Item = (usize, usize)> + 'a {
    block.match_indices(open).filter_map(move |(i, _)| {
        let after = i + open.len();
        block.as_bytes().get(after).filter(|c| c.is_ascii_whitespace()).map(|_| (i, after))
    })
}

/// A footprint's Reference or Value, whichever way its library wrote it (wiki #138: library files spaced or broken
/// over lines differently from KiCad's own, and KiCad 5/6's `fp_text`, kept their REF**). A footprint with neither
/// gets the property. Rewritten as KiCad writes it, because `board_only` and other readers look for that spelling.
fn set_property(block: &str, key: &str, value: &str) -> String {
    let unquote = |t: &str| t.trim_matches('"').to_string();
    for (start, after) in list_heads(block, "(property") {
        let k0 = skip_space(block, after);
        let k1 = token_end(block, k0);
        if unquote(&block[k0..k1]) != key {
            continue;
        }
        let v0 = skip_space(block, k1);
        let v1 = token_end(block, v0);
        let mut out = block.to_string();
        out.replace_range(start..v1, &format!("(property {} {}", q(key), q(value)));
        return out;
    }
    let legacy = key.to_ascii_lowercase();
    for (_, after) in list_heads(block, "(fp_text") {
        let k0 = skip_space(block, after);
        let k1 = token_end(block, k0);
        if block[k0..k1] != legacy {
            continue;
        }
        let v0 = skip_space(block, k1);
        let v1 = token_end(block, v0);
        let mut out = block.to_string();
        out.replace_range(v0..v1, &q(value));
        return out;
    }
    let layer = if key == "Reference" { "F.SilkS" } else { "F.Fab" };
    let prop = format!("(property {} {} (at 0 0 0) (layer {}) (uuid {}) (effects (font (size 1 1) (thickness 0.15))))\n\t\t", q(key), q(value), q(layer), q(&new_uuid()));
    let mut out = block.to_string();
    let at = list_heads(block, "(fp_").chain(list_heads(block, "(pad")).map(|(i, _)| i).min().or_else(|| block.rfind(')')).unwrap_or(block.len());
    out.insert_str(at, &prop);
    out
}

fn net_clause(fmt: NetFormat, number: Option<i64>, name: &str) -> String {
    match fmt {
        NetFormat::Name => format!("(net {})", q(name)),
        NetFormat::Number => format!("(net {} {})", number.unwrap_or(0), q(name)),
    }
}

/// Set (or clear, with `None`) the net of every pad numbered `pad` in the footprint block.
pub fn set_pad_net(block: &str, pad: &str, net: Option<&str>, fmt: NetFormat, numbers: &BTreeMap<String, i64>) -> String {
    let mut out = block.to_string();
    let head = format!("(pad {} ", q(pad));
    // walk from the end so earlier spans stay valid
    let pads: Vec<(usize, usize)> = child_spans(&out, (0, out.len())).into_iter().filter(|(s, _)| out[*s..].starts_with(&head)).collect();
    for (s, e) in pads.into_iter().rev() {
        let pad_text = out[s..e].to_string();
        let children = child_spans(&pad_text, (0, pad_text.len()));
        let existing = children.iter().find(|(a, _)| pad_text[*a..].starts_with("(net "));
        let clause = net.map(|n| net_clause(fmt, numbers.get(n).copied(), n));
        let new_pad = match (existing, clause) {
            (Some((a, b)), Some(c)) => format!("{}{}{}", &pad_text[..*a], c, &pad_text[*b..]),
            (Some((a, b)), None) => {
                let mut a2 = *a;
                while a2 > 0 && matches!(pad_text.as_bytes()[a2 - 1], b' ' | b'\t') {
                    a2 -= 1;
                }
                if a2 > 0 && pad_text.as_bytes()[a2 - 1] == b'\n' {
                    a2 -= 1;
                }
                format!("{}{}", &pad_text[..a2], &pad_text[*b..])
            }
            (None, Some(c)) => {
                // before the uuid when there is one, else before the closing paren
                match children.iter().find(|(a, _)| pad_text[*a..].starts_with("(uuid")) {
                    Some((a, _)) => format!("{}{}\n\t\t\t{}", &pad_text[..*a], c, &pad_text[*a..]),
                    None => format!("{} {})", pad_text[..pad_text.len() - 1].trim_end(), c),
                }
            }
            (None, None) => pad_text.clone(),
        };
        out.replace_range(s..e, &new_pad);
    }
    out
}

fn add_angle(block: &str, rot: f64) -> String {
    if rot == 0.0 {
        return block.to_string();
    }
    let mut out = block.to_string();
    let targets: Vec<(usize, usize)> = child_spans(&out, (0, out.len())).into_iter().filter(|(s, _)| ["(pad", "(property", "(fp_text"].iter().any(|h| out[*s..].starts_with(h) && out[*s + h.len()..].starts_with(|c: char| c.is_whitespace()))).collect();
    for (s, e) in targets.into_iter().rev() {
        let item = out[s..e].to_string();
        let Some(at) = child_spans(&item, (0, item.len())).into_iter().find(|(a, _)| item[*a..].starts_with("(at ")) else { continue };
        let inner = &item[at.0 + 4..at.1 - 1];
        let nums: Vec<&str> = inner.split_whitespace().collect();
        if nums.len() < 2 {
            continue;
        }
        let a = nums.get(2).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0) + rot;
        let a = ((a % 360.0) + 360.0) % 360.0;
        let rest = if nums.len() > 3 { format!(" {}", nums[3..].join(" ")) } else { String::new() };
        let new_at = format!("(at {} {} {}{rest})", nums[0], nums[1], pcb::num(a));
        let mut it = item.clone();
        it.replace_range(at.0..at.1, &new_at);
        out.replace_range(s..e, &it);
    }
    out
}

/// A library `.kicad_mod` turned into a board footprint block: lib id, uuid, pose,
/// reference, value, schematic link, fresh child uuids, pad nets.
#[allow(clippy::too_many_arguments)]
pub fn instantiate(module: &str, lib_id: &str, reference: &str, value: &str, at: (f64, f64), rot: f64, layer_side: &str, uuid: &str, path: Option<&str>, pad_net: &dyn Fn(&str) -> Option<String>, fmt: NetFormat, numbers: &BTreeMap<String, i64>) -> R<String> {
    let t = module.trim();
    if !(t.starts_with("(footprint") || t.starts_with("(module")) {
        return Err(RoutingError::new("library_unreadable", format!("{lib_id}: not a .kicad_mod footprint")));
    }
    if layer_side != "F.Cu" {
        return Err(RoutingError::new("back_side_not_supported", "footprints are added on the front; flip them in KiCad afterwards"));
    }
    let spans = top_level_spans(t);
    let mut body = String::new();
    for (s, e) in &spans {
        let c = &t[*s..*e];
        if ["(version", "(generator", "(generator_version", "(uuid", "(at ", "(path", "(sheetname", "(sheetfile", "(tedit"].iter().any(|h| c.starts_with(h)) {
            continue;
        }
        body.push_str("\n\t\t");
        body.push_str(c);
    }
    let head_name = format!("(footprint {}", q(lib_id));
    let mut block = format!("{head_name}{body}\n\t)");
    // pose and identity right after the layer
    let ident = format!("\n\t\t(uuid {})\n\t\t(at {} {}{}){}", q(uuid), pcb::num(at.0), pcb::num(at.1), if rot != 0.0 { format!(" {}", pcb::num(rot)) } else { String::new() }, path.map(|p| format!("\n\t\t(path {})", q(p))).unwrap_or_default());
    match block.find("(layer ") {
        Some(i) => {
            let j = block[i..].find(')').map(|j| i + j + 1).unwrap_or(i);
            block.insert_str(j, &ident);
        }
        None => {
            let j = head_name.len();
            block.insert_str(j, &format!("\n\t\t(layer \"F.Cu\"){ident}"));
        }
    }
    // fresh uuids for every child
    let mut fresh = String::with_capacity(block.len());
    let mut rest = block.as_str();
    let mut first = true;
    while let Some(i) = rest.find("(uuid \"") {
        fresh.push_str(&rest[..i + 7]);
        let after = &rest[i + 7..];
        let j = after.find('"').unwrap_or(0);
        if first {
            fresh.push_str(&after[..j]);
            first = false;
        } else {
            fresh.push_str(&new_uuid());
        }
        rest = &after[j..];
    }
    fresh.push_str(rest);
    let mut block = set_property(&fresh, "Reference", reference);
    block = set_property(&block, "Value", value);
    block = add_angle(&block, rot);
    let mut numbers_seen = Vec::new();
    let parsed = pcb::parse_pcb_text(&format!("(kicad_pcb\n\t{block}\n)"), "lib").map_err(|e| RoutingError::new("library_unparsable", format!("{lib_id}: {e}")))?;
    if let Some(fp) = parsed.footprints.first() {
        // Wiki #138: never hand back a part that still answers to its library's placeholder.
        if fp.reference != reference {
            return Err(RoutingError::new("reference_not_set", format!("{lib_id}: the footprint reads as {:?} after setting its reference to {reference}", fp.reference)));
        }
        for p in &fp.pads {
            if p.number.is_empty() || numbers_seen.contains(&p.number) {
                continue;
            }
            numbers_seen.push(p.number.clone());
            block = set_pad_net(&block, &p.number, pad_net(&p.number).as_deref(), fmt, numbers);
        }
    }
    Ok(format!("\t{block}"))
}

// ---------------------------------------------------------------------------
// Library lookup
// ---------------------------------------------------------------------------

/// The `.kicad_mod` for a `Lib:Name` id: the project's fp-lib-table first, then the global
/// one, `${KIPRJMOD}` resolved to the board's folder.
pub fn resolve_footprint(lib_id: &str, board_dir: &Path, ctx: &LibCtx) -> Result<PathBuf, String> {
    let (nick, name) = lib_id.split_once(':').ok_or_else(|| format!("{lib_id} is not Library:Footprint"))?;
    let mut tables = vec![board_dir.join("fp-lib-table")];
    if let Some(g) = ctx.fp_lib_table() {
        tables.push(g);
    }
    let prj = board_dir.to_string_lossy().replace('\\', "/");
    for t in tables.iter().filter(|t| t.is_file()) {
        let Ok(table) = LibTable::parse_file(t) else { continue };
        let Some(e) = table.get(nick) else { continue };
        if e.disabled {
            continue;
        }
        let uri = e.uri.replace("${KIPRJMOD}", &prj);
        let Some(dir) = libraries::expand_kicad_uri(&uri, ctx) else { return Err(format!("{nick}: cannot expand {}", e.uri)) };
        let f = Path::new(&dir).join(format!("{name}.kicad_mod"));
        return if f.is_file() { Ok(f) } else { Err(format!("{name}.kicad_mod is not in {dir}")) };
    }
    Err(format!("no library named {nick} in the project or global fp-lib-table"))
}

// ---------------------------------------------------------------------------
// Apply
// ---------------------------------------------------------------------------

pub struct ApplyOpts {
    pub delete_extra: bool,
    pub update_footprints: bool,
}

/// The board text with the plan applied, and what was done. `module` resolves a lib id
/// to its `.kicad_mod` text.
pub fn apply(text: &str, board: &Board, nl: &Netlist, opts: &ApplyOpts, module: &dyn Fn(&str) -> Result<String, String>) -> R<(String, Value)> {
    let fmt = board.net_format;
    let want = expected_nets(nl);
    let mut out = text.to_string();
    // nets a numbered board must declare
    let mut numbers: BTreeMap<String, i64> = board.nets.iter().filter_map(|n| n.number.map(|k| (n.name.clone(), k))).collect();
    let mut declared = Vec::new();
    if fmt == NetFormat::Number {
        let mut next = numbers.values().max().copied().unwrap_or(0) + 1;
        let mut decl = String::new();
        for n in &nl.nets {
            if !numbers.contains_key(&n.name) {
                numbers.insert(n.name.clone(), next);
                decl.push_str(&format!("\n\t(net {next} {})", q(&n.name)));
                declared.push(n.name.clone());
                next += 1;
            }
        }
        if !decl.is_empty() {
            let last = top_level_spans(&out).into_iter().filter(|(s, _)| out[*s..].starts_with("(net ")).last();
            let at = last.map(|(_, e)| e).ok_or_else(|| RoutingError::new("board_unparsable", "a numbered-net board without a net table"))?;
            out.insert_str(at, &decl);
        }
    }
    let mut done = json!({"valuesUpdated": [], "padNetsAssigned": [], "footprintsAdded": [], "footprintsReplaced": [], "extrasMarked": [], "extrasDeleted": [], "relinked": [], "notApplied": [], "netsDeclared": declared});
    let push = |d: &mut Value, k: &str, v: Value| d[k].as_array_mut().unwrap().push(v);
    let comps: HashMap<&str, &Component> = nl.components.iter().map(|c| (c.reference.as_str(), c)).collect();
    for f in &board.footprints {
        let Some(c) = comps.get(f.reference.as_str()) else { continue };
        let Some(span) = footprint_span(&out, &f.reference) else { continue };
        let mut block = out[span.0..span.1].to_string();
        let replace = opts.update_footprints && !c.footprint.is_empty() && f.footprint != c.footprint;
        if replace {
            let pos = f.position.clone().unwrap_or(pcb::Position { x: 0.0, y: 0.0, rotation: None });
            let side = f.layer.clone().unwrap_or_else(|| "F.Cu".into());
            let made = module(&c.footprint).map_err(|e| RoutingError::new("library_missing", e)).and_then(|m| {
                let net = |pad: &str| want.get(&(c.reference.clone(), pad.to_string())).cloned();
                instantiate(&m, &c.footprint, &c.reference, &c.value, (pos.x, pos.y), pos.rotation.unwrap_or(0.0), &side, f.uuid.as_deref().unwrap_or(&new_uuid()), symbol_path(c).as_deref().or(f.path.as_deref()), &net, fmt, &numbers)
            });
            match made {
                Ok(b) => {
                    out.replace_range(span.0..span.1, b.trim_start());
                    push(&mut done, "footprintsReplaced", json!({"ref": c.reference, "from": f.footprint, "to": c.footprint}));
                    continue;
                }
                Err(e) => push(&mut done, "notApplied", json!({"ref": c.reference, "change": "footprint", "to": c.footprint, "reason": e.message})),
            }
        }
        if f.value != c.value {
            block = set_property(&block, "Value", &c.value);
            push(&mut done, "valuesUpdated", json!({"ref": c.reference, "from": f.value, "to": c.value}));
        }
        if let Some(p) = symbol_path(c) {
            if f.path.as_deref() != Some(p.as_str()) {
                match child_spans(&block, (0, block.len())).into_iter().find(|(a, _)| block[*a..].starts_with("(path ")) {
                    Some((a, b)) => block.replace_range(a..b, &format!("(path {})", q(&p))),
                    None => {
                        if let Some(i) = block.find("(at ") {
                            let j = block[i..].find(')').map(|j| i + j + 1).unwrap_or(i);
                            block.insert_str(j, &format!("\n\t\t(path {})", q(&p)));
                        }
                    }
                }
                push(&mut done, "relinked", json!({"ref": c.reference, "path": p}));
            }
        }
        let mut seen = Vec::new();
        for p in &f.pads {
            if p.number.is_empty() || seen.contains(&p.number) {
                continue;
            }
            seen.push(p.number.clone());
            let have = p.net_name.clone().unwrap_or_default();
            let to = want.get(&(f.reference.clone(), p.number.clone())).cloned().unwrap_or_default();
            if have != to && !(have.is_empty() && to.starts_with("unconnected-")) {
                block = set_pad_net(&block, &p.number, if to.is_empty() { None } else { Some(&to) }, fmt, &numbers);
                push(&mut done, "padNetsAssigned", json!({"pad": format!("{}.{}", f.reference, p.number), "from": have, "to": to}));
            }
        }
        out.replace_range(span.0..span.1, &block);
    }
    // extras
    let known: Vec<&str> = nl.components.iter().map(|c| c.reference.as_str()).collect();
    for f in &board.footprints {
        if known.contains(&f.reference.as_str()) || board_only(&out, &f.reference) {
            continue;
        }
        let Some(span) = footprint_span(&out, &f.reference) else { continue };
        if opts.delete_extra {
            let mut s = span.0;
            while s > 0 && matches!(out.as_bytes()[s - 1], b' ' | b'\t') {
                s -= 1;
            }
            if s > 0 && out.as_bytes()[s - 1] == b'\n' {
                s -= 1;
            }
            out.replace_range(s..span.1, "");
            push(&mut done, "extrasDeleted", json!(f.reference));
        } else if !out[span.0..span.1].contains(EXTRA_MARK) {
            let mark = format!("\n\t\t(property {} \"yes\"\n\t\t\t(at 0 0 0)\n\t\t\t(layer \"F.Fab\")\n\t\t\t(hide yes)\n\t\t\t(uuid {})\n\t\t\t(effects\n\t\t\t\t(font\n\t\t\t\t\t(size 1 1)\n\t\t\t\t\t(thickness 0.15)\n\t\t\t\t)\n\t\t\t)\n\t\t)", q(EXTRA_MARK), q(&new_uuid()));
            let block = &out[span.0..span.1];
            let at = block.find("(property ").map(|i| span.0 + i).unwrap_or(span.1 - 1);
            // after the last property
            let kids = child_spans(&out, span);
            let after = kids.iter().filter(|(a, _)| out[*a..].starts_with("(property ")).last().map(|(_, b)| *b).unwrap_or(at);
            out.insert_str(after, &mark);
            push(&mut done, "extrasMarked", json!(f.reference));
        }
    }
    // missing footprints: a row below the outline
    let outline = placement::outline(board);
    let (mut x, y0) = outline.map(|o| (o.min_x, o.max_y + 5.0)).unwrap_or((0.0, 0.0));
    let mut added_blocks = String::new();
    for c in &nl.components {
        if board.footprints.iter().any(|f| f.reference == c.reference) || c.footprint.is_empty() {
            continue;
        }
        let m = match module(&c.footprint) {
            Ok(m) => m,
            Err(e) => {
                push(&mut done, "notApplied", json!({"ref": c.reference, "change": "add", "footprint": c.footprint, "reason": e}));
                continue;
            }
        };
        let net = |pad: &str| want.get(&(c.reference.clone(), pad.to_string())).cloned();
        // measure first at the origin, then place so the courtyard's left edge sits at x
        let probe = instantiate(&m, &c.footprint, &c.reference, &c.value, (0.0, 0.0), 0.0, "F.Cu", &new_uuid(), None, &net, fmt, &numbers)?;
        let parsed = pcb::parse_pcb_text(&format!("(kicad_pcb\n{probe}\n)"), "probe").map_err(|e| RoutingError::new("library_unparsable", e))?;
        let (r, _) = parsed.footprints.first().map(|f| placement::courtyard_at(f, (0.0, 0.0), 0.0)).unwrap_or((placement::Rect { min_x: -1.0, min_y: -1.0, max_x: 1.0, max_y: 1.0 }, "position"));
        let at = (pcb::round6(x - r.min_x), pcb::round6(y0 - r.min_y));
        let block = instantiate(&m, &c.footprint, &c.reference, &c.value, at, 0.0, "F.Cu", &new_uuid(), symbol_path(c).as_deref(), &net, fmt, &numbers)?;
        added_blocks.push('\n');
        added_blocks.push_str(&block);
        push(&mut done, "footprintsAdded", json!({"ref": c.reference, "footprint": c.footprint, "x": at.0, "y": at.1}));
        x += r.width() + 1.0;
    }
    if !added_blocks.is_empty() {
        let fps = footprint_spans(&out);
        let at = fps.last().map(|(_, e)| *e).unwrap_or_else(|| out.trim_end().len() - 1);
        out.insert_str(at, &added_blocks);
    }
    Ok((out, done))
}

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

    #[test]
    fn a_reference_is_set_however_the_library_spaced_it() {
        // wiki #138: each of these kept REF** and then read as a missing part
        let pad = "(pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\"))";
        for (name, field) in [
            ("KiCad's own", "(property \"Reference\" \"REF**\" (at 0 0 0) (layer \"F.SilkS\"))"),
            ("two spaces", "(property \"Reference\"  \"REF**\" (at 0 0 0) (layer \"F.SilkS\"))"),
            ("value on its own line", "(property \"Reference\"\n\t\t\"REF**\"\n\t\t(at 0 0 0) (layer \"F.SilkS\"))"),
            ("CRLF", "(property \"Reference\"\r\n\t\"REF**\"\r\n\t(at 0 0 0) (layer \"F.SilkS\"))"),
            ("each token on a line", "(property\n\t\t\"Reference\"\n\t\t\"REF**\"\n\t\t(at 0 0 0) (layer \"F.SilkS\"))"),
            ("bare words", "(property Reference REF** (at 0 0 0) (layer \"F.SilkS\"))"),
            ("KiCad 6's fp_text", "(fp_text reference \"REF**\" (at 0 0) (layer \"F.SilkS\"))"),
            ("KiCad 5's fp_text, bare", "(fp_text reference REF** (at 0 0) (layer F.SilkS))"),
            ("no Reference at all", ""),
        ] {
            let module = format!("(footprint \"CR0402-FX-1002GLF\" (layer \"F.Cu\") {field} (property \"Value\" \"X\" (at 0 1 0) (layer \"F.Fab\")) {pad})");
            let b = instantiate(&module, "PCM_lib:CR0402-FX-1002GLF", "R15", "10k", (1.0, 2.0), 0.0, "F.Cu", "u-1", Some("/abc"), &|_| None, NetFormat::Name, &BTreeMap::new()).unwrap_or_else(|e| panic!("{name}: {}", e.message));
            assert!(!b.contains("REF**"), "{name}: {b}");
            let parsed = pcb::parse_pcb_text(&format!("(kicad_pcb\n{b}\n)"), "t").unwrap();
            assert_eq!(parsed.footprints[0].reference, "R15", "{name}: {b}");
            assert_eq!(parsed.footprints[0].value, "10k", "{name}: {b}");
        }
    }

    #[test]
    fn an_escaped_board_net_is_the_netlists_plain_name() {
        // pic_programmer: the label VPP/MCLR is /VPP{slash}MCLR on the board and /VPP/MCLR in the netlist
        assert_eq!(unescape_net("/VPP{slash}MCLR"), "/VPP/MCLR");
        assert_eq!(unescape_net("unconnected-(U5-OSC1{slash}CLKI-Pad16)"), "unconnected-(U5-OSC1/CLKI-Pad16)");
        assert_eq!(unescape_net("GND"), "GND");
        assert_eq!(unescape_net("A{lt}B{gt}{colon}C"), "A<B>:C");
    }

    #[test]
    fn a_pad_net_is_set_replaced_and_cleared() {
        let block = "(footprint \"L:R\"\n\t\t(layer \"F.Cu\")\n\t\t(pad \"1\" smd rect\n\t\t\t(at 0 0)\n\t\t\t(net \"A\")\n\t\t\t(uuid \"u1\")\n\t\t)\n\t\t(pad \"2\" smd rect\n\t\t\t(at 1 0)\n\t\t\t(uuid \"u2\")\n\t\t)\n\t)";
        let m = BTreeMap::new();
        let b = set_pad_net(block, "1", Some("B"), NetFormat::Name, &m);
        assert!(b.contains("(net \"B\")") && !b.contains("(net \"A\")"));
        let b = set_pad_net(&b, "2", Some("C"), NetFormat::Name, &m);
        assert!(b.contains("(net \"C\")\n\t\t\t(uuid \"u2\")"), "{b}");
        let b = set_pad_net(&b, "1", None, NetFormat::Name, &m);
        assert!(!b.contains("(net \"B\")"));
        let mut nums = BTreeMap::new();
        nums.insert("C".to_string(), 7);
        assert!(set_pad_net(block, "1", Some("C"), NetFormat::Number, &nums).contains("(net 7 \"C\")"));
    }

    #[test]
    fn angles_add_to_pads_and_properties() {
        let block = "(footprint \"L:R\"\n\t(property \"Reference\" \"R1\" (at 0 -1 0))\n\t(pad \"1\" smd rect (at 1 0) (size 1 1))\n\t(fp_line (start 0 0) (end 1 1))\n)";
        let b = add_angle(block, 90.0);
        assert!(b.contains("(property \"Reference\" \"R1\" (at 0 -1 90))"), "{b}");
        assert!(b.contains("(pad \"1\" smd rect (at 1 0 90)"), "{b}");
        assert!(b.contains("(fp_line (start 0 0) (end 1 1))"));
    }

    #[test]
    fn the_symbol_path_joins_sheet_and_uuid() {
        let c = Component { reference: "R1".into(), tstamp: "abc".into(), sheet_tstamps: "/".into(), ..Default::default() };
        assert_eq!(symbol_path(&c).as_deref(), Some("/abc"));
        let c = Component { tstamp: "abc".into(), sheet_tstamps: "/s1".into(), ..Default::default() };
        assert_eq!(symbol_path(&c).as_deref(), Some("/s1/abc"));
    }
}