app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
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(®istered),
"registeredSymbols": count(®istered),
"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, ®istered),
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(®istered) {
Ok(t) => extract_symbols_tolerant(&t).0.into_iter().map(|(n, _)| n.to_lowercase()).collect(),
Err(_) => list_symbols(®istered).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(®, "(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(®), "", ""));
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/../.."), "..");
}
}