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…
baad156
14d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
//! A span-keeping s-expression reader for editing KiCad files in place.
//!
//! `model_check::Sx` drops the byte offsets, so an edit through it means re-serialising
//! the whole file in our own formatting. This reader keeps, for every list and atom, the
//! exact byte range it came from, so an edit replaces only the bytes it means to change
//! and the rest of the file stays byte-for-byte what KiCad wrote.
/// One node: a list `( ... )` with children, or an atom (bare or quoted).
#[derive(Clone, Debug, PartialEq)]
pub struct Node {
/// Byte offset of the first character (`(` or the atom's first byte or `"`).
pub start: usize,
/// Byte offset one past the last character.
pub end: usize,
/// Unquoted, unescaped value for an atom; None for a list.
pub atom: Option<String>,
/// True for a `"quoted"` atom.
pub quoted: bool,
pub kids: Vec<Node>,
}
impl Node {
pub fn is_list(&self) -> bool {
self.atom.is_none()
}
/// The list's tag: its first child when that is a bare atom.
pub fn tag(&self) -> Option<&str> {
self.kids.first().filter(|k| !k.quoted).and_then(|k| k.atom.as_deref())
}
/// The i-th child as an atom value (0 is the tag).
pub fn atom(&self, i: usize) -> Option<&str> {
self.kids.get(i).and_then(|k| k.atom.as_deref())
}
pub fn f64_at(&self, i: usize) -> Option<f64> {
self.atom(i)?.parse().ok()
}
/// The first direct child list tagged `tag`.
pub fn kid(&self, tag: &str) -> Option<&Node> {
self.kids.iter().find(|k| k.tag() == Some(tag))
}
/// Every direct child list tagged `tag`.
pub fn kids_tagged<'a>(&'a self, tag: &'a str) -> impl Iterator<Item = &'a Node> + 'a {
self.kids.iter().filter(move |k| k.tag() == Some(tag))
}
/// `(tag "value")` -> value.
pub fn value_of(&self, tag: &str) -> Option<&str> {
self.kid(tag).and_then(|k| k.atom(1))
}
/// The source text of this node.
pub fn src<'a>(&self, text: &'a str) -> &'a str {
&text[self.start..self.end]
}
/// A bare atom child equal to `word` (e.g. `locked` in older footprints).
pub fn has_word(&self, word: &str) -> bool {
self.kids.iter().skip(1).any(|k| !k.quoted && k.atom.as_deref() == Some(word))
}
/// Depth-first walk over every list node, self included.
pub fn walk<'a>(&'a self, f: &mut dyn FnMut(&'a Node)) {
if self.is_list() {
f(self);
for k in &self.kids {
k.walk(f);
}
}
}
/// The `(uuid "...")` (or older `(tstamp ...)`) of this list.
pub fn uuid(&self) -> Option<&str> {
self.value_of("uuid").or_else(|| self.value_of("tstamp"))
}
}
/// Parse the first top-level expression of `text`.
pub fn parse(text: &str) -> Result<Node, String> {
let b = text.as_bytes();
let mut i = 0;
skip_ws(b, &mut i);
if i >= b.len() {
return Err("empty input".into());
}
let n = node(text, b, &mut i, 0)?;
Ok(n)
}
fn skip_ws(b: &[u8], i: &mut usize) {
while *i < b.len() && (b[*i] as char).is_ascii_whitespace() {
*i += 1;
}
}
fn node(text: &str, b: &[u8], i: &mut usize, depth: usize) -> Result<Node, String> {
if depth > 400 {
return Err("nesting too deep".into());
}
let start = *i;
match b[*i] {
b'(' => {
*i += 1;
let mut kids = Vec::new();
loop {
skip_ws(b, i);
if *i >= b.len() {
return Err(format!("unclosed list starting at byte {start}"));
}
if b[*i] == b')' {
*i += 1;
return Ok(Node { start, end: *i, atom: None, quoted: false, kids });
}
kids.push(node(text, b, i, depth + 1)?);
}
}
b')' => Err(format!("unexpected ) at byte {start}")),
b'"' => {
*i += 1;
let mut s = String::new();
let mut seg = *i;
loop {
if *i >= b.len() {
return Err(format!("unterminated string at byte {start}"));
}
match b[*i] {
b'"' => {
s.push_str(&text[seg..*i]);
*i += 1;
break;
}
b'\\' if *i + 1 < b.len() => {
s.push_str(&text[seg..*i]);
let c = b[*i + 1];
match c {
b'"' => s.push('"'),
b'\\' => s.push('\\'),
b'n' => s.push('\n'),
_ => {
s.push('\\');
s.push(c as char);
}
}
*i += 2;
seg = *i;
}
_ => *i += 1,
}
}
Ok(Node { start, end: *i, atom: Some(s), quoted: true, kids: Vec::new() })
}
_ => {
while *i < b.len() && !(b[*i] as char).is_ascii_whitespace() && b[*i] != b'(' && b[*i] != b')' {
*i += 1;
}
Ok(Node { start, end: *i, atom: Some(text[start..*i].to_string()), quoted: false, kids: Vec::new() })
}
}
}
/// KiCad's string quoting: `"`, `\` and newlines escaped.
pub fn q(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
c => out.push(c),
}
}
out.push('"');
out
}
/// KiCad-style trimmed number (`1.5`, `-0.95`, `0`).
pub fn n(v: f64) -> String {
crate::pcb::num(v)
}
/// A set of byte-range replacements applied in one pass. Ranges must not overlap.
#[derive(Default, Debug, Clone)]
pub struct Edits {
rows: Vec<(usize, usize, String)>,
}
impl Edits {
pub fn new() -> Edits {
Edits::default()
}
pub fn replace(&mut self, start: usize, end: usize, with: impl Into<String>) {
self.rows.push((start, end, with.into()));
}
pub fn replace_node(&mut self, n: &Node, with: impl Into<String>) {
self.replace(n.start, n.end, with);
}
pub fn insert(&mut self, at: usize, with: impl Into<String>) {
self.rows.push((at, at, with.into()));
}
/// Remove a node together with the whitespace before it on its line, so a removed
/// top-level item leaves no blank line behind.
pub fn remove_node(&mut self, text: &str, n: &Node) {
let b = text.as_bytes();
let mut s = n.start;
while s > 0 && (b[s - 1] == b' ' || b[s - 1] == b'\t') {
s -= 1;
}
if s > 0 && b[s - 1] == b'\n' {
s -= 1;
if s > 0 && b[s - 1] == b'\r' {
s -= 1;
}
}
self.rows.push((s, n.end, String::new()));
}
pub fn is_empty(&self) -> bool {
self.rows.is_empty()
}
pub fn apply(mut self, text: &str) -> Result<String, String> {
self.rows.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
let mut out = String::with_capacity(text.len());
let mut last = 0;
for (s, e, w) in &self.rows {
if *s < last {
return Err(format!("overlapping edits at byte {s}"));
}
out.push_str(&text[last..*s]);
out.push_str(w);
last = *e;
}
out.push_str(&text[last..]);
Ok(out)
}
}
/// Insert `blocks` as new top-level items just before the root's closing paren.
pub fn append_top_level(text: &str, root: &Node, blocks: &[String]) -> String {
if blocks.is_empty() {
return text.to_string();
}
let close = root.end - 1;
let mut add = String::new();
for b in blocks {
add.push('\t');
add.push_str(b.trim());
add.push('\n');
}
let head = text[..close].trim_end_matches([' ', '\t']);
let head = if head.ends_with('\n') { head.to_string() } else { format!("{head}\n") };
format!("{head}{add}{}", &text[close..])
}
/// Re-indent a one-item block so it reads like KiCad's own output when inserted at the
/// top level (best effort: the parser does not care).
pub fn tidy(block: &str) -> String {
block.trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spans_round_trip_and_edits_touch_only_their_bytes() {
let t = "(kicad_pcb (version 1)\n\t(gr_line (start 0 0) (end 1 2) (layer \"Edge.Cuts\") (uuid \"a\\\"b\"))\n\t(net 1 \"GND\")\n)";
let root = parse(t).unwrap();
assert_eq!(root.tag(), Some("kicad_pcb"));
let line = root.kid("gr_line").unwrap();
assert_eq!(line.uuid(), Some("a\"b"));
assert_eq!(line.value_of("layer"), Some("Edge.Cuts"));
assert_eq!(line.kid("end").unwrap().f64_at(2), Some(2.0));
let mut e = Edits::new();
e.remove_node(t, line);
let out = e.apply(t).unwrap();
assert_eq!(out, "(kicad_pcb (version 1)\n\t(net 1 \"GND\")\n)");
let again = append_top_level(&out, &parse(&out).unwrap(), &["(gr_circle (center 1 1) (end 2 1))".into()]);
assert!(again.ends_with("\t(gr_circle (center 1 1) (end 2 1))\n)"), "{again}");
assert_eq!(q("a\"b\\c"), "\"a\\\"b\\\\c\"");
assert!(parse("(a (b)").is_err());
}
}