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
//! 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());
    }
}