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
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189
//! Schematic edit in place. KiCad 10 has no schematic API, so the kicad_sch_* verbs
//! rewrite `.kicad_sch` on disk. This module is the OS-neutral half of that:
//!
//! * a lossless s-expression document model. Every token keeps its original text
//!   (numbers stay the digits KiCad wrote, quoted strings keep their escapes) and every
//!   run of whitespace is kept on the node that follows it, so a file that is parsed
//!   and written back unchanged is byte-identical, and an edit touches only the nodes
//!   it edits;
//! * a pretty printer that lays out NEW nodes the way eeschema's formatter does
//!   (tabs, one list per line, closing paren on its own line when the list has list
//!   children, `(xy ..)` pairs sharing a line);
//! * the edit operations: place a symbol (with its `lib_symbols` entry copied from the
//!   library, derived symbols flattened), wire, label, move, delete, set property;
//! * library resolution for a `Nick:Name` lib id through the project and global
//!   sym-lib-table (via `libraries::LibCtx` and `expand_kicad_uri`), and a v4 UUID
//!   generator built on std only.
//!
//! Validation (ERC) and the reload hint live in the verb layer.

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

use crate::libraries::{self, expand_kicad_uri, LibCtx, LibTable};

// ---------------------------------------------------------------------------
// Lossless document model
// ---------------------------------------------------------------------------

/// One s-expression node. `lead` is the whitespace that precedes it in the file.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Node {
    pub lead: String,
    pub kind: Kind,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Kind {
    /// Raw token text: a bare atom (`yes`, `1.27`, `xy`) or a quoted string WITH its
    /// quotes and escapes (`"R1"`, `"a \"b\""`).
    Atom(String),
    /// `close_lead` is the whitespace before the closing paren.
    List { children: Vec<Node>, close_lead: String },
}

/// A whole file: the root list plus whatever follows it (KiCad writes one "\n").
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Document {
    pub root: Node,
    pub tail: String,
}

/// Quote a string the way KiCad's CTX_QUOTED_STR escaper does.
pub fn quote(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('"');
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            _ => out.push(c),
        }
    }
    out.push('"');
    out
}

/// Undo `quote`. A bare atom comes back unchanged.
pub fn unquote(raw: &str) -> String {
    if raw.len() < 2 || !raw.starts_with('"') || !raw.ends_with('"') {
        return raw.to_string();
    }
    let inner = &raw[1..raw.len() - 1];
    let mut out = String::with_capacity(inner.len());
    let mut chars = inner.chars();
    while let Some(c) = chars.next() {
        if c != '\\' {
            out.push(c);
            continue;
        }
        match chars.next() {
            Some('n') => out.push('\n'),
            Some('r') => out.push('\r'),
            Some('t') => out.push('\t'),
            Some(o) => out.push(o),
            None => out.push('\\'),
        }
    }
    out
}

/// Millimetres the way eeschema writes them: up to four decimals, no trailing zeros.
pub fn fmt_num(v: f64) -> String {
    let r = (v * 10000.0).round() / 10000.0;
    let r = if r == 0.0 { 0.0 } else { r }; // no "-0"
    let mut s = format!("{r:.4}");
    while s.ends_with('0') {
        s.pop();
    }
    if s.ends_with('.') {
        s.pop();
    }
    s
}

impl Node {
    pub fn atom(raw: impl Into<String>) -> Node {
        Node { lead: String::new(), kind: Kind::Atom(raw.into()) }
    }
    pub fn quoted(s: &str) -> Node {
        Node::atom(quote(s))
    }
    pub fn number(v: f64) -> Node {
        Node::atom(fmt_num(v))
    }
    /// `(head child child ...)` with no layout yet; run `pretty` before inserting.
    pub fn list(head: &str, mut rest: Vec<Node>) -> Node {
        let mut children = Vec::with_capacity(rest.len() + 1);
        children.push(Node::atom(head));
        children.append(&mut rest);
        Node { lead: String::new(), kind: Kind::List { children, close_lead: String::new() } }
    }

    pub fn is_list(&self) -> bool {
        matches!(self.kind, Kind::List { .. })
    }
    pub fn children(&self) -> &[Node] {
        match &self.kind {
            Kind::List { children, .. } => children,
            Kind::Atom(_) => &[],
        }
    }
    pub fn children_mut(&mut self) -> Option<&mut Vec<Node>> {
        match &mut self.kind {
            Kind::List { children, .. } => Some(children),
            Kind::Atom(_) => None,
        }
    }
    /// Raw token text of an atom.
    pub fn raw(&self) -> Option<&str> {
        match &self.kind {
            Kind::Atom(s) => Some(s),
            Kind::List { .. } => None,
        }
    }
    /// Unquoted value of an atom.
    pub fn value(&self) -> Option<String> {
        self.raw().map(unquote)
    }
    /// The head atom of a list: `lib_id` for `(lib_id "Device:R")`.
    pub fn head(&self) -> Option<&str> {
        self.children().first().and_then(Node::raw)
    }
    /// The i-th atom after the head, unquoted. `arg(0)` of `(at 1 2 3)` is `1`.
    pub fn arg(&self, i: usize) -> Option<String> {
        self.children().get(i + 1).and_then(Node::value)
    }
    pub fn num(&self, i: usize) -> Option<f64> {
        self.arg(i)?.parse().ok()
    }
    pub fn child(&self, name: &str) -> Option<&Node> {
        self.children().iter().find(|c| c.head() == Some(name))
    }
    pub fn child_mut(&mut self, name: &str) -> Option<&mut Node> {
        self.children_mut()?.iter_mut().find(|c| c.head() == Some(name))
    }
    pub fn child_value(&self, name: &str) -> Option<String> {
        self.child(name)?.arg(0)
    }
    /// Replace the i-th atom after the head (index must exist).
    pub fn set_arg(&mut self, i: usize, node: Node) -> bool {
        match self.children_mut().and_then(|c| c.get_mut(i + 1)) {
            Some(slot) => {
                let lead = std::mem::take(&mut slot.lead);
                *slot = node;
                slot.lead = if lead.is_empty() { " ".into() } else { lead };
                true
            }
            None => false,
        }
    }

    pub fn write(&self, out: &mut String) {
        out.push_str(&self.lead);
        match &self.kind {
            Kind::Atom(s) => out.push_str(s),
            Kind::List { children, close_lead } => {
                out.push('(');
                for c in children {
                    c.write(out);
                }
                out.push_str(close_lead);
                out.push(')');
            }
        }
    }
    pub fn to_text(&self) -> String {
        let mut s = String::new();
        self.write(&mut s);
        s
    }
}

struct Parser<'a> {
    text: &'a str,
    b: &'a [u8],
    i: usize,
}

impl<'a> Parser<'a> {
    fn ws(&mut self) -> String {
        let start = self.i;
        while self.i < self.b.len() && (self.b[self.i] as char).is_ascii_whitespace() {
            self.i += 1;
        }
        self.text[start..self.i].to_string()
    }
    fn node(&mut self, lead: String) -> Result<Node, String> {
        let Some(&c) = self.b.get(self.i) else { return Err("unexpected end of file".into()) };
        match c {
            b'(' => {
                self.i += 1;
                let mut children = Vec::new();
                loop {
                    let ws = self.ws();
                    match self.b.get(self.i) {
                        None => return Err(format!("unbalanced s-expression: list opened at byte {} never closes", self.i)),
                        Some(b')') => {
                            self.i += 1;
                            return Ok(Node { lead, kind: Kind::List { children, close_lead: ws } });
                        }
                        Some(_) => children.push(self.node(ws)?),
                    }
                }
            }
            b')' => Err(format!("unexpected ')' at byte {}", self.i)),
            b'"' => {
                let start = self.i;
                self.i += 1;
                let mut esc = false;
                loop {
                    let Some(&ch) = self.b.get(self.i) else { return Err(format!("unterminated quoted string at byte {start}")) };
                    self.i += 1;
                    if esc {
                        esc = false;
                    } else if ch == b'\\' {
                        esc = true;
                    } else if ch == b'"' {
                        break;
                    }
                }
                Ok(Node { lead, kind: Kind::Atom(self.text[start..self.i].to_string()) })
            }
            _ => {
                let start = self.i;
                while let Some(&ch) = self.b.get(self.i) {
                    if (ch as char).is_ascii_whitespace() || ch == b'(' || ch == b')' || ch == b'"' {
                        break;
                    }
                    self.i += 1;
                }
                Ok(Node { lead, kind: Kind::Atom(self.text[start..self.i].to_string()) })
            }
        }
    }
}

/// Parse a single node (leading whitespace kept as its `lead`); trailing text is an error.
pub fn parse_node(text: &str) -> Result<Node, String> {
    let mut p = Parser { text, b: text.as_bytes(), i: 0 };
    let lead = p.ws();
    let n = p.node(lead)?;
    if !text[p.i..].trim().is_empty() {
        return Err(format!("trailing content after the s-expression at byte {}", p.i));
    }
    Ok(n)
}

impl Document {
    pub fn parse(text: &str) -> Result<Document, String> {
        let mut p = Parser { text, b: text.as_bytes(), i: 0 };
        let lead = p.ws();
        if p.b.get(p.i) != Some(&b'(') {
            return Err("not an s-expression file (no opening paren)".into());
        }
        let root = p.node(lead)?;
        let tail = text[p.i..].to_string();
        if !tail.trim().is_empty() {
            return Err("trailing content after the root list".into());
        }
        Ok(Document { root, tail })
    }
    pub fn to_text(&self) -> String {
        let mut s = String::new();
        self.root.write(&mut s);
        s.push_str(&self.tail);
        s
    }
}

/// Lay a node out the way eeschema's formatter does, at `depth` tabs. Atoms follow the
/// head on one line; every list child starts a new line one level deeper; a list with
/// list children closes on its own line; consecutive `(xy ..)` pairs share a line.
pub fn pretty(node: &mut Node, depth: usize) {
    fn indent(depth: usize) -> String {
        let mut s = String::with_capacity(depth + 1);
        s.push('\n');
        for _ in 0..depth {
            s.push('\t');
        }
        s
    }
    node.lead = indent(depth);
    layout_children(node, depth);
    fn layout_children(node: &mut Node, depth: usize) {
        let Kind::List { children, close_lead } = &mut node.kind else { return };
        let mut has_list = false;
        let mut prev_xy = false;
        for (i, c) in children.iter_mut().enumerate() {
            match &c.kind {
                Kind::Atom(_) => {
                    c.lead = if i == 0 { String::new() } else { " ".into() };
                    prev_xy = false;
                }
                Kind::List { .. } => {
                    has_list = true;
                    let is_xy = c.head() == Some("xy");
                    c.lead = if is_xy && prev_xy { " ".into() } else { indent(depth + 1) };
                    prev_xy = is_xy;
                    layout_children(c, depth + 1);
                }
            }
        }
        *close_lead = if has_list { indent(depth) } else { String::new() };
    }
}

// ---------------------------------------------------------------------------
// UUIDs
// ---------------------------------------------------------------------------

/// A random v4 UUID from std only: the OS-seeded `RandomState` hasher mixed with the
/// clock, the pid and a process-wide counter. Same code on every OS.
pub fn new_uuid() -> String {
    use std::hash::{BuildHasher, Hasher};
    use std::sync::atomic::{AtomicU64, Ordering};
    static COUNTER: AtomicU64 = AtomicU64::new(0);
    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
    let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
    let mut words = [0u64; 2];
    let stack_marker = &n as *const u64 as usize;
    for (k, w) in words.iter_mut().enumerate() {
        let mut h = std::collections::hash_map::RandomState::new().build_hasher();
        h.write_u128(nanos);
        h.write_u32(std::process::id());
        h.write_u64(n);
        h.write_usize(k);
        h.write_usize(stack_marker);
        *w = h.finish();
    }
    let mut b = [0u8; 16];
    b[..8].copy_from_slice(&words[0].to_le_bytes());
    b[8..].copy_from_slice(&words[1].to_le_bytes());
    b[6] = (b[6] & 0x0f) | 0x40; // version 4
    b[8] = (b[8] & 0x3f) | 0x80; // RFC 4122 variant
    let hex: Vec<String> = b.iter().map(|x| format!("{x:02x}")).collect();
    let h = hex.concat();
    format!("{}-{}-{}-{}-{}", &h[0..8], &h[8..12], &h[12..16], &h[16..20], &h[20..32])
}

// ---------------------------------------------------------------------------
// Schematic
// ---------------------------------------------------------------------------

/// One placed symbol as the verbs report it.
#[derive(Clone, Debug, serde::Serialize)]
pub struct SymbolInfo {
    pub uuid: String,
    pub reference: String,
    #[serde(rename = "libId")]
    pub lib_id: String,
    pub value: String,
    pub at: [f64; 2],
    pub rotation: f64,
    pub unit: u32,
}

#[derive(Clone, Debug)]
pub struct PlaceSymbol<'a> {
    pub lib_id: &'a str,
    pub reference: &'a str,
    pub at: (f64, f64),
    pub rotation: f64,
    pub unit: u32,
    pub value: Option<&'a str>,
    pub footprint: Option<&'a str>,
}

#[derive(Clone, Debug)]
pub struct Placed {
    pub uuid: String,
    /// True when the `lib_symbols` entry was copied in by this call.
    pub lib_symbol_added: bool,
    pub pins: Vec<String>,
    pub is_power: bool,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LabelKind {
    Local,
    Global,
    Hierarchical,
}

impl LabelKind {
    pub fn parse(s: &str) -> Option<LabelKind> {
        match s.trim().to_ascii_lowercase().as_str() {
            "local" | "label" | "net" | "" => Some(LabelKind::Local),
            "global" | "global_label" => Some(LabelKind::Global),
            "hierarchical" | "hier" | "hierarchical_label" | "sheet" => Some(LabelKind::Hierarchical),
            _ => None,
        }
    }
    pub fn token(self) -> &'static str {
        match self {
            LabelKind::Local => "label",
            LabelKind::Global => "global_label",
            LabelKind::Hierarchical => "hierarchical_label",
        }
    }
}

/// A `.kicad_sch` open for editing.
pub struct Schematic {
    pub doc: Document,
    /// Project name hint from the file's location (`<name>.kicad_pro` next to it, else
    /// the file stem). Existing symbol instances win over it.
    pub project_hint: Option<String>,
}

const TOP_TAIL: &[&str] = &["sheet_instances", "symbol_instances", "embedded_fonts"];

impl Schematic {
    pub fn parse(text: &str) -> Result<Schematic, String> {
        let doc = Document::parse(text)?;
        if doc.root.head() != Some("kicad_sch") {
            return Err(format!("not a schematic: the root list is ({})", doc.root.head().unwrap_or("")));
        }
        Ok(Schematic { doc, project_hint: None })
    }

    pub fn load(path: &Path) -> Result<Schematic, String> {
        let text = std::fs::read_to_string(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
        let mut s = Schematic::parse(&text)?;
        s.project_hint = project_name_for(path);
        Ok(s)
    }

    pub fn to_text(&self) -> String {
        self.doc.to_text()
    }

    /// Copy the current file to `<file>.bak`, then write the document atomically.
    pub fn save_with_backup(&self, path: &Path) -> Result<PathBuf, String> {
        let mut bak = path.as_os_str().to_owned();
        bak.push(".bak");
        let bak = PathBuf::from(bak);
        if path.exists() {
            std::fs::copy(path, &bak).map_err(|e| format!("cannot write backup {}: {e}", bak.display()))?;
        }
        libraries::write_atomic(path, self.to_text().as_bytes())?;
        Ok(bak)
    }

    pub fn root_uuid(&self) -> Option<String> {
        self.doc.root.child_value("uuid")
    }

    pub fn version(&self) -> Option<String> {
        self.doc.root.child_value("version")
    }

    pub fn top(&self) -> &[Node] {
        self.doc.root.children()
    }
    pub fn top_mut(&mut self) -> &mut Vec<Node> {
        self.doc.root.children_mut().expect("root is a list")
    }

    /// Placed symbols (top-level `(symbol (lib_id ..) ..)` items).
    pub fn symbols(&self) -> Vec<SymbolInfo> {
        self.top().iter().filter(|n| is_symbol_instance(n)).map(symbol_info).collect()
    }

    pub fn find_symbol_index(&self, reference: &str) -> Option<usize> {
        self.top().iter().position(|n| is_symbol_instance(n) && symbol_property(n, "Reference").as_deref() == Some(reference))
    }

    pub fn find_uuid_index(&self, uuid: &str) -> Option<usize> {
        self.top().iter().position(|n| n.is_list() && n.child_value("uuid").as_deref() == Some(uuid))
    }

    /// Project name for the instances block: what the existing symbols use, else the
    /// `.kicad_pro` next to the file, else the file stem.
    pub fn project_name(&self) -> String {
        for s in self.top().iter().filter(|n| is_symbol_instance(n)) {
            if let Some(p) = s.child("instances").and_then(|i| i.child_value("project")) {
                if !p.is_empty() {
                    return p;
                }
            }
        }
        self.project_hint.clone().unwrap_or_else(|| "project".into())
    }

    /// Instances path for a new symbol: the path existing symbols use on this sheet
    /// (so a sub-sheet keeps its hierarchy), else "/<root uuid>".
    pub fn instance_path(&self) -> Result<String, String> {
        for s in self.top().iter().filter(|n| is_symbol_instance(n)) {
            if let Some(p) = s.child("instances").and_then(|i| i.child("project")).and_then(|p| p.child_value("path")) {
                if p.starts_with('/') {
                    return Ok(p);
                }
            }
        }
        self.root_uuid().map(|u| format!("/{u}")).ok_or_else(|| "the schematic has no (uuid ...) at the root; cannot build the symbol instances path".to_string())
    }

    pub fn has_lib_symbol(&self, lib_id: &str) -> bool {
        self.lib_symbol(lib_id).is_some()
    }

    pub fn lib_symbol(&self, lib_id: &str) -> Option<&Node> {
        self.doc.root.child("lib_symbols")?.children().iter().find(|n| n.head() == Some("symbol") && n.arg(0).as_deref() == Some(lib_id))
    }

    /// Add a library symbol block (as read from a `.kicad_sym`, already flattened) under
    /// `lib_symbols` as `lib_id`. No-op when the entry exists.
    pub fn add_lib_symbol(&mut self, lib_id: &str, block: &Node) -> Result<bool, String> {
        if self.has_lib_symbol(lib_id) {
            return Ok(false);
        }
        if block.head() != Some("symbol") || block.arg(0).is_none() {
            return Err("library symbol block must be (symbol \"Name\" ...)".into());
        }
        let mut entry = block.clone();
        entry.set_arg(0, Node::quoted(lib_id));
        pretty(&mut entry, 2);
        if self.doc.root.child("lib_symbols").is_none() {
            let mut ls = Node::list("lib_symbols", vec![]);
            pretty(&mut ls, 1);
            let top = self.top_mut();
            let after = ["title_block", "paper", "uuid", "generator_version", "generator", "version"];
            let mut idx = 1;
            for name in after {
                if let Some(i) = top.iter().position(|n| n.head() == Some(name)) {
                    idx = i + 1;
                    break;
                }
            }
            top.insert(idx, ls);
        }
        let ls = self.doc.root.child_mut("lib_symbols").expect("just ensured");
        let Kind::List { children, close_lead } = &mut ls.kind else { unreachable!() };
        children.push(entry);
        if close_lead.is_empty() {
            *close_lead = "\n\t".into();
        }
        Ok(true)
    }

    pub fn insert_item(&mut self, mut node: Node) {
        pretty(&mut node, 1);
        let top = self.top_mut();
        let idx = top.iter().position(|n| n.head().map(|h| TOP_TAIL.contains(&h)).unwrap_or(false)).unwrap_or(top.len());
        top.insert(idx, node);
    }

    /// Place a symbol. `lib_block` is the `(symbol "Name" ...)` block from the library,
    /// needed only when the sheet has no `lib_symbols` entry for the lib id yet.
    pub fn place_symbol(&mut self, p: &PlaceSymbol, lib_block: Option<&Node>) -> Result<Placed, String> {
        if p.reference.trim().is_empty() {
            return Err("reference is required".into());
        }
        // Another unit of a placed multi-unit part is fine (same lib id, a unit not yet
        // placed), and unannotated `X?` references may repeat; anything else is a clash.
        if !p.reference.trim().ends_with('?') {
            for n in self.top().iter().filter(|n| is_symbol_instance(n) && symbol_property(n, "Reference").as_deref() == Some(p.reference)) {
                let same_part = n.child_value("lib_id").as_deref() == Some(p.lib_id);
                let unit: u32 = n.child_value("unit").and_then(|u| u.parse().ok()).unwrap_or(1);
                if !same_part {
                    return Err(format!("reference {} is already placed on this sheet", p.reference));
                }
                if unit == p.unit {
                    return Err(format!("reference {} unit {} is already placed on this sheet", p.reference, p.unit));
                }
            }
        }
        let rot = norm_rotation(p.rotation)?;
        let mut added = false;
        if !self.has_lib_symbol(p.lib_id) {
            let blk = lib_block.ok_or_else(|| format!("no lib_symbols entry for {} in the sheet and no library block supplied", p.lib_id))?;
            added = self.add_lib_symbol(p.lib_id, blk)?;
        }
        let lib = self.lib_symbol(p.lib_id).expect("ensured").clone();
        let base_name = p.lib_id.split_once(':').map(|(_, n)| n).unwrap_or(p.lib_id);
        let is_power = lib.child("power").is_some();
        let pins = lib_symbol_pins(&lib, base_name, p.unit);
        let project = self.project_name();
        let path = self.instance_path()?;
        let (x, y) = p.at;
        let uuid = new_uuid();

        let mut props: Vec<Node> = Vec::new();
        let lib_prop = |name: &str| -> String { lib_property_value(&lib, name).unwrap_or_default() };
        let value = p.value.map(str::to_string).unwrap_or_else(|| {
            let v = lib_prop("Value");
            if v.is_empty() { base_name.to_string() } else { v }
        });
        let footprint = p.footprint.map(str::to_string).unwrap_or_else(|| lib_prop("Footprint"));
        // Reference and Value sit beside the body; the rest is hidden at the anchor.
        props.push(property_node("Reference", p.reference, (x + 2.54, y - 1.27), Some("left"), is_power));
        props.push(property_node("Value", &value, (x + 2.54, y + 1.27), Some("left"), false));
        props.push(property_node("Footprint", &footprint, (x, y), None, true));
        props.push(property_node("Datasheet", &lib_prop("Datasheet"), (x, y), None, true));
        props.push(property_node("Description", &lib_prop("Description"), (x, y), None, true));
        for extra in lib.children().iter().filter(|n| n.head() == Some("property")) {
            let name = extra.arg(0).unwrap_or_default();
            if name.starts_with("ki_") || ["Reference", "Value", "Footprint", "Datasheet", "Description"].contains(&name.as_str()) {
                continue;
            }
            props.push(property_node(&name, &extra.arg(1).unwrap_or_default(), (x, y), None, true));
        }

        let mut children = vec![
            Node::list("lib_id", vec![Node::quoted(p.lib_id)]),
            Node::list("at", vec![Node::number(x), Node::number(y), Node::number(rot)]),
            Node::list("unit", vec![Node::atom(p.unit.to_string())]),
            Node::list("exclude_from_sim", vec![Node::atom("no")]),
            Node::list("in_bom", vec![Node::atom("yes")]),
            Node::list("on_board", vec![Node::atom("yes")]),
            Node::list("dnp", vec![Node::atom("no")]),
            Node::list("fields_autoplaced", vec![Node::atom("yes")]),
            Node::list("uuid", vec![Node::quoted(&uuid)]),
        ];
        children.append(&mut props);
        for pin in &pins {
            children.push(Node::list("pin", vec![Node::quoted(pin), Node::list("uuid", vec![Node::quoted(&new_uuid())])]));
        }
        children.push(Node::list(
            "instances",
            vec![Node::list(
                "project",
                vec![
                    Node::quoted(&project),
                    Node::list(
                        "path",
                        vec![
                            Node::quoted(&path),
                            Node::list("reference", vec![Node::quoted(p.reference)]),
                            Node::list("unit", vec![Node::atom(p.unit.to_string())]),
                        ],
                    ),
                ],
            )],
        ));
        self.insert_item(Node::list("symbol", children));
        Ok(Placed { uuid, lib_symbol_added: added, pins, is_power })
    }

    /// One `(wire ...)` per straight segment; returns their uuids in order.
    pub fn add_wire(&mut self, points: &[(f64, f64)]) -> Result<Vec<String>, String> {
        if points.len() < 2 {
            return Err("a wire needs at least two points".into());
        }
        let mut uuids = Vec::new();
        for seg in points.windows(2) {
            let (a, b) = (seg[0], seg[1]);
            if a == b {
                continue;
            }
            let uuid = new_uuid();
            self.insert_item(Node::list(
                "wire",
                vec![
                    Node::list("pts", vec![xy(a), xy(b)]),
                    Node::list("stroke", vec![Node::list("width", vec![Node::number(0.0)]), Node::list("type", vec![Node::atom("default")])]),
                    Node::list("uuid", vec![Node::quoted(&uuid)]),
                ],
            ));
            uuids.push(uuid);
        }
        if uuids.is_empty() {
            return Err("all points are the same; nothing to draw".into());
        }
        Ok(uuids)
    }

    /// A net label. `shape` (input|output|bidirectional|tri_state|passive) applies to
    /// global and hierarchical labels only.
    pub fn add_label(&mut self, text: &str, kind: LabelKind, at: (f64, f64), rotation: f64, shape: Option<&str>) -> Result<String, String> {
        if text.trim().is_empty() {
            return Err("label text is required".into());
        }
        let rot = norm_rotation(rotation)?;
        let uuid = new_uuid();
        let (x, y) = at;
        let right = rot == 180.0 || rot == 270.0;
        let mut children = vec![Node::quoted(text)];
        let justify: Vec<Node> = match kind {
            LabelKind::Local => vec![Node::atom(if right { "right" } else { "left" }), Node::atom("bottom")],
            _ => vec![Node::atom(if right { "right" } else { "left" })],
        };
        if kind != LabelKind::Local {
            let shape = shape.map(|s| s.trim().to_ascii_lowercase()).unwrap_or_else(|| if kind == LabelKind::Global { "bidirectional".into() } else { "input".into() });
            if !["input", "output", "bidirectional", "tri_state", "passive"].contains(&shape.as_str()) {
                return Err(format!("shape must be input, output, bidirectional, tri_state or passive (got {shape})"));
            }
            children.push(Node::list("shape", vec![Node::atom(shape)]));
        }
        children.push(Node::list("at", vec![Node::number(x), Node::number(y), Node::number(rot)]));
        if kind != LabelKind::Local {
            children.push(Node::list("fields_autoplaced", vec![Node::atom("yes")]));
        }
        children.push(Node::list("effects", vec![font_node(), Node::list("justify", justify)]));
        children.push(Node::list("uuid", vec![Node::quoted(&uuid)]));
        if kind == LabelKind::Global {
            children.push(property_node("Intersheetrefs", "${INTERSHEET_REFS}", (x, y), None, true));
        }
        self.insert_item(Node::list(kind.token(), children));
        Ok(uuid)
    }

    /// Where each pin of a placed symbol connects, in sheet millimetres (y down), every unit placed
    /// under that reference: [(pin number, (x, y))]. Library pins are y up; the symbol's angle turns
    /// them counterclockwise as seen, then its mirror flips them (KiCad's order).
    pub fn pin_points(&self, reference: &str) -> Result<Vec<(String, (f64, f64))>, String> {
        let mut out = Vec::new();
        let mut found = false;
        for inst in self.top().iter().filter(|n| is_symbol_instance(n) && symbol_property(n, "Reference").as_deref() == Some(reference)) {
            found = true;
            let lib_key = inst.child_value("lib_name").or_else(|| inst.child_value("lib_id")).unwrap_or_default();
            let Some(lib) = self.lib_symbol(&lib_key) else { return Err(format!("{reference}: its library symbol {lib_key} is not in the sheet's lib_symbols")) };
            let base = lib_key.rsplit(':').next().unwrap_or(&lib_key).to_string();
            let unit = inst.child("unit").and_then(|u| u.num(0)).unwrap_or(1.0) as u32;
            let at = inst.child("at").ok_or("symbol has no (at ...)")?;
            let (sx, sy, rot) = (at.num(0).unwrap_or(0.0), at.num(1).unwrap_or(0.0), at.num(2).unwrap_or(0.0));
            let mirror = inst.child_value("mirror");
            let (sin, cos) = rot.to_radians().sin_cos();
            for sub in lib.children().iter().filter(|n| n.head() == Some("symbol")) {
                let Some(name) = sub.arg(0) else { continue };
                let Some(u) = sub_unit(&name, &base) else { continue };
                if u != 0 && u != unit {
                    continue;
                }
                for pin in sub.children().iter().filter(|n| n.head() == Some("pin")) {
                    let (Some(num), Some(pat)) = (pin.child_value("number"), pin.child("at")) else { continue };
                    let (px, py) = (pat.num(0).unwrap_or(0.0), pat.num(1).unwrap_or(0.0));
                    let (mut qx, mut qy) = (px * cos - py * sin, px * sin + py * cos);
                    match mirror.as_deref() {
                        Some("x") => qy = -qy,
                        Some("y") => qx = -qx,
                        _ => {}
                    }
                    let r = |v: f64| (v * 1e4).round() / 1e4 + 0.0;
                    out.push((num, (r(sx + qx), r(sy - qy))));
                }
            }
        }
        if !found {
            return Err(format!("no symbol with reference {reference} on this sheet"));
        }
        Ok(out)
    }

    /// A placed symbol's pins as (number, name), from its library symbol.
    pub fn pin_names(&self, reference: &str) -> Vec<(String, String)> {
        let mut out = Vec::new();
        for inst in self.top().iter().filter(|n| is_symbol_instance(n) && symbol_property(n, "Reference").as_deref() == Some(reference)) {
            let lib_key = inst.child_value("lib_name").or_else(|| inst.child_value("lib_id")).unwrap_or_default();
            let Some(lib) = self.lib_symbol(&lib_key) else { continue };
            for sub in lib.children().iter().filter(|n| n.head() == Some("symbol")) {
                for pin in sub.children().iter().filter(|n| n.head() == Some("pin")) {
                    if let (Some(num), Some(name)) = (pin.child_value("number"), pin.child_value("name")) {
                        if !out.iter().any(|(n, _): &(String, String)| *n == num) {
                            out.push((num, name));
                        }
                    }
                }
            }
        }
        out
    }

    /// A no-connect flag at `at` (on a pin's connection point): `(no_connect (at x y) (uuid ..))`.
    pub fn add_no_connect(&mut self, at: (f64, f64)) -> Result<String, String> {
        let uuid = new_uuid();
        self.insert_item(Node::list("no_connect", vec![Node::list("at", vec![Node::number(at.0), Node::number(at.1)]), Node::list("uuid", vec![Node::quoted(&uuid)])]));
        Ok(uuid)
    }

    /// Move a symbol (and its fields with it) so its anchor lands on `to`.
    pub fn move_symbol(&mut self, reference: &str, to: (f64, f64)) -> Result<(String, (f64, f64)), String> {
        let idx = self.find_symbol_index(reference).ok_or_else(|| format!("no symbol with reference {reference} on this sheet"))?;
        let node = &mut self.top_mut()[idx];
        let at = node.child("at").ok_or("symbol has no (at ...)")?;
        let (ox, oy) = (at.num(0).unwrap_or(0.0), at.num(1).unwrap_or(0.0));
        let (dx, dy) = (to.0 - ox, to.1 - oy);
        shift_at(node.child_mut("at").expect("checked"), dx, dy);
        for prop in node.children_mut().expect("list").iter_mut().filter(|n| n.head() == Some("property")) {
            if let Some(a) = prop.child_mut("at") {
                shift_at(a, dx, dy);
            }
        }
        let uuid = node.child_value("uuid").unwrap_or_default();
        Ok((uuid, (ox, oy)))
    }

    /// Delete any top-level item by uuid (symbol, wire, label, junction, sheet, ...).
    /// Returns the item's kind.
    pub fn delete(&mut self, uuid: &str) -> Result<String, String> {
        let idx = self.find_uuid_index(uuid).ok_or_else(|| format!("no item with uuid {uuid} on this sheet"))?;
        let node = self.top_mut().remove(idx);
        let kind = node.head().unwrap_or("item").to_string();
        Ok(kind)
    }

    /// Set (or add) a field on a symbol. Reference also updates the instances block.
    /// Set (or add, hidden) field `name` on every unit of the part `reference` placed on this sheet
    /// (a multi-unit part is one top-level symbol per unit). Answers how many units changed, and the
    /// value the first one had before (None when the field was added).
    pub fn set_property_all_units(&mut self, reference: &str, name: &str, value: &str) -> Result<(usize, Option<String>), String> {
        if name == "Reference" {
            return Err("Reference is renamed with sch_set_property, one part at a time".into());
        }
        let idx: Vec<usize> = self.top().iter().enumerate().filter(|(_, n)| is_symbol_instance(n) && symbol_property(n, "Reference").as_deref() == Some(reference)).map(|(i, _)| i).collect();
        if idx.is_empty() {
            return Err(format!("no symbol with reference {reference} on this sheet"));
        }
        let mut first_old: Option<Option<String>> = None;
        for i in idx.iter().copied() {
            let node = &mut self.top_mut()[i];
            let (ax, ay) = node.child("at").map(|a| (a.num(0).unwrap_or(0.0), a.num(1).unwrap_or(0.0))).unwrap_or((0.0, 0.0));
            let children = node.children_mut().expect("list");
            let old = match children.iter_mut().find(|n| n.head() == Some("property") && n.arg(0).as_deref() == Some(name)) {
                Some(prop) => {
                    let o = prop.arg(1);
                    prop.set_arg(1, Node::quoted(value));
                    o
                }
                None => {
                    let mut p = property_node(name, value, (ax, ay), None, true);
                    pretty(&mut p, 2);
                    let last_prop = children.iter().rposition(|n| n.head() == Some("property"));
                    let at = last_prop.map(|i| i + 1).unwrap_or_else(|| children.iter().position(|n| n.head() == Some("pin") || n.head() == Some("instances")).unwrap_or(children.len()));
                    children.insert(at, p);
                    None
                }
            };
            first_old.get_or_insert(old);
        }
        Ok((idx.len(), first_old.flatten()))
    }

    pub fn set_property(&mut self, reference: &str, name: &str, value: &str) -> Result<(String, Option<String>), String> {
        if name.trim().is_empty() {
            return Err("property name is required".into());
        }
        if name == "Reference" && value.trim().is_empty() {
            return Err("Reference cannot be empty".into());
        }
        if name == "Reference" && value != reference && self.find_symbol_index(value).is_some() {
            return Err(format!("reference {value} is already used on this sheet"));
        }
        let idx = self.find_symbol_index(reference).ok_or_else(|| format!("no symbol with reference {reference} on this sheet"))?;
        let node = &mut self.top_mut()[idx];
        let uuid = node.child_value("uuid").unwrap_or_default();
        let (ax, ay) = node.child("at").map(|a| (a.num(0).unwrap_or(0.0), a.num(1).unwrap_or(0.0))).unwrap_or((0.0, 0.0));
        let children = node.children_mut().expect("list");
        let mut old = None;
        if let Some(prop) = children.iter_mut().find(|n| n.head() == Some("property") && n.arg(0).as_deref() == Some(name)) {
            old = prop.arg(1);
            prop.set_arg(1, Node::quoted(value));
        } else {
            let mut p = property_node(name, value, (ax, ay), None, true);
            let depth = 2;
            pretty(&mut p, depth);
            let last_prop = children.iter().rposition(|n| n.head() == Some("property"));
            let at = last_prop.map(|i| i + 1).unwrap_or_else(|| children.iter().position(|n| n.head() == Some("pin") || n.head() == Some("instances")).unwrap_or(children.len()));
            children.insert(at, p);
        }
        if name == "Reference" {
            if let Some(inst) = children.iter_mut().find(|n| n.head() == Some("instances")) {
                for proj in inst.children_mut().expect("list").iter_mut().filter(|n| n.head() == Some("project")) {
                    for path in proj.children_mut().expect("list").iter_mut().filter(|n| n.head() == Some("path")) {
                        if let Some(r) = path.child_mut("reference") {
                            r.set_arg(0, Node::quoted(value));
                        }
                    }
                }
            }
        }
        Ok((uuid, old))
    }

    /// Next free `<prefix><n>` reference on this sheet (`#PWR` gives `#PWR01` style).
    pub fn next_reference(&self, prefix: &str) -> String {
        let used: BTreeSet<u32> = self
            .symbols()
            .iter()
            .filter_map(|s| s.reference.strip_prefix(prefix).and_then(|n| n.parse::<u32>().ok()))
            .collect();
        let mut n = 1;
        while used.contains(&n) {
            n += 1;
        }
        if prefix.starts_with('#') {
            format!("{prefix}{n:02}")
        } else {
            format!("{prefix}{n}")
        }
    }
}

pub fn xy(p: (f64, f64)) -> Node {
    Node::list("xy", vec![Node::number(p.0), Node::number(p.1)])
}

pub fn font_node() -> Node {
    Node::list("font", vec![Node::list("size", vec![Node::number(1.27), Node::number(1.27)])])
}

pub fn property_node(name: &str, value: &str, at: (f64, f64), justify: Option<&str>, hide: bool) -> Node {
    let mut effects = vec![font_node()];
    if let Some(j) = justify {
        effects.push(Node::list("justify", vec![Node::atom(j)]));
    }
    if hide {
        effects.push(Node::list("hide", vec![Node::atom("yes")]));
    }
    Node::list(
        "property",
        vec![
            Node::quoted(name),
            Node::quoted(value),
            Node::list("at", vec![Node::number(at.0), Node::number(at.1), Node::number(0.0)]),
            Node::list("effects", effects),
        ],
    )
}

pub fn shift_at(at: &mut Node, dx: f64, dy: f64) {
    let x = at.num(0).unwrap_or(0.0) + dx;
    let y = at.num(1).unwrap_or(0.0) + dy;
    at.set_arg(0, Node::number(x));
    at.set_arg(1, Node::number(y));
}

pub fn norm_rotation(r: f64) -> Result<f64, String> {
    let r = ((r % 360.0) + 360.0) % 360.0;
    if [0.0, 90.0, 180.0, 270.0].contains(&r) {
        Ok(r)
    } else {
        Err(format!("rotation must be 0, 90, 180 or 270 (got {r})"))
    }
}

/// A top-level `(symbol (lib_id ...) ...)`, as opposed to a `lib_symbols` entry.
pub fn is_symbol_instance(n: &Node) -> bool {
    n.head() == Some("symbol") && n.child("lib_id").is_some()
}

pub fn symbol_property(n: &Node, name: &str) -> Option<String> {
    n.children().iter().find(|c| c.head() == Some("property") && c.arg(0).as_deref() == Some(name))?.arg(1)
}

fn symbol_info(n: &Node) -> SymbolInfo {
    let at = n.child("at");
    SymbolInfo {
        uuid: n.child_value("uuid").unwrap_or_default(),
        reference: symbol_property(n, "Reference").unwrap_or_default(),
        lib_id: n.child_value("lib_id").unwrap_or_default(),
        value: symbol_property(n, "Value").unwrap_or_default(),
        at: [at.and_then(|a| a.num(0)).unwrap_or(0.0), at.and_then(|a| a.num(1)).unwrap_or(0.0)],
        rotation: at.and_then(|a| a.num(2)).unwrap_or(0.0),
        unit: n.child_value("unit").and_then(|u| u.parse().ok()).unwrap_or(1),
    }
}

fn lib_property_value(lib: &Node, name: &str) -> Option<String> {
    symbol_property(lib, name)
}

/// Pin numbers a placed unit carries: the pins of sub-symbols `Name_0_*` (shared) and
/// `Name_<unit>_*`, in file order, de-duplicated.
pub fn lib_symbol_pins(lib: &Node, base_name: &str, unit: u32) -> Vec<String> {
    let mut out: Vec<String> = Vec::new();
    for sub in lib.children().iter().filter(|n| n.head() == Some("symbol")) {
        let Some(name) = sub.arg(0) else { continue };
        let Some(u) = sub_unit(&name, base_name) else { continue };
        if u != 0 && u != unit {
            continue;
        }
        for pin in sub.children().iter().filter(|n| n.head() == Some("pin")) {
            if let Some(num) = pin.child_value("number") {
                if !out.contains(&num) {
                    out.push(num);
                }
            }
        }
    }
    out
}

/// `R_1_1` with base `R` -> unit 1.
pub fn sub_unit(name: &str, base: &str) -> Option<u32> {
    let rest = name.strip_prefix(base)?.strip_prefix('_')?;
    let (u, _style) = rest.split_once('_')?;
    u.parse().ok()
}

pub fn project_name_for(path: &Path) -> Option<String> {
    let dir = path.parent()?;
    if let Ok(rd) = std::fs::read_dir(dir) {
        let mut pros: Vec<String> = rd
            .flatten()
            .filter_map(|e| {
                let p = e.path();
                if p.extension().and_then(|x| x.to_str()) == Some("kicad_pro") {
                    p.file_stem().map(|s| s.to_string_lossy().to_string())
                } else {
                    None
                }
            })
            .collect();
        pros.sort();
        let stem = path.file_stem().map(|s| s.to_string_lossy().to_string());
        if let Some(s) = &stem {
            if pros.iter().any(|p| p == s) {
                return stem;
            }
        }
        if pros.len() == 1 {
            return pros.pop();
        }
    }
    path.file_stem().map(|s| s.to_string_lossy().to_string())
}

// ---------------------------------------------------------------------------
// Library symbols
// ---------------------------------------------------------------------------

/// The `(symbol "Name" ...)` block for `name` from a library's text, with `extends`
/// resolved the way KiCad's Flatten does: the parent's drawings and pins, the child's
/// properties on top, units renamed to the child's name.
pub fn flatten_lib_symbol(lib_text: &str, name: &str) -> Result<Node, String> {
    let symbols = libraries::extract_top_level_symbols(lib_text)?;
    flatten_in(&symbols, name, 0)
}

fn flatten_in(symbols: &[(String, String)], name: &str, depth: u32) -> Result<Node, String> {
    if depth > 8 {
        return Err(format!("symbol {name}: extends chain deeper than 8"));
    }
    let (_, blk) = symbols.iter().find(|(n, _)| n == name).ok_or_else(|| format!("symbol {name} not found in the library ({} symbols)", symbols.len()))?;
    let mut node = parse_node(blk)?;
    node.lead.clear();
    let Some(parent_name) = node.child_value("extends") else { return Ok(node) };
    let mut parent = flatten_in(symbols, &parent_name, depth + 1)?;
    parent.set_arg(0, Node::quoted(name));
    let parent_children = parent.children_mut().expect("list");
    parent_children.retain(|n| n.head() != Some("extends"));
    for sub in parent_children.iter_mut().filter(|n| n.head() == Some("symbol")) {
        if let Some(sn) = sub.arg(0) {
            if let Some(rest) = sn.strip_prefix(&parent_name).and_then(|r| r.strip_prefix('_')) {
                sub.set_arg(0, Node::quoted(&format!("{name}_{rest}")));
            }
        }
    }
    for child in node.children().iter().filter(|n| n.head() == Some("property")) {
        let pname = child.arg(0).unwrap_or_default();
        let mut replacement = child.clone();
        replacement.lead.clear();
        if let Some(slot) = parent_children.iter_mut().find(|n| n.head() == Some("property") && n.arg(0).as_deref() == Some(pname.as_str())) {
            *slot = replacement;
        } else {
            let at = parent_children.iter().rposition(|n| n.head() == Some("property")).map(|i| i + 1).unwrap_or(parent_children.len());
            parent_children.insert(at, replacement);
        }
    }
    // Child-level flags (power, pin_names offset, ...) override the parent's.
    for child in node.children().iter().filter(|n| n.is_list() && !matches!(n.head(), Some("property") | Some("symbol") | Some("extends"))) {
        let h = child.head().unwrap_or("");
        let mut replacement = child.clone();
        replacement.lead.clear();
        if let Some(slot) = parent_children.iter_mut().find(|n| n.head() == Some(h)) {
            *slot = replacement;
        }
    }
    Ok(parent)
}

#[derive(Clone, Debug)]
pub struct ResolvedSymbol {
    pub library_path: String,
    pub name: String,
    pub block: Node,
    pub tables_checked: Vec<String>,
}

/// Find `Nick:Name` through the project's `sym-lib-table` (next to the schematic,
/// `${KIPRJMOD}` honoured) and then the global one from `ctx`, and return the
/// flattened block.
pub fn resolve_lib_symbol(ctx: &LibCtx, lib_id: &str, project_dir: Option<&Path>) -> Result<ResolvedSymbol, String> {
    let (nick, name) = lib_id.split_once(':').ok_or_else(|| format!("libId must be Library:Symbol (got {lib_id})"))?;
    let mut tables: Vec<PathBuf> = Vec::new();
    if let Some(d) = project_dir {
        tables.push(d.join("sym-lib-table"));
    }
    if let Some(g) = ctx.sym_lib_table() {
        tables.push(g);
    }
    let mut checked = Vec::new();
    for t in &tables {
        if !t.is_file() {
            continue;
        }
        checked.push(libraries::norm_path(t));
        let table = LibTable::parse_file_resolved(t)?;
        let Some(entry) = table.get(nick) else { continue };
        let uri = match project_dir {
            Some(d) => entry.uri.replace("${KIPRJMOD}", &libraries::norm_path(d)),
            None => entry.uri.clone(),
        };
        let path = expand_kicad_uri(&uri, ctx).ok_or_else(|| format!("library {nick} uri {} uses a path variable this bridge cannot expand (is KiCad installed?)", entry.uri))?;
        let mut r = load_lib_symbol(Path::new(&path), name)?;
        r.tables_checked = checked;
        return Ok(r);
    }
    Err(format!("library {nick} is not in any sym-lib-table (checked: {})", if checked.is_empty() { "none found".to_string() } else { checked.join(", ") }))
}

/// Read `name` straight from a `.kicad_sym` file.
pub fn load_lib_symbol(lib_path: &Path, name: &str) -> Result<ResolvedSymbol, String> {
    let text = std::fs::read_to_string(lib_path).map_err(|e| format!("cannot read library {}: {e}", lib_path.display()))?;
    let block = flatten_lib_symbol(&text, name).map_err(|e| format!("{}: {e}", lib_path.display()))?;
    Ok(ResolvedSymbol { library_path: libraries::norm_path(lib_path), name: name.to_string(), block, tables_checked: vec![] })
}

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

    #[test]
    fn quote_roundtrip() {
        for s in ["", "R1", "a \"b\" c", "back\\slash", "tab\there", "line\nbreak"] {
            assert_eq!(unquote(&quote(s)), s);
        }
    }

    #[test]
    fn numbers_look_like_kicad() {
        assert_eq!(fmt_num(127.0), "127");
        assert_eq!(fmt_num(80.0099), "80.0099");
        assert_eq!(fmt_num(1.27), "1.27");
        assert_eq!(fmt_num(-0.00001), "0");
        assert_eq!(fmt_num(2.54 * 3.0), "7.62");
    }

    #[test]
    fn pretty_matches_eeschema_layout() {
        let mut n = Node::list("wire", vec![Node::list("pts", vec![xy((1.0, 2.0)), xy((3.0, 4.0))]), Node::list("uuid", vec![Node::quoted("u")])]);
        pretty(&mut n, 1);
        assert_eq!(n.to_text(), "\n\t(wire\n\t\t(pts\n\t\t\t(xy 1 2) (xy 3 4)\n\t\t)\n\t\t(uuid \"u\")\n\t)");
    }

    #[test]
    fn uuids_are_v4_and_unique() {
        let a = new_uuid();
        let b = new_uuid();
        assert_ne!(a, b);
        assert_eq!(a.len(), 36);
        assert_eq!(&a[14..15], "4");
        assert!("89ab".contains(&a[19..20]));
    }
}