master
John Lauer Honest DRC/ERC reports: derive counts from native findings, exit 5 on error findings, ERC coordinate-scale check, load… fa1d9ce 1mo ago
//! Minimal KiCad s-expression reader. Enough to walk `(kicad_sch ...)` /
//! `(kicad_pcb ...)` files and to say WHERE a malformed one breaks.

#[derive(Debug, Clone, PartialEq)]
pub enum Node {
    /// `(head child child ...)`; `line` is where the list opened (1-based).
    List { items: Vec<Node>, line: usize },
    /// Bare token: numbers, keywords, unquoted identifiers.
    Atom(String),
    /// `"quoted"` string, escapes resolved.
    Str(String),
}

impl Node {
    pub fn items(&self) -> &[Node] {
        match self { Node::List { items, .. } => items, _ => &[] }
    }
    /// Head keyword of a list, e.g. `symbol` for `(symbol ...)`.
    pub fn head(&self) -> Option<&str> {
        match self.items().first() { Some(Node::Atom(a)) => Some(a), _ => None }
    }
    /// Text of an atom or string.
    pub fn text(&self) -> Option<&str> {
        match self { Node::Atom(a) | Node::Str(a) => Some(a), _ => None }
    }
    pub fn as_f64(&self) -> Option<f64> {
        self.text().and_then(|t| t.parse().ok())
    }
    /// Child lists with the given head keyword.
    pub fn children<'a, 'h: 'a>(&'a self, head: &'h str) -> impl Iterator<Item = &'a Node> + 'a {
        self.items().iter().skip(1).filter(move |n| n.head() == Some(head))
    }
    pub fn child(&self, head: &str) -> Option<&Node> {
        self.items().iter().skip(1).find(|n| n.head() == Some(head))
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct ParseError {
    pub line: usize,
    pub col: usize,
    pub message: String,
}

impl std::fmt::Display for ParseError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "line {}, col {}: {}", self.line, self.col, self.message)
    }
}

struct Reader<'a> {
    chars: std::iter::Peekable<std::str::CharIndices<'a>>,
    line: usize,
    col: usize,
}

impl<'a> Reader<'a> {
    fn new(s: &'a str) -> Self {
        Reader { chars: s.char_indices().peekable(), line: 1, col: 0 }
    }
    fn bump(&mut self) -> Option<char> {
        let (_, c) = self.chars.next()?;
        if c == '\n' { self.line += 1; self.col = 0; } else { self.col += 1; }
        Some(c)
    }
    fn peek(&mut self) -> Option<char> {
        self.chars.peek().map(|(_, c)| *c)
    }
    fn err(&self, message: impl Into<String>) -> ParseError {
        ParseError { line: self.line, col: self.col, message: message.into() }
    }
    fn skip_ws(&mut self) {
        while matches!(self.peek(), Some(c) if c.is_whitespace()) { self.bump(); }
    }
    fn node(&mut self, depth: usize) -> Result<Node, ParseError> {
        self.skip_ws();
        match self.peek() {
            None => Err(self.err("unexpected end of file")),
            Some('(') => {
                self.bump();
                let line = self.line;
                let mut items = Vec::new();
                loop {
                    self.skip_ws();
                    match self.peek() {
                        None => {
                            return Err(ParseError {
                                line: self.line,
                                col: self.col,
                                message: format!("unterminated list opened at line {line} (depth {depth}); missing ')'"),
                            })
                        }
                        Some(')') => { self.bump(); return Ok(Node::List { items, line }); }
                        Some(_) => items.push(self.node(depth + 1)?),
                    }
                }
            }
            Some(')') => Err(self.err("unexpected ')' with no open list")),
            Some('"') => {
                self.bump();
                let mut s = String::new();
                loop {
                    match self.bump() {
                        None => return Err(self.err("unterminated string")),
                        Some('"') => return Ok(Node::Str(s)),
                        Some('\\') => match self.bump() {
                            Some('n') => s.push('\n'),
                            Some('t') => s.push('\t'),
                            Some('r') => s.push('\r'),
                            Some(c) => s.push(c),
                            None => return Err(self.err("unterminated string escape")),
                        },
                        Some(c) => s.push(c),
                    }
                }
            }
            Some(_) => {
                let mut s = String::new();
                while let Some(c) = self.peek() {
                    if c.is_whitespace() || c == '(' || c == ')' || c == '"' { break; }
                    s.push(c);
                    self.bump();
                }
                Ok(Node::Atom(s))
            }
        }
    }
}

/// Parse one root list. Trailing content after it is an error.
pub fn parse(text: &str) -> Result<Node, ParseError> {
    let mut r = Reader::new(text);
    let root = r.node(0)?;
    r.skip_ws();
    if r.peek().is_some() {
        return Err(r.err("trailing content after the root list"));
    }
    Ok(root)
}

/// Explain why kicad-cli might refuse a file. `expected_root` is
/// `kicad_sch` or `kicad_pcb`. Returns an empty list when nothing obvious
/// is wrong (the parser cannot judge semantic validity).
pub fn diagnose(bytes: &[u8], expected_root: &str) -> Vec<String> {
    let mut out = Vec::new();
    if bytes.is_empty() {
        out.push("input is empty (0 bytes)".into());
        return out;
    }
    if let Some(i) = bytes.iter().position(|b| *b == 0) {
        out.push(format!("binary content: NUL byte at offset {i}; expected a text .{expected_root} file"));
        return out;
    }
    let text = match std::str::from_utf8(bytes) {
        Ok(t) => t,
        Err(e) => {
            out.push(format!("not valid UTF-8 at byte {}", e.valid_up_to()));
            return out;
        }
    };
    let trimmed = text.trim_start_matches('\u{feff}').trim_start();
    if trimmed.starts_with("EESchema Schematic File Version") {
        out.push("legacy KiCad 5 schematic format (.sch); kicad-cli needs the s-expression .kicad_sch format".into());
        return out;
    }
    if trimmed.starts_with("(kicad_pcb") && expected_root == "kicad_sch" {
        out.push("this is a board (.kicad_pcb), not a schematic".into());
        return out;
    }
    if trimmed.starts_with("(kicad_sch") && expected_root == "kicad_pcb" {
        out.push("this is a schematic (.kicad_sch), not a board".into());
        return out;
    }
    if !trimmed.starts_with('(') {
        let first: String = trimmed.chars().take(40).collect();
        out.push(format!("file does not start with '(': first bytes are {first:?}"));
        return out;
    }
    match parse(text) {
        Err(e) => {
            out.push(format!("s-expression error at {e}"));
            let (opens, closes) = text.chars().fold((0usize, 0usize), |(o, c), ch| match ch {
                '(' => (o + 1, c),
                ')' => (o, c + 1),
                _ => (o, c),
            });
            if opens != closes {
                out.push(format!("paren balance: {opens} '(' vs {closes} ')' (file may be truncated)"));
            }
        }
        Ok(root) => {
            match root.head() {
                Some(h) if h == expected_root => {}
                Some(h) => out.push(format!("root list is ({h} ...), expected ({expected_root} ...)")),
                None => out.push(format!("root list has no head keyword, expected ({expected_root} ...)")),
            }
            if let Some(v) = root.child("version").and_then(|n| n.items().get(1)).and_then(Node::text) {
                if v.len() != 8 || !v.chars().all(|c| c.is_ascii_digit()) {
                    out.push(format!("(version {v}) is not a YYYYMMDD format version"));
                }
            } else {
                out.push("no (version ...) token under the root".into());
            }
        }
    }
    out
}

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

    #[test]
    fn parses_nested_lists_and_strings() {
        let n = parse(r#"(kicad_sch (version 20250114) (property "Reference" "U1" (at 1.5 -2 0)))"#).unwrap();
        assert_eq!(n.head(), Some("kicad_sch"));
        let prop = n.child("property").unwrap();
        assert_eq!(prop.items()[1].text(), Some("Reference"));
        assert_eq!(prop.child("at").unwrap().items()[2].as_f64(), Some(-2.0));
    }

    #[test]
    fn truncated_file_names_the_open_list() {
        let d = diagnose(b"(kicad_sch (version 20250114)\n  (symbol (at 1 2", "kicad_sch");
        assert!(d[0].contains("unterminated list"), "{d:?}");
        assert!(d.iter().any(|s| s.contains("paren balance")), "{d:?}");
    }

    #[test]
    fn wrong_kind_is_named() {
        let d = diagnose(b"(kicad_pcb (version 20240108))", "kicad_sch");
        assert_eq!(d, vec!["this is a board (.kicad_pcb), not a schematic".to_string()]);
    }

    #[test]
    fn well_formed_has_no_diagnostics() {
        assert!(diagnose(b"(kicad_sch (version 20250114) (generator x))", "kicad_sch").is_empty());
    }
}