Skip to content

Commit c67b81a

Browse files
committed
Make formatter KiCad-style compliant and simplify stackup patching
1 parent 043f5d8 commit c67b81a

File tree

11 files changed

+811
-379
lines changed

11 files changed

+811
-379
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ and this project adheres to Semantic Versioning (https://semver.org/spec/v2.0.0.
1212

1313
- `pcb layout --check` now runs layout sync against a shadow copy.
1414
- Removed `--sync-board-config`; board config sync is now always enabled for layout sync (CLI, MCP `run_layout`, and `pcb_layout::process_layout`).
15+
- `pcb fmt` now formats KiCad S-expression files (for example `.kicad_pcb`, `.kicad_sch`, `fp-lib-table`) only when an explicit file path is provided; default/discovery mode remains `.zen`-only.
16+
- Stackup/layers patching in `pcb layout` now uses structural S-expression mutation + canonical KiCad-style formatting, with unconditional patch/write.
1517

1618
## [0.3.42] - 2026-02-13
1719

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/pcb-layout/src/lib.rs

Lines changed: 109 additions & 158 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@ use anyhow::{Context, Result as AnyhowResult};
22
use log::{debug, info};
33
use pcb_sch::{AttributeValue, InstanceKind, Schematic, ATTR_LAYOUT_PATH};
44
use pcb_zen_core::diagnostics::Diagnostic;
5-
use pcb_zen_core::lang::stackup::{
6-
ApproxEq, BoardConfig, BoardConfigError, NetClass, Stackup, StackupError, THICKNESS_EPS,
7-
};
5+
use pcb_zen_core::lang::stackup::{BoardConfig, BoardConfigError, NetClass, Stackup, StackupError};
86
use rust_decimal::prelude::ToPrimitive;
97
use serde::{Deserialize, Serialize};
108
use starlark::errors::EvalSeverity;
@@ -601,7 +599,7 @@ pub fn process_layout(
601599
// Apply board config (stackup + netclass patterns)
602600
if let Some(ref config) = board_config {
603601
if let Some(ref stackup) = config.stackup {
604-
patch_stackup_if_needed(&paths.pcb, stackup)?;
602+
patch_stackup(&paths.pcb, stackup)?;
605603
}
606604

607605
let assignments = build_netclass_assignments(schematic, config.netclasses());
@@ -940,189 +938,142 @@ fn patch_netclass_patterns(
940938
Ok(())
941939
}
942940

943-
/// Apply stackup configuration if it differs from existing PCB file
944-
fn patch_stackup_if_needed(pcb_path: &Path, zen_stackup: &Stackup) -> Result<(), LayoutError> {
945-
// Read current PCB file
941+
/// Apply and normalize stackup-related sections in a PCB file.
942+
fn patch_stackup(pcb_path: &Path, zen_stackup: &Stackup) -> Result<(), LayoutError> {
946943
let pcb_content = fs::read_to_string(pcb_path).map_err(|e| {
947944
LayoutError::StackupPatchingError(format!("Failed to read PCB file: {}", e))
948945
})?;
949946

950-
// Parse existing stackup from PCB file
951-
let existing_stackup = Stackup::from_kicad_pcb(&pcb_content)?;
952-
953-
// Compare stackups - only patch if they're different
954-
let needs_update = match existing_stackup {
955-
Some(existing) => {
956-
let equivalent = zen_stackup.approx_eq(&existing, THICKNESS_EPS);
957-
if !equivalent {
958-
debug!("Zen stackup: {:?}", zen_stackup);
959-
debug!("Existing stackup: {:?}", existing);
960-
}
961-
!equivalent
962-
}
963-
None => {
964-
debug!("No existing stackup found in PCB file");
965-
true // No existing stackup, so we need to add it
966-
}
967-
};
968-
969-
if !needs_update {
970-
debug!("Stackup configuration matches, skipping update");
971-
return Ok(());
972-
}
947+
let mut board = pcb_sexpr::parse(&pcb_content).map_err(|e| {
948+
LayoutError::StackupPatchingError(format!("Failed to parse PCB file: {}", e))
949+
})?;
973950

974-
info!("Updating stackup configuration in {}", pcb_path.display());
951+
let layers = zen_stackup.generate_layers_expr(4);
952+
let stackup = zen_stackup.generate_stackup_expr();
975953

976-
// Generate new S-expressions (using default user layers)
977-
let layers_sexpr = zen_stackup.generate_layers_sexpr(4);
978-
let stackup_sexpr = zen_stackup.generate_stackup_sexpr();
954+
apply_stackup_sections(&mut board, layers, stackup)?;
979955

980-
// Use surgical string replacement to avoid parsing issues with hex numbers
981-
let mut updated_content = pcb_content;
982-
updated_content = replace_section_in_pcb_content(&updated_content, "layers", &layers_sexpr)?;
983-
updated_content = replace_section_in_pcb_content(&updated_content, "stackup", &stackup_sexpr)?;
956+
let updated_content =
957+
pcb_sexpr::formatter::format_tree(&board, pcb_sexpr::formatter::FormatMode::Normal);
984958

985-
// Write updated content back to file
959+
info!("Updating stackup configuration in {}", pcb_path.display());
986960
fs::write(pcb_path, updated_content).map_err(|e| {
987961
LayoutError::StackupPatchingError(format!("Failed to write updated PCB file: {}", e))
988962
})?;
989-
990963
info!("Successfully updated stackup configuration");
964+
991965
Ok(())
992966
}
993967

994-
/// Replace a section in KiCad PCB content using careful string matching
995-
fn replace_section_in_pcb_content(
996-
content: &str,
997-
section_name: &str,
998-
new_section: &str,
999-
) -> Result<String, LayoutError> {
1000-
// Find the section by parsing just enough to locate it
1001-
let section_start = find_section_start(content, section_name)?;
1002-
1003-
if let Some(start_pos) = section_start {
1004-
let end_pos = find_matching_paren(content, start_pos)?;
1005-
1006-
// Replace the section with the new content
1007-
let mut result = String::with_capacity(content.len() + new_section.len());
1008-
result.push_str(&content[..start_pos]);
1009-
result.push_str(new_section);
1010-
result.push_str(&content[end_pos + 1..]);
1011-
Ok(result)
1012-
} else {
1013-
// Section doesn't exist, need to add it
1014-
add_section_to_pcb_content(content, section_name, new_section)
968+
fn apply_stackup_sections(
969+
board: &mut pcb_sexpr::Sexpr,
970+
layers: pcb_sexpr::Sexpr,
971+
stackup: pcb_sexpr::Sexpr,
972+
) -> Result<(), LayoutError> {
973+
let root_items = board.as_list_mut().ok_or_else(|| {
974+
LayoutError::StackupPatchingError("PCB root is not an S-expression list".to_string())
975+
})?;
976+
977+
if root_items.first().and_then(pcb_sexpr::Sexpr::as_sym) != Some("kicad_pcb") {
978+
return Err(LayoutError::StackupPatchingError(
979+
"PCB root must start with (kicad_pcb ...)".to_string(),
980+
));
1015981
}
1016-
}
1017982

1018-
/// Find the start position of a section in PCB content
1019-
fn find_section_start(content: &str, section_name: &str) -> Result<Option<usize>, LayoutError> {
1020-
let pattern = format!("({}", section_name);
1021-
let mut pos = 0;
983+
set_or_insert_named_list(root_items, "layers", layers, Some("general"));
1022984

1023-
while let Some(found) = content[pos..].find(&pattern) {
1024-
let abs_pos = pos + found;
985+
if let Some(setup_idx) = find_named_list_index(root_items, "setup") {
986+
let setup_items = root_items[setup_idx].as_list_mut().ok_or_else(|| {
987+
LayoutError::StackupPatchingError("setup section is not a list".to_string())
988+
})?;
989+
set_or_insert_named_list(setup_items, "stackup", stackup, None);
990+
} else {
991+
root_items.push(pcb_sexpr::Sexpr::list(vec![
992+
pcb_sexpr::Sexpr::symbol("setup"),
993+
stackup,
994+
]));
995+
}
1025996

1026-
// Check if this is a word boundary (not part of a larger identifier)
1027-
let next_char_pos = abs_pos + pattern.len();
1028-
if next_char_pos < content.len() {
1029-
let next_char = content.chars().nth(next_char_pos).unwrap();
1030-
if next_char.is_whitespace() || next_char == '\n' || next_char == '\t' {
1031-
return Ok(Some(abs_pos));
1032-
}
1033-
} else {
1034-
return Ok(Some(abs_pos));
1035-
}
997+
Ok(())
998+
}
999+
1000+
fn find_named_list_index(items: &[pcb_sexpr::Sexpr], name: &str) -> Option<usize> {
1001+
items.iter().position(|item| {
1002+
item.as_list()
1003+
.and_then(|list| list.first())
1004+
.and_then(pcb_sexpr::Sexpr::as_sym)
1005+
== Some(name)
1006+
})
1007+
}
10361008

1037-
pos = abs_pos + 1;
1009+
fn set_or_insert_named_list(
1010+
parent: &mut Vec<pcb_sexpr::Sexpr>,
1011+
name: &str,
1012+
replacement: pcb_sexpr::Sexpr,
1013+
insert_after: Option<&str>,
1014+
) {
1015+
if let Some(idx) = find_named_list_index(parent, name) {
1016+
parent[idx] = replacement;
1017+
return;
10381018
}
10391019

1040-
Ok(None)
1020+
let insert_idx = insert_after
1021+
.and_then(|anchor| find_named_list_index(parent, anchor).map(|idx| idx + 1))
1022+
.unwrap_or(parent.len());
1023+
1024+
parent.insert(insert_idx, replacement);
10411025
}
10421026

1043-
/// Find the matching closing parenthesis for an opening parenthesis
1044-
fn find_matching_paren(content: &str, start_pos: usize) -> Result<usize, LayoutError> {
1045-
let mut depth = 0;
1046-
let mut in_string = false;
1047-
let mut escaped = false;
1027+
#[cfg(test)]
1028+
mod tests {
1029+
use super::apply_stackup_sections;
1030+
1031+
#[test]
1032+
fn apply_stackup_sections_replaces_existing_layers_and_stackup() {
1033+
let mut board = pcb_sexpr::parse(
1034+
r#"(kicad_pcb
1035+
(general (thickness 1.6))
1036+
(layers (0 "F.Cu" signal))
1037+
(setup (stackup (old yes)) (other 1))
1038+
)"#,
1039+
)
1040+
.unwrap();
10481041

1049-
let chars: Vec<char> = content.chars().collect();
1042+
let new_layers =
1043+
pcb_sexpr::parse(r#"(layers (0 "F.Cu" signal) (2 "B.Cu" signal))"#).unwrap();
1044+
let new_stackup = pcb_sexpr::parse(r#"(stackup (layer "F.Cu" (type "copper")))"#).unwrap();
10501045

1051-
for (i, &ch) in chars.iter().enumerate().skip(start_pos) {
1052-
if escaped {
1053-
escaped = false;
1054-
continue;
1055-
}
1046+
apply_stackup_sections(&mut board, new_layers, new_stackup).unwrap();
10561047

1057-
match ch {
1058-
'\\' if in_string => escaped = true,
1059-
'"' => in_string = !in_string,
1060-
'(' if !in_string => depth += 1,
1061-
')' if !in_string => {
1062-
depth -= 1;
1063-
if depth == 0 {
1064-
return Ok(i);
1065-
}
1066-
}
1067-
_ => {}
1068-
}
1048+
let formatted =
1049+
pcb_sexpr::formatter::format_tree(&board, pcb_sexpr::formatter::FormatMode::Normal);
1050+
assert!(formatted.contains(r#"(2 "B.Cu" signal)"#));
1051+
assert!(formatted.contains(r#"(layer "F.Cu""#));
1052+
assert!(formatted.contains(r#"(type "copper")"#));
1053+
assert!(!formatted.contains("(old yes)"));
10691054
}
10701055

1071-
Err(LayoutError::StackupPatchingError(
1072-
"Could not find matching closing parenthesis".to_string(),
1073-
))
1074-
}
1056+
#[test]
1057+
fn apply_stackup_sections_inserts_layers_and_setup_when_missing() {
1058+
let mut board =
1059+
pcb_sexpr::parse(r#"(kicad_pcb (version 20240101) (general (thickness 1.6)))"#)
1060+
.unwrap();
10751061

1076-
/// Add a new section to PCB content
1077-
fn add_section_to_pcb_content(
1078-
content: &str,
1079-
section_name: &str,
1080-
new_section: &str,
1081-
) -> Result<String, LayoutError> {
1082-
match section_name {
1083-
"layers" => {
1084-
// Add after general section
1085-
if let Some(general_start) = find_section_start(content, "general")? {
1086-
let general_end = find_matching_paren(content, general_start)?;
1087-
let insert_pos = general_end + 1;
1088-
1089-
let mut result = String::with_capacity(content.len() + new_section.len() + 10);
1090-
result.push_str(&content[..insert_pos]);
1091-
result.push('\n');
1092-
result.push('\t');
1093-
result.push_str(new_section);
1094-
result.push_str(&content[insert_pos..]);
1095-
Ok(result)
1096-
} else {
1097-
Err(LayoutError::StackupPatchingError(
1098-
"Could not find general section for layers insertion".to_string(),
1099-
))
1100-
}
1101-
}
1102-
"stackup" => {
1103-
// Add within setup section
1104-
if let Some(setup_start) = find_section_start(content, "setup")? {
1105-
let setup_end = find_matching_paren(content, setup_start)?;
1106-
let insert_pos = setup_end; // Before closing paren
1107-
1108-
let mut result = String::with_capacity(content.len() + new_section.len() + 20);
1109-
result.push_str(&content[..insert_pos]);
1110-
result.push('\n');
1111-
result.push_str("\t\t");
1112-
result.push_str(new_section);
1113-
result.push('\n');
1114-
result.push('\t');
1115-
result.push_str(&content[insert_pos..]);
1116-
Ok(result)
1117-
} else {
1118-
Err(LayoutError::StackupPatchingError(
1119-
"Could not find setup section for stackup insertion".to_string(),
1120-
))
1121-
}
1122-
}
1123-
_ => Err(LayoutError::StackupPatchingError(format!(
1124-
"Unknown section type: {}",
1125-
section_name
1126-
))),
1062+
let new_layers =
1063+
pcb_sexpr::parse(r#"(layers (0 "F.Cu" signal) (2 "B.Cu" signal))"#).unwrap();
1064+
let new_stackup = pcb_sexpr::parse(r#"(stackup (layer "F.Cu" (type "copper")))"#).unwrap();
1065+
1066+
apply_stackup_sections(&mut board, new_layers, new_stackup).unwrap();
1067+
1068+
let formatted =
1069+
pcb_sexpr::formatter::format_tree(&board, pcb_sexpr::formatter::FormatMode::Normal);
1070+
assert!(formatted.contains("\n\t(layers\n"));
1071+
assert!(formatted.contains("\n\t(setup\n"));
1072+
assert!(formatted.contains(r#"(layer "F.Cu""#));
1073+
assert!(formatted.contains(r#"(type "copper")"#));
1074+
1075+
let general_pos = formatted.find("\n\t(general").unwrap();
1076+
let layers_pos = formatted.find("\n\t(layers").unwrap();
1077+
assert!(layers_pos > general_pos);
11271078
}
11281079
}

crates/pcb-sch/src/kicad_schematic.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ use std::collections::HashMap;
44
use std::fs;
55
use std::path::{Path, PathBuf};
66

7-
use pcb_sexpr::{format_sexpr, parse, Sexpr, SexprKind};
7+
use pcb_sexpr::formatter::{format_tree, FormatMode};
8+
use pcb_sexpr::{parse, Sexpr, SexprKind};
89
use uuid::Uuid;
910

1011
use crate::hierarchical_layout::{HierarchicalLayout, Size};
@@ -1208,7 +1209,7 @@ impl SchematicConverter {
12081209
});
12091210

12101211
// Convert to string with proper formatting
1211-
format_sexpr(&schematic_sexpr, 0)
1212+
format_tree(&schematic_sexpr, FormatMode::Normal)
12121213
}
12131214

12141215
fn junction_to_sexpr(&self, junction: &Junction) -> Sexpr {

0 commit comments

Comments
 (0)