Skip to content

Commit 7f0f081

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

File tree

15 files changed

+842
-385
lines changed

15 files changed

+842
-385
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: 124 additions & 160 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,13 @@ 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;
119
use std::collections::{HashMap, HashSet};
1210
use std::fs;
13-
use std::io::Write;
11+
use std::io::{BufWriter, Write};
1412
use std::path::{Path, PathBuf};
1513
use tempfile::TempDir;
1614
use thiserror::Error;
@@ -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,155 @@ 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)?;
947+
let mut board = pcb_sexpr::parse(&pcb_content).map_err(|e| {
948+
LayoutError::StackupPatchingError(format!("Failed to parse PCB file: {}", e))
949+
})?;
952950

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-
};
951+
let layers = zen_stackup.generate_layers_expr(4);
952+
let stackup = zen_stackup.generate_stackup_expr();
968953

969-
if !needs_update {
970-
debug!("Stackup configuration matches, skipping update");
971-
return Ok(());
972-
}
954+
apply_stackup_sections(&mut board, layers, stackup)?;
955+
956+
let updated_content =
957+
pcb_sexpr::formatter::format_tree(&board, pcb_sexpr::formatter::FormatMode::Normal);
973958

974959
info!("Updating stackup configuration in {}", pcb_path.display());
960+
write_text_atomic_buffered(pcb_path, &updated_content).map_err(|e| {
961+
LayoutError::StackupPatchingError(format!(
962+
"Failed to write updated PCB file {}: {}",
963+
pcb_path.display(),
964+
e
965+
))
966+
})?;
967+
info!("Successfully updated stackup configuration");
975968

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();
969+
Ok(())
970+
}
979971

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)?;
972+
fn write_text_atomic_buffered(path: &Path, text: &str) -> std::io::Result<()> {
973+
let tmp_path = path.with_extension("kicad_pcb.tmp");
974+
let mut writer = BufWriter::new(fs::File::create(&tmp_path)?);
975+
writer.write_all(text.as_bytes())?;
976+
writer.flush()?;
977+
fs::rename(&tmp_path, path)?;
978+
Ok(())
979+
}
984980

985-
// Write updated content back to file
986-
fs::write(pcb_path, updated_content).map_err(|e| {
987-
LayoutError::StackupPatchingError(format!("Failed to write updated PCB file: {}", e))
981+
fn apply_stackup_sections(
982+
board: &mut pcb_sexpr::Sexpr,
983+
layers: pcb_sexpr::Sexpr,
984+
stackup: pcb_sexpr::Sexpr,
985+
) -> Result<(), LayoutError> {
986+
let root_items = board.as_list_mut().ok_or_else(|| {
987+
LayoutError::StackupPatchingError("PCB root is not an S-expression list".to_string())
988988
})?;
989989

990-
info!("Successfully updated stackup configuration");
991-
Ok(())
992-
}
990+
if root_items.first().and_then(pcb_sexpr::Sexpr::as_sym) != Some("kicad_pcb") {
991+
return Err(LayoutError::StackupPatchingError(
992+
"PCB root must start with (kicad_pcb ...)".to_string(),
993+
));
994+
}
993995

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)
996+
set_or_insert_named_list(root_items, "layers", layers, Some("general"));
997+
998+
if let Some(setup_idx) = find_named_list_index(root_items, "setup") {
999+
let setup_items = root_items[setup_idx].as_list_mut().ok_or_else(|| {
1000+
LayoutError::StackupPatchingError("setup section is not a list".to_string())
1001+
})?;
1002+
set_or_insert_named_list(setup_items, "stackup", stackup, None);
10121003
} else {
1013-
// Section doesn't exist, need to add it
1014-
add_section_to_pcb_content(content, section_name, new_section)
1004+
root_items.push(pcb_sexpr::Sexpr::list(vec![
1005+
pcb_sexpr::Sexpr::symbol("setup"),
1006+
stackup,
1007+
]));
10151008
}
1016-
}
1017-
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;
10221009

1023-
while let Some(found) = content[pos..].find(&pattern) {
1024-
let abs_pos = pos + found;
1010+
Ok(())
1011+
}
10251012

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-
}
1013+
fn find_named_list_index(items: &[pcb_sexpr::Sexpr], name: &str) -> Option<usize> {
1014+
items.iter().position(|item| {
1015+
item.as_list()
1016+
.and_then(|list| list.first())
1017+
.and_then(pcb_sexpr::Sexpr::as_sym)
1018+
== Some(name)
1019+
})
1020+
}
10361021

1037-
pos = abs_pos + 1;
1022+
fn set_or_insert_named_list(
1023+
parent: &mut Vec<pcb_sexpr::Sexpr>,
1024+
name: &str,
1025+
replacement: pcb_sexpr::Sexpr,
1026+
insert_after: Option<&str>,
1027+
) {
1028+
if let Some(idx) = find_named_list_index(parent, name) {
1029+
parent[idx] = replacement;
1030+
return;
10381031
}
10391032

1040-
Ok(None)
1033+
let insert_idx = insert_after
1034+
.and_then(|anchor| find_named_list_index(parent, anchor).map(|idx| idx + 1))
1035+
.unwrap_or(parent.len());
1036+
1037+
parent.insert(insert_idx, replacement);
10411038
}
10421039

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;
1040+
#[cfg(test)]
1041+
mod tests {
1042+
use super::apply_stackup_sections;
1043+
1044+
#[test]
1045+
fn apply_stackup_sections_replaces_existing_layers_and_stackup() {
1046+
let mut board = pcb_sexpr::parse(
1047+
r#"(kicad_pcb
1048+
(general (thickness 1.6))
1049+
(layers (0 "F.Cu" signal))
1050+
(setup (stackup (old yes)) (other 1))
1051+
)"#,
1052+
)
1053+
.unwrap();
10481054

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

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

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-
}
1061+
let formatted =
1062+
pcb_sexpr::formatter::format_tree(&board, pcb_sexpr::formatter::FormatMode::Normal);
1063+
assert!(formatted.contains(r#"(2 "B.Cu" signal)"#));
1064+
assert!(formatted.contains(r#"(layer "F.Cu""#));
1065+
assert!(formatted.contains(r#"(type "copper")"#));
1066+
assert!(!formatted.contains("(old yes)"));
10691067
}
10701068

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

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-
))),
1075+
let new_layers =
1076+
pcb_sexpr::parse(r#"(layers (0 "F.Cu" signal) (2 "B.Cu" signal))"#).unwrap();
1077+
let new_stackup = pcb_sexpr::parse(r#"(stackup (layer "F.Cu" (type "copper")))"#).unwrap();
1078+
1079+
apply_stackup_sections(&mut board, new_layers, new_stackup).unwrap();
1080+
1081+
let formatted =
1082+
pcb_sexpr::formatter::format_tree(&board, pcb_sexpr::formatter::FormatMode::Normal);
1083+
assert!(formatted.contains("\n\t(layers\n"));
1084+
assert!(formatted.contains("\n\t(setup\n"));
1085+
assert!(formatted.contains(r#"(layer "F.Cu""#));
1086+
assert!(formatted.contains(r#"(type "copper")"#));
1087+
1088+
let general_pos = formatted.find("\n\t(general").unwrap();
1089+
let layers_pos = formatted.find("\n\t(layers").unwrap();
1090+
assert!(layers_pos > general_pos);
11271091
}
11281092
}

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)