0023. KiCad S-Expression Tokenizer & AST Inspector with nom
Extracting a standalone L0 kicad-sexpr parser crate with zero workspace or UI dependencies, composing nom parser combinators (delimited, many0, alt, take_while1) to build a recursive SexprNode syntax tree, and inspecting live KiCad .kicad_pcb trees with egui::CollapsingHeader.
Why KiCad Uses S-Expressions & Why Parsers Live at Layer L0
Real-world KiCad .kicad_pcb and .kicad_sch files store every board outline, net definition, footprint, pad, and copper trace segment as nested symbolic expressions (S-expressions):Standalone L0 Format Crates (egui-app-architecture): Principle #2 of our architecture requires format parsers like crates/kicad-sexpr to sit at Layer L0 (Standalone) with zero workspace or UI dependencies (validate_l0_standalone_manifest). That keeps the parser independently testable, fuzzable, and reusable across CLI tools, WebAssembly builds, and language servers.
(kicad_pcb
(version 20240108)
(net 1 "VCC_3V3")
(footprint "SOT-23" (ref "U1") (val "LDO_3V3") (at 20.0 27.0) (size 12.0 16.0) (net 1))
(pad "1" smd (at 20.0 22.0) (size 4.0 2.4) (layer "F.Cu") (net 1)))
Instead of hand-rolling fragile index-based character loops that can panic on UTF-8 boundaries or unclosed parentheses, we use nom—Rust's declarative parser combinator library. Every nom parser is a pure function fn(&str) -> IResult<&str, Output> returning Ok((remaining_input, parsed_value)) or a structured Err without panicking.
Scaffold the standalone crates/kicad-sexpr library crate and add nom, thiserror, and proptest with cargo add:
cargo new --lib crates/kicad-sexpr
cargo add --package kicad-sexpr nom@7.1.3 thiserror
cargo add --package kicad-sexpr --dev proptest
cargo add --package pcb-doc --path crates/kicad-sexpr
Here are the resulting crates/kicad-sexpr/Cargo.toml and crates/pcb-doc/Cargo.toml:
[package]
name = "kicad-sexpr"
version = "0.1.0"
edition = "2024"
[lints]
workspace = true
[dependencies]
nom = "7.1.3"
thiserror = "2.0"
[dev-dependencies]
proptest = "1.11"
[package]
name = "pcb-doc"
version = "0.1.0"
edition = "2024"
[lints]
workspace = true
[dependencies]
emath = { version = "0.37", features = ["serde"] }
kicad-sexpr = { path = "../kicad-sexpr" }
serde = { version = "1.0", features = ["derive", "rc"] }
serde_json = "1.0"
thiserror = "2.0"
[dev-dependencies]
proptest = "1.11"
flowchart LR
Raw["Raw KiCad .kicad_pcb Text"] --> Trim["parse_sexpr_str(input)<br/>1. Check EmptyInput"]
Trim --> Node["parse_node = alt((<br/> parse_list_node,<br/> parse_quoted_str,<br/> parse_symbol_str<br/>))"]
Node --> Trailing{"Remaining input<br/>empty?"}
Trailing -- "Yes" --> Ast["Ok(SexprNode)<br/>Symbol | Quoted | List"]
Trailing -- "No" --> ErrTrail["Err(SexprParseError::TrailingTokens)"]
Node -- "Err" --> ErrSyn["Err(SexprParseError::SyntaxError)"]
Ast --> UI["egui::CollapsingHeader<br/>AST Inspector Panel"]
style Ast fill:#14231c,stroke:#3dd68c,color:#e6edf3
style ErrTrail fill:#2a1418,stroke:#ff5555,color:#e6edf3
style ErrSyn fill:#2a1418,stroke:#ff5555,color:#e6edf3
2. Anatomy of crates/kicad-sexpr & src/main.rs: Block by Block
An S-expression has only three grammar productions:
SexprNode::Symbol(String): An unquoted token of non-whitespace characters excluding(,), and"(parsed bytake_while1(is_symbol_char)).SexprNode::Quoted(String): A double-quoted string literal"..."(parsed bydelimited(char('"'), take_while(|c| c != '"'), char('"'))).SexprNode::List(Vec<SexprNode>): A parenthesized list(...)containing zero or more whitespace-separated child nodes (parsed recursively bydelimited(char('('), many0(delimited(multispace0, parse_node, multispace0)), char(')'))).
Let's walk through the standalone crates/kicad-sexpr/src/lib.rs parser, its crates/pcb-doc/src/lib.rs re-export, and the egui::CollapsingHeader AST inspector in src/main.rs block by block before viewing the complete listings in context.
Block 1: L0 Standalone Manifest Gate & Recursive SexprNode AST (crates/kicad-sexpr/src/lib.rs)
/// Standalone `L0` parser crates must have zero workspace or UI dependencies.
pub const FORBIDDEN_DEPS_IN_L0_STANDALONE: &[&str] = &[
"pcb-doc",
"pcb-reviewer",
"egui",
"eframe",
"winit",
"egui_kittest",
"rfd",
];
/// Verifies that an `L0` standalone parser crate's `Cargo.toml` has no workspace or UI dependencies.
pub fn validate_l0_standalone_manifest(cargo_toml: &str) -> Result<(), &'static str> {
for &forbidden in FORBIDDEN_DEPS_IN_L0_STANDALONE {
let key_eq = format!("{forbidden} =");
let key_dot = format!("dependencies.{forbidden}");
if cargo_toml.contains(&key_eq) || cargo_toml.contains(&key_dot) {
return Err(forbidden);
}
}
Ok(())
}
/// Typed, panic-free errors returned by the KiCad S-expression parser.
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum SexprParseError {
#[error("input must not be empty")]
EmptyInput,
#[error("unclosed or malformed S-expression near `{0}`")]
SyntaxError(String),
#[error("unexpected trailing tokens: `{0}`")]
TrailingTokens(String),
}
/// Recursive S-expression abstract syntax tree node for KiCad `.kicad_pcb` and `.kicad_sch` files.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SexprNode {
Symbol(String),
Quoted(String),
List(Vec<SexprNode>),
}
FORBIDDEN_DEPS_IN_L0_STANDALONE&validate_l0_standalone_manifest: Enforces Principle #2 of our architecture (L0standalone format crates have zero workspace or UI dependencies). ScanningCargo.tomlfor both"{forbidden} ="and"dependencies.{forbidden}"catches both inline and table-header dependency declarations, returningErr(forbidden)if a higher-layer crate (pcb-doc,egui,eframe, etc.) is ever added.SexprParseError: Usesthiserror::Errorto classify all syntax failures into three deterministic variants:EmptyInput(whitespace-only strings),SyntaxError(String)(unclosed parentheses or quotes), andTrailingTokens(String)(extra unparsed characters after the root expression).SexprNodeRecursive Enum: Models arbitrary S-expression trees using three variants:Symbol(String)for bare identifiers/numbers likekicad_pcbor20.0,Quoted(String)for string literals like"SOT-23", andList(Vec<SexprNode>)for parenthesized expressions(...). BecauseVec<SexprNode>is heap-allocated, Rust knows the exact stack size ofSexprNodewithout needing explicitBox<SexprNode>indirection.
Block 2: AST Navigation & Telemetry Methods (impl SexprNode)
impl SexprNode {
/// If this node is a parenthesized list whose first element is an unquoted `Symbol`,
/// returns that symbol's name (e.g., `"kicad_pcb"`, `"footprint"`, `"pad"`).
pub fn tag_name(&self) -> Option<&str> {
match self {
SexprNode::List(items) => match items.first() {
Some(SexprNode::Symbol(tag)) => Some(tag.as_str()),
_ => None,
},
_ => None,
}
}
/// Returns the text of a `Symbol` or `Quoted` leaf node, or `None` for a `List`.
pub fn as_text(&self) -> Option<&str> {
match self {
SexprNode::Symbol(s) | SexprNode::Quoted(s) => Some(s.as_str()),
SexprNode::List(_) => None,
}
}
/// Returns the slice of child nodes following the leading tag in a `List` node.
pub fn children(&self) -> &[SexprNode] {
match self {
SexprNode::List(items) => items.get(1..).unwrap_or(&[]),
_ => &[],
}
}
/// Finds the first child list whose `tag_name()` equals `tag`.
pub fn find_child_list(&self, tag: &str) -> Option<&SexprNode> {
self.children()
.iter()
.find(|node| node.tag_name() == Some(tag))
}
/// Counts the total number of AST nodes (`Symbol`, `Quoted`, and `List`) in this subtree.
pub fn node_count(&self) -> usize {
match self {
SexprNode::Symbol(_) | SexprNode::Quoted(_) => 1,
SexprNode::List(items) => 1 + items.iter().map(Self::node_count).sum::<usize>(),
}
}
/// Computes the maximum nesting depth of lists in this subtree (`0` for leaf atoms).
pub fn max_depth(&self) -> usize {
match self {
SexprNode::Symbol(_) | SexprNode::Quoted(_) => 0,
SexprNode::List(items) => 1 + items.iter().map(Self::max_depth).max().unwrap_or(0),
}
}
/// Formats a concise telemetry badge summarizing the root tag, total nodes, and depth.
pub fn summary_badge(&self) -> String {
format!(
"AST: ({}) • {} nodes, depth {}",
self.tag_name().unwrap_or("atom"),
self.node_count(),
self.max_depth()
)
}
}
tag_name(&self) -> Option<&str>: In KiCad S-expressions, the first element inside a list(footprint ...)is an unquotedSexprNode::Symbolnaming the record type. Usingitems.first()avoids out-of-bounds indexing on empty lists().children(&self) -> &[SexprNode]: Uses safe range slicingitems.get(1..).unwrap_or(&[])to return all arguments following the head tag without panicking when a list is empty or has only one element.find_child_list(&self, tag: &str): Scansself.children()for the first nested list whosetag_name()matchestag(such as(at 20.0 27.0)or(ref "U1")), which Lesson 0024 will use to lower footprints, pads, and segments intoBoardModel.node_count&max_depth: Recursively fold overitems.iter()using.map(Self::node_count).sum::<usize>()and.map(Self::max_depth).max().unwrap_or(0)to compute structural telemetry forsummary_badge().
Block 3: Composing nom Parser Combinators (parse_sexpr_str)
pub fn is_symbol_char(c: char) -> bool {
!c.is_whitespace() && c != '(' && c != ')' && c != '"'
}
fn parse_quoted_str(input: &str) -> IResult<&str, SexprNode> {
map(
delimited(char('"'), take_while(|c: char| c != '"'), char('"')),
|s: &str| SexprNode::Quoted(s.to_owned()),
)(input)
}
fn parse_symbol_str(input: &str) -> IResult<&str, SexprNode> {
map(take_while1(is_symbol_char), |s: &str| {
SexprNode::Symbol(s.to_owned())
})(input)
}
fn parse_list_node(input: &str) -> IResult<&str, SexprNode> {
map(
delimited(
char('('),
many0(delimited(multispace0, parse_node, multispace0)),
char(')'),
),
SexprNode::List,
)(input)
}
fn parse_node(input: &str) -> IResult<&str, SexprNode> {
alt((parse_list_node, parse_quoted_str, parse_symbol_str))(input)
}
/// Parses a complete KiCad S-expression string into a `SexprNode` without panicking.
pub fn parse_sexpr_str(input: &str) -> Result<SexprNode, SexprParseError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(SexprParseError::EmptyInput);
}
match parse_node(trimmed) {
Ok((rest, node)) => {
let trailing = rest.trim();
if trailing.is_empty() {
Ok(node)
} else {
Err(SexprParseError::TrailingTokens(trailing.to_owned()))
}
}
Err(_) => Err(SexprParseError::SyntaxError(trimmed.to_owned())),
}
}
IResult<&str, SexprNode>: Every internalnomcombinator takes an input slice&strand returnsOk((remaining_slice, parsed_node))or anom::Err, slicing UTF-8 string views in zero-copy fashion until.to_owned()constructs the ownedSexprNodeleaf.is_symbol_char&take_while1: Accepts any character that is not whitespace,(,), or". Because(and"are excluded from symbol characters, tightly packed KiCad tokens like(footprint"SOT-23"(at 20.0 27.0))tokenize cleanly without requiring spaces before delimiters.delimited,many0, andalt:parse_quoted_strusesdelimited(char('"'), take_while(|c| c != '"'), char('"'))to consume opening and closing quotes and return the inner slice.parse_list_nodeusesdelimited(char('('), many0(delimited(multispace0, parse_node, multispace0)), char(')'))to recursively collect zero or more whitespace-padded child nodes into aVec<SexprNode>wrapped bySexprNode::List.parse_nodeusesalt((parse_list_node, parse_quoted_str, parse_symbol_str))to try each branch in order.
parse_sexpr_strEntry Point: Trims outer whitespace, guards againstEmptyInput, invokesparse_node(trimmed), and checks thatrest.trim()is empty so stray tokens after the closing)returnErr(SexprParseError::TrailingTokens(...)).
Block 4: Re-Exporting kicad-sexpr & Interactive AST Inspector (crates/pcb-doc & src/main.rs)
// In crates/pcb-doc/src/lib.rs:
pub use kicad_sexpr::{
FORBIDDEN_DEPS_IN_L0_STANDALONE, SAMPLE_KICAD_PCB_SNIPPET, SexprNode, SexprParseError,
is_symbol_char, parse_sexpr_str, validate_l0_standalone_manifest,
};
// In src/main.rs (ViewState fields & methods):
pub struct ViewState {
// ... existing fields ...
pub ast_source_text: String,
pub parsed_ast: Option<SexprNode>,
pub last_ast_error: Option<String>,
}
impl ViewState {
pub fn parse_active_sexpr(&mut self) -> bool {
match parse_sexpr_str(&self.ast_source_text) {
Ok(node) => {
self.parsed_ast = Some(node);
self.last_ast_error = None;
true
}
Err(err) => {
self.parsed_ast = None;
self.last_ast_error = Some(err.to_string());
false
}
}
}
pub fn ast_status_badge(&self) -> String {
if let Some(err) = &self.last_ast_error {
format!("AST Error: {err}")
} else if let Some(node) = &self.parsed_ast {
node.summary_badge()
} else {
"AST: None".to_owned()
}
}
}
// Inside PcbReviewerApp::render_session_panel(&mut self, ui: &mut egui::Ui):
ui.separator();
ui.heading("KiCad S-Expr AST (nom)");
ui.label(self.view.ast_status_badge());
ui.horizontal(|ui| {
if ui.button("Parse Sample KiCad AST").clicked() {
self.view.ast_source_text = SAMPLE_KICAD_PCB_SNIPPET.to_owned();
self.view.parse_active_sexpr();
}
if ui.button("Test Unclosed S-Expr").clicked() {
self.view.ast_source_text = "(kicad_pcb (net 1".to_owned();
self.view.parse_active_sexpr();
}
});
if let Some(root) = &self.view.parsed_ast {
egui::CollapsingHeader::new(format!(
"Root ({}) [{} children]",
root.tag_name().unwrap_or("atom"),
root.children().len()
))
.default_open(true)
.show(ui, |ui| {
for child in root.children() {
if let Some(tag) = child.tag_name() {
ui.label(format!("Node: ({tag}) • {} items", child.children().len()));
}
}
});
}
- Clean
L0 -> L1 -> L7Layering:crates/pcb-doc(L1) depends onkicad-sexpr(L0) and re-exportsSexprNode,SexprParseError, andparse_sexpr_strso thepcb-reviewerbinary (L7) can inspect parsed syntax trees without violating layer boundaries. ViewState::parse_active_sexpr&ast_status_badge: Synchronizesself.parsed_astandself.last_ast_errorwhenever the user clicks"Parse Sample KiCad AST"or"Test Unclosed S-Expr", formatting eitherAST Error: ...,AST: (kicad_pcb) • 51 nodes, depth 3, orAST: None.egui::CollapsingHeader: Renders a foldable tree container titled"Root (kicad_pcb) [5 children]"(.default_open(true)), iterating overroot.children()inside its closure to display each child list's tag and item count (Node: (footprint) • 6 items).
Block 5: Unit, proptest!, and Headless UI Tests (crates/kicad-sexpr/src/lib.rs & src/main.rs)
#[test]
fn l0_standalone_manifest_gate_rejects_workspace_and_ui_deps() {
let actual_manifest = include_str!("../Cargo.toml");
assert_eq!(validate_l0_standalone_manifest(actual_manifest), Ok(()));
assert_eq!(
validate_l0_standalone_manifest("[dependencies]\npcb-doc = { path = \"../pcb-doc\" }"),
Err("pcb-doc")
);
assert_eq!(
validate_l0_standalone_manifest("[dependencies.egui]\nversion = \"0.37\""),
Err("egui")
);
}
proptest! {
#[test]
fn prop_sexpr_parser_roundtrips_arbitrary_footprints(
ref_des in "[A-Z]{1,3}[0-9]{1,3}",
val in "[A-Za-z0-9_.-]{1,12}",
x in -100.0_f32..100.0_f32,
y in -100.0_f32..100.0_f32,
) {
let src = format!(
"(footprint \"SOT-23\" (ref \"{ref_des}\") (val \"{val}\") (at {x:.1} {y:.1}))"
);
let ast = parse_sexpr_str(&src).unwrap();
prop_assert_eq!(ast.tag_name(), Some("footprint"));
prop_assert_eq!(ast.max_depth(), 2);
prop_assert_eq!(ast.node_count(), 13);
let ref_node = ast.find_child_list("ref").unwrap();
prop_assert_eq!(ref_node.children()[0].as_text(), Some(ref_des.as_str()));
let val_node = ast.find_child_list("val").unwrap();
prop_assert_eq!(val_node.children()[0].as_text(), Some(val.as_str()));
}
}
- Compile-Time Manifest Verification:
include_str!("../Cargo.toml")embeds the actualcrates/kicad-sexpr/Cargo.tomlinto the test binary sovalidate_l0_standalone_manifestproves zero workspace or UI dependencies are present. - Property-Based Fuzzing (
proptest!): Generates randomizedref_des,val, and(x, y)coordinates, formats a KiCad(footprint ...)expression, and asserts thatparse_sexpr_str,max_depth(),node_count(), andfind_child_list(...)recover the exact input tokens across hundreds of generated cases.
Complete crates/kicad-sexpr/src/lib.rs & Updated src/main.rs Context Listing
Here is the complete, runnable crates/kicad-sexpr/src/lib.rs alongside the AST inspector additions in src/main.rs:
#![forbid(unsafe_code)]
#![deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unimplemented,
clippy::todo,
clippy::unreachable
)]
use nom::{
IResult,
branch::alt,
bytes::complete::{take_while, take_while1},
character::complete::{char, multispace0},
combinator::map,
multi::many0,
sequence::delimited,
};
use thiserror::Error;
/// Standalone `L0` parser crates must have zero workspace or UI dependencies.
pub const FORBIDDEN_DEPS_IN_L0_STANDALONE: &[&str] = &[
"pcb-doc",
"pcb-reviewer",
"egui",
"eframe",
"winit",
"egui_kittest",
"rfd",
];
/// Verifies that an `L0` standalone parser crate's `Cargo.toml` has no workspace or UI dependencies.
pub fn validate_l0_standalone_manifest(cargo_toml: &str) -> Result<(), &'static str> {
for &forbidden in FORBIDDEN_DEPS_IN_L0_STANDALONE {
let key_eq = format!("{forbidden} =");
let key_dot = format!("dependencies.{forbidden}");
if cargo_toml.contains(&key_eq) || cargo_toml.contains(&key_dot) {
return Err(forbidden);
}
}
Ok(())
}
/// Sample KiCad `.kicad_pcb` S-expression snippet used by the AST inspector and tests.
pub const SAMPLE_KICAD_PCB_SNIPPET: &str = "(kicad_pcb (version 20240108) (net 1 \"VCC_3V3\") (net 2 \"SPI_CLK\") (footprint \"SOT-23\" (ref \"U1\") (val \"LDO_3V3\") (at 20.0 27.0) (size 12.0 16.0) (net 1)) (pad \"1\" smd (at 20.0 22.0) (size 4.0 2.4) (layer \"F.Cu\") (net 1)))";
/// Typed, panic-free errors returned by the KiCad S-expression parser.
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum SexprParseError {
#[error("input must not be empty")]
EmptyInput,
#[error("unclosed or malformed S-expression near `{0}`")]
SyntaxError(String),
#[error("unexpected trailing tokens: `{0}`")]
TrailingTokens(String),
}
/// Recursive S-expression abstract syntax tree node for KiCad `.kicad_pcb` and `.kicad_sch` files.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SexprNode {
Symbol(String),
Quoted(String),
List(Vec<SexprNode>),
}
impl SexprNode {
/// If this node is a parenthesized list whose first element is an unquoted `Symbol`,
/// returns that symbol's name (e.g., `"kicad_pcb"`, `"footprint"`, `"pad"`).
pub fn tag_name(&self) -> Option<&str> {
match self {
SexprNode::List(items) => match items.first() {
Some(SexprNode::Symbol(tag)) => Some(tag.as_str()),
_ => None,
},
_ => None,
}
}
/// Returns the text of a `Symbol` or `Quoted` leaf node, or `None` for a `List`.
pub fn as_text(&self) -> Option<&str> {
match self {
SexprNode::Symbol(s) | SexprNode::Quoted(s) => Some(s.as_str()),
SexprNode::List(_) => None,
}
}
/// Returns the slice of child nodes following the leading tag in a `List` node.
pub fn children(&self) -> &[SexprNode] {
match self {
SexprNode::List(items) => items.get(1..).unwrap_or(&[]),
_ => &[],
}
}
/// Finds the first child list whose `tag_name()` equals `tag`.
pub fn find_child_list(&self, tag: &str) -> Option<&SexprNode> {
self.children()
.iter()
.find(|node| node.tag_name() == Some(tag))
}
/// Counts the total number of AST nodes (`Symbol`, `Quoted`, and `List`) in this subtree.
pub fn node_count(&self) -> usize {
match self {
SexprNode::Symbol(_) | SexprNode::Quoted(_) => 1,
SexprNode::List(items) => 1 + items.iter().map(Self::node_count).sum::<usize>(),
}
}
/// Computes the maximum nesting depth of lists in this subtree (`0` for leaf atoms).
pub fn max_depth(&self) -> usize {
match self {
SexprNode::Symbol(_) | SexprNode::Quoted(_) => 0,
SexprNode::List(items) => 1 + items.iter().map(Self::max_depth).max().unwrap_or(0),
}
}
/// Formats a concise telemetry badge summarizing the root tag, total nodes, and depth.
pub fn summary_badge(&self) -> String {
format!(
"AST: ({}) • {} nodes, depth {}",
self.tag_name().unwrap_or("atom"),
self.node_count(),
self.max_depth()
)
}
}
pub fn is_symbol_char(c: char) -> bool {
!c.is_whitespace() && c != '(' && c != ')' && c != '"'
}
fn parse_quoted_str(input: &str) -> IResult<&str, SexprNode> {
map(
delimited(char('"'), take_while(|c: char| c != '"'), char('"')),
|s: &str| SexprNode::Quoted(s.to_owned()),
)(input)
}
fn parse_symbol_str(input: &str) -> IResult<&str, SexprNode> {
map(take_while1(is_symbol_char), |s: &str| {
SexprNode::Symbol(s.to_owned())
})(input)
}
fn parse_list_node(input: &str) -> IResult<&str, SexprNode> {
map(
delimited(
char('('),
many0(delimited(multispace0, parse_node, multispace0)),
char(')'),
),
SexprNode::List,
)(input)
}
fn parse_node(input: &str) -> IResult<&str, SexprNode> {
alt((parse_list_node, parse_quoted_str, parse_symbol_str))(input)
}
/// Parses a complete KiCad S-expression string into a `SexprNode` without panicking.
pub fn parse_sexpr_str(input: &str) -> Result<SexprNode, SexprParseError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(SexprParseError::EmptyInput);
}
match parse_node(trimmed) {
Ok((rest, node)) => {
let trailing = rest.trim();
if trailing.is_empty() {
Ok(node)
} else {
Err(SexprParseError::TrailingTokens(trailing.to_owned()))
}
}
Err(_) => Err(SexprParseError::SyntaxError(trimmed.to_owned())),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn l0_standalone_manifest_gate_rejects_workspace_and_ui_deps() {
let actual_manifest = include_str!("../Cargo.toml");
assert_eq!(validate_l0_standalone_manifest(actual_manifest), Ok(()));
assert_eq!(
validate_l0_standalone_manifest("[dependencies]\npcb-doc = { path = \"../pcb-doc\" }"),
Err("pcb-doc")
);
assert_eq!(
validate_l0_standalone_manifest("[dependencies.egui]\nversion = \"0.37\""),
Err("egui")
);
}
#[test]
fn is_symbol_char_and_sexpr_combinators_parse_and_reject_edge_cases() {
assert!(is_symbol_char('a'));
assert!(is_symbol_char('2'));
assert!(is_symbol_char('.'));
assert!(!is_symbol_char(' '));
assert!(!is_symbol_char('\n'));
assert!(!is_symbol_char('('));
assert!(!is_symbol_char(')'));
assert!(!is_symbol_char('"'));
assert_eq!(parse_sexpr_str(" \n\t "), Err(SexprParseError::EmptyInput));
assert_eq!(
parse_sexpr_str("(kicad_pcb (net 1)"),
Err(SexprParseError::SyntaxError("(kicad_pcb (net 1)".to_owned()))
);
assert_eq!(
parse_sexpr_str("\"unterminated"),
Err(SexprParseError::SyntaxError("\"unterminated".to_owned()))
);
assert_eq!(
parse_sexpr_str("(net 1 \"VCC\") extra_token"),
Err(SexprParseError::TrailingTokens("extra_token".to_owned()))
);
// Tightly packed tokens without spaces before `(`, `)`, or `"`
let packed = parse_sexpr_str("(footprint\"SOT-23\"(at 20.0 27.0))").unwrap();
assert_eq!(packed.tag_name(), Some("footprint"));
assert_eq!(packed.children().len(), 2);
assert_eq!(packed.children()[0].as_text(), Some("SOT-23"));
assert_eq!(
packed
.find_child_list("at")
.and_then(|n| n.children().first())
.and_then(SexprNode::as_text),
Some("20.0")
);
assert_eq!(packed.find_child_list("missing"), None);
// Leaf node and empty list edge cases
let sym = SexprNode::Symbol("F.Cu".to_owned());
assert_eq!(sym.tag_name(), None);
assert_eq!(sym.as_text(), Some("F.Cu"));
assert!(sym.children().is_empty());
assert_eq!(sym.node_count(), 1);
assert_eq!(sym.max_depth(), 0);
assert_eq!(sym.summary_badge(), "AST: (atom) • 1 nodes, depth 0");
let empty_list = parse_sexpr_str("()").unwrap();
assert_eq!(empty_list.tag_name(), None);
assert_eq!(empty_list.as_text(), None);
assert!(empty_list.children().is_empty());
assert_eq!(empty_list.node_count(), 1);
assert_eq!(empty_list.max_depth(), 1);
let quoted_head = parse_sexpr_str("(\"quoted_head\" 42)").unwrap();
assert_eq!(quoted_head.tag_name(), None);
assert_eq!(quoted_head.children().len(), 1);
// Full sample KiCad snippet metrics
let root = parse_sexpr_str(SAMPLE_KICAD_PCB_SNIPPET).unwrap();
assert_eq!(root.tag_name(), Some("kicad_pcb"));
assert_eq!(root.children().len(), 5);
assert_eq!(root.node_count(), 51);
assert_eq!(root.max_depth(), 3);
assert_eq!(
root.summary_badge(),
"AST: (kicad_pcb) • 51 nodes, depth 3"
);
}
proptest! {
#[test]
fn prop_sexpr_parser_roundtrips_arbitrary_footprints(
ref_des in "[A-Z]{1,3}[0-9]{1,3}",
val in "[A-Za-z0-9_.-]{1,12}",
x in -100.0_f32..100.0_f32,
y in -100.0_f32..100.0_f32,
) {
let src = format!(
"(footprint \"SOT-23\" (ref \"{ref_des}\") (val \"{val}\") (at {x:.1} {y:.1}))"
);
let ast = parse_sexpr_str(&src).unwrap();
prop_assert_eq!(ast.tag_name(), Some("footprint"));
prop_assert_eq!(ast.max_depth(), 2);
prop_assert_eq!(ast.node_count(), 13);
let ref_node = ast.find_child_list("ref").unwrap();
prop_assert_eq!(ref_node.children()[0].as_text(), Some(ref_des.as_str()));
let val_node = ast.find_child_list("val").unwrap();
prop_assert_eq!(val_node.children()[0].as_text(), Some(val.as_str()));
}
}
}
impl ViewState {
pub fn parse_active_sexpr(&mut self) -> bool {
match parse_sexpr_str(&self.ast_source_text) {
Ok(node) => {
self.parsed_ast = Some(node);
self.last_ast_error = None;
true
}
Err(err) => {
self.parsed_ast = None;
self.last_ast_error = Some(err.to_string());
false
}
}
}
pub fn ast_status_badge(&self) -> String {
if let Some(err) = &self.last_ast_error {
format!("AST Error: {err}")
} else if let Some(node) = &self.parsed_ast {
node.summary_badge()
} else {
"AST: None".to_owned()
}
}
// ...
}
// Inside PcbReviewerApp::render_session_panel(&mut self, ui: &mut egui::Ui):
ui.separator();
ui.heading("KiCad S-Expr AST (nom)");
ui.label(self.view.ast_status_badge());
ui.horizontal(|ui| {
if ui.button("Parse Sample KiCad AST").clicked() {
self.view.ast_source_text = SAMPLE_KICAD_PCB_SNIPPET.to_owned();
self.view.parse_active_sexpr();
}
if ui.button("Test Unclosed S-Expr").clicked() {
self.view.ast_source_text = "(kicad_pcb (net 1".to_owned();
self.view.parse_active_sexpr();
}
});
if let Some(root) = &self.view.parsed_ast {
egui::CollapsingHeader::new(format!(
"Root ({}) [{} children]",
root.tag_name().unwrap_or("atom"),
root.children().len()
))
.default_open(true)
.show(ui, |ui| {
for child in root.children() {
if let Some(tag) = child.tag_name() {
ui.label(format!("Node: ({tag}) • {} items", child.children().len()));
}
}
});
}
// Inside tests::standalone_layer_swatch_and_headless_ui_workflows_pass in src/main.rs:
assert!(
harness
.query_by_label("AST: (kicad_pcb) • 51 nodes, depth 3")
.is_some()
);
assert!(
harness
.query_by_label("Node: (footprint) • 6 items")
.is_some()
);
harness.get_by_label("Test Unclosed S-Expr").click();
harness.run();
assert!(harness.state().view.parsed_ast.is_none());
assert!(
harness
.state()
.view
.ast_status_badge()
.starts_with("AST Error:")
);
harness.get_by_label("Parse Sample KiCad AST").click();
harness.run();
assert!(
harness
.query_by_label("AST: (kicad_pcb) • 51 nodes, depth 3")
.is_some()
);
harness.state_mut().view.parsed_ast = None;
harness.state_mut().view.last_ast_error = None;
assert_eq!(harness.state().view.ast_status_badge(), "AST: None");
nom)
L0 kicad-sexpr • SexprNode • egui::CollapsingHeader
Select a KiCad S-expression input below to run parse_sexpr_str(input) and inspect the resulting SexprNode tree, node count, and nesting depth rendered inside egui::CollapsingHeader.
1. S-Expression Input
2. egui::CollapsingHeader Tree
3. SexprNode Telemetry
nom Combinators & Inspect SexprNode Trees
Skill Practice • nom & SexprNode
Complete is_symbol_char, parse_list_node, and parse_sexpr_str in crates/kicad-sexpr/src/lib.rs.
pub fn is_symbol_char(c: char) -> bool {
!c.is_whitespace() && c != '(' && c != ')' && c
}
fn parse_list_node(input: &str) -> IResult<&str, SexprNode> {
map(
(
char('('),
many0(delimited(multispace0, parse_node, multispace0)),
char(')'),
),
SexprNode::List,
)(input)
}
pub fn parse_sexpr_str(input: &str) -> Result<SexprNode, SexprParseError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(SexprParseError::);
}
match parse_node(trimmed) {
Ok((rest, node)) => {
let trailing = rest.trim();
if trailing.is_empty() {
Ok(node)
} else {
Err(SexprParseError::TrailingTokens(trailing.to_owned()))
}
}
Err(_) => Err(SexprParseError::SyntaxError(trimmed.to_owned())),
}
}
L0 Standalone Layer Gate, nom Property Fuzzing & Mutation Gate (0023)
cargo test --workspace + proptest + cargo mutants
Run the 3-crate workspace test suite (kicad-sexpr, pcb-doc, and pcb-reviewer) and mutate all kicad-sexpr combinators, AST metrics, and egui::CollapsingHeader UI handlers:
cargo test --manifest-path ./apps/0023-sexpr-tokenizer-and-ast-with-nom/Cargo.toml --workspace
cargo mutants --manifest-path ./apps/0023-sexpr-tokenizer-and-ast-with-nom/Cargo.toml --workspace --in-place -F "validate_l0_standalone_manifest|SexprNode|is_symbol_char|parse_quoted_str|parse_symbol_str|parse_list_node|parse_node|parse_sexpr_str|parse_active_sexpr|ast_status_badge"
Recommended Primary Source
nom Official Crate Documentation & Combinator Recipes and egui::CollapsingHeader API Docs.
.kicad_pcb & .kicad_sch ASTs into the Domain Model & Corpus Oracles, where we lower SexprNode trees into typed SmdPad, TraceSegment, and ComponentSymbol structs and build a Tier 4 pinned KiCad corpus oracle harness!