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
Merge PR #353: kicad-lane: 1.0.89, animate_view, lint_library on the standard's rules, engine 0.21.1
be88db0
11d ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477
//! Lint group, ported from `handlers/kicad_cli_lint.py` and `handlers/kicad_parse_check.py`.
//!
//! Headless pre-checks that run before any GUI automation: a magic-byte sniff of the
//! file, DRC or ERC through `kicad-cli` with a JSON report, a structural check and
//! inventory of symbol and footprint libraries, KiCad's OWN verdict on a `.kicad_sym`
//! (`kicad-cli sym export svg`, the one read-only command that parses a symbol
//! library), and the `kicad-cli <kind> upgrade` runner.
//!
//! Everything here is OS-neutral. The only way out to the OS is `cli::run`, which
//! goes through `kicad_platform::native()` for the quiet child process. The Python
//! used regexes for the inventory; there is no regex crate in this workspace, so the
//! same patterns are small scanners below, each named after the pattern it replaces.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use serde_json::{json, Value};
use crate::cli;
/// Expected root atom of a board file.
pub const PCB: &str = "kicad_pcb";
/// Expected root atom of a schematic file.
pub const SCH: &str = "kicad_sch";
/// Python `_run_kicad_cli_json` and `handle_format_upgrade` deadline.
pub const CLI_TIMEOUT: Duration = Duration::from_secs(120);
/// Python `kicad_parse_check.verdict_file` deadline, per file.
pub const VERDICT_TIMEOUT: Duration = Duration::from_secs(60);
// ───────────────────────── text helpers (the Python regexes) ─────────────────────────
/// `s` cut to at most `max` bytes, on a char boundary.
fn head_of(s: &str, max: usize) -> &str {
if s.len() <= max {
return s;
}
let mut i = max;
while !s.is_char_boundary(i) {
i -= 1;
}
&s[..i]
}
/// Python `f"{x!r}"` for a short str: single-quoted, backslashes and quotes escaped,
/// newlines as `\n`. Only used inside error messages.
fn py_repr(s: &str) -> String {
let mut out = String::from("'");
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'\'' => out.push_str("\\'"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c => out.push(c),
}
}
out.push('\'');
out
}
/// After `(kw` there must be at least one whitespace char; returns the text after it.
fn after_keyword<'a>(s: &'a str, kw: &str) -> Option<(&'a str, &'a str)> {
let needle = format!("({kw}");
let i = s.find(&needle)?;
let after = &s[i + needle.len()..];
let t = after.trim_start();
if t.len() == after.len() {
return None; // `(version)` or `(versionx`: no whitespace, not a match; caller advances
}
Some((t, after))
}
/// `\(version\s+(\d+)\)`: the first file-format version in `s`.
pub fn file_version(s: &str) -> Option<u64> {
let mut rest = s;
while let Some(i) = rest.find("(version") {
let after = &rest[i + "(version".len()..];
let t = after.trim_start();
if t.len() < after.len() {
let n = t.bytes().take_while(u8::is_ascii_digit).count();
if n > 0 && t[n..].starts_with(')') {
return t[..n].parse().ok();
}
}
rest = after;
}
None
}
/// `\(<kw>\s+(?:"([^"]+)"|(\S+))\)`: every value of a quoted-or-bare keyword, in order.
/// Callers that only want the file head pass `head_of(text, 500)`.
fn keyword_values(s: &str, kw: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = s;
let needle = format!("({kw}");
while let Some(i) = rest.find(&needle) {
let after = &rest[i + needle.len()..];
if let Some((t, _)) = after_keyword(&rest[i..], kw) {
if let Some(q) = t.strip_prefix('"') {
if let Some(end) = q.find('"') {
if end > 0 && q[end + 1..].starts_with(')') {
out.push(q[..end].to_string());
}
}
} else if let Some(end) = t.find(')') {
let tok = &t[..end];
if !tok.is_empty() && !tok.chars().any(char::is_whitespace) {
out.push(tok.to_string());
}
}
}
rest = after;
}
out
}
/// `\(generator\s+(?:"([^"]+)"|(\S+))\)` in the head of the file.
pub fn generator(s: &str) -> Option<String> {
keyword_values(s, "generator").into_iter().next()
}
/// Count of `(<kw>` followed by whitespace (and, when `word` is set, a word char):
/// `\(pin\s+\w+` and `\(pad\s`.
fn count_keyword(s: &str, kw: &str, word: bool) -> usize {
let needle = format!("({kw}");
let mut n = 0;
let mut rest = s;
while let Some(i) = rest.find(&needle) {
let after = &rest[i + needle.len()..];
let mut it = after.chars();
match it.next() {
Some(c) if c.is_whitespace() => {
if word {
let t = after.trim_start();
if t.chars().next().map(|c| c.is_alphanumeric() || c == '_').unwrap_or(false) {
n += 1;
}
} else {
n += 1;
}
}
_ => {}
}
rest = after;
}
n
}
/// `(symbol "NAME"` with `NAME` = `[^"]+`, starting at byte `at` of `s` (which must
/// point at the `(`). Returns the name.
fn symbol_name_at(s: &str, at: usize) -> Option<&str> {
let t = s[at..].strip_prefix("(symbol")?;
let t2 = t.trim_start();
if t2.len() == t.len() {
return None;
}
let q = t2.strip_prefix('"')?;
let end = q.find('"')?;
if end == 0 {
return None;
}
Some(&q[..end])
}
/// `_\d+_\d+$`: nested unit variants like `R_0_1`.
fn ends_with_unit_suffix(name: &str) -> bool {
let b = name.as_bytes();
let mut i = b.len();
let mut d2 = 0;
while i > 0 && b[i - 1].is_ascii_digit() {
i -= 1;
d2 += 1;
}
if d2 == 0 || i == 0 || b[i - 1] != b'_' {
return false;
}
i -= 1;
let mut d1 = 0;
while i > 0 && b[i - 1].is_ascii_digit() {
i -= 1;
d1 += 1;
}
d1 > 0 && i > 0 && b[i - 1] == b'_'
}
/// For `name` = `<parent>_<N>_<M>` returns `N` as text.
fn unit_of<'a>(name: &'a str, parent: &str) -> Option<&'a str> {
let rest = name.strip_prefix(parent)?.strip_prefix('_')?;
let n = rest.bytes().take_while(u8::is_ascii_digit).count();
if n == 0 {
return None;
}
let tail = rest[n..].strip_prefix('_')?;
if tail.is_empty() || !tail.bytes().all(|c| c.is_ascii_digit()) {
return None;
}
Some(&rest[..n])
}
// ───────────────────────── file format sniff ─────────────────────────
/// Python `_file_format_sniff` on the first bytes of the file. `expected` is `PCB` or
/// `SCH`. Returns the Python dict verbatim: `{ok, expected, magic, fileVersion, _note}`
/// or `{ok:false, expected?, actual?, _error, _hint}`.
pub fn sniff_head(head: &str, expected: &str) -> Value {
let head_text = head.trim_start();
let magic = format!("({expected}");
if !head_text.starts_with(&magic) {
let wrong_type = if expected == PCB && head_text.starts_with("(kicad_sch") {
Some(SCH)
} else if expected == SCH && head_text.starts_with("(kicad_pcb") {
Some(PCB)
} else {
None
};
let hint = match wrong_type {
Some(w) => format!(
"You passed what looks like a {w} file to a verb expecting {expected}. Use the matching verb instead \
(kicad_open_board for .kicad_pcb, kicad_open_schematic / kicad_lint_schematic for .kicad_sch)."
),
None => format!(
"The file doesn't start with the expected {expected} S-expression header. \
It may be corrupted, truncated, or not a KiCad file at all."
),
};
return json!({
"ok": false,
"expected": expected,
"actual": wrong_type.unwrap_or("non-kicad"),
"_error": format!("file is not a {expected} (first bytes: {})", py_repr(head_of(head_text, 80))),
"_hint": hint,
});
}
json!({
"ok": true,
"expected": expected,
"magic": "matched",
"fileVersion": file_version(head_text),
"_note": "magic + format sniff only; kicad-cli does the real validation",
})
}
/// Python `_file_format_sniff(path, expected)`: existence, is-a-file, then `sniff_head`
/// on the first 512 bytes.
pub fn file_format_sniff(path: &Path, expected: &str) -> Value {
if !path.exists() {
return json!({
"ok": false,
"_error": format!("file not found: {}", path.display()),
"_hint": "Verify the path. If the AI is on Docker, the path must exist on the Windows host (use push_file to copy from Docker first).",
});
}
if !path.is_file() {
return json!({"ok": false, "_error": format!("not a file: {}", path.display())});
}
let head = match read_head(path, 512) {
Ok(h) => h,
Err(e) => return json!({"ok": false, "_error": format!("read failed: {e}")}),
};
sniff_head(&head, expected)
}
fn read_head(path: &Path, n: usize) -> std::io::Result<String> {
use std::io::Read;
let mut f = std::fs::File::open(path)?;
let mut buf = vec![0u8; n];
let mut got = 0;
while got < n {
let k = f.read(&mut buf[got..])?;
if k == 0 {
break;
}
got += k;
}
buf.truncate(got);
Ok(String::from_utf8_lossy(&buf).to_string())
}
// ───────────────────────── kicad-cli with a JSON report ─────────────────────────
/// Python `_run_kicad_cli_json`: `{ok, exitCode, stdout, stderr, data?, _error?, _hint?}`.
pub struct CliJson {
pub ok: bool,
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
pub elapsed_ms: u64,
pub timed_out: bool,
/// The parsed `--output` report, when kicad-cli wrote one.
pub data: Option<Value>,
pub error: Option<String>,
pub hint: Option<String>,
}
fn scratch_path(prefix: &str, suffix: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!("{prefix}-{}-{nanos}{suffix}", std::process::id()))
}
/// Run kicad-cli with `--output <tempfile>` appended and read the JSON it wrote there.
/// `pcb drc` and `sch erc` put the structured report in that file; stdout and stderr
/// carry status text only.
///
/// Deviation from the Python: a non-zero exit that wrote no report is `ok: false`
/// (the Python returned `ok: true, data: None`, which the lint verbs then reported as a
/// clean file with zero violations).
pub fn run_cli_json(cli: &Path, args: &[&str], timeout: Duration) -> CliJson {
let report = scratch_path("adom-kicad-lint", ".json");
let report_s = report.to_string_lossy().to_string();
let mut argv: Vec<&str> = args.to_vec();
argv.push("--output");
argv.push(&report_s);
let r = cli::run(cli, &argv, timeout);
let raw = std::fs::read(&report).ok().filter(|b| !b.is_empty());
let _ = std::fs::remove_file(&report);
let mut out = CliJson {
ok: true,
exit_code: r.exit_code,
stdout: r.stdout,
stderr: r.stderr,
elapsed_ms: r.elapsed_ms,
timed_out: r.timed_out,
data: None,
error: None,
hint: None,
};
if r.timed_out {
out.ok = false;
out.error = Some(format!("kicad-cli timed out after {}s", timeout.as_secs()));
out.hint = Some("Bump the timeout, or the board/schematic may be pathologically large.".into());
return out;
}
if out.exit_code == -1 {
out.ok = false;
out.error = Some(format!("kicad-cli invocation failed: {}", out.stderr.trim()));
return out;
}
match raw {
Some(bytes) => match serde_json::from_slice::<Value>(&bytes) {
Ok(v) => out.data = Some(v),
Err(e) => {
out.ok = false;
out.error = Some(format!("could not parse kicad-cli JSON output: {e}"));
}
},
None if out.exit_code != 0 => {
out.ok = false;
let first = out.stderr.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("");
out.error = Some(format!("kicad-cli exited {} and wrote no report: {first}", out.exit_code));
}
None => {}
}
out
}
// ───────────────────────── violation classification ─────────────────────────
/// Python `_parse_violations`: one row per violation, `{type, severity, description,
/// items:[{description, pos}]}`, plus counts by severity (always carrying the four
/// keys error, warning, info, exclusion; other severities are added as seen).
///
/// Buckets walked: `violations`, `unconnected_items` (DRC), and, beyond the Python,
/// `schematic_parity` (DRC with `--schematic-parity`) and `sheets[].violations` (the
/// ERC report of KiCad 8 and later nests everything under sheets; the Python saw none
/// of it and reported every schematic clean).
pub fn parse_violations(data: Option<&Value>) -> (Vec<Value>, BTreeMap<String, u64>) {
let mut counts: BTreeMap<String, u64> = BTreeMap::new();
for k in ["error", "warning", "info", "exclusion"] {
counts.insert(k.into(), 0);
}
let mut out = Vec::new();
let Some(data) = data.filter(|d| d.is_object()) else {
return (out, counts);
};
let mut walk = |bucket: Option<&Value>, label: Option<&str>| {
let Some(items) = bucket.and_then(Value::as_array) else { return };
for v in items {
let sev = v.get("severity").and_then(Value::as_str).unwrap_or("warning").to_string();
let items: Vec<Value> = v
.get("items")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.map(|i| {
json!({
"description": i.get("description").cloned().unwrap_or(json!("")),
"pos": i.get("pos").cloned().unwrap_or(json!({})),
})
})
.collect()
})
.unwrap_or_default();
out.push(json!({
"type": v.get("type").cloned().unwrap_or_else(|| json!(label.unwrap_or("unknown"))),
"severity": sev,
"description": v.get("description").cloned().unwrap_or(json!("")),
"items": items,
}));
*counts.entry(sev).or_insert(0) += 1;
}
};
walk(data.get("violations"), None);
walk(data.get("unconnected_items"), Some("unconnected_items"));
walk(data.get("schematic_parity"), Some("schematic_parity"));
if let Some(sheets) = data.get("sheets").and_then(Value::as_array) {
for s in sheets {
walk(s.get("violations"), None);
}
}
(out, counts)
}
/// Sorted, de-duplicated `type` of every error-severity violation, first five.
pub fn top_error_types(violations: &[Value]) -> Vec<String> {
let set: BTreeSet<String> = violations
.iter()
.filter(|v| v.get("severity").and_then(Value::as_str) == Some("error"))
.filter_map(|v| v.get("type").and_then(Value::as_str).map(str::to_string))
.collect();
set.into_iter().take(5).collect()
}
fn types_str(violations: &[Value]) -> String {
let t = top_error_types(violations);
if t.is_empty() {
"various".into()
} else {
t.join(", ")
}
}
/// The tiered `_hint` of `kicad_lint_board`.
pub fn board_hint(violations: &[Value], errors: u64, warnings: u64) -> String {
if errors == 0 && warnings == 0 {
"✅ DRC clean. Safe to open kicad_open_board, generate gerbers via export, or hand off to fabrication. No human intervention needed.".into()
} else if errors == 0 {
format!(
"⚠️ {warnings} DRC warning(s), 0 errors. The board is fabricable as-is but the warnings may \
indicate sloppy design (silkscreen overlapping pads, courtyard violations, etc.). \
Review violations[].description and decide if any need fixing. Safe to proceed with kicad_open_board."
)
} else {
format!(
"❌ {errors} DRC error(s) + {warnings} warning(s). Top error types: {}. \
DO NOT proceed to fabrication. Open kicad_open_board and resolve these in the PCB Editor, \
OR fix the underlying schematic if these are schematic-parity errors (e.g. nets connected on the schematic \
but unrouted on the board). Re-run kicad_lint_board after fixing to confirm clean.",
types_str(violations)
)
}
}
/// The tiered `_hint` of `kicad_lint_schematic`.
pub fn schematic_hint(violations: &[Value], errors: u64, warnings: u64) -> String {
if errors == 0 && warnings == 0 {
"✅ ERC clean. Safe to open kicad_open_schematic or move on to PCB layout.".into()
} else if errors == 0 {
format!(
"⚠️ {warnings} ERC warning(s), 0 errors. Common warnings: pin-type mismatch on connections, \
missing power flags. Inspect violations[].description and decide. Schematic is functionally OK."
)
} else {
format!(
"❌ {errors} ERC error(s) + {warnings} warning(s). Top error types: {}. \
Common causes: unconnected pins (add NoConnect flag), duplicate references (run Annotate Schematic), \
hierarchical-label mismatches, no-driver on a net (e.g. an input pin with no source). \
Fix in eeschema via kicad_open_schematic, then re-run kicad_lint_schematic to confirm.",
types_str(violations)
)
}
}
/// `handle_lint_board` / `handle_lint_schematic` after the arg checks: run kicad-cli,
/// classify, build the Python response. `expected` is `PCB` or `SCH`; `file_version`
/// comes from the sniff; `schematic_parity` only matters for boards.
pub fn lint_document(cli: &Path, path: &Path, expected: &str, file_version: Option<u64>, schematic_parity: bool, timeout: Duration) -> Value {
let is_pcb = expected == PCB;
let path_s = path.to_string_lossy().to_string();
let mut argv: Vec<&str> = if is_pcb {
vec!["pcb", "drc", "--format", "json", "--severity-all", "--all-track-errors"]
} else {
vec!["sch", "erc", "--format", "json", "--severity-all"]
};
if is_pcb && schematic_parity {
// Only meaningful if the matching .kicad_sch exists in the project dir.
argv.push("--schematic-parity");
}
argv.push(&path_s);
let r = run_cli_json(cli, &argv, timeout);
let file_format = json!({"expected": expected, "fileVersion": file_version});
if !r.ok {
let hint = if is_pcb {
"If exitCode is nonzero and stderr mentions 'older format', the file needs upgrading: \
`kicad-cli pcb upgrade` (or just opening it in KiCad and saving) does this. \
If schematic-parity failed because no matching .kicad_sch was found in the project dir, \
retry with schematicParity:false."
} else {
"If exitCode is nonzero and stderr mentions an older format, run `kicad-cli sch upgrade` (or open + save in eeschema)."
};
return json!({
"success": false,
"errorCode": if r.timed_out { "cli_timeout" } else { "cli_failed" },
"error": r.error.clone().unwrap_or_else(|| "kicad-cli failed".into()),
"exitCode": r.exit_code,
"stdout": r.stdout,
"stderr": r.stderr,
"fileFormat": file_format,
"cliElapsedMs": r.elapsed_ms,
"_hint": r.hint.unwrap_or_else(|| hint.into()),
});
}
let (violations, counts) = parse_violations(r.data.as_ref());
let total = violations.len();
let errors = counts.get("error").copied().unwrap_or(0);
let warnings = counts.get("warning").copied().unwrap_or(0);
let info = counts.get("info").copied().unwrap_or(0);
let hint = if is_pcb { board_hint(&violations, errors, warnings) } else { schematic_hint(&violations, errors, warnings) };
let mut data = json!({
"violations": violations,
"summary": {"total": total, "errors": errors, "warnings": warnings, "info": info},
"fileFormat": file_format,
"source_file": path_s,
});
if is_pcb {
data["schematicParityChecked"] = json!(schematic_parity);
}
json!({
"success": true,
"output": format!("Lint complete: {errors} error(s), {warnings} warning(s), {total} total"),
"data": data,
"exitCode": r.exit_code,
"cliElapsedMs": r.elapsed_ms,
"_hint": hint,
})
}
// ───────────────────────── library structural check and inventory ─────────────────────────
/// Python `_LIB_MAGIC`.
pub fn sym_magic_ok(head: &str) -> bool {
head.trim_start().starts_with("(kicad_symbol_lib")
}
pub fn mod_magic_ok(head: &str) -> bool {
let h = head.trim_start();
h.starts_with("(footprint") || h.starts_with("(module") // KiCad <= 6 used "module"
}
/// Python `adom_library._quote_aware_children`: the FIRST top-level s-expression list
/// in `text`. Returns `(root_open, root_close, children)` where children are the byte
/// spans of the root's direct child lists. Errs on any imbalance (an extra `)` names
/// its offset, a never-closed list says so) and on an unterminated string.
pub fn quote_aware_children(text: &str) -> Result<(usize, usize, Vec<(usize, usize)>), String> {
let (mut in_str, mut esc) = (false, false);
let mut depth: i64 = 0;
let (mut root_open, mut root_close) = (None, None);
let mut child_start: Option<usize> = None;
let mut children = Vec::new();
for (i, ch) in text.bytes().enumerate() {
if in_str {
if esc {
esc = false;
} else if ch == b'\\' {
esc = true;
} else if ch == b'"' {
in_str = false;
}
continue;
}
match ch {
b'"' => in_str = true,
b'(' => {
depth += 1;
if depth == 1 {
root_open = Some(i);
} else if depth == 2 && child_start.is_none() {
child_start = Some(i);
}
}
b')' => {
depth -= 1;
if depth == 1 {
if let Some(cs) = child_start.take() {
children.push((cs, 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 Some(ro) = root_open else { return Err("no s-expression found".into()) };
let Some(rc) = root_close else { return Err("unbalanced s-expression: the top-level list never closes".into()) };
Ok((ro, rc, children))
}
/// Python `adom_library._parse_library_nodes` minus the node list: balanced, root is
/// `kicad_symbol_lib`, nothing after the root's closer.
pub fn check_symbol_lib_structure(text: &str) -> Result<(), String> {
let (ro, rc, _) = quote_aware_children(text)?;
let head = if text[ro + 1..].trim().is_empty() {
""
} else {
head_of(&text[ro + 1..], 39).split_whitespace().next().unwrap_or("")
};
if head != "kicad_symbol_lib" {
return Err(format!("not a symbol library (top-level list is '{head}')"));
}
let tail = text[rc + 1..].trim();
if !tail.is_empty() {
return Err(format!("trailing content after the library's closing paren: {}", py_repr(head_of(tail, 40))));
}
Ok(())
}
/// The footprint structural check `handle_lint_library` runs: balanced, and nothing
/// after the footprint's closer.
pub fn check_footprint_structure(text: &str) -> Result<(), String> {
let (_, rc, _) = quote_aware_children(text)?;
let tail = text[rc + 1..].trim();
if !tail.is_empty() {
return Err(format!("trailing content after the footprint's closing paren: {}", py_repr(head_of(tail, 40))));
}
Ok(())
}
/// Python `_parse_kicad_sym` on the file text: `{symbolCount, symbols:[{name, pinCount,
/// unitCount}], fileVersion, generator, _warnings}`. Regex-style inventory, same
/// heuristics as the Python (a 5000-char window per symbol for the pin count).
pub fn parse_kicad_sym(text: &str) -> Value {
let head = head_of(text, 500);
// Every `(symbol "NAME"` anywhere, for the unit lookup.
let mut all_names: Vec<&str> = Vec::new();
let mut rest = 0;
while let Some(i) = text[rest..].find("(symbol") {
let at = rest + i;
if let Some(n) = symbol_name_at(text, at) {
all_names.push(n);
}
rest = at + "(symbol".len();
}
let mut symbols = Vec::new();
let mut offset = 0;
for line in text.split_inclusive('\n') {
let lead = line.len() - line.trim_start().len();
let at = offset + lead;
offset += line.len();
if !line[lead..].starts_with("(symbol") {
continue;
}
let Some(name) = symbol_name_at(text, at) else { continue };
if ends_with_unit_suffix(name) {
continue; // nested unit variant like "R_0_0"
}
let window = head_of(&text[at..], 5000);
let pin_count = count_keyword(window, "pin", true);
let units: BTreeSet<&str> = all_names.iter().filter_map(|n| unit_of(n, name)).collect();
symbols.push(json!({"name": name, "pinCount": pin_count, "unitCount": units.len().max(1)}));
}
json!({
"symbolCount": symbols.len(),
"symbols": symbols,
"fileVersion": file_version(head),
"generator": generator(head),
"_warnings": [],
})
}
/// Python `_parse_kicad_mod`: `{name, padCount, layers, fileVersion, generator, _warnings}`.
pub fn parse_kicad_mod(text: &str) -> Value {
let head = head_of(text, 500);
let name: Option<String> = match after_keyword(head, "footprint") {
Some((t, _)) if t.starts_with('"') => t[1..].find('"').filter(|&e| e > 0).map(|e| t[1..1 + e].to_string()),
_ => after_keyword(head, "module").and_then(|(t, _)| {
let tok: String = t.chars().take_while(|c| !c.is_whitespace()).collect();
if tok.is_empty() { None } else { Some(tok) }
}),
};
let layers: BTreeSet<String> = keyword_values(text, "layer").into_iter().collect();
let layers: Vec<String> = layers.into_iter().take(10).collect();
json!({
"name": name,
"padCount": count_keyword(text, "pad", false),
"layers": layers,
"fileVersion": file_version(head),
"generator": generator(head),
"_warnings": [],
})
}
// ───────────────────────── KiCad's own verdict (kicad_parse_check.py) ─────────────────────────
/// `Plotting symbol 'NAME' unit N` lines from `kicad-cli sym export svg`: unique symbol
/// names in order, and the number of units plotted.
pub fn parse_plot_lines(text: &str) -> (Vec<String>, usize) {
let mut names: Vec<String> = Vec::new();
let mut units = 0;
for line in text.lines() {
if let Some(name) = plot_line_name(line) {
units += 1;
if !names.iter().any(|n| n == name) {
names.push(name.to_string());
}
}
}
(names, units)
}
fn plot_line_name(line: &str) -> Option<&str> {
const P: &str = "Plotting symbol '";
let i = line.find(P)?;
let rest = &line[i + P.len()..];
let end = rest.find('\'')?;
if end == 0 {
return None;
}
let after = rest[end + 1..].strip_prefix(" unit ")?;
if after.bytes().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {
Some(&rest[..end])
} else {
None
}
}
/// `kicad_parse_check.cli_exe`: the recorded kicad-cli if it exists, else the two
/// spellings next to it in the bin dir.
pub fn cli_exe(kicad_cli: &str, bin_dir: &str) -> Option<PathBuf> {
if !kicad_cli.is_empty() && Path::new(kicad_cli).exists() {
return Some(PathBuf::from(kicad_cli));
}
if !bin_dir.is_empty() {
for name in ["kicad-cli.exe", "kicad-cli"] {
let c = Path::new(bin_dir).join(name);
if c.exists() {
return Some(c);
}
}
}
None
}
/// `kicad_parse_check.verdict_file`: KiCad's verdict on an existing `.kicad_sym`.
/// `{available, ok, symbols[], units, error, exitCode, durationMs}`. `available: false`
/// means kicad-cli was not found: no verdict either way, and callers must SAY that
/// rather than treat it as a pass.
pub fn verdict_file(cli: Option<&Path>, lib_path: &Path, timeout: Duration) -> Value {
let Some(cli) = cli 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: {}", lib_path.display())});
}
let out_dir = scratch_path("adom-kicad-parse", "");
if let Err(e) = std::fs::create_dir_all(&out_dir) {
return json!({"available": true, "ok": false, "symbols": [], "units": 0,
"error": head_of(&format!("scratch dir failed: {e}"), 300)});
}
let t0 = Instant::now();
let out_s = out_dir.to_string_lossy().to_string();
let lib_s = lib_path.to_string_lossy().to_string();
let r = cli::run(cli, &["sym", "export", "svg", "-o", &out_s, &lib_s], timeout);
let _ = std::fs::remove_dir_all(&out_dir);
if r.timed_out {
// no answer is not a refusal: KiCad's verdict on this file is unknown
return json!({"available": true, "ok": null, "timedOut": true, "symbols": [], "units": 0,
"error": format!("kicad-cli did not answer within {}s", timeout.as_secs())});
}
if r.exit_code == -1 {
return json!({"available": true, "ok": false, "symbols": [], "units": 0,
"error": head_of(&r.stderr, 300)});
}
let text = format!("{}\n{}", r.stdout, r.stderr);
let (names, units) = parse_plot_lines(&text);
let ok = r.exit_code == 0;
let err = if ok {
String::new()
} else {
let lines: Vec<&str> = text.lines().map(str::trim).filter(|l| !l.is_empty() && plot_line_name(l).is_none()).take(3).collect();
let joined = head_of(&lines.join(" | "), 400).to_string();
if joined.is_empty() { format!("kicad-cli exit {}", r.exit_code) } else { joined }
};
json!({
"available": true, "ok": ok, "symbols": names, "units": units, "error": err,
"exitCode": r.exit_code, "durationMs": t0.elapsed().as_millis() as u64,
})
}
// ───────────────────────── kicad_lint_library ─────────────────────────
fn ext_lower(p: &Path) -> String {
p.extension().map(|e| format!(".{}", e.to_string_lossy().to_lowercase())).unwrap_or_default()
}
/// Files in `dir` with the given (lower-case, dotted) extension, sorted by path.
fn glob_ext(dir: &Path, ext: &str) -> Vec<PathBuf> {
let mut v: Vec<PathBuf> = std::fs::read_dir(dir)
.map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.is_file() && ext_lower(p) == ext).collect())
.unwrap_or_default();
v.sort();
v
}
/// What `handle_lint_library` found on disk.
#[derive(Debug)]
pub struct LibraryInput {
pub kind: &'static str,
pub sym_files: Vec<PathBuf>,
pub mod_files: Vec<PathBuf>,
}
/// Discover what kind of input a library path is. Errors are the Python's
/// `(error, _hint)` pairs.
pub fn discover_library(path: &Path) -> Result<LibraryInput, (String, String)> {
if !path.exists() {
return Err((
format!("path not found: {}", path.display()),
"The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first.".into(),
));
}
let mut li = LibraryInput { kind: "", sym_files: Vec::new(), mod_files: Vec::new() };
if path.is_file() {
match ext_lower(path).as_str() {
".kicad_sym" => {
li.sym_files.push(path.to_path_buf());
li.kind = "symbol_lib_file";
}
".kicad_mod" => {
li.mod_files.push(path.to_path_buf());
li.kind = "footprint_file";
}
ext => {
return Err((
format!("unsupported file extension: {ext}"),
"Pass a .kicad_sym, .kicad_mod, or a directory containing them (.pretty for footprint libraries).".into(),
))
}
}
} else if path.is_dir() {
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
if ext_lower(path) == ".pretty" || name.ends_with(".pretty") {
li.kind = "footprint_lib_dir";
li.mod_files = glob_ext(path, ".kicad_mod");
} else {
li.kind = "mixed_dir";
li.sym_files = glob_ext(path, ".kicad_sym");
li.mod_files = glob_ext(path, ".kicad_mod");
}
if li.sym_files.is_empty() && li.mod_files.is_empty() {
return Err((
format!("directory contains no .kicad_sym or .kicad_mod files: {}", path.display()),
"Symbol libraries are single .kicad_sym files. Footprint libraries are .pretty/ directories containing .kicad_mod files.".into(),
));
}
} else {
return Err((format!("not a file or directory: {}", path.display()), "Pass a .kicad_sym, .kicad_mod or .pretty directory.".into()));
}
Ok(li)
}
/// Python list repr of sorted versions, e.g. `[20211014, 20231120]`.
fn versions_repr(v: &[u64]) -> String {
format!("[{}]", v.iter().map(u64::to_string).collect::<Vec<_>>().join(", "))
}
fn file_name(p: &str) -> String {
Path::new(p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.to_string())
}
/// The tiered `_hint` of `kicad_lint_library`. Pure, so it is testable without files.
pub fn library_hint(
kicad_refused: &[Value],
sym_file_errors: usize,
mod_file_errors: usize,
symbol_count: u64,
footprint_count: u64,
versions: &[u64],
kicad_verdicts: &[Value],
) -> String {
if !kicad_refused.is_empty() {
let names: Vec<String> = kicad_refused
.iter()
.take(3)
.map(|v| format!("{}: {}", file_name(v.get("path").and_then(Value::as_str).unwrap_or("")), v.get("error").and_then(Value::as_str).unwrap_or("")))
.collect();
return format!(
"❌ KICAD REFUSES {} symbol file(s) that the bridge's structural check passed: {}. \
KiCad lists such a library EMPTY, and one malformed symbol does that to the whole file. \
DO NOT install. Fix the file until `kicad-cli sym export svg` accepts it \
(common: bare `hide yes` inside (effects ...) instead of `(hide yes)`).",
kicad_refused.len(),
names.join("; ")
);
}
if sym_file_errors > 0 || mod_file_errors > 0 {
return format!(
"❌ {sym_file_errors} symbol file(s) + {mod_file_errors} footprint file(s) \
failed to parse. Inspect _errors[] for specifics. Common causes: file truncated mid-write, \
wrong file type renamed with .kicad_sym extension, file from a much newer KiCad than installed. \
DO NOT install via kicad_install_library / kicad_install_symbol / kicad_install_footprint: \
the install will land broken files."
);
}
// Outdated format. KiCad 10 uses YYYYMMDD versions like 20240108. Anything below
// 20200101 is KiCad 5-era; 2023xxxx is KiCad 7-era and warrants a kicad_format_upgrade first.
let outdated: Vec<u64> = versions.iter().copied().filter(|&v| v < 20231101).collect();
if !outdated.is_empty() {
return format!(
"⚠️ {symbol_count} symbol(s) + {footprint_count} footprint(s) parsed cleanly, \
BUT file-format version(s) {} are pre-KiCad-7. KiCad 10 can read these but \
will silently upgrade them on first save. To migrate proactively, call \
kicad_format_upgrade (or just run `kicad-cli sym/fp upgrade` manually). \
OK to proceed with install but expect a one-time format change.",
versions_repr(&outdated)
);
}
let versions_s = if versions.is_empty() { "unspecified".to_string() } else { versions_repr(versions) };
let available = |v: &Value| v.get("available").and_then(Value::as_bool).unwrap_or(false);
let kp_ok: Vec<&Value> = kicad_verdicts.iter().filter(|v| available(v) && v.get("ok").and_then(Value::as_bool).unwrap_or(false)).collect();
let kp_syms: usize = kp_ok.iter().map(|v| v.get("symbols").and_then(Value::as_array).map(|a| a.len()).unwrap_or(0)).sum();
if !kicad_verdicts.is_empty() && kicad_verdicts.iter().all(available) {
format!(
"✅ KiCad's own parser loads {kp_syms} symbol(s) from {} file(s) (kicadParser); \
the bridge's structural check counts {symbol_count} symbol(s) + {footprint_count} footprint(s), \
format version(s) {versions_s}. Safe to install via \
kicad_install_library / kicad_install_symbol / kicad_install_footprint.",
kp_ok.len()
)
} else if kicad_verdicts.is_empty() && footprint_count > 0 && symbol_count == 0 {
// wiki #103: footprints alone. kicad-cli lints SYMBOL libraries; it has no footprint
// verdict, so saying "kicad-cli not found" blames a missing tool for a gap that is by
// design, and sends the caller hunting for an executable that is right there.
format!(
"✅ {footprint_count} footprint(s) balanced by the BRIDGE's reader, \
format version(s) {versions_s}. KiCad's own parser has no footprint verdict to give \
(kicad-cli checks symbol libraries), so footprints are a structural check by design, \
not a missing tool. Safe to install via kicad_install_footprint."
)
} else {
format!(
"✅ {symbol_count} symbol(s) + {footprint_count} footprint(s) balanced by the BRIDGE's reader, \
format version(s) {versions_s}. KiCad's own parser was \
NOT consulted (kicad-cli not found), so this is a structural check only, not KiCad's verdict."
)
}
}
/// `handle_lint_library` after the arg check. `cli` is None when kicad-cli is not on
/// this box; the structural check still runs and the hint says KiCad was not asked.
pub fn lint_library(path: &Path, cli: Option<&Path>, verdict_timeout: Duration) -> Value {
let li = match discover_library(path) {
Ok(li) => li,
Err((error, hint)) => return json!({"success": false, "errorCode": "bad_input", "error": error, "_hint": hint}),
};
let mut parsed_sym: Vec<Value> = Vec::new();
for f in &li.sym_files {
let p = f.to_string_lossy().to_string();
let text = match std::fs::read(f) {
Ok(b) => String::from_utf8_lossy(&b).to_string(),
Err(e) => {
parsed_sym.push(json!({"path": p, "_error": format!("read failed: {e}")}));
continue;
}
};
if !sym_magic_ok(head_of(&text, 200)) {
parsed_sym.push(json!({"path": p, "_error": "not a kicad_symbol_lib (bad magic)"}));
continue;
}
// Structural check (#54): unbalanced parens, trailing garbage after the root
// closer, or a non-library root are hard errors; a regex inventory cannot see them.
if let Err(e) = check_symbol_lib_structure(&text) {
parsed_sym.push(json!({"path": p, "_error": format!("structural: {e}")}));
continue;
}
let mut info = parse_kicad_sym(&text);
info["path"] = json!(p);
info["structuralCheck"] = json!("balanced");
parsed_sym.push(info);
}
let mut parsed_mod: Vec<Value> = Vec::new();
for f in &li.mod_files {
let p = f.to_string_lossy().to_string();
let text = match std::fs::read(f) {
Ok(b) => String::from_utf8_lossy(&b).to_string(),
Err(e) => {
parsed_mod.push(json!({"path": p, "_error": format!("read failed: {e}")}));
continue;
}
};
if !mod_magic_ok(head_of(&text, 200)) {
parsed_mod.push(json!({"path": p, "_error": "not a kicad_mod (bad magic)"}));
continue;
}
if let Err(e) = check_footprint_structure(&text) {
parsed_mod.push(json!({"path": p, "_error": format!("structural: {e}")}));
continue;
}
let mut info = parse_kicad_mod(&text);
info["path"] = json!(p);
parsed_mod.push(info);
}
let has_err = |v: &Value| v.get("_error").is_some();
let symbol_count: u64 = parsed_sym.iter().filter(|s| !has_err(s)).map(|s| s.get("symbolCount").and_then(Value::as_u64).unwrap_or(0)).sum();
let footprint_count: u64 = parsed_mod.iter().filter(|m| !has_err(m)).count() as u64;
let sym_file_errors = parsed_sym.iter().filter(|s| has_err(s)).count();
let mod_file_errors = parsed_mod.iter().filter(|m| has_err(m)).count();
let versions: Vec<u64> = parsed_sym
.iter()
.chain(parsed_mod.iter())
.filter_map(|s| s.get("fileVersion").and_then(Value::as_u64))
.filter(|&v| v != 0)
.collect::<BTreeSet<u64>>()
.into_iter()
.collect();
// wiki #71: the parse above is the BRIDGE's reader. Ask KiCad for every .kicad_sym
// (read-only export) and let its verdict override the checkmark.
let mut kicad_verdicts: Vec<Value> = Vec::new();
let mut kicad_refused: Vec<Value> = Vec::new();
for f in &li.sym_files {
let mut v = verdict_file(cli, f, verdict_timeout);
v["path"] = json!(f.to_string_lossy().to_string());
let available = v.get("available").and_then(Value::as_bool).unwrap_or(false);
let ok = v.get("ok").and_then(Value::as_bool).unwrap_or(false);
if available && !ok && v.get("timedOut") != Some(&json!(true)) {
kicad_refused.push(v.clone());
}
kicad_verdicts.push(v);
}
let hint = library_hint(&kicad_refused, sym_file_errors, mod_file_errors, symbol_count, footprint_count, &versions, &kicad_verdicts);
let ok = kicad_refused.is_empty() && sym_file_errors == 0 && mod_file_errors == 0;
let kicad_parser = match kicad_verdicts.len() {
0 => Value::Null,
1 => kicad_verdicts[0].clone(),
_ => json!(kicad_verdicts),
};
let mut output = format!(
"Lint: {symbol_count} symbol(s), {footprint_count} footprint(s), {} file error(s)",
sym_file_errors + mod_file_errors
);
if !kicad_refused.is_empty() {
output.push_str(&format!(", KiCad refuses {} file(s)", kicad_refused.len()));
}
let not_asked = kicad_verdicts.iter().filter(|v| v.get("timedOut") == Some(&json!(true))).count();
if not_asked > 0 {
output.push_str(&format!(", KiCad did not answer in time for {not_asked} file(s) (its verdict is unknown, not a refusal)"));
}
json!({
"success": true,
"ok": ok,
"kicadParser": kicad_parser,
"output": output,
"data": {
"kind": li.kind,
"source_path": path.to_string_lossy().to_string(),
"summary": {
"symbolCount": symbol_count,
"footprintCount": footprint_count,
"symFileErrorCount": sym_file_errors,
"modFileErrorCount": mod_file_errors,
"fileFormatVersions": versions,
},
"symbols": parsed_sym,
"footprints": parsed_mod,
},
"_hint": hint,
})
}
// ───────────────────────── kicad_format_upgrade ─────────────────────────
/// The four `kicad-cli <kind> upgrade` kinds.
pub const UPGRADE_KINDS: [&str; 4] = ["pcb", "sch", "sym", "fp"];
/// A caller-supplied `kind`, lower-cased and checked. Empty means auto-detect.
pub fn normalize_kind(kind: &str) -> Option<&'static str> {
let k = kind.trim().to_lowercase();
UPGRADE_KINDS.iter().copied().find(|&u| u == k)
}
/// Python auto-detect: by extension for a file (case-sensitive, as the Python was), by
/// contents for a directory (`.pretty` or any `.kicad_mod` means fp, any `.kicad_sym`
/// means sym).
pub fn detect_kind(path: &Path) -> Option<&'static str> {
if path.is_file() {
let ext = path.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
return match ext.as_str() {
"kicad_pcb" => Some("pcb"),
"kicad_sch" => Some("sch"),
"kicad_sym" => Some("sym"),
"kicad_mod" => Some("fp"),
_ => None,
};
}
if path.is_dir() {
if ext_lower(path) == ".pretty" || !glob_ext(path, ".kicad_mod").is_empty() {
return Some("fp");
}
if !glob_ext(path, ".kicad_sym").is_empty() {
return Some("sym");
}
}
None
}
/// The lint verb to re-run after an upgrade of `kind`. (The Python compared `kind`
/// against "board"/"schematic", which never occur, so it always said lint_library.)
pub fn lint_verb_for(kind: &str) -> &'static str {
match kind {
"pcb" => "kicad_lint_board",
"sch" => "kicad_lint_schematic",
_ => "kicad_lint_library",
}
}
/// The `_hint` of `kicad_format_upgrade`.
pub fn upgrade_hint(kind: &str, success: bool, exit_code: i32) -> String {
if success {
format!(
"✅ kicad-cli {kind} upgrade completed. File(s) rewritten to current KiCad format. \
Any open KiCad GUI windows with this file loaded should be closed + reopened; they \
have the OLD in-memory state. Re-run {} to confirm the post-upgrade file is clean.",
lint_verb_for(kind)
)
} else {
format!(
"❌ kicad-cli {kind} upgrade failed with exitCode={exit_code}. \
stderr likely names the offending file or syntax issue. If it's a 'file format too new' \
error, the user's KiCad install is OLDER than the source file; surface to user. \
If 'file already up to date', pass force:true to resave anyway."
)
}
}
/// `handle_format_upgrade` after the arg checks: `kicad-cli <kind> upgrade [--force] <path>`.
/// WARNING: this MUTATES the file in place (kicad-cli has no --dry-run).
pub fn format_upgrade(cli: &Path, path: &Path, kind: &str, force: bool, timeout: Duration) -> Value {
let path_s = path.to_string_lossy().to_string();
let mut argv: Vec<&str> = vec![kind, "upgrade"];
if force {
argv.push("--force");
}
argv.push(&path_s);
let r = cli::run(cli, &argv, timeout);
if r.timed_out {
return json!({
"success": false,
"errorCode": "cli_timeout",
"error": format!("kicad-cli upgrade timed out after {}s", timeout.as_secs()),
"_hint": "Likely a huge directory of footprints. Re-run with a single file at a time.",
});
}
if r.exit_code == -1 {
return json!({"success": false, "errorCode": "cli_failed", "error": r.stderr.trim(), "_hint": "kicad-cli could not be started; check kicad_list_versions."});
}
let success = r.exit_code == 0;
json!({
"success": success,
"exitCode": r.exit_code,
"kind": kind,
"stdout": r.stdout,
"stderr": r.stderr,
"source_path": path_s,
"cliElapsedMs": r.elapsed_ms,
"_hint": upgrade_hint(kind, success, r.exit_code),
})
}
// ───────────────────────── tests ─────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_and_generator() {
let head = "(kicad_symbol_lib\n\t(version 20231120)\n\t(generator \"kicad_symbol_editor\")\n";
assert_eq!(file_version(head), Some(20231120));
assert_eq!(generator(head), Some("kicad_symbol_editor".into()));
assert_eq!(generator("(generator eeschema)"), Some("eeschema".into()));
assert_eq!(file_version("(version)"), None);
assert_eq!(file_version("(version_x 1)"), None);
assert_eq!(file_version("(generator_version \"8.0\") (version 20240108)"), Some(20240108));
}
#[test]
fn sniff_pcb_ok() {
let v = sniff_head("(kicad_pcb (version 20240108) (generator \"pcbnew\")", PCB);
assert_eq!(v["ok"], json!(true));
assert_eq!(v["magic"], json!("matched"));
assert_eq!(v["fileVersion"], json!(20240108));
assert_eq!(v["expected"], json!("kicad_pcb"));
}
#[test]
fn sniff_wrong_type_and_garbage() {
let v = sniff_head(" (kicad_sch (version 20231120)", PCB);
assert_eq!(v["ok"], json!(false));
assert_eq!(v["actual"], json!("kicad_sch"));
assert!(v["_hint"].as_str().unwrap().contains("kicad_lint_schematic"));
assert!(v["_error"].as_str().unwrap().starts_with("file is not a kicad_pcb (first bytes: '(kicad_sch"));
let g = sniff_head("PK\u{3}\u{4}zip", SCH);
assert_eq!(g["actual"], json!("non-kicad"));
assert!(g["_hint"].as_str().unwrap().contains("corrupted"));
let ok = sniff_head("(kicad_sch (version 20231120)", SCH);
assert_eq!(ok["ok"], json!(true));
}
const DRC: &str = r#"{
"$schema": "https://schemas.kicad.org/drc.v1.json",
"kicad_version": "10.0.5",
"source": "board.kicad_pcb",
"violations": [
{"type": "clearance", "severity": "error", "description": "Clearance violation",
"items": [{"description": "Track [GND]", "pos": {"x": 1.0, "y": 2.0}}]},
{"type": "silk_over_copper", "severity": "warning", "description": "Silk over pad", "items": []},
{"type": "courtyards_overlap", "severity": "error", "description": "Courtyards overlap", "items": []}
],
"unconnected_items": [
{"type": "unconnected_items", "severity": "error", "description": "Missing connection", "items": [{"description": "Pad 1"}]}
],
"schematic_parity": [
{"type": "net_conflict", "severity": "warning", "description": "Net mismatch", "items": []}
]
}"#;
#[test]
fn parse_drc_report() {
let data: Value = serde_json::from_str(DRC).unwrap();
let (v, c) = parse_violations(Some(&data));
assert_eq!(v.len(), 5);
assert_eq!(c["error"], 3);
assert_eq!(c["warning"], 2);
assert_eq!(c["info"], 0);
assert_eq!(v[0]["items"][0]["pos"]["x"], json!(1.0));
assert_eq!(v[3]["type"], json!("unconnected_items"));
assert_eq!(v[3]["items"][0]["pos"], json!({}));
assert_eq!(top_error_types(&v), vec!["clearance", "courtyards_overlap", "unconnected_items"]);
let hint = board_hint(&v, 3, 2);
assert!(hint.starts_with("❌ 3 DRC error(s) + 2 warning(s). Top error types: clearance, courtyards_overlap, unconnected_items."));
assert!(board_hint(&[], 0, 0).contains("DRC clean"));
assert!(board_hint(&[], 0, 4).starts_with("⚠️ 4 DRC warning(s), 0 errors."));
}
const ERC: &str = r#"{
"$schema": "https://schemas.kicad.org/erc.v1.json",
"kicad_version": "10.0.5",
"source": "board.kicad_sch",
"sheets": [
{"path": "/", "uuid_path": "/abc", "violations": [
{"type": "pin_not_connected", "severity": "error", "description": "Pin not connected", "items": [{"description": "U1 pin 3", "pos": {"x": 5, "y": 6}}]},
{"type": "power_pin_not_driven", "severity": "warning", "description": "Not driven", "items": []}
]},
{"path": "/sub", "uuid_path": "/abc/def", "violations": [
{"type": "lib_symbol_mismatch", "description": "no severity given", "items": []}
]}
]
}"#;
#[test]
fn parse_erc_report_walks_sheets() {
let data: Value = serde_json::from_str(ERC).unwrap();
let (v, c) = parse_violations(Some(&data));
assert_eq!(v.len(), 3);
assert_eq!(c["error"], 1);
assert_eq!(c["warning"], 2, "missing severity defaults to warning");
assert!(schematic_hint(&v, 1, 2).starts_with("❌ 1 ERC error(s) + 2 warning(s). Top error types: pin_not_connected."));
assert!(schematic_hint(&[], 0, 0).contains("ERC clean"));
assert!(schematic_hint(&[], 0, 1).starts_with("⚠️ 1 ERC warning(s), 0 errors."));
let (none, c0) = parse_violations(None);
assert!(none.is_empty());
assert_eq!(c0.len(), 4);
let (arr, _) = parse_violations(Some(&json!([1, 2])));
assert!(arr.is_empty());
}
#[test]
fn structure_checks() {
let good = "(kicad_symbol_lib (version 20231120)\n (symbol \"R\" (property \"a\" \"x)\"))\n)\n";
assert!(check_symbol_lib_structure(good).is_ok());
let (ro, rc, kids) = quote_aware_children(good).unwrap();
assert_eq!(ro, 0);
assert_eq!(rc, good.len() - 2);
assert_eq!(kids.len(), 2);
assert_eq!(quote_aware_children("(a (b)").unwrap_err(), "unbalanced s-expression: the top-level list never closes");
assert_eq!(quote_aware_children(") (a)").unwrap_err(), "unbalanced s-expression: extra ')' at offset 0");
assert!(quote_aware_children("(a) )").is_ok(), "the scan stops at the root's closer; trailing text is the structure check's job");
assert_eq!(quote_aware_children("(a \"open").unwrap_err(), "unterminated quoted string");
assert_eq!(quote_aware_children("nothing").unwrap_err(), "no s-expression found");
assert_eq!(check_symbol_lib_structure("(kicad_symbol_lib (a))\n(junk)").unwrap_err(), "trailing content after the library's closing paren: '(junk)'");
assert_eq!(check_symbol_lib_structure("(kicad_pcb (a))").unwrap_err(), "not a symbol library (top-level list is 'kicad_pcb')");
assert!(check_footprint_structure("(footprint \"X\" (pad \"1\" smd rect))").is_ok());
assert!(check_footprint_structure("(footprint \"X\") extra").unwrap_err().starts_with("trailing content after the footprint's closing paren"));
}
#[test]
fn inventory_of_symbol_lib() {
let text = r#"(kicad_symbol_lib
(version 20231120)
(generator "kicad_symbol_editor")
(symbol "R" (pin_numbers hide)
(property "Reference" "R")
(symbol "R_0_1" (rectangle (start 0 0) (end 1 1)))
(symbol "R_1_1"
(pin passive line (at 0 3.81 270) (length 1.27))
(pin passive line (at 0 -3.81 90) (length 1.27))
)
)
(symbol "OpAmp_Dual"
(symbol "OpAmp_Dual_1_1" (pin input line (at 0 0 0)))
(symbol "OpAmp_Dual_2_1" (pin input line (at 0 0 0)))
(symbol "OpAmp_Dual_3_1" (pin power_in line (at 0 0 0)))
)
)
"#;
let v = parse_kicad_sym(text);
assert_eq!(v["symbolCount"], json!(2));
assert_eq!(v["fileVersion"], json!(20231120));
assert_eq!(v["generator"], json!("kicad_symbol_editor"));
assert_eq!(v["symbols"][0]["name"], json!("R"));
// The Python counts pins in a 5000-char window from the symbol, which here runs
// into OpAmp_Dual's three pins: 2 + 3. Same over-count, on purpose.
assert_eq!(v["symbols"][0]["pinCount"], json!(5));
assert_eq!(v["symbols"][0]["unitCount"], json!(2), "units 0 and 1, as the Python's _N_M set counts them");
assert_eq!(v["symbols"][1]["name"], json!("OpAmp_Dual"));
assert_eq!(v["symbols"][1]["pinCount"], json!(3));
assert_eq!(v["symbols"][1]["unitCount"], json!(3));
assert!(ends_with_unit_suffix("R_0_1"));
assert!(!ends_with_unit_suffix("R_0"));
assert!(!ends_with_unit_suffix("LM358"));
assert!(sym_magic_ok("\n(kicad_symbol_lib"));
assert!(!sym_magic_ok("(kicad_pcb"));
}
#[test]
fn inventory_of_footprint() {
let text = r#"(footprint "R_0603_1608Metric" (version 20240108) (generator "pcbnew")
(layer "F.Cu")
(fp_line (start -1 -1) (end 1 1) (layer "F.SilkS"))
(pad "1" smd roundrect (at -0.8 0) (size 0.9 0.95) (layers "F.Cu" "F.Paste" "F.Mask"))
(pad "2" smd roundrect (at 0.8 0) (size 0.9 0.95) (layers "F.Cu" "F.Paste" "F.Mask"))
)
"#;
let v = parse_kicad_mod(text);
assert_eq!(v["name"], json!("R_0603_1608Metric"));
assert_eq!(v["padCount"], json!(2));
assert_eq!(v["layers"], json!(["F.Cu", "F.SilkS"]));
assert_eq!(v["fileVersion"], json!(20240108));
assert_eq!(v["generator"], json!("pcbnew"));
let legacy = parse_kicad_mod("(module R_0805 (layer F.Cu) (tedit 5A1)\n (pad 1 smd rect (at 0 0))\n)");
assert_eq!(legacy["name"], json!("R_0805"));
assert_eq!(legacy["padCount"], json!(1));
assert_eq!(legacy["layers"], json!(["F.Cu"]));
assert_eq!(legacy["fileVersion"], Value::Null);
assert!(mod_magic_ok("(module X"));
assert!(!mod_magic_ok("(kicad_symbol_lib"));
}
#[test]
fn plot_lines_from_sym_export() {
let text = "Plotting symbol 'R' unit 1\nPlotting symbol 'OpAmp' unit 1\nPlotting symbol 'OpAmp' unit 2\nDone.\n";
let (names, units) = parse_plot_lines(text);
assert_eq!(names, vec!["R", "OpAmp"]);
assert_eq!(units, 3);
assert_eq!(plot_line_name("Plotting symbol 'X' unit x"), None);
assert_eq!(parse_plot_lines("Unable to load library").0.len(), 0);
}
#[test]
fn library_hint_tiers() {
let refused = vec![json!({"path": "C:/libs/Adom.kicad_sym", "error": "Unable to load library", "available": true, "ok": false})];
let h = library_hint(&refused, 0, 0, 5, 0, &[20231120], &refused);
assert!(h.starts_with("❌ KICAD REFUSES 1 symbol file(s) that the bridge's structural check passed: Adom.kicad_sym: Unable to load library."));
let h = library_hint(&[], 1, 2, 0, 0, &[], &[]);
assert!(h.starts_with("❌ 1 symbol file(s) + 2 footprint file(s) failed to parse."));
let h = library_hint(&[], 0, 0, 3, 1, &[20211014, 20231120], &[]);
assert!(h.starts_with("⚠️ 3 symbol(s) + 1 footprint(s) parsed cleanly, BUT file-format version(s) [20211014] are pre-KiCad-7."));
let ok = vec![json!({"available": true, "ok": true, "symbols": ["A", "B"]})];
let h = library_hint(&[], 0, 0, 2, 0, &[20231120], &ok);
assert!(h.starts_with("✅ KiCad's own parser loads 2 symbol(s) from 1 file(s) (kicadParser); the bridge's structural check counts 2 symbol(s) + 0 footprint(s), format version(s) [20231120]."));
let na = vec![json!({"available": false, "ok": null, "symbols": []})];
let h = library_hint(&[], 0, 0, 2, 0, &[], &na);
assert!(h.contains("format version(s) unspecified. KiCad's own parser was NOT consulted"));
// wiki #103: footprints alone are not a missing kicad-cli. kicad-cli lints symbol
// libraries and has no footprint verdict, so say that instead of sending the caller
// hunting for an executable that is installed and working.
let h = library_hint(&[], 0, 0, 0, 3, &[20260206], &[]);
assert!(h.starts_with("✅ 3 footprint(s) balanced by the BRIDGE's reader"), "{h}");
assert!(h.contains("no footprint verdict to give"), "{h}");
assert!(!h.contains("kicad-cli not found"), "a working kicad-cli must not be blamed: {h}");
// a symbol library with no verdict still reads as a missing tool
let h = library_hint(&[], 0, 0, 1, 0, &[20231120], &[]);
assert!(h.contains("NOT consulted (kicad-cli not found)"), "{h}");
}
#[test]
fn upgrade_kind_and_hint() {
assert_eq!(normalize_kind("PCB"), Some("pcb"));
assert_eq!(normalize_kind(" fp "), Some("fp"));
assert_eq!(normalize_kind("board"), None);
assert_eq!(normalize_kind(""), None);
assert!(upgrade_hint("pcb", true, 0).contains("Re-run kicad_lint_board to confirm"));
assert!(upgrade_hint("sch", true, 0).contains("Re-run kicad_lint_schematic to confirm"));
assert!(upgrade_hint("sym", true, 0).contains("Re-run kicad_lint_library to confirm"));
assert!(upgrade_hint("fp", false, 2).starts_with("❌ kicad-cli fp upgrade failed with exitCode=2."));
}
#[test]
fn detect_kind_and_discover_on_disk() {
let dir = std::env::temp_dir().join(format!("kicad-lint-test-{}", std::process::id()));
let pretty = dir.join("Test.pretty");
std::fs::create_dir_all(&pretty).unwrap();
std::fs::write(pretty.join("R.kicad_mod"), "(footprint \"R\" (version 20240108) (pad \"1\" smd rect))\n").unwrap();
std::fs::write(dir.join("Lib.kicad_sym"), "(kicad_symbol_lib (version 20231120)\n (symbol \"R\" (pin passive line))\n)\n").unwrap();
std::fs::write(dir.join("board.kicad_pcb"), "(kicad_pcb (version 20240108))\n").unwrap();
std::fs::write(dir.join("notes.txt"), "hello").unwrap();
assert_eq!(detect_kind(&pretty), Some("fp"));
assert_eq!(detect_kind(&dir), Some("sym"));
assert_eq!(detect_kind(&dir.join("board.kicad_pcb")), Some("pcb"));
assert_eq!(detect_kind(&dir.join("Lib.kicad_sym")), Some("sym"));
assert_eq!(detect_kind(&dir.join("notes.txt")), None);
let li = discover_library(&pretty).unwrap();
assert_eq!(li.kind, "footprint_lib_dir");
assert_eq!(li.mod_files.len(), 1);
let li = discover_library(&dir).unwrap();
assert_eq!(li.kind, "mixed_dir");
assert_eq!((li.sym_files.len(), li.mod_files.len()), (1, 0));
assert_eq!(discover_library(&dir.join("Lib.kicad_sym")).unwrap().kind, "symbol_lib_file");
assert!(discover_library(&dir.join("notes.txt")).unwrap_err().0.starts_with("unsupported file extension: .txt"));
assert!(discover_library(&dir.join("missing")).unwrap_err().0.starts_with("path not found"));
// Full library lint with no kicad-cli: structural verdict only, hint says KiCad was not asked.
let v = lint_library(&dir, None, Duration::from_secs(1));
assert_eq!(v["success"], json!(true));
assert_eq!(v["ok"], json!(true));
assert_eq!(v["data"]["summary"]["symbolCount"], json!(1));
assert_eq!(v["data"]["summary"]["fileFormatVersions"], json!([20231120]));
assert_eq!(v["kicadParser"]["available"], json!(false));
assert!(v["_hint"].as_str().unwrap().contains("NOT consulted"));
assert_eq!(v["data"]["symbols"][0]["structuralCheck"], json!("balanced"));
// A broken library is a file error, not a pass.
std::fs::write(dir.join("Bad.kicad_sym"), "(kicad_symbol_lib (symbol \"R\")\n").unwrap();
let v = lint_library(&dir.join("Bad.kicad_sym"), None, Duration::from_secs(1));
assert_eq!(v["ok"], json!(false));
assert_eq!(v["data"]["summary"]["symFileErrorCount"], json!(1));
assert!(v["data"]["symbols"][0]["_error"].as_str().unwrap().starts_with("structural: unbalanced"));
assert!(v["_hint"].as_str().unwrap().starts_with("❌ 1 symbol file(s) + 0 footprint file(s) failed to parse."));
// Sniff on disk.
let s = file_format_sniff(&dir.join("board.kicad_pcb"), PCB);
assert_eq!(s["ok"], json!(true));
assert_eq!(file_format_sniff(&dir.join("nope.kicad_pcb"), PCB)["ok"], json!(false));
assert_eq!(file_format_sniff(&dir, PCB)["_error"], json!(format!("not a file: {}", dir.display())));
let _ = std::fs::remove_dir_all(&dir);
}
}