master
Colby Knox Fix #133 (outline box from arcs and circles; ring courtyards: 2-degree sampling, interior probes), #130 (KICAD10_3RD_PA… f7d1e2b 15d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353
//! KiCad library tables and symbol libraries, ported from the Python bridge's
//! `lib_table.py`, `adom_library.py`, `handlers/list_symbols.py`,
//! `handlers/install_footprint.py::handle_list_footprints` and
//! `handlers/kicad_parse_check.py`.
//!
//! Everything here is file work: a minimal s-expression tokenizer (lib tables are
//! tiny and rigid; symbol names need a quote-aware scan), KiCad path-variable
//! expansion (`${KICAD10_SYMBOL_DIR}` and friends, with the user's Configure Paths
//! overrides from `kicad_common.json` winning), the Adom library row-first rule
//! (wiki #71: the sym-lib-table row is the file KiCad reads, so that is where the
//! bridge reads and writes), the split report, and KiCad's own verdict on a
//! symbol library through `kicad-cli sym export svg`.

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

use serde_json::{json, Value};

pub const ADOM_LIB_NAME: &str = "Adom";
pub const ADOM_LIB_FILE: &str = "Adom.kicad_sym";
pub const ADOM_LIB_DESCRIPTION: &str = "Adom Bridge - custom symbols";
pub const ADOM_FP_LIB_NAME: &str = "Adom";
pub const ADOM_FP_DIR_NAME: &str = "Adom.pretty";

const EMPTY_LIB: &str = "(kicad_symbol_lib\n  (version 20231120)\n  (generator \"adom-desktop\")\n)\n";

// ---------------------------------------------------------------------------
// Minimal s-expression tokenizer
// ---------------------------------------------------------------------------

/// One node of a KiCad s-expression. Quoted strings and bare atoms both become
/// `Atom`; the reader does not need to tell them apart.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Sexp {
    Atom(String),
    List(Vec<Sexp>),
}

impl Sexp {
    pub fn as_atom(&self) -> Option<&str> {
        match self {
            Sexp::Atom(s) => Some(s.as_str()),
            Sexp::List(_) => None,
        }
    }
    pub fn as_list(&self) -> Option<&[Sexp]> {
        match self {
            Sexp::List(v) => Some(v.as_slice()),
            Sexp::Atom(_) => None,
        }
    }
    /// The head atom of a list, e.g. `lib` for `(lib ...)`.
    pub fn head(&self) -> Option<&str> {
        self.as_list().and_then(|l| l.first()).and_then(Sexp::as_atom)
    }
    /// The first child list whose head is `name`.
    pub fn child(&self, name: &str) -> Option<&Sexp> {
        self.as_list()?.iter().find(|c| c.head() == Some(name))
    }
    /// The first atom after the head of the child list `name`: `(uri "x")` -> `x`.
    pub fn child_value(&self, name: &str) -> Option<&str> {
        self.child(name)?.as_list()?.get(1)?.as_atom()
    }
}

/// Parse the first complete s-expression in `text`. Errors name the offset so a
/// corrupt table is reported rather than guessed at.
pub fn parse_sexp(text: &str) -> Result<Sexp, String> {
    let bytes = text.as_bytes();
    let mut i = 0usize;
    skip_ws(bytes, &mut i);
    if i >= bytes.len() {
        return Err("no s-expression found".into());
    }
    let node = parse_node(text, bytes, &mut i)?;
    Ok(node)
}

fn skip_ws(b: &[u8], i: &mut usize) {
    while *i < b.len() && (b[*i] as char).is_ascii_whitespace() {
        *i += 1;
    }
}

fn parse_node(text: &str, b: &[u8], i: &mut usize) -> Result<Sexp, String> {
    skip_ws(b, i);
    if *i >= b.len() {
        return Err("unexpected end of input".into());
    }
    match b[*i] {
        b'(' => {
            let open = *i;
            *i += 1;
            let mut items = Vec::new();
            loop {
                skip_ws(b, i);
                if *i >= b.len() {
                    return Err(format!("unbalanced s-expression: the list opened at offset {open} never closes"));
                }
                if b[*i] == b')' {
                    *i += 1;
                    return Ok(Sexp::List(items));
                }
                items.push(parse_node(text, b, i)?);
            }
        }
        b')' => Err(format!("unbalanced s-expression: extra ')' at offset {}", *i)),
        b'"' => {
            *i += 1;
            let mut out = String::new();
            loop {
                if *i >= b.len() {
                    return Err("unterminated quoted string".into());
                }
                let c = b[*i];
                if c == b'\\' && *i + 1 < b.len() {
                    let n = b[*i + 1];
                    out.push(match n {
                        b'n' => '\n',
                        b't' => '\t',
                        b'r' => '\r',
                        _ => n as char,
                    });
                    *i += 2;
                    continue;
                }
                if c == b'"' {
                    *i += 1;
                    return Ok(Sexp::Atom(out));
                }
                // Copy one UTF-8 scalar at a time.
                let ch = text[*i..].chars().next().unwrap();
                out.push(ch);
                *i += ch.len_utf8();
            }
        }
        _ => {
            let start = *i;
            while *i < b.len() && !(b[*i] as char).is_ascii_whitespace() && b[*i] != b'(' && b[*i] != b')' && b[*i] != b'"' {
                *i += 1;
            }
            Ok(Sexp::Atom(text[start..*i].to_string()))
        }
    }
}

fn quote(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('"');
    for ch in s.chars() {
        match ch {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            _ => out.push(ch),
        }
    }
    out.push('"');
    out
}

// ---------------------------------------------------------------------------
// Library tables (sym-lib-table, fp-lib-table)
// ---------------------------------------------------------------------------

#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct LibEntry {
    pub name: String,
    pub lib_type: String,
    pub uri: String,
    pub options: String,
    pub descr: String,
    /// `(disabled)` flag KiCad 8+ writes for a library the user switched off.
    pub disabled: bool,
    /// `(hidden)` flag (PCM-managed rows).
    pub hidden: bool,
}

impl LibEntry {
    pub fn new(name: &str, lib_type: &str, uri: &str, options: &str, descr: &str) -> Self {
        LibEntry { name: name.into(), lib_type: lib_type.into(), uri: uri.into(), options: options.into(), descr: descr.into(), disabled: false, hidden: false }
    }
    pub fn to_sexpr(&self) -> String {
        let mut s = format!(
            "  (lib (name {})(type {})(uri {})(options {})(descr {})",
            quote(&self.name), quote(&self.lib_type), quote(&self.uri), quote(&self.options), quote(&self.descr)
        );
        if self.disabled {
            s.push_str("(disabled)");
        }
        if self.hidden {
            s.push_str("(hidden)");
        }
        s.push(')');
        s
    }
}

#[derive(Clone, Debug, Default)]
pub struct LibTable {
    /// `sym_lib_table` or `fp_lib_table`.
    pub table_type: String,
    pub version: Option<u32>,
    pub entries: Vec<LibEntry>,
}

impl LibTable {
    pub fn empty(table_type: &str) -> Self {
        LibTable { table_type: table_type.into(), version: Some(7), entries: Vec::new() }
    }

    /// Parse a table file. A missing file yields an empty table (KiCad 9+ shape,
    /// version 7), exactly like the Python `LibTable.parse_file`.
    pub fn parse_file(path: &Path) -> Result<LibTable, String> {
        if !path.exists() {
            let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
            return Ok(LibTable::empty(if name.contains("sym") { "sym_lib_table" } else { "fp_lib_table" }));
        }
        let text = std::fs::read_to_string(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
        LibTable::parse_str(&text).map_err(|e| format!("{}: {e}", path.display()))
    }

    /// READ-ONLY view with nested tables expanded. A fresh KiCad 10 install does not list the stock
    /// libraries in the user's table: it writes ONE row of type "Table" whose uri is KiCad's own
    /// template table. Looking only at the top level made every stock library (Device, ...) look
    /// unregistered on a blank box (winvm, 10.0.6). Never write this view back: it would flatten
    /// KiCad's file. Rows of the outer table win over nested rows of the same name.
    pub fn parse_file_resolved(path: &Path) -> Result<LibTable, String> {
        let mut table = LibTable::parse_file(path)?;
        let mut seen = vec![path.to_path_buf()];
        let mut depth = 0;
        while depth < 4 && table.entries.iter().any(|e| e.lib_type.eq_ignore_ascii_case("Table")) {
            depth += 1;
            let mut flat: Vec<LibEntry> = Vec::new();
            let mut nested: Vec<LibEntry> = Vec::new();
            for e in std::mem::take(&mut table.entries) {
                if !e.lib_type.eq_ignore_ascii_case("Table") {
                    flat.push(e);
                    continue;
                }
                let inner = PathBuf::from(e.uri.replace('\\', "/"));
                if e.disabled || seen.contains(&inner) || !inner.is_file() {
                    continue;
                }
                seen.push(inner.clone());
                if let Ok(t) = LibTable::parse_file(&inner) {
                    nested.extend(t.entries);
                }
            }
            for e in nested {
                if !flat.iter().any(|f| f.name == e.name) {
                    flat.push(e);
                }
            }
            table.entries = flat;
        }
        table.entries.retain(|e| !e.lib_type.eq_ignore_ascii_case("Table"));
        Ok(table)
    }

    pub fn parse_str(text: &str) -> Result<LibTable, String> {
        let root = parse_sexp(text).map_err(|e| format!("Cannot parse lib table: {e}"))?;
        let head = root.head().ok_or_else(|| "Cannot parse lib table: no root expression found".to_string())?;
        let mut table = LibTable { table_type: head.to_string(), version: None, entries: Vec::new() };
        for node in root.as_list().unwrap_or(&[]).iter().skip(1) {
            match node.head() {
                Some("version") => {
                    table.version = node.as_list().and_then(|l| l.get(1)).and_then(Sexp::as_atom).and_then(|v| v.parse().ok());
                }
                Some("lib") => {
                    let get = |k: &str| node.child_value(k).unwrap_or("").to_string();
                    let flag = |k: &str| node.child(k).is_some();
                    table.entries.push(LibEntry {
                        name: get("name"),
                        lib_type: get("type"),
                        uri: get("uri"),
                        options: get("options"),
                        descr: get("descr"),
                        disabled: flag("disabled"),
                        hidden: flag("hidden"),
                    });
                }
                _ => {}
            }
        }
        Ok(table)
    }

    pub fn has_library(&self, name: &str) -> bool {
        self.entries.iter().any(|e| e.name == name)
    }

    pub fn get(&self, name: &str) -> Option<&LibEntry> {
        self.entries.iter().find(|e| e.name == name)
    }

    /// Add a row. Returns false (and adds nothing) when the name already exists.
    pub fn add_library(&mut self, entry: LibEntry) -> bool {
        if self.has_library(&entry.name) {
            return false;
        }
        self.entries.push(entry);
        true
    }

    pub fn remove_library(&mut self, name: &str) -> bool {
        let before = self.entries.len();
        self.entries.retain(|e| e.name != name);
        self.entries.len() < before
    }

    pub fn to_text(&self) -> String {
        let mut lines = vec![format!("({}", self.table_type)];
        if let Some(v) = self.version {
            lines.push(format!("  (version {v})"));
        }
        for e in &self.entries {
            lines.push(e.to_sexpr());
        }
        lines.push(")".into());
        lines.join("\n") + "\n"
    }

    /// Write the table, creating parent dirs. Atomic: a sibling temp file is
    /// renamed over the target so a crash never leaves KiCad a half-written table.
    pub fn write_file(&self, path: &Path) -> Result<(), String> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
        }
        write_atomic(path, self.to_text().as_bytes())
    }
}

/// Write bytes to `path` through a sibling temp file and a rename.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
    let tmp = path.with_extension(format!("{}.adom-tmp", path.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default()));
    std::fs::write(&tmp, bytes).map_err(|e| format!("cannot write {}: {e}", tmp.display()))?;
    if let Err(e) = std::fs::rename(&tmp, path) {
        // Windows refuses to rename over an open file; fall back to a plain write.
        let _ = std::fs::remove_file(&tmp);
        std::fs::write(path, bytes).map_err(|e2| format!("cannot write {}: {e2} (rename failed: {e})", path.display()))?;
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// Path-variable expansion
// ---------------------------------------------------------------------------

/// What the expansion needs to know about the KiCad this bridge drives. Built by
/// the verb layer from detection plus the platform's config and user dirs.
#[derive(Clone, Debug, Default)]
pub struct LibCtx {
    /// major.minor, e.g. "10.0".
    pub version: String,
    /// KiCad install root (the dir above bin/), if KiCad is installed.
    pub base_dir: Option<String>,
    /// Per-user settings dir (sym-lib-table, fp-lib-table, kicad_common.json live here).
    pub config_dir: Option<PathBuf>,
    /// Documents/KiCad/<ver>.
    pub user_dir: Option<PathBuf>,
    /// kicad-cli path when KiCad is installed.
    pub kicad_cli: Option<PathBuf>,
    /// Preferences > Configure Paths overrides (kicad_common.json environment.vars).
    pub env_overrides: BTreeMap<String, String>,
}

impl LibCtx {
    pub fn major(&self) -> &str {
        self.version.split('.').next().unwrap_or("")
    }
    pub fn sym_lib_table(&self) -> Option<PathBuf> {
        self.config_dir.as_ref().map(|c| c.join("sym-lib-table"))
    }
    pub fn fp_lib_table(&self) -> Option<PathBuf> {
        self.config_dir.as_ref().map(|c| c.join("fp-lib-table"))
    }
    /// Load the Configure Paths overrides from `<config_dir>/kicad_common.json`.
    pub fn load_env_overrides(&mut self) {
        if let Some(c) = &self.config_dir {
            self.env_overrides = read_env_overrides(&c.join("kicad_common.json"));
        }
    }
}

/// `environment.vars` from a kicad_common.json: the variables the user overrode in
/// Preferences > Configure Paths. Only non-empty string values count.
pub fn read_env_overrides(kicad_common: &Path) -> BTreeMap<String, String> {
    let mut out = BTreeMap::new();
    let Ok(text) = std::fs::read_to_string(kicad_common) else { return out };
    let Ok(v) = serde_json::from_str::<Value>(&text) else { return out };
    if let Some(vars) = v.get("environment").and_then(|e| e.get("vars")).and_then(Value::as_object) {
        for (k, val) in vars {
            if let Some(s) = val.as_str() {
                if !s.is_empty() {
                    out.insert(k.clone(), s.to_string());
                }
            }
        }
    }
    out
}

/// Lexical normalisation the way `os.path.normpath` does it, with forward slashes:
/// collapse `//`, drop `.`, resolve `..` against the previous segment.
pub fn normpath(p: &str) -> String {
    let s = p.replace('\\', "/");
    let absolute = s.starts_with('/');
    let mut parts: Vec<&str> = Vec::new();
    for seg in s.split('/') {
        match seg {
            "" | "." => {}
            ".." => {
                match parts.last() {
                    Some(&last) if last != ".." && !(parts.len() == 1 && last.ends_with(':')) => {
                        parts.pop();
                    }
                    Some(_) if absolute => {}
                    _ => parts.push(".."),
                }
            }
            _ => parts.push(seg),
        }
    }
    let joined = parts.join("/");
    if absolute {
        format!("/{joined}")
    } else if joined.is_empty() {
        ".".into()
    } else if parts.len() == 1 && parts[0].ends_with(':') {
        format!("{joined}/")
    } else {
        joined
    }
}

/// Expand the path variables a sym-lib-table / fp-lib-table uri may carry.
///
/// `KICAD<n>_SYMBOL_DIR`, `_FOOTPRINT_DIR`, `_3DMODEL_DIR` and `_TEMPLATE_DIR` are
/// KiCad's stock libraries under `<install>/share/kicad` (issue #80: mapping them
/// to Documents made every stock library look missing). A Configure Paths override
/// wins. `KIPRJMOD` is project-relative and refused; an unknown variable is refused
/// rather than guessed. Returns the normalised, forward-slash path.
pub fn expand_kicad_uri(uri: &str, ctx: &LibCtx) -> Option<String> {
    let major = ctx.major();
    let mut subs: Vec<(String, Option<String>)> = Vec::new();
    if let Some(base) = &ctx.base_dir {
        let share = format!("{}/share/kicad", base.trim_end_matches(['/', '\\']));
        subs.push((format!("KICAD{major}_SYMBOL_DIR"), Some(format!("{share}/symbols"))));
        subs.push((format!("KICAD{major}_FOOTPRINT_DIR"), Some(format!("{share}/footprints"))));
        subs.push((format!("KICAD{major}_3DMODEL_DIR"), Some(format!("{share}/3dmodels"))));
        subs.push((format!("KICAD{major}_TEMPLATE_DIR"), Some(format!("{share}/template"))));
    }
    if let Some(docs) = &ctx.user_dir {
        subs.push(("KICAD_USER_TEMPLATE_DIR".into(), Some(norm_path(&docs.join("template")))));
    }
    // Where the Plugin and Content Manager installs (wiki #130): KICAD<n>_3RD_PARTY, or the older
    // KICAD_3RD_PARTY spelling, from Configure Paths; else Documents/KiCad/<ver>/3rdparty, KiCad's own
    // default (the same order model_check uses).
    let third = ctx.env_overrides.get(&format!("KICAD{major}_3RD_PARTY")).cloned()
        .or_else(|| ctx.env_overrides.get("KICAD_3RD_PARTY").cloned())
        .or_else(|| ctx.user_dir.as_ref().map(|u| norm_path(&u.join("3rdparty"))))
        .or_else(|| ctx.config_dir.as_ref().filter(|_| !ctx.version.is_empty()).map(|c| norm_path(&c.join("3rdparty"))));
    if let Some(t) = third {
        subs.push((format!("KICAD{major}_3RD_PARTY"), Some(t.clone())));
        subs.push(("KICAD_3RD_PARTY".into(), Some(t)));
    }
    for (k, v) in &ctx.env_overrides {
        if k.ends_with("3RD_PARTY") {
            continue; // resolved above, so either spelling reaches both names
        }
        subs.retain(|(name, _)| name != k);
        subs.push((k.clone(), Some(v.clone())));
    }
    subs.push(("KIPRJMOD".into(), None));
    let mut out = uri.replace('\\', "/");
    for (k, v) in &subs {
        let token = format!("${{{k}}}");
        if out.contains(&token) {
            match v {
                None => return None,
                Some(val) => out = out.replace(&token, &val.replace('\\', "/")),
            }
        }
    }
    if out.contains("${") {
        return None;
    }
    Some(normpath(&out))
}

pub fn norm_path(p: &Path) -> String {
    p.to_string_lossy().replace('\\', "/")
}

// ---------------------------------------------------------------------------
// Symbol libraries (.kicad_sym)
// ---------------------------------------------------------------------------

/// The names of the top-level symbols in a .kicad_sym, the way the Python
/// `adom_library.list_symbols` reads them: any line whose first token is
/// `(symbol "NAME"` at any indentation (including none, wiki #37), minus the
/// `_N_N` sub-unit names. Missing file: empty list.
pub fn list_symbols(lib_path: &Path) -> Vec<String> {
    match std::fs::read_to_string(lib_path) {
        Ok(text) => list_symbols_in_text(&text),
        Err(_) => Vec::new(),
    }
}

pub fn list_symbols_in_text(text: &str) -> Vec<String> {
    let mut names = Vec::new();
    for line in text.lines() {
        let t = line.trim_start_matches([' ', '\t']);
        let Some(rest) = t.strip_prefix("(symbol") else { continue };
        let rest = rest.trim_start();
        if rest.len() == t.len() - "(symbol".len() {
            // no whitespace after `(symbol`: a different token such as `(symbols`
            continue;
        }
        let Some(rest) = rest.strip_prefix('"') else { continue };
        let Some(end) = rest.find('"') else { continue };
        let name = &rest[..end];
        if !is_subunit_name(name) {
            names.push(name.to_string());
        }
    }
    names
}

/// `Foo_0_1` style sub-unit names: ends with `_<digits>_<digits>`.
pub fn is_subunit_name(name: &str) -> bool {
    let Some(i) = name.rfind('_') else { return false };
    let tail = &name[i + 1..];
    if tail.is_empty() || !tail.bytes().all(|b| b.is_ascii_digit()) {
        return false;
    }
    let head = &name[..i];
    let Some(j) = head.rfind('_') else { return false };
    let mid = &head[j + 1..];
    !mid.is_empty() && mid.bytes().all(|b| b.is_ascii_digit())
}

/// `(symbol "NAME"` at the start of a block: the name, unescaped.
fn symbol_block_name(block: &str) -> Option<String> {
    let t = block.trim_start().strip_prefix('(')?.trim_start();
    let t = t.strip_prefix("symbol")?;
    let t = t.trim_start();
    if t.len() == block.trim_start().len() {
        return None;
    }
    let t = t.strip_prefix('"')?;
    let mut out = String::new();
    let mut chars = t.chars();
    while let Some(c) = chars.next() {
        match c {
            '\\' => {
                if let Some(n) = chars.next() {
                    out.push(n);
                }
            }
            '"' => return Some(out),
            _ => out.push(c),
        }
    }
    None
}

/// Quote-aware scan of the FIRST top-level list in `text`. Returns
/// (root_open, root_close, direct-child spans). Errors on any imbalance, since a
/// library that does not balance must never be rebuilt or merged (wiki #54).
fn quote_aware_children(text: &str) -> Result<(usize, usize, Vec<(usize, usize)>), String> {
    let mut in_str = false;
    let mut esc = false;
    let mut depth: i32 = 0;
    let mut root_open = None;
    let mut root_close = None;
    let mut child_start = None;
    let mut children = Vec::new();
    for (i, ch) in text.char_indices() {
        if in_str {
            if esc {
                esc = false;
            } else if ch == '\\' {
                esc = true;
            } else if ch == '"' {
                in_str = false;
            }
            continue;
        }
        match ch {
            '"' => in_str = true,
            '(' => {
                depth += 1;
                if depth == 1 {
                    root_open = Some(i);
                } else if depth == 2 && child_start.is_none() {
                    child_start = Some(i);
                }
            }
            ')' => {
                depth -= 1;
                if depth == 1 {
                    if let Some(s) = child_start.take() {
                        children.push((s, i + 1));
                    }
                } else if depth == 0 {
                    root_close = Some(i);
                    break;
                } else if depth < 0 {
                    return Err(format!("unbalanced s-expression: extra ')' at offset {i}"));
                }
            }
            _ => {}
        }
    }
    if in_str {
        return Err("unterminated quoted string".into());
    }
    let open = root_open.ok_or_else(|| "no s-expression found".to_string())?;
    let close = root_close.ok_or_else(|| "unbalanced s-expression: the top-level list never closes".to_string())?;
    Ok((open, close, children))
}

/// The direct `(symbol "NAME" ...)` children of a `(kicad_symbol_lib ...)` root, by
/// position (a top-level part whose name ends in `_N_N` is still honoured).
/// Strict: trailing text after the root's closer, or a non-library root, is an error.
pub fn extract_top_level_symbols(text: &str) -> Result<Vec<(String, String)>, String> {
    let (open, close, children) = quote_aware_children(text)?;
    let head = text[open + 1..].split_whitespace().next().unwrap_or("").trim_end_matches(')');
    if head != "kicad_symbol_lib" {
        return Err(format!("not a symbol library (top-level list is '{head}')"));
    }
    if !text[close + 1..].trim().is_empty() {
        let tail: String = text[close + 1..].trim().chars().take(40).collect();
        return Err(format!("trailing content after the library's closing paren: {tail:?}"));
    }
    let mut out = Vec::new();
    for (a, b) in children {
        let blk = &text[a..b];
        if let Some(name) = symbol_block_name(blk) {
            out.push((name, blk.to_string()));
        }
    }
    Ok(out)
}

/// Where the top-level list closes (quote-aware), or None if it never does.
fn library_close_index(text: &str) -> Option<usize> {
    let mut depth: i32 = 0;
    let mut in_str = false;
    let mut esc = false;
    for (i, ch) in text.char_indices() {
        if in_str {
            if esc {
                esc = false;
            } else if ch == '\\' {
                esc = true;
            } else if ch == '"' {
                in_str = false;
            }
            continue;
        }
        match ch {
            '"' => in_str = true,
            '(' => depth += 1,
            ')' => {
                depth -= 1;
                if depth == 0 {
                    return Some(i);
                }
            }
            _ => {}
        }
    }
    None
}

/// (symbols, unparseable) from a .kicad_sym that may carry symbol blocks AFTER the
/// library's closing paren (the old appender bug). Each trailing block is
/// balance-checked on its own; duplicates keep the first (inside) copy.
pub fn extract_symbols_tolerant(text: &str) -> (Vec<(String, String)>, Vec<String>) {
    let close = library_close_index(text);
    let (inside, trail) = match close {
        Some(c) => (&text[..c + 1], &text[c + 1..]),
        None => (text, ""),
    };
    let mut out: Vec<(String, String)> = extract_top_level_symbols(inside).unwrap_or_default();
    let mut bad: Vec<String> = Vec::new();
    if !trail.trim().is_empty() {
        let mut pos = 0usize;
        while pos < trail.len() {
            let Some(rel) = trail[pos..].find('(') else { break };
            let start = pos + rel;
            match library_close_index(&trail[start..]) {
                None => {
                    bad.push(symbol_block_name(&trail[start..]).unwrap_or_else(|| "<unnamed block>".into()));
                    break;
                }
                Some(c) => {
                    let block = &trail[start..start + c + 1];
                    pos = start + c + 1;
                    let Some(name) = symbol_block_name(block) else { continue };
                    match extract_top_level_symbols(&format!("(kicad_symbol_lib\n{block}\n)")) {
                        Ok(_) => out.push((name, block.to_string())),
                        Err(_) => bad.push(name),
                    }
                }
            }
        }
    }
    let mut seen = std::collections::HashSet::new();
    let mut unique = Vec::new();
    let mut dupes = 0;
    for (name, blk) in out {
        if !seen.insert(name.to_lowercase()) {
            dupes += 1;
            continue;
        }
        unique.push((name, blk));
    }
    if dupes > 0 {
        bad.push(format!("<{dupes} duplicate copies skipped>"));
    }
    (unique, bad)
}

// ---------------------------------------------------------------------------
// Bootstrap of a never-launched KiCad's tables
// ---------------------------------------------------------------------------

/// Make sure a lib table file exists. A missing file is seeded from KiCad's own
/// template (`<base>/share/kicad/template/<name>`) so the stock libraries survive,
/// else written empty. An existing file is never touched.
pub fn ensure_lib_table(path: &Path, root: &str, base_dir: Option<&str>) -> Result<(), String> {
    if path.exists() {
        return Ok(());
    }
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
    }
    let file_name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
    if let Some(base) = base_dir {
        let tmpl = Path::new(base).join("share").join("kicad").join("template").join(&file_name);
        if tmpl.is_file() {
            if let Ok(bytes) = std::fs::read(&tmpl) {
                return write_atomic(path, &bytes);
            }
        }
    }
    write_atomic(path, format!("({root}\n  (version 7)\n)\n").as_bytes())
}

// ---------------------------------------------------------------------------
// The Adom symbol library
// ---------------------------------------------------------------------------

/// (sym-lib-table path, Adom.kicad_sym path). ROW FIRST (wiki #71): if a row named
/// Adom exists, its uri is the library; otherwise `<user_dir>/symbols/Adom.kicad_sym`.
pub fn adom_paths(ctx: &LibCtx) -> Option<(PathBuf, PathBuf)> {
    let table_path = ctx.sym_lib_table()?;
    let user_dir = ctx.user_dir.clone()?;
    if let Ok(table) = LibTable::parse_file(&table_path) {
        if let Some(row) = table.get(ADOM_LIB_NAME) {
            if !row.uri.is_empty() {
                if let Some(expanded) = expand_kicad_uri(&row.uri, ctx) {
                    let lib_path = PathBuf::from(expanded);
                    if let Some(parent) = lib_path.parent() {
                        let _ = std::fs::create_dir_all(parent);
                    }
                    return Some((table_path, lib_path));
                }
            }
        }
    }
    let sym_dir = user_dir.join("symbols");
    let _ = std::fs::create_dir_all(&sym_dir);
    Some((table_path, sym_dir.join(ADOM_LIB_FILE)))
}

/// Python `ensure_adom_library`: create Adom.kicad_sym if missing and register it.
/// The returned object carries `exists`, `registered`, `created`, `symbols` and,
/// on failure, `error` (never a `success` key; the verb adds that).
pub fn ensure_adom_library(ctx: &LibCtx, installed: bool) -> Value {
    let empty = |err: &str| json!({"exists": false, "registered": false, "created": false, "symbols": [], "error": err});
    if !installed {
        return empty("KiCad not installed");
    }
    if let (Some(t), Some(_)) = (ctx.sym_lib_table(), ctx.user_dir.as_ref()) {
        let _ = ensure_lib_table(&t, "sym_lib_table", ctx.base_dir.as_deref());
    }
    let Some((table_path, lib_path)) = adom_paths(ctx) else {
        return empty("KiCad config/user directory not found");
    };
    let existed = lib_path.exists();
    if !existed {
        if let Err(e) = write_atomic(&lib_path, EMPTY_LIB.as_bytes()) {
            return empty(&format!("cannot create {}: {e}", lib_path.display()));
        }
    }
    let mut table = match LibTable::parse_file(&table_path) {
        Ok(t) => t,
        Err(e) => return empty(&format!("Failed to parse sym-lib-table: {e}")),
    };
    let was_registered = table.has_library(ADOM_LIB_NAME);
    if !was_registered {
        table.add_library(LibEntry::new(ADOM_LIB_NAME, "KiCad", &norm_path(&lib_path), "", ADOM_LIB_DESCRIPTION));
        if let Err(e) = table.write_file(&table_path) {
            return empty(&format!("cannot write sym-lib-table: {e}"));
        }
    }
    json!({
        "exists": existed,
        "registered": was_registered,
        "created": !existed,
        "symbols": list_symbols(&lib_path),
    })
}

fn same_file(a: &Path, b: &Path) -> bool {
    match (a.canonicalize(), b.canonicalize()) {
        (Ok(x), Ok(y)) => x == y,
        _ => normpath(&norm_path(a)).to_lowercase() == normpath(&norm_path(b)).to_lowercase(),
    }
}

/// Python `library_split_report`: which Adom.kicad_sym KiCad READS (the row), which
/// the bridge would WRITE by its default rule, and whether they differ.
pub fn library_split_report(ctx: &LibCtx) -> Value {
    let Some((_table, registered)) = adom_paths(ctx) else {
        return json!({"split": null});
    };
    let default = ctx.user_dir.as_ref().map(|u| u.join("symbols").join(ADOM_LIB_FILE));
    let count = |p: &Path| -> Value { if p.exists() { json!(list_symbols(p).len()) } else { Value::Null } };
    let mut rep = json!({
        "registeredPath": norm_path(&registered),
        "registeredSymbols": count(&registered),
        "bridgeDefaultPath": default.as_ref().map(|p| norm_path(p)),
        "bridgeDefaultSymbols": default.as_ref().map(|p| count(p)).unwrap_or(Value::Null),
    });
    let split = match &default {
        Some(d) => !same_file(d, &registered),
        None => false,
    };
    rep["split"] = json!(split);
    if split {
        let default = default.unwrap();
        let reg_names: std::collections::HashSet<String> = match std::fs::read_to_string(&registered) {
            Ok(t) => extract_symbols_tolerant(&t).0.into_iter().map(|(n, _)| n.to_lowercase()).collect(),
            Err(_) => list_symbols(&registered).into_iter().map(|n| n.to_lowercase()).collect(),
        };
        let orphan_syms = if default.exists() {
            std::fs::read_to_string(&default).map(|t| extract_symbols_tolerant(&t).0).unwrap_or_default()
        } else {
            Vec::new()
        };
        let mut missing: Vec<String> = orphan_syms.into_iter().map(|(n, _)| n).filter(|n| !reg_names.contains(&n.to_lowercase())).collect();
        missing.sort();
        missing.dedup();
        rep["orphanMissingFromRegistered"] = json!(missing.len());
        if !missing.is_empty() {
            rep["orphanedLibrary"] = rep["bridgeDefaultPath"].clone();
            rep["orphanMissingSample"] = json!(missing.iter().take(10).collect::<Vec<_>>());
        }
    }
    rep
}

// ---------------------------------------------------------------------------
// KiCad's own verdict on a symbol library (kicad-cli sym export svg)
// ---------------------------------------------------------------------------

/// `{available, ok, symbols[], units, error, exitCode, durationMs}`. available=false
/// means kicad-cli was not found: no verdict either way, and callers must say so
/// rather than treat it as a pass (wiki #71).
pub fn kicad_parser_verdict(cli: Option<&Path>, lib_path: &Path, timeout: Duration) -> Value {
    let Some(cli) = cli.filter(|c| c.is_file()) else {
        return json!({"available": false, "ok": null, "symbols": [], "units": 0,
                      "error": "kicad-cli not found; KiCad's parser could not be asked"});
    };
    if !lib_path.exists() {
        return json!({"available": true, "ok": false, "symbols": [], "units": 0,
                      "error": format!("file not found: {}", norm_path(lib_path))});
    }
    let out_dir = std::env::temp_dir().join(format!(
        "adom-kicad-parse-{}-{}",
        std::process::id(),
        std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0)
    ));
    let _ = std::fs::create_dir_all(&out_dir);
    let out_s = norm_path(&out_dir);
    let lib_s = norm_path(lib_path);
    let r = crate::cli::run(cli, &["sym", "export", "svg", "-o", out_s.as_str(), lib_s.as_str()], timeout);
    let _ = std::fs::remove_dir_all(&out_dir);
    if r.timed_out {
        return json!({"available": true, "ok": false, "timedOut": true, "symbols": [], "units": 0,
                      "error": format!("kicad-cli did not answer within {}s", timeout.as_secs())});
    }
    let text = format!("{}\n{}", r.stdout, r.stderr);
    let mut names: Vec<String> = Vec::new();
    let mut units = 0u64;
    for line in text.lines() {
        if let Some(name) = plotted_symbol(line) {
            units += 1;
            if !names.iter().any(|n| n == &name) {
                names.push(name);
            }
        }
    }
    let ok = r.exit_code == 0;
    let mut err = String::new();
    if !ok {
        let lines: Vec<&str> = text.lines().map(str::trim).filter(|l| !l.is_empty() && plotted_symbol(l).is_none()).take(3).collect();
        err = lines.join(" | ").chars().take(400).collect();
        if err.is_empty() {
            err = format!("kicad-cli exit {}", r.exit_code);
        }
    }
    json!({"available": true, "ok": ok, "symbols": names, "units": units, "error": err,
           "exitCode": r.exit_code, "durationMs": r.elapsed_ms})
}

/// `Plotting symbol 'NAME' unit N` -> NAME.
fn plotted_symbol(line: &str) -> Option<String> {
    let i = line.find("Plotting symbol '")?;
    let rest = &line[i + "Plotting symbol '".len()..];
    let j = rest.find("' unit ")?;
    let tail = &rest[j + "' unit ".len()..];
    if !tail.trim().chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {
        return None;
    }
    Some(rest[..j].to_string())
}

// ---------------------------------------------------------------------------
// Footprint libraries (.pretty)
// ---------------------------------------------------------------------------

/// Sorted stems of the `.kicad_mod` files in a `.pretty` dir.
pub fn list_pretty(pretty: &Path) -> Vec<String> {
    let mut names: Vec<String> = match std::fs::read_dir(pretty) {
        Ok(rd) => rd
            .flatten()
            .filter_map(|e| {
                let p = e.path();
                if p.extension().map(|x| x == "kicad_mod").unwrap_or(false) && p.is_file() {
                    p.file_stem().map(|s| s.to_string_lossy().to_string())
                } else {
                    None
                }
            })
            .collect(),
        Err(_) => Vec::new(),
    };
    names.sort();
    names
}

/// One row of `kicad_list_footprints.libraries`.
#[derive(Clone, Debug)]
pub struct PrettyInfo {
    pub path: String,
    pub footprints: Vec<String>,
}

/// Python `handle_list_footprints`: every library the user's fp-lib-table knows
/// (URIs expanded the way KiCad expands them, stock libraries included), then any
/// unregistered `.pretty` folders under `<user_dir>/footprints`. `want` filters by
/// library name, case-insensitive. Returns (libraries in discovery order, total).
pub fn list_footprints(ctx: &LibCtx, want: Option<&str>) -> (Vec<(String, PrettyInfo)>, usize) {
    let mut libs: Vec<(String, PrettyInfo)> = Vec::new();
    let mut total = 0usize;
    let mut add = |name: &str, pretty: &Path, libs: &mut Vec<(String, PrettyInfo)>| {
        if libs.iter().any(|(n, _)| n == name) || !pretty.is_dir() {
            return;
        }
        if let Some(w) = want {
            if name.to_lowercase() != w.to_lowercase() {
                return;
            }
        }
        let names = list_pretty(pretty);
        total += names.len();
        libs.push((name.to_string(), PrettyInfo { path: norm_path(pretty), footprints: names }));
    };
    if let Some(table) = ctx.fp_lib_table() {
        if table.is_file() {
            if let Ok(t) = LibTable::parse_file_resolved(&table) {
                for e in t.entries.iter().filter(|e| !e.disabled) {
                    if let Some(resolved) = expand_kicad_uri(&e.uri, ctx) {
                        add(&e.name, Path::new(&resolved), &mut libs);
                    }
                }
            }
        }
    }
    if let Some(user) = &ctx.user_dir {
        let root = user.join("footprints");
        if root.is_dir() {
            let mut dirs: Vec<PathBuf> = std::fs::read_dir(&root)
                .map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.extension().map(|x| x == "pretty").unwrap_or(false)).collect())
                .unwrap_or_default();
            dirs.sort();
            for pretty in dirs {
                let stem = pretty.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
                add(&stem, &pretty, &mut libs);
            }
        }
    }
    (libs, total)
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    const SYM_TABLE: &str = r#"(sym_lib_table
  (version 7)
  (lib (name "Device")(type "KiCad")(uri "${KICAD10_SYMBOL_DIR}/Device.kicad_sym")(options "")(descr "Generic devices"))
  (lib (name "Adom")(type "KiCad")(uri "C:/Users/john/Documents/KiCad/10.0/symbols/Adom.kicad_sym")(options "")(descr "Adom Bridge - custom symbols"))
  (lib (name "Old")(type "Legacy")(uri "${KIPRJMOD}/old.lib")(options "")(descr "quote \"inside\"")(disabled))
)
"#;

    const FP_TABLE: &str = "(fp_lib_table\n\t(version 7)\n\t(lib (name \"Resistor_SMD\")(type \"KiCad\")(uri \"${KICAD10_FOOTPRINT_DIR}/Resistor_SMD.pretty\")(options \"\")(descr \"Resistor footprints, SMD\"))\n\t(lib (name \"Mine\")(type \"KiCad\")(uri \"${MY_LIBS}/Mine.pretty\")(options \"\")(descr \"\"))\n)\n";

    const KICAD_SYM: &str = r#"(kicad_symbol_lib
	(version 20231120)
	(generator "kicad_symbol_editor")
	(symbol "R"
		(property "Reference" "R" (at 0 0 0))
		(symbol "R_0_1" (rectangle (start -1 -2) (end 1 2)))
		(symbol "R_1_1" (pin passive line (at 0 3 270) (length 1) (name "~") (number "1")))
	)
(symbol "STM32F103"
    (property "Value" "PA13(JTMS/SWDIO)" (at 0 0 0))
    (symbol "STM32F103_0_1" (rectangle (start -1 -2) (end 1 2)))
)
	(embedded_fonts no)
)
"#;

    fn ctx() -> LibCtx {
        LibCtx {
            version: "10.0".into(),
            base_dir: Some("C:/Program Files/KiCad/10.0".into()),
            config_dir: Some(PathBuf::from("C:/Users/john/AppData/Roaming/kicad/10.0")),
            user_dir: Some(PathBuf::from("C:/Users/john/Documents/KiCad/10.0")),
            kicad_cli: None,
            env_overrides: BTreeMap::new(),
        }
    }

    #[test]
    fn pcm_libraries_expand_to_the_3rdparty_folder() {
        // wiki #130: KiCad 10's PCM writes ${KICAD10_3RD_PARTY} into the global sym-lib-table
        let uri = "${KICAD10_3RD_PARTY}/symbols/inc_adom_wiki_x/lib.kicad_sym";
        assert_eq!(expand_kicad_uri(uri, &ctx()).unwrap(), "C:/Users/john/Documents/KiCad/10.0/3rdparty/symbols/inc_adom_wiki_x/lib.kicad_sym");
        assert_eq!(expand_kicad_uri("${KICAD_3RD_PARTY}/a.kicad_sym", &ctx()).unwrap(), "C:/Users/john/Documents/KiCad/10.0/3rdparty/a.kicad_sym");
        let mut c = ctx();
        c.env_overrides.insert("KICAD_3RD_PARTY".into(), "D:/pcm".into());
        assert_eq!(expand_kicad_uri(uri, &c).unwrap(), "D:/pcm/symbols/inc_adom_wiki_x/lib.kicad_sym", "the older spelling from Configure Paths applies to both");
    }

    #[test]
    fn tokenizer_handles_quotes_and_nesting() {
        let s = parse_sexp(r#"(a "b c" (d "e\"f") g)"#).unwrap();
        let l = s.as_list().unwrap();
        assert_eq!(l[0].as_atom(), Some("a"));
        assert_eq!(l[1].as_atom(), Some("b c"));
        assert_eq!(l[2].child_value("e\"f"), None);
        assert_eq!(l[2].as_list().unwrap()[1].as_atom(), Some("e\"f"));
        assert_eq!(l[3].as_atom(), Some("g"));
        assert!(parse_sexp("(a (b)").is_err());
        assert!(parse_sexp("(a))").is_ok());
        assert!(parse_sexp("").is_err());
    }

    #[test]
    fn sym_lib_table_parses_and_round_trips() {
        let t = LibTable::parse_str(SYM_TABLE).unwrap();
        assert_eq!(t.table_type, "sym_lib_table");
        assert_eq!(t.version, Some(7));
        assert_eq!(t.entries.len(), 3);
        assert_eq!(t.entries[0].name, "Device");
        assert_eq!(t.entries[0].uri, "${KICAD10_SYMBOL_DIR}/Device.kicad_sym");
        assert_eq!(t.entries[0].descr, "Generic devices");
        assert!(t.has_library("Adom"));
        assert!(t.entries[2].disabled);
        assert_eq!(t.entries[2].descr, "quote \"inside\"");
        let text = t.to_text();
        assert!(text.starts_with("(sym_lib_table\n  (version 7)\n  (lib (name \"Device\")"));
        assert!(text.contains("(descr \"quote \\\"inside\\\"\")(disabled))"));
        let again = LibTable::parse_str(&text).unwrap();
        assert_eq!(again.entries, t.entries);
    }

    #[test]
    fn fp_lib_table_tabs_and_add() {
        let mut t = LibTable::parse_str(FP_TABLE).unwrap();
        assert_eq!(t.table_type, "fp_lib_table");
        assert_eq!(t.entries.len(), 2);
        assert!(t.add_library(LibEntry::new("Adom", "KiCad", "C:/x/Adom.pretty", "", "Adom Bridge - custom footprints")));
        assert!(!t.add_library(LibEntry::new("Adom", "KiCad", "C:/y", "", "")));
        assert!(t.remove_library("Mine"));
        assert_eq!(t.entries.len(), 2);
        assert!(t.to_text().ends_with("(descr \"Adom Bridge - custom footprints\"))\n)\n"));
    }

    #[test]
    fn a_fresh_kicad10_nested_table_row_is_followed_for_reads_only() {
        let d = std::env::temp_dir().join(format!("kicad-core-nested-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(&d).unwrap();
        let stock = d.join("stock-sym-lib-table");
        std::fs::write(&stock, "(sym_lib_table\n  (version 7)\n  (lib (name \"Device\")(type \"KiCad\")(uri \"${KICAD10_SYMBOL_DIR}/Device.kicad_sym\")(options \"\")(descr \"\"))\n  (lib (name \"Adom\")(type \"KiCad\")(uri \"stock-adom\")(options \"\")(descr \"\"))\n)\n").unwrap();
        let user = d.join("sym-lib-table");
        std::fs::write(&user, format!("(sym_lib_table\n  (version 7)\n  (lib (name \"KiCad\")(type \"Table\")(uri \"{}\")(options \"\")(descr \"KiCad Default Libraries\"))\n  (lib (name \"Adom\")(type \"KiCad\")(uri \"user-adom\")(options \"\")(descr \"\"))\n)\n", norm_path(&stock))).unwrap();
        let plain = LibTable::parse_file(&user).unwrap();
        assert!(!plain.has_library("Device") && plain.has_library("KiCad")); // the writers' view is untouched
        let r = LibTable::parse_file_resolved(&user).unwrap();
        assert!(r.has_library("Device") && !r.has_library("KiCad"));
        assert_eq!(r.get("Adom").unwrap().uri, "user-adom"); // the outer row wins
        let _ = std::fs::remove_dir_all(&d);
    }

    #[test]
    fn missing_table_file_is_empty() {
        let t = LibTable::parse_file(Path::new("/nonexistent/dir/sym-lib-table")).unwrap();
        assert_eq!(t.table_type, "sym_lib_table");
        assert_eq!(t.version, Some(7));
        assert!(t.entries.is_empty());
        let t = LibTable::parse_file(Path::new("/nonexistent/dir/fp-lib-table")).unwrap();
        assert_eq!(t.table_type, "fp_lib_table");
    }

    #[test]
    fn uri_expansion_stock_dirs() {
        let c = ctx();
        assert_eq!(
            expand_kicad_uri("${KICAD10_SYMBOL_DIR}/Device.kicad_sym", &c).as_deref(),
            Some("C:/Program Files/KiCad/10.0/share/kicad/symbols/Device.kicad_sym")
        );
        assert_eq!(
            expand_kicad_uri("${KICAD10_FOOTPRINT_DIR}\\Resistor_SMD.pretty", &c).as_deref(),
            Some("C:/Program Files/KiCad/10.0/share/kicad/footprints/Resistor_SMD.pretty")
        );
        assert_eq!(
            expand_kicad_uri("${KICAD10_3DMODEL_DIR}/x.step", &c).as_deref(),
            Some("C:/Program Files/KiCad/10.0/share/kicad/3dmodels/x.step")
        );
        assert_eq!(
            expand_kicad_uri("${KICAD10_TEMPLATE_DIR}/t", &c).as_deref(),
            Some("C:/Program Files/KiCad/10.0/share/kicad/template/t")
        );
        assert_eq!(
            expand_kicad_uri("${KICAD_USER_TEMPLATE_DIR}/t", &c).as_deref(),
            Some("C:/Users/john/Documents/KiCad/10.0/template/t")
        );
        assert_eq!(
            // Documents/KiCad/<ver>/3rdparty, where KiCad's PCM installs (checked on a KiCad 10.0.4 box, wiki #130)
            expand_kicad_uri("${KICAD_3RD_PARTY}/footprints/x.pretty", &c).as_deref(),
            Some("C:/Users/john/Documents/KiCad/10.0/3rdparty/footprints/x.pretty")
        );
        // Plain paths pass through normalised.
        assert_eq!(expand_kicad_uri("C:\\a\\.\\b\\..\\c.kicad_sym", &c).as_deref(), Some("C:/a/c.kicad_sym"));
    }

    #[test]
    fn uri_expansion_refuses_unknown_and_project() {
        let c = ctx();
        assert_eq!(expand_kicad_uri("${KIPRJMOD}/x.pretty", &c), None);
        assert_eq!(expand_kicad_uri("${MY_LIBS}/x.pretty", &c), None);
        // A different major is not this KiCad's variable.
        assert_eq!(expand_kicad_uri("${KICAD9_SYMBOL_DIR}/Device.kicad_sym", &c), None);
        // No install: the stock variables are unknown.
        let mut nb = ctx();
        nb.base_dir = None;
        assert_eq!(expand_kicad_uri("${KICAD10_SYMBOL_DIR}/Device.kicad_sym", &nb), None);
    }

    #[test]
    fn uri_expansion_env_overrides_win() {
        let mut c = ctx();
        c.env_overrides.insert("KICAD10_SYMBOL_DIR".into(), "D:\\libs\\symbols".into());
        c.env_overrides.insert("MY_LIBS".into(), "D:/mine".into());
        assert_eq!(expand_kicad_uri("${KICAD10_SYMBOL_DIR}/Device.kicad_sym", &c).as_deref(), Some("D:/libs/symbols/Device.kicad_sym"));
        assert_eq!(expand_kicad_uri("${MY_LIBS}/Mine.pretty", &c).as_deref(), Some("D:/mine/Mine.pretty"));
        // The other stock variables keep their defaults.
        assert_eq!(
            expand_kicad_uri("${KICAD10_FOOTPRINT_DIR}/R.pretty", &c).as_deref(),
            Some("C:/Program Files/KiCad/10.0/share/kicad/footprints/R.pretty")
        );
    }

    #[test]
    fn env_overrides_from_kicad_common() {
        let dir = std::env::temp_dir().join(format!("adom-libtest-{}", std::process::id()));
        let _ = std::fs::create_dir_all(&dir);
        let f = dir.join("kicad_common.json");
        std::fs::write(&f, r#"{"environment": {"vars": {"KICAD10_SYMBOL_DIR": "E:/syms", "EMPTY": "", "NUM": 3}}}"#).unwrap();
        let o = read_env_overrides(&f);
        assert_eq!(o.get("KICAD10_SYMBOL_DIR").map(String::as_str), Some("E:/syms"));
        assert!(!o.contains_key("EMPTY"));
        assert!(!o.contains_key("NUM"));
        assert!(read_env_overrides(&dir.join("missing.json")).is_empty());
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn symbol_names_skip_subunits_any_indentation() {
        let names = list_symbols_in_text(KICAD_SYM);
        assert_eq!(names, vec!["R".to_string(), "STM32F103".to_string()]);
        assert!(is_subunit_name("R_0_1"));
        assert!(is_subunit_name("Foo_12_3"));
        assert!(!is_subunit_name("Foo_1"));
        assert!(!is_subunit_name("Foo_a_1"));
        assert!(!is_subunit_name("R"));
    }

    #[test]
    fn structural_symbols_are_quote_aware() {
        let syms = extract_top_level_symbols(KICAD_SYM).unwrap();
        let names: Vec<&str> = syms.iter().map(|(n, _)| n.as_str()).collect();
        assert_eq!(names, vec!["R", "STM32F103"]);
        assert!(syms[1].1.starts_with("(symbol \"STM32F103\""));
        assert!(extract_top_level_symbols("(kicad_symbol_lib (symbol \"A\")) (symbol \"B\")").is_err());
        assert!(extract_top_level_symbols("(kicad_pcb (symbol \"A\"))").is_err());
        assert!(extract_top_level_symbols("(kicad_symbol_lib (symbol \"A\"").is_err());
    }

    #[test]
    fn tolerant_extraction_handles_trailing_blocks() {
        let text = "(kicad_symbol_lib (version 1) (symbol \"A\" (pin (name \"x)\")))\n)\n(symbol \"B\" (x))\n(symbol \"a\" (y))\n(symbol \"C\" (broken";
        let (syms, bad) = extract_symbols_tolerant(text);
        let names: Vec<&str> = syms.iter().map(|(n, _)| n.as_str()).collect();
        assert_eq!(names, vec!["A", "B"]);
        assert!(bad.contains(&"C".to_string()));
        assert!(bad.iter().any(|b| b.contains("1 duplicate")));
    }

    #[test]
    fn pretty_listing_and_footprints() {
        let dir = std::env::temp_dir().join(format!("adom-fptest-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        let base = dir.join("kicad");
        let stock = base.join("share/kicad/footprints/Resistor_SMD.pretty");
        std::fs::create_dir_all(&stock).unwrap();
        std::fs::write(stock.join("R_0603.kicad_mod"), "(footprint \"R_0603\")").unwrap();
        std::fs::write(stock.join("R_0402.kicad_mod"), "(footprint \"R_0402\")").unwrap();
        std::fs::write(stock.join("notes.txt"), "x").unwrap();
        let cfg = dir.join("cfg");
        std::fs::create_dir_all(&cfg).unwrap();
        std::fs::write(cfg.join("fp-lib-table"), FP_TABLE).unwrap();
        let user = dir.join("user");
        let adom = user.join("footprints/Adom.pretty");
        std::fs::create_dir_all(&adom).unwrap();
        std::fs::write(adom.join("BMI423.kicad_mod"), "(footprint \"x\")").unwrap();
        let c = LibCtx {
            version: "10.0".into(),
            base_dir: Some(norm_path(&base)),
            config_dir: Some(cfg.clone()),
            user_dir: Some(user.clone()),
            kicad_cli: None,
            env_overrides: BTreeMap::new(),
        };
        let (libs, total) = list_footprints(&c, None);
        assert_eq!(total, 3);
        assert_eq!(libs[0].0, "Resistor_SMD");
        assert_eq!(libs[0].1.footprints, vec!["R_0402".to_string(), "R_0603".to_string()]);
        assert_eq!(libs[1].0, "Adom");
        let (libs, total) = list_footprints(&c, Some("resistor_smd"));
        assert_eq!(total, 2);
        assert_eq!(libs.len(), 1);
        let (libs, _) = list_footprints(&c, Some("Nope"));
        assert!(libs.is_empty());
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn ensure_adom_library_creates_and_registers() {
        let dir = std::env::temp_dir().join(format!("adom-ensure-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        let cfg = dir.join("cfg");
        let user = dir.join("user");
        let c = LibCtx { version: "10.0".into(), base_dir: Some("/nowhere".into()), config_dir: Some(cfg.clone()), user_dir: Some(user.clone()), kicad_cli: None, env_overrides: BTreeMap::new() };
        let r = ensure_adom_library(&c, true);
        assert_eq!(r["exists"], json!(false));
        assert_eq!(r["registered"], json!(false));
        assert_eq!(r["created"], json!(true));
        assert_eq!(r["symbols"], json!([]));
        let t = LibTable::parse_file(&cfg.join("sym-lib-table")).unwrap();
        assert!(t.has_library("Adom"));
        assert!(user.join("symbols/Adom.kicad_sym").is_file());
        let r2 = ensure_adom_library(&c, true);
        assert_eq!(r2["exists"], json!(true));
        assert_eq!(r2["registered"], json!(true));
        let rep = library_split_report(&c);
        assert_eq!(rep["split"], json!(false));
        assert_eq!(rep["registeredSymbols"], json!(0));
        assert_eq!(ensure_adom_library(&c, false)["error"], json!("KiCad not installed"));
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn split_report_names_orphans() {
        let dir = std::env::temp_dir().join(format!("adom-split-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        let cfg = dir.join("cfg");
        let user = dir.join("user");
        let other = dir.join("elsewhere");
        std::fs::create_dir_all(&cfg).unwrap();
        std::fs::create_dir_all(user.join("symbols")).unwrap();
        std::fs::create_dir_all(&other).unwrap();
        let reg = other.join("Adom.kicad_sym");
        std::fs::write(&reg, "(kicad_symbol_lib (version 1)\n  (symbol \"A\" (x))\n)\n").unwrap();
        std::fs::write(user.join("symbols/Adom.kicad_sym"), "(kicad_symbol_lib (version 1)\n\t(symbol \"A\" (x))\n\t(symbol \"B\" (y))\n)\n").unwrap();
        let mut t = LibTable::empty("sym_lib_table");
        t.add_library(LibEntry::new("Adom", "KiCad", &norm_path(&reg), "", ""));
        t.write_file(&cfg.join("sym-lib-table")).unwrap();
        let c = LibCtx { version: "10.0".into(), base_dir: None, config_dir: Some(cfg), user_dir: Some(user), kicad_cli: None, env_overrides: BTreeMap::new() };
        let rep = library_split_report(&c);
        assert_eq!(rep["split"], json!(true));
        assert_eq!(rep["registeredSymbols"], json!(1));
        assert_eq!(rep["bridgeDefaultSymbols"], json!(2));
        assert_eq!(rep["orphanMissingFromRegistered"], json!(1));
        assert_eq!(rep["orphanMissingSample"], json!(["B"]));
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn parser_verdict_without_cli_is_honest() {
        let v = kicad_parser_verdict(None, Path::new("/nope.kicad_sym"), Duration::from_secs(1));
        assert_eq!(v["available"], json!(false));
        assert_eq!(v["ok"], Value::Null);
        assert_eq!(plotted_symbol("Plotting symbol 'R' unit 1"), Some("R".into()));
        assert_eq!(plotted_symbol("Plotting symbol 'PA13(JTMS)' unit 12"), Some("PA13(JTMS)".into()));
        assert_eq!(plotted_symbol("Unable to load library"), None);
    }

    #[test]
    fn normpath_cases() {
        assert_eq!(normpath("C:/a//b/./c/../d"), "C:/a/b/d");
        assert_eq!(normpath("/x/y/../../z"), "/z");
        assert_eq!(normpath("C:/"), "C:/");
        assert_eq!(normpath("rel/../.."), "..");
    }
}