Skip to content

Commit 58123ad

Browse files
authored
Format imported KiCad symbol and footprint files in pcb search (#552)
* Format imported KiCad symbol and footprint files in `pcb search` * Normalize imported Footprint refs in pcb search
1 parent d1857d6 commit 58123ad

File tree

4 files changed

+126
-48
lines changed

4 files changed

+126
-48
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ and this project adheres to Semantic Versioning (https://semver.org/spec/v2.0.0.
2222
- `pcb layout` stackup sync now also patches `general (thickness ...)` from computed stackup thickness.
2323
- Removed MCP resource `zener-docs` (https://docs.pcb.new/llms.txt) from `pcb mcp`, with Zener docs now embedded in `pcb doc`.
2424
- Move board-config/title-block patching to Rust; simplify Python sync; only update `.kicad_pro` netclass patterns when assignments exist.
25+
- `pcb search` now formats generated component `.kicad_sym` and `.kicad_mod` files with the KiCad S-expression formatter.
26+
- `pcb search` now rewrites imported symbol `property "Footprint"` to the local `lib:footprint` form (`<stem>:<stem>`), matching fp-lib-table resolution during layout sync.
2527

2628
### Fixed
2729

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-diode-api/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ pcb-fmt = { workspace = true }
3434
pcb-kicad = { workspace = true }
3535
pcb-mcp = { workspace = true }
3636
pcb-sch = { workspace = true }
37+
pcb-sexpr = { workspace = true }
3738
pcb-zen = { workspace = true }
3839
pcb-zen-core = { workspace = true }
3940
rand = { workspace = true }

crates/pcb-diode-api/src/component.rs

Lines changed: 122 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ use clap::Args;
33
use colored::Colorize;
44
use indicatif::ProgressBar;
55
use inquire::{Select, Text};
6+
use pcb_sexpr::formatter::{prettify, FormatMode};
7+
use pcb_sexpr::PatchSet;
68
use pcb_zen_core::config::find_workspace_root;
79
use regex::Regex;
810
use reqwest::blocking::Client;
@@ -438,11 +440,11 @@ fn embed_step_into_footprint_file(
438440

439441
let embedded_content = embed_step_in_footprint(footprint_content, step_bytes, step_filename)?;
440442

441-
// Normalize line endings and write to temporary file
443+
// Normalize line endings, format as KiCad S-expression, then write atomically.
442444
let normalized_content = embedded_content.replace("\r\n", "\n");
445+
let formatted_content = format_kicad_sexpr_source(&normalized_content, footprint_path)?;
443446
let temp_path = footprint_path.with_extension("kicad_mod.tmp");
444-
fs::write(&temp_path, normalized_content)
445-
.context("Failed to write temporary footprint file")?;
447+
fs::write(&temp_path, formatted_content).context("Failed to write temporary footprint file")?;
446448

447449
// Atomic rename to replace original
448450
fs::rename(&temp_path, footprint_path).context("Failed to rename temporary footprint file")?;
@@ -705,13 +707,7 @@ pub fn add_component_to_workspace(
705707
}
706708

707709
// Finalize: embed STEP, generate .zen file
708-
finalize_component(
709-
&component_dir,
710-
part_number,
711-
manufacturer,
712-
has_footprint,
713-
has_datasheet,
714-
)?;
710+
finalize_component(&component_dir, part_number, manufacturer)?;
715711

716712
Ok(AddComponentResult {
717713
component_path: zen_file,
@@ -737,48 +733,118 @@ fn component_dir_path(workspace_root: &Path, manufacturer: Option<&str>, mpn: &s
737733
}
738734

739735
/// Embed STEP into footprint (if both exist) and generate .zen file
740-
fn finalize_component(
741-
component_dir: &Path,
742-
mpn: &str,
743-
manufacturer: Option<&str>,
744-
has_footprint: bool,
745-
has_datasheet: bool,
746-
) -> Result<()> {
736+
fn finalize_component(component_dir: &Path, mpn: &str, manufacturer: Option<&str>) -> Result<()> {
747737
let sanitized_mpn = pcb_component_gen::sanitize_mpn_for_path(mpn);
748738
let symbol_path = component_dir.join(format!("{}.kicad_sym", &sanitized_mpn));
749739
let footprint_path = component_dir.join(format!("{}.kicad_mod", &sanitized_mpn));
750740
let step_path = component_dir.join(format!("{}.step", &sanitized_mpn));
741+
let datasheet_path = component_dir.join(format!("{}.pdf", &sanitized_mpn));
751742

752-
// Embed STEP into footprint if both exist
753-
if footprint_path.exists() && step_path.exists() {
754-
embed_step_into_footprint_file(&footprint_path, &step_path, true)?;
743+
if footprint_path.exists() {
744+
if step_path.exists() {
745+
embed_step_into_footprint_file(&footprint_path, &step_path, true)?;
746+
} else {
747+
format_kicad_sexpr_file(&footprint_path)?;
748+
}
755749
}
756750

757-
// Generate .zen file if symbol exists
758-
if symbol_path.exists() {
759-
let symbol_lib = pcb_eda::SymbolLibrary::from_file(&symbol_path)?;
760-
let symbol = symbol_lib
761-
.first_symbol()
762-
.ok_or_else(|| anyhow::anyhow!("No symbols in library"))?;
751+
if !symbol_path.exists() {
752+
return Ok(());
753+
}
763754

764-
let content = generate_zen_file(
765-
mpn,
766-
&sanitized_mpn,
767-
symbol,
768-
&format!("{}.kicad_sym", &sanitized_mpn),
769-
has_footprint
770-
.then(|| format!("{}.kicad_mod", &sanitized_mpn))
771-
.as_deref(),
772-
has_datasheet
773-
.then(|| format!("{}.pdf", &sanitized_mpn))
774-
.as_deref(),
775-
manufacturer,
776-
)?;
755+
let mut symbol_source = fs::read_to_string(&symbol_path)
756+
.with_context(|| format!("Failed to read KiCad symbol {}", symbol_path.display()))?;
777757

778-
let zen_file = component_dir.join(format!("{}.zen", &sanitized_mpn));
779-
write_component_files(&zen_file, component_dir, &content)?;
758+
if footprint_path.exists() {
759+
let footprint_path_str = footprint_path.to_string_lossy();
760+
let (footprint_ref, _) = pcb_sch::kicad_netlist::format_footprint(&footprint_path_str);
761+
symbol_source = rewrite_symbol_footprint_property_text(&symbol_source, &footprint_ref)?;
780762
}
781763

764+
let symbol_formatted = format_kicad_sexpr_source(&symbol_source, &symbol_path)?;
765+
fs::write(&symbol_path, &symbol_formatted)
766+
.with_context(|| format!("Failed to write KiCad symbol {}", symbol_path.display()))?;
767+
768+
// Generate .zen file from the exact symbol content we just wrote.
769+
let symbol_lib = pcb_eda::SymbolLibrary::from_string(&symbol_formatted, "kicad_sym")?;
770+
let symbol = symbol_lib
771+
.first_symbol()
772+
.ok_or_else(|| anyhow::anyhow!("No symbols in library"))?;
773+
774+
let content = generate_zen_file(
775+
mpn,
776+
&sanitized_mpn,
777+
symbol,
778+
&format!("{}.kicad_sym", &sanitized_mpn),
779+
footprint_path
780+
.exists()
781+
.then(|| format!("{}.kicad_mod", &sanitized_mpn))
782+
.as_deref(),
783+
datasheet_path
784+
.exists()
785+
.then(|| format!("{}.pdf", &sanitized_mpn))
786+
.as_deref(),
787+
manufacturer,
788+
)?;
789+
790+
let zen_file = component_dir.join(format!("{}.zen", &sanitized_mpn));
791+
write_component_files(&zen_file, component_dir, &content)?;
792+
793+
Ok(())
794+
}
795+
796+
fn rewrite_symbol_footprint_property_text(source: &str, footprint_ref: &str) -> Result<String> {
797+
let parsed = pcb_sexpr::parse(source).map_err(|e| anyhow::anyhow!(e))?;
798+
let mut patches = PatchSet::new();
799+
800+
parsed.walk(|node, _ctx| {
801+
let Some(items) = node.as_list() else {
802+
return;
803+
};
804+
805+
let is_footprint_property = items.first().and_then(|n| n.as_sym()) == Some("property")
806+
&& items.get(1).and_then(|n| n.as_str().or_else(|| n.as_sym())) == Some("Footprint");
807+
if !is_footprint_property {
808+
return;
809+
}
810+
811+
let Some(value_node) = items.get(2) else {
812+
return;
813+
};
814+
let current = value_node.as_str().or_else(|| value_node.as_sym());
815+
if current != Some(footprint_ref) {
816+
patches.replace_string(value_node.span, footprint_ref);
817+
}
818+
});
819+
820+
let mut out = Vec::new();
821+
patches
822+
.write_to(source, &mut out)
823+
.context("Failed to apply Footprint property patch")?;
824+
let updated = String::from_utf8(out).context("Patched symbol is not valid UTF-8")?;
825+
Ok(updated)
826+
}
827+
828+
fn format_kicad_sexpr_source(source: &str, path_for_error: &Path) -> Result<String> {
829+
pcb_sexpr::parse(source)
830+
.map_err(|e| anyhow::anyhow!(e))
831+
.with_context(|| {
832+
format!(
833+
"Failed to parse KiCad S-expression file {}",
834+
path_for_error.display()
835+
)
836+
})?;
837+
838+
Ok(prettify(source, FormatMode::Normal))
839+
}
840+
841+
fn format_kicad_sexpr_file(path: &Path) -> Result<()> {
842+
let source = fs::read_to_string(path)
843+
.with_context(|| format!("Failed to read KiCad file {}", path.display()))?;
844+
let formatted = format_kicad_sexpr_source(&source, path)?;
845+
fs::write(path, formatted)
846+
.with_context(|| format!("Failed to write KiCad file {}", path.display()))?;
847+
782848
Ok(())
783849
}
784850

@@ -1170,13 +1236,7 @@ fn execute_from_dir(dir: &Path, workspace_root: &Path) -> Result<()> {
11701236

11711237
// Finalize: embed STEP, generate .zen file
11721238
println!("{} Generating .zen file...", "→".blue().bold());
1173-
finalize_component(
1174-
&component_dir,
1175-
&mpn,
1176-
manufacturer.as_deref(),
1177-
has_footprint,
1178-
has_datasheet,
1179-
)?;
1239+
finalize_component(&component_dir, &mpn, manufacturer.as_deref())?;
11801240

11811241
// Show result
11821242
let display_path = zen_file.strip_prefix(workspace_root).unwrap_or(&zen_file);
@@ -1921,4 +1981,18 @@ mod tests {
19211981
assert!(zen_content.contains("\"VIN\": Pins.VIN"));
19221982
assert!(zen_content.contains("\"VOUT\": Pins.VOUT"));
19231983
}
1984+
1985+
#[test]
1986+
fn test_rewrite_symbol_footprint_property_text() {
1987+
let symbol = r#"(kicad_symbol_lib
1988+
(symbol "TEST"
1989+
(property "Reference" "U" (at 0 0 0))
1990+
(property "Footprint" "OldLib:OldFootprint" (at 0 0 0))
1991+
)
1992+
)"#;
1993+
let updated =
1994+
rewrite_symbol_footprint_property_text(symbol, "NewLib:NewFootprint").unwrap();
1995+
assert!(updated.contains("(property \"Footprint\" \"NewLib:NewFootprint\""));
1996+
assert!(!updated.contains("OldLib:OldFootprint"));
1997+
}
19241998
}

0 commit comments

Comments
 (0)