main
John Lauer source: the crates, flows, docs and skills of adom-aiflow 0.1.5, so PRs can be opened against them ad411ea
//! A span-keeping S-expression reader for `.kicad_pcb` files: every node knows where it starts and
//! ends in the text, so the writers splice edits without re-serialising anything.

#[derive(Debug, Clone)]
pub enum Item {
    Atom { text: String, start: usize, end: usize },
    Node(Node),
}

#[derive(Debug, Clone)]
pub struct Node {
    pub tag: String,
    pub items: Vec<Item>,
    pub start: usize,
    pub end: usize,
}

impl Node {
    /// The i-th atom (0 = the tag), unquoted.
    pub fn atom(&self, i: usize) -> Option<&str> {
        if i == 0 {
            return Some(&self.tag);
        }
        match self.items.get(i - 1) {
            Some(Item::Atom { text, .. }) => Some(text.as_str()),
            _ => None,
        }
    }

    pub fn number(&self, i: usize) -> Option<f64> {
        self.atom(i)?.parse::<f64>().ok()
    }

    /// The first atom after the tag (what `(layer "F.Cu")` means).
    pub fn value(&self) -> Option<&str> {
        self.atom(1)
    }

    pub fn find(&self, tag: &str) -> Option<&Node> {
        self.items.iter().find_map(|it| match it {
            Item::Node(n) if n.tag == tag => Some(n),
            _ => None,
        })
    }

    pub fn find_all<'a>(&'a self, tag: &'a str) -> impl Iterator<Item = &'a Node> + 'a {
        self.items.iter().filter_map(move |it| match it {
            Item::Node(n) if n.tag == tag => Some(n),
            _ => None,
        })
    }

    pub fn nodes(&self) -> impl Iterator<Item = &Node> {
        self.items.iter().filter_map(|it| match it {
            Item::Node(n) => Some(n),
            _ => None,
        })
    }
}

fn unescape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut chars = s.chars();
    while let Some(c) = chars.next() {
        if c == '\\' {
            match chars.next() {
                Some('n') => out.push('\n'),
                Some('t') => out.push('\t'),
                Some(o) => out.push(o),
                None => {}
            }
        } else {
            out.push(c);
        }
    }
    out
}

/// Parse the whole text; the root is the outer list (normally `kicad_pcb`).
pub fn parse(text: &str) -> Result<Node, String> {
    let b = text.as_bytes();
    let mut i = 0;
    while i < b.len() && b[i].is_ascii_whitespace() {
        i += 1;
    }
    if i >= b.len() || b[i] != b'(' {
        return Err("text does not start with a list".into());
    }
    let (node, _) = parse_node(text, b, i)?;
    Ok(node)
}

fn parse_node(text: &str, b: &[u8], start: usize) -> Result<(Node, usize), String> {
    // b[start] == '('
    let mut i = start + 1;
    let mut items = Vec::new();
    let mut tag = String::new();
    let mut have_tag = false;
    loop {
        while i < b.len() && b[i].is_ascii_whitespace() {
            i += 1;
        }
        if i >= b.len() {
            return Err(format!("unterminated list starting at {start}"));
        }
        match b[i] {
            b')' => {
                return Ok((Node { tag, items, start, end: i + 1 }, i + 1));
            }
            b'(' => {
                let (n, e) = parse_node(text, b, i)?;
                items.push(Item::Node(n));
                i = e;
            }
            b'"' => {
                let s = i;
                i += 1;
                let mut raw = String::new();
                while i < b.len() && b[i] != b'"' {
                    if b[i] == b'\\' && i + 1 < b.len() {
                        raw.push(b[i] as char);
                        raw.push(b[i + 1] as char);
                        i += 2;
                        continue;
                    }
                    // copy one UTF-8 char
                    let ch = text[i..].chars().next().unwrap();
                    raw.push(ch);
                    i += ch.len_utf8();
                }
                if i >= b.len() {
                    return Err(format!("unterminated string at {s}"));
                }
                i += 1;
                let txt = unescape(&raw);
                if !have_tag {
                    tag = txt;
                    have_tag = true;
                } else {
                    items.push(Item::Atom { text: txt, start: s, end: i });
                }
            }
            _ => {
                let s = i;
                while i < b.len() && !b[i].is_ascii_whitespace() && b[i] != b'(' && b[i] != b')' {
                    i += 1;
                }
                let txt = text[s..i].to_string();
                if !have_tag {
                    tag = txt;
                    have_tag = true;
                } else {
                    items.push(Item::Atom { text: txt, start: s, end: i });
                }
            }
        }
    }
}

/// KiCad's own float formatting: trimmed, at most six decimals, never "-0".
pub fn num(v: f64) -> String {
    let mut s = format!("{:.6}", v);
    while s.ends_with('0') {
        s.pop();
    }
    if s.ends_with('.') {
        s.pop();
    }
    if s.is_empty() || s == "-" || s == "-0" {
        "0".to_string()
    } else {
        s
    }
}

pub fn quote(s: &str) -> String {
    format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_nested_lists_with_spans() {
        let t = "(kicad_pcb (version 20240108) (net 0 \"\") (footprint \"Lib:X\" (at 1 2 90) (property \"Reference\" \"R1\")))";
        let n = parse(t).unwrap();
        assert_eq!(n.tag, "kicad_pcb");
        let fp = n.find("footprint").unwrap();
        assert_eq!(fp.value(), Some("Lib:X"));
        assert_eq!(fp.find("at").unwrap().number(3), Some(90.0));
        assert_eq!(&t[fp.start..fp.end], "(footprint \"Lib:X\" (at 1 2 90) (property \"Reference\" \"R1\"))");
        assert_eq!(num(1.5), "1.5");
        assert_eq!(num(-0.0000001), "0");
        assert_eq!(num(120.54), "120.54");
    }
}