app
adom-project-manager
Public Made by Adomby adom
DEPRECATED — renamed to Adom Nucleus (adom/adom-nucleus). Kept installable so existing installs and references keep working; depends on adom-nucleus. Install adom/adom-nucleus going forward.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
//! KiCad-style s-expression reader — the shared parser for every `.kicad_sym`
//! / `.kicad_mod` consumer in this workspace (replaces the old regex parsing).
//!
//! Modeled on KiCad's own parser pair (DSNLEXER + SCH_IO_KICAD_SEXPR_PARSER in
//! the KiCad repo), from which it borrows three design points:
//! 1. a line-tracking lexer, so every parse error cites its line
//! (KiCad: `THROW_PARSE_ERROR(..., CurLineNumber(), CurOffset())`);
//! 2. strict structure enforcement — unbalanced parens, unterminated strings
//! and trailing garbage are ERRORS, never a silently-partial tree
//! (KiCad: `Expecting(T_LEFT)` / `NeedRIGHT()` discipline);
//! 3. the format-version gate — semantics key off the file's declared
//! `(version N)` (KiCad: `m_requiredVersion`), e.g. `~` means "empty
//! string" only in files older than 20250318.
use anyhow::{bail, Result};
/// A parsed s-expression node. Atoms carry their text with string quoting
/// already resolved (`"a b"` and `a_b` both become plain atoms; a `\u{0}`
/// marker is NOT leaked — quoted-ness is dropped after parse).
#[derive(Debug)]
pub enum Sx {
Atom(String),
List(Vec<Sx>),
}
impl Sx {
pub fn head(&self) -> Option<&str> {
match self {
Sx::List(v) => match v.first() {
Some(Sx::Atom(a)) => Some(a.as_str()),
_ => None,
},
_ => None,
}
}
pub fn items(&self) -> &[Sx] {
match self {
Sx::List(v) => v,
_ => &[],
}
}
/// First child list whose head == name.
pub fn child(&self, name: &str) -> Option<&Sx> {
self.items().iter().find(|s| s.head() == Some(name))
}
/// Every direct child list whose head == name (non-recursive).
pub fn children<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a Sx> + 'a {
self.items().iter().filter(move |s| s.head() == Some(name))
}
/// Atom string at position i (after the head), if it's an atom.
pub fn atom_at(&self, i: usize) -> Option<&str> {
match self.items().get(i) {
Some(Sx::Atom(a)) => Some(a.as_str()),
_ => None,
}
}
/// Numeric atoms in this list (skipping the head atom).
pub fn nums(&self) -> Vec<f64> {
self.items()
.iter()
.skip(1)
.filter_map(|s| match s {
Sx::Atom(a) => a.parse::<f64>().ok(),
_ => None,
})
.collect()
}
/// Walk the whole tree, collecting every list whose head == name.
pub fn collect<'a>(&'a self, name: &str, out: &mut Vec<&'a Sx>) {
if self.head() == Some(name) {
out.push(self);
}
for s in self.items() {
if let Sx::List(_) = s {
s.collect(name, out);
}
}
}
/// The document's declared `(version N)`, if present — the equivalent of
/// KiCad's `m_requiredVersion` gate. Callers use this for version-scoped
/// semantics (e.g. `~` means empty only when `version < 20250318`).
pub fn doc_version(&self) -> Option<u32> {
self.child("version")
.and_then(|v| v.atom_at(1))
.and_then(|s| s.parse().ok())
}
}
/// A property/field string under the document's version semantics: before
/// format 20250318 a bare `~` meant the empty string (KiCad parser:
/// `if( m_requiredVersion < 20250318 && CurStr() == "~" )`).
pub fn resolve_tilde(s: &str, doc_version: Option<u32>) -> String {
if s == "~" && doc_version.unwrap_or(0) < 20250318 {
String::new()
} else {
s.to_string()
}
}
#[derive(Debug)]
enum Tok {
Open,
Close,
Atom(String),
}
/// Tokenize with line tracking. Errors: unterminated string.
fn tokenize(text: &str) -> Result<Vec<(Tok, u32)>> {
let mut toks = Vec::new();
let mut it = text.chars().peekable();
let mut line: u32 = 1;
while let Some(&c) = it.peek() {
if c == '\n' {
line += 1;
it.next();
} else if c.is_whitespace() {
it.next();
} else if c == '(' {
toks.push((Tok::Open, line));
it.next();
} else if c == ')' {
toks.push((Tok::Close, line));
it.next();
} else if c == '"' {
let start = line;
it.next();
let mut buf = String::new();
let mut closed = false;
while let Some(&c) = it.peek() {
match c {
'"' => {
it.next();
closed = true;
break;
}
'\\' => {
it.next();
if let Some(&e) = it.peek() {
// resolve the standard escapes; pass others through
buf.push(match e {
'n' => '\n',
't' => '\t',
other => other,
});
if e == '\n' {
line += 1;
}
it.next();
}
}
_ => {
if c == '\n' {
line += 1;
}
buf.push(c);
it.next();
}
}
}
if !closed {
bail!("unterminated string starting at line {start}");
}
toks.push((Tok::Atom(buf), start));
} else {
let mut buf = String::new();
while let Some(&c) = it.peek() {
if c.is_whitespace() || c == '(' || c == ')' {
break;
}
buf.push(c);
it.next();
}
toks.push((Tok::Atom(buf), line));
}
}
Ok(toks)
}
/// Parse one list starting at toks[*pos] (which must be Open). Strict: a
/// missing close paren is an error, not an implicit EOF-close.
fn parse_list(toks: &[(Tok, u32)], pos: &mut usize) -> Result<Sx> {
let open_line = toks[*pos].1;
*pos += 1; // consume '('
let mut list = Vec::new();
loop {
match toks.get(*pos) {
Some((Tok::Open, _)) => list.push(parse_list(toks, pos)?),
Some((Tok::Close, _)) => {
*pos += 1;
return Ok(Sx::List(list));
}
Some((Tok::Atom(a), _)) => {
list.push(Sx::Atom(a.clone()));
*pos += 1;
}
None => bail!("unbalanced s-expression: '(' at line {open_line} is never closed"),
}
}
}
/// Parse a document into its single root list. Strict about structure
/// (unbalanced/unterminated input errors with a line number); tolerant about
/// leading junk before the first '(' (some exporters emit a BOM/comment).
pub fn parse_root(text: &str) -> Result<Sx> {
let toks = tokenize(text)?;
let Some(start) = toks.iter().position(|(t, _)| matches!(t, Tok::Open)) else {
bail!("no s-expression found");
};
let mut pos = start;
let root = parse_list(&toks, &mut pos)?;
// Anything after the root except stray whitespace is a malformed file —
// report it instead of silently ignoring half the document.
if let Some((tok, line)) = toks.get(pos) {
let what = match tok {
Tok::Open => "'('".to_string(),
Tok::Close => "')'".to_string(),
Tok::Atom(a) => format!("{a:?}"),
};
bail!("trailing content after the root s-expression: {what} at line {line}");
}
Ok(root)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strict_errors_carry_lines() {
let e = parse_root("(a\n(b c)\n").unwrap_err().to_string();
assert!(e.contains("line 1"), "unclosed root cites its open line: {e}");
let e = parse_root("(a \"unterminated\n").unwrap_err().to_string();
assert!(e.contains("line 1"), "unterminated string cites its line: {e}");
let e = parse_root("(a) (b)").unwrap_err().to_string();
assert!(e.contains("trailing"), "second root is trailing content: {e}");
}
#[test]
fn tilde_gate_matches_kicad() {
assert_eq!(resolve_tilde("~", Some(20211014)), "");
assert_eq!(resolve_tilde("~", Some(20231120)), "");
assert_eq!(resolve_tilde("~", Some(20251024)), "~"); // literal in new files
assert_eq!(resolve_tilde("~", None), ""); // no version → legacy file
assert_eq!(resolve_tilde("A~B", Some(20211014)), "A~B");
}
#[test]
fn doc_version_reads_header() {
let r = parse_root("(kicad_symbol_lib (version 20231120) (generator \"x\"))").unwrap();
assert_eq!(r.doc_version(), Some(20231120));
}
}