app
service-kicad
Public Made by Adomby adom
Shared headless KiCad 10: DRC, ERC, SVG/Gerber/STEP export, Altium library conversion, symbol/footprint/3D-model lookup. Every Adom tool shells to this CLI instead of calling the HTTP API directly.
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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
//! 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());
}
}