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
John Lauer
1.0.61: Every Konnect v0.13.0 capability, 211 verbs (was 133; docs/konnect-gap.md maps all 228 Konnect tools). Schemati…
0009b41
14d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
//! kicad_search_library: find symbols and footprints across every registered library
//! (global and project sym-lib-table / fp-lib-table, URIs expanded the way KiCad expands
//! them) by name, keyword and description, ranked. Library contents are cached per file
//! by modification time, so a second search over the stock libraries is fast.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::SystemTime;
use serde_json::{json, Value};
use crate::libraries::{expand_kicad_uri, list_pretty, norm_path, LibCtx, LibTable};
#[derive(Clone, Debug, PartialEq)]
pub struct LibRef {
pub kind: &'static str,
pub nickname: String,
pub path: PathBuf,
pub scope: &'static str,
}
#[derive(Clone, Debug)]
pub struct Entry {
pub name: String,
pub description: String,
pub keywords: String,
}
/// Every enabled library of `kind` ("symbol" or "footprint"): the project's table first
/// (it wins on a nickname clash, as in KiCad), then the global table.
pub fn registered(ctx: &LibCtx, project_dir: Option<&Path>, kind: &'static str) -> (Vec<LibRef>, Vec<String>) {
let mut out: Vec<LibRef> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
let table_name = if kind == "symbol" { "sym-lib-table" } else { "fp-lib-table" };
let mut tables: Vec<(PathBuf, &'static str)> = Vec::new();
if let Some(p) = project_dir {
tables.push((p.join(table_name), "project"));
}
if let Some(g) = if kind == "symbol" { ctx.sym_lib_table() } else { ctx.fp_lib_table() } {
tables.push((g, "global"));
}
for (t, scope) in tables {
if !t.is_file() {
continue;
}
let Ok(table) = LibTable::parse_file_resolved(&t) else {
skipped.push(format!("{} (unreadable)", norm_path(&t)));
continue;
};
for e in table.entries.iter().filter(|e| !e.disabled) {
if out.iter().any(|r| r.nickname == e.name) {
continue;
}
let uri = match project_dir {
Some(p) => e.uri.replace("${KIPRJMOD}", &norm_path(p)),
None => e.uri.clone(),
};
match expand_kicad_uri(&uri, ctx) {
Some(p) => out.push(LibRef { kind, nickname: e.name.clone(), path: PathBuf::from(p), scope }),
None => skipped.push(format!("{} ({})", e.name, e.uri)),
}
}
}
(out, skipped)
}
static CACHE: Mutex<Option<HashMap<PathBuf, (SystemTime, Vec<Entry>)>>> = Mutex::new(None);
fn mtime(p: &Path) -> Option<SystemTime> {
std::fs::metadata(p).and_then(|m| m.modified()).ok()
}
/// The quoted value after `(property "<key>" ` in a block, if present.
fn property_in(block: &str, key: &str) -> String {
let pat = format!("(property \"{key}\" \"");
let Some(i) = block.find(&pat) else { return String::new() };
let rest = &block[i + pat.len()..];
let mut out = String::new();
let mut esc = false;
for c in rest.chars() {
if esc {
out.push(c);
esc = false;
} else if c == '\\' {
esc = true;
} else if c == '"' {
break;
} else {
out.push(c);
}
}
out
}
/// `(descr "...")` / `(tags "...")` near the top of a footprint file.
fn atom_in(text: &str, key: &str) -> String {
let pat = format!("({key} \"");
let Some(i) = text.find(&pat) else { return String::new() };
text[i + pat.len()..].split('"').next().unwrap_or("").to_string()
}
/// Symbols of a .kicad_sym with their description and keywords.
pub fn symbol_entries(text: &str) -> Vec<Entry> {
let (blocks, _) = crate::libraries::extract_symbols_tolerant(text);
blocks
.into_iter()
.map(|(name, block)| Entry { description: property_in(&block, "Description"), keywords: property_in(&block, "ki_keywords"), name })
.collect()
}
fn entries_for(r: &LibRef) -> Vec<Entry> {
let key = r.path.clone();
let stamp = if r.kind == "symbol" { mtime(&r.path) } else { mtime(&r.path) };
if let (Some(t), Ok(g)) = (stamp, CACHE.lock()) {
if let Some((ct, e)) = g.as_ref().and_then(|m| m.get(&key)) {
if *ct == t {
return e.clone();
}
}
}
let entries: Vec<Entry> = if r.kind == "symbol" {
std::fs::read_to_string(&r.path).map(|t| symbol_entries(&t)).unwrap_or_default()
} else {
list_pretty(&r.path)
.into_iter()
.map(|name| {
// descr and tags sit in the first lines; read a bounded head only.
let mut head = String::new();
if let Ok(mut f) = std::fs::File::open(r.path.join(format!("{name}.kicad_mod"))) {
use std::io::Read;
let mut buf = vec![0u8; 4096];
if let Ok(n) = f.read(&mut buf) {
head = String::from_utf8_lossy(&buf[..n]).to_string();
}
}
Entry { description: atom_in(&head, "descr"), keywords: atom_in(&head, "tags"), name }
})
.collect()
};
if let (Some(t), Ok(mut g)) = (stamp, CACHE.lock()) {
g.get_or_insert_with(HashMap::new).insert(key, (t, entries.clone()));
}
entries
}
/// Rank one entry against the query tokens; None when a token matches nowhere.
pub fn score(q: &str, tokens: &[String], nickname: &str, e: &Entry) -> Option<f64> {
let name = e.name.to_lowercase();
let lib = nickname.to_lowercase();
let kw = e.keywords.to_lowercase();
let desc = e.description.to_lowercase();
let mut s = 0.0;
for t in tokens {
let mut hit = false;
if name.contains(t.as_str()) {
s += 15.0;
hit = true;
}
if kw.split_whitespace().any(|k| k == t) {
s += 10.0;
hit = true;
} else if kw.contains(t.as_str()) {
s += 6.0;
hit = true;
}
if desc.contains(t.as_str()) {
s += 4.0;
hit = true;
}
if lib.contains(t.as_str()) {
s += 3.0;
hit = true;
}
if !hit {
return None;
}
}
let ql = q.to_lowercase();
let full = format!("{lib}:{name}");
if name == ql || full == ql {
s += 100.0;
} else if name.starts_with(&ql) {
s += 50.0;
} else if name.contains(&ql) {
s += 25.0;
}
// shorter names first among equals: "R" before "R_Pack08"
s -= (name.len() as f64) * 0.01;
Some(s)
}
/// Search `libs` for `query`. Returns (ranked hits, libraries scanned, entries scanned).
pub fn search(libs: &[LibRef], query: &str, limit: usize) -> (Vec<Value>, usize, usize) {
let tokens: Vec<String> = query.to_lowercase().split(|c: char| c.is_whitespace() || c == ',').filter(|t| !t.is_empty()).map(str::to_string).collect();
let mut hits: Vec<(f64, Value)> = Vec::new();
let mut scanned = 0;
let mut libs_scanned = 0;
for r in libs {
if !(r.path.is_file() || r.path.is_dir()) {
continue;
}
libs_scanned += 1;
for e in entries_for(r) {
scanned += 1;
if let Some(s) = score(query, &tokens, &r.nickname, &e) {
hits.push((s, json!({
"kind": r.kind, "libId": format!("{}:{}", r.nickname, e.name), "library": r.nickname, "name": e.name,
"description": e.description, "keywords": e.keywords, "scope": r.scope, "libraryPath": norm_path(&r.path),
"score": (s * 10.0).round() / 10.0,
})));
}
}
}
hits.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap().then_with(|| a.1["libId"].as_str().cmp(&b.1["libId"].as_str())));
(hits.into_iter().take(limit.max(1)).map(|(_, v)| v).collect(), libs_scanned, scanned)
}
#[cfg(test)]
mod tests {
use super::*;
fn repo(rel: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").join(rel)
}
#[test]
fn project_tables_expand_kiprjmod_and_rank_hits() {
let d = std::env::temp_dir().join(format!("kc-libsearch-{}-{}", std::process::id(), crate::schematic::new_uuid()));
std::fs::create_dir_all(&d).unwrap();
let sym = norm_path(&repo("pcm/src/adom-sample-lib/symbols"));
let fp = norm_path(&repo("pcm/src/adom-sample-lib/footprints"));
std::fs::write(d.join("sym-lib-table"), format!("(sym_lib_table (version 7) (lib (name \"Sample\") (type \"KiCad\") (uri \"{sym}/Adom_Sample.kicad_sym\") (options \"\") (descr \"\")) (lib (name \"Off\") (type \"KiCad\") (uri \"${{KIPRJMOD}}/off.kicad_sym\") (options \"\") (descr \"\") (disabled)))")).unwrap();
std::fs::write(d.join("fp-lib-table"), format!("(fp_lib_table (version 7) (lib (name \"SampleFp\") (type \"KiCad\") (uri \"{fp}/Adom_Sample.pretty\") (options \"\") (descr \"\")) (lib (name \"Local\") (type \"KiCad\") (uri \"${{KIPRJMOD}}/Local.pretty\") (options \"\") (descr \"\")))")).unwrap();
std::fs::create_dir_all(d.join("Local.pretty")).unwrap();
std::fs::write(d.join("Local.pretty/MyPad.kicad_mod"), "(footprint \"MyPad\" (version 20260206) (descr \"test pad for resistor probing\") (tags \"probe\"))").unwrap();
let ctx = LibCtx::default();
let (syms, skipped) = registered(&ctx, Some(&d), "symbol");
assert_eq!(syms.len(), 1, "the disabled row is left out");
assert!(skipped.is_empty());
let (fps, _) = registered(&ctx, Some(&d), "footprint");
assert_eq!(fps.len(), 2);
assert_eq!(fps[1].path, PathBuf::from(norm_path(&d.join("Local.pretty"))));
let (hits, libs, scanned) = search(&syms, "resistor", 10);
assert_eq!(libs, 1);
assert!(scanned >= 10);
assert_eq!(hits[0]["libId"], "Sample:AdomSample_R", "{hits:?}");
let (hits, _, _) = search(&syms, "AdomSample_C", 10);
assert_eq!(hits[0]["name"], "AdomSample_C", "exact name first");
let mut all = syms.clone();
all.extend(fps.clone());
let (hits, _, _) = search(&all, "soic 8", 5);
assert!(hits.iter().any(|h| h["kind"] == "footprint" && h["name"].as_str().unwrap().contains("SOIC-8")), "{hits:?}");
let (hits, _, _) = search(&fps, "probe", 5);
assert_eq!(hits[0]["libId"], "Local:MyPad");
assert_eq!(hits[0]["scope"], "project");
let (none, _, _) = search(&all, "zzzz-no-such-thing", 5);
assert!(none.is_empty());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn scoring_requires_every_token() {
let e = Entry { name: "LM358".into(), description: "Dual operational amplifier".into(), keywords: "opamp dual".into() };
assert!(score("dual opamp", &["dual".into(), "opamp".into()], "Amplifier_Operational", &e).is_some());
assert!(score("quad opamp", &["quad".into(), "opamp".into()], "Amplifier_Operational", &e).is_none());
let exact = score("lm358", &["lm358".into()], "X", &e).unwrap();
let loose = score("amplifier", &["amplifier".into()], "X", &e).unwrap();
assert!(exact > loose);
}
}