1use std::fs;
2use std::path::Path;
3
4use kiutils_sexpr::{parse_one, Atom, CstDocument, Node};
5
6use crate::diagnostic::{Diagnostic, Severity};
7use crate::sexpr_edit::{
8 atom_quoted, atom_symbol, ensure_root_head_any, mutate_root_and_refresh,
9 remove_property as remove_property_node, root_head, upsert_property_preserve_tail,
10 upsert_scalar,
11};
12use crate::sexpr_utils::{
13 atom_as_f64, atom_as_string, head_of, list_child_head_count, second_atom_bool, second_atom_f64,
14 second_atom_i32, second_atom_string,
15};
16use crate::version_diag::collect_version_diagnostics;
17use crate::{Error, UnknownNode, WriteMode};
18
19#[derive(Debug, Clone, PartialEq)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23pub struct FpPadNet {
24 pub code: Option<i32>,
25 pub name: Option<String>,
26}
27
28#[derive(Debug, Clone, PartialEq)]
29#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
30pub struct FpPadDrill {
31 pub shape: Option<String>,
32 pub diameter: Option<f64>,
33 pub width: Option<f64>,
34 pub offset: Option<[f64; 2]>,
35}
36
37#[derive(Debug, Clone, PartialEq)]
38#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
39pub struct FpPad {
40 pub number: Option<String>,
41 pub pad_type: Option<String>,
42 pub shape: Option<String>,
43 pub at: Option<[f64; 2]>,
44 pub rotation: Option<f64>,
45 pub size: Option<[f64; 2]>,
46 pub layers: Vec<String>,
47 pub net: Option<FpPadNet>,
48 pub drill: Option<FpPadDrill>,
49 pub uuid: Option<String>,
50 pub pin_function: Option<String>,
51 pub pin_type: Option<String>,
52 pub locked: bool,
53 pub property: Option<String>,
54 pub remove_unused_layers: bool,
55 pub keep_end_layers: bool,
56 pub roundrect_rratio: Option<f64>,
57 pub chamfer_ratio: Option<f64>,
58 pub chamfer: Vec<String>,
59 pub die_length: Option<f64>,
60 pub solder_mask_margin: Option<f64>,
61 pub solder_paste_margin: Option<f64>,
62 pub solder_paste_margin_ratio: Option<f64>,
63 pub clearance: Option<f64>,
64 pub zone_connect: Option<i32>,
65 pub thermal_width: Option<f64>,
66 pub thermal_gap: Option<f64>,
67 pub custom_clearance: Option<String>,
68 pub custom_anchor: Option<String>,
69 pub custom_primitives: usize,
70}
71
72#[derive(Debug, Clone, PartialEq)]
75#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
76pub struct FpGraphic {
77 pub token: String,
78 pub layer: Option<String>,
79 pub text: Option<String>,
80 pub start: Option<[f64; 2]>,
81 pub end: Option<[f64; 2]>,
82 pub center: Option<[f64; 2]>,
83 pub uuid: Option<String>,
84 pub locked: bool,
85 pub width: Option<f64>,
86 pub stroke_type: Option<String>,
87 pub fill_type: Option<String>,
88 pub at: Option<[f64; 2]>,
89 pub angle: Option<f64>,
90 pub font_size: Option<[f64; 2]>,
91 pub font_thickness: Option<f64>,
92}
93
94#[derive(Debug, Clone, PartialEq)]
97#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
98pub struct FpModel {
99 pub path: Option<String>,
100 pub at: Option<[f64; 3]>,
101 pub scale: Option<[f64; 3]>,
102 pub rotate: Option<[f64; 3]>,
103 pub hide: bool,
104}
105
106#[derive(Debug, Clone, PartialEq)]
109#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
110pub struct FpZone {
111 pub net: Option<i32>,
112 pub net_name: Option<String>,
113 pub name: Option<String>,
114 pub layer: Option<String>,
115 pub layers: Vec<String>,
116 pub hatch: Option<String>,
117 pub fill_enabled: Option<bool>,
118 pub polygon_count: usize,
119 pub filled_polygon_count: usize,
120 pub has_keepout: bool,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
126#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
127pub struct FpGroup {
128 pub name: Option<String>,
129 pub group_id: Option<String>,
130 pub member_count: usize,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
134#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
135pub struct FpProperty {
136 pub key: String,
137 pub value: String,
138}
139
140#[derive(Debug, Clone, PartialEq)]
141#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
142pub struct FootprintAst {
143 pub lib_id: Option<String>,
144 pub version: Option<i32>,
145 pub tedit: Option<String>,
146 pub generator: Option<String>,
147 pub generator_version: Option<String>,
148 pub layer: Option<String>,
149 pub descr: Option<String>,
150 pub tags: Option<String>,
151 pub property_count: usize,
152 pub attr_present: bool,
153 pub locked_present: bool,
154 pub private_layers_present: bool,
155 pub net_tie_pad_groups_present: bool,
156 pub embedded_fonts_present: bool,
157 pub has_embedded_files: bool,
158 pub embedded_file_count: usize,
159 pub clearance: Option<String>,
160 pub solder_mask_margin: Option<String>,
161 pub solder_paste_margin: Option<String>,
162 pub solder_paste_margin_ratio: Option<String>,
163 pub duplicate_pad_numbers_are_jumpers: Option<bool>,
164 pub pad_count: usize,
165 pub pads: Vec<FpPad>,
166 pub model_count: usize,
167 pub models: Vec<FpModel>,
168 pub zone_count: usize,
169 pub zones: Vec<FpZone>,
170 pub group_count: usize,
171 pub groups: Vec<FpGroup>,
172 pub fp_line_count: usize,
173 pub fp_rect_count: usize,
174 pub fp_circle_count: usize,
175 pub fp_arc_count: usize,
176 pub fp_poly_count: usize,
177 pub fp_curve_count: usize,
178 pub fp_text_count: usize,
179 pub fp_text_box_count: usize,
180 pub dimension_count: usize,
181 pub graphic_count: usize,
182 pub graphics: Vec<FpGraphic>,
183 pub attr: Vec<String>,
184 pub locked: bool,
185 pub placed: bool,
186 pub private_layers: Vec<String>,
187 pub net_tie_pad_groups: Vec<Vec<String>>,
188 pub reference: Option<String>,
189 pub value: Option<String>,
190 pub properties: Vec<FpProperty>,
191 pub unknown_nodes: Vec<UnknownNode>,
192}
193#[derive(Debug, Clone)]
194pub struct FootprintDocument {
195 ast: FootprintAst,
196 cst: CstDocument,
197 diagnostics: Vec<Diagnostic>,
198 ast_dirty: bool,
199}
200
201impl FootprintDocument {
202 pub fn ast(&self) -> &FootprintAst {
203 &self.ast
204 }
205
206 pub fn ast_mut(&mut self) -> &mut FootprintAst {
207 self.ast_dirty = true;
208 &mut self.ast
209 }
210
211 pub fn set_lib_id<S: Into<String>>(&mut self, lib_id: S) -> &mut Self {
212 let lib_id = lib_id.into();
213 self.mutate_root_items(|items| {
214 let value = atom_quoted(lib_id);
215 if let Some(current) = items.get(1) {
216 if *current == value {
217 false
218 } else {
219 items[1] = value;
220 true
221 }
222 } else {
223 items.push(value);
224 true
225 }
226 })
227 }
228
229 pub fn set_version(&mut self, version: i32) -> &mut Self {
230 self.mutate_root_items(|items| {
231 upsert_scalar(items, "version", atom_symbol(version.to_string()), 2)
232 })
233 }
234
235 pub fn set_generator<S: Into<String>>(&mut self, generator: S) -> &mut Self {
236 self.mutate_root_items(|items| {
237 upsert_scalar(items, "generator", atom_symbol(generator.into()), 2)
238 })
239 }
240
241 pub fn set_generator_version<S: Into<String>>(&mut self, generator_version: S) -> &mut Self {
242 self.mutate_root_items(|items| {
243 upsert_scalar(
244 items,
245 "generator_version",
246 atom_quoted(generator_version.into()),
247 2,
248 )
249 })
250 }
251
252 pub fn set_layer<S: Into<String>>(&mut self, layer: S) -> &mut Self {
253 self.mutate_root_items(|items| upsert_scalar(items, "layer", atom_quoted(layer.into()), 2))
254 }
255
256 pub fn set_descr<S: Into<String>>(&mut self, descr: S) -> &mut Self {
257 self.mutate_root_items(|items| upsert_scalar(items, "descr", atom_quoted(descr.into()), 2))
258 }
259
260 pub fn set_tags<S: Into<String>>(&mut self, tags: S) -> &mut Self {
261 self.mutate_root_items(|items| upsert_scalar(items, "tags", atom_quoted(tags.into()), 2))
262 }
263
264 pub fn set_reference<S: Into<String>>(&mut self, value: S) -> &mut Self {
265 self.upsert_property("Reference", value)
266 }
267
268 pub fn set_value<S: Into<String>>(&mut self, value: S) -> &mut Self {
269 self.upsert_property("Value", value)
270 }
271
272 pub fn upsert_property<K: Into<String>, V: Into<String>>(
273 &mut self,
274 key: K,
275 value: V,
276 ) -> &mut Self {
277 let key = key.into();
278 let value = value.into();
279 self.mutate_root_items(|items| upsert_property_preserve_tail(items, &key, &value, 2))
280 }
281
282 pub fn remove_property(&mut self, key: &str) -> &mut Self {
283 let key = key.to_string();
284 self.mutate_root_items(|items| remove_property_node(items, &key, 2))
285 }
286
287 pub fn cst(&self) -> &CstDocument {
288 &self.cst
289 }
290
291 pub fn diagnostics(&self) -> &[Diagnostic] {
292 &self.diagnostics
293 }
294
295 pub fn write<P: AsRef<Path>>(&self, path: P) -> Result<(), Error> {
296 self.write_mode(path, WriteMode::Lossless)
297 }
298
299 pub fn write_mode<P: AsRef<Path>>(&self, path: P, mode: WriteMode) -> Result<(), Error> {
300 if self.ast_dirty {
301 return Err(Error::Validation(
302 "ast_mut changes are not serializable; use document setter APIs".to_string(),
303 ));
304 }
305 match mode {
306 WriteMode::Lossless => fs::write(path, self.cst.to_lossless_string())?,
307 WriteMode::Canonical => fs::write(path, self.cst.to_canonical_string())?,
308 }
309 Ok(())
310 }
311
312 fn mutate_root_items<F>(&mut self, mutate: F) -> &mut Self
313 where
314 F: FnOnce(&mut Vec<Node>) -> bool,
315 {
316 mutate_root_and_refresh(
317 &mut self.cst,
318 &mut self.ast,
319 &mut self.diagnostics,
320 mutate,
321 parse_ast,
322 |cst, ast| collect_diagnostics(cst, ast.version),
323 );
324 self.ast_dirty = false;
325 self
326 }
327}
328
329pub struct FootprintFile;
330
331impl FootprintFile {
332 pub fn read<P: AsRef<Path>>(path: P) -> Result<FootprintDocument, Error> {
333 let raw = fs::read_to_string(path)?;
334 let cst = parse_one(&raw)?;
335 ensure_root_head_any(&cst, &["footprint", "module"])?;
336 let ast = parse_ast(&cst);
337 let diagnostics = collect_diagnostics(&cst, ast.version);
338 Ok(FootprintDocument {
339 ast,
340 cst,
341 diagnostics,
342 ast_dirty: false,
343 })
344 }
345}
346
347fn collect_diagnostics(cst: &CstDocument, version: Option<i32>) -> Vec<Diagnostic> {
348 let mut diagnostics = collect_version_diagnostics(version);
349 if root_head(cst) == Some("module") {
350 diagnostics.push(Diagnostic {
351 severity: Severity::Warning,
352 code: "legacy_root",
353 message: "legacy root token `module` detected; parsing in compatibility mode"
354 .to_string(),
355 span: None,
356 hint: Some("save from newer KiCad to normalize root token to `footprint`".to_string()),
357 });
358 }
359 diagnostics
360}
361
362fn parse_ast(cst: &CstDocument) -> FootprintAst {
363 let mut lib_id = None;
364 let mut version = None;
365 let mut tedit = None;
366 let mut generator = None;
367 let mut generator_version = None;
368 let mut layer = None;
369 let mut descr = None;
370 let mut tags = None;
371 let mut property_count = 0usize;
372 let mut attr_present = false;
373 let mut locked_present = false;
374 let mut private_layers_present = false;
375 let mut net_tie_pad_groups_present = false;
376 let mut embedded_fonts_present = false;
377 let mut has_embedded_files = false;
378 let mut embedded_file_count = 0usize;
379 let mut clearance = None;
380 let mut solder_mask_margin = None;
381 let mut solder_paste_margin = None;
382 let mut solder_paste_margin_ratio = None;
383 let mut duplicate_pad_numbers_are_jumpers = None;
384 let mut pad_count = 0usize;
385 let mut pads = Vec::new();
386 let mut model_count = 0usize;
387 let mut models = Vec::new();
388 let mut zone_count = 0usize;
389 let mut zones = Vec::new();
390 let mut group_count = 0usize;
391 let mut groups = Vec::new();
392 let mut fp_line_count = 0usize;
393 let mut fp_rect_count = 0usize;
394 let mut fp_circle_count = 0usize;
395 let mut fp_arc_count = 0usize;
396 let mut fp_poly_count = 0usize;
397 let mut fp_curve_count = 0usize;
398 let mut fp_text_count = 0usize;
399 let mut fp_text_box_count = 0usize;
400 let mut dimension_count = 0usize;
401 let mut graphic_count = 0usize;
402 let mut graphics = Vec::new();
403 let mut attr = Vec::new();
404 let mut locked = false;
405 let mut placed = false;
406 let mut private_layers = Vec::new();
407 let mut net_tie_pad_groups = Vec::new();
408 let mut reference = None;
409 let mut value = None;
410 let mut properties = Vec::new();
411 let mut unknown_nodes = Vec::new();
412
413 if let Some(Node::List { items, .. }) = cst.nodes.first() {
414 lib_id = items.get(1).and_then(atom_as_string);
415 for item in items.iter().skip(2) {
416 match head_of(item) {
417 Some("version") => version = second_atom_i32(item),
418 Some("tedit") => tedit = second_atom_string(item),
419 Some("generator") => generator = second_atom_string(item),
420 Some("generator_version") => generator_version = second_atom_string(item),
421 Some("layer") => layer = second_atom_string(item),
422 Some("descr") => descr = second_atom_string(item),
423 Some("tags") => tags = second_atom_string(item),
424 Some("property") => {
425 property_count += 1;
426 if let Node::List { items: props, .. } = item {
427 let key = props.get(1).and_then(atom_as_string);
428 let val = props.get(2).and_then(atom_as_string);
429 if let (Some(k), Some(v)) = (key.clone(), val.clone()) {
430 properties.push(FpProperty { key: k, value: v });
431 }
432 match key.as_deref() {
433 Some("Reference") => reference = val,
434 Some("Value") => value = val,
435 _ => {}
436 }
437 }
438 }
439 Some("attr") => {
440 attr_present = true;
441 if let Node::List { items: inner, .. } = item {
442 attr = inner.iter().skip(1).filter_map(atom_as_string).collect();
443 }
444 }
445 Some("locked") => {
446 locked_present = true;
447 locked = true;
448 }
449 Some("placed") => placed = true,
450 Some("private_layers") => {
451 private_layers_present = true;
452 if let Node::List { items: inner, .. } = item {
453 private_layers = inner.iter().skip(1).filter_map(atom_as_string).collect();
454 }
455 }
456 Some("net_tie_pad_groups") => {
457 net_tie_pad_groups_present = true;
458 if let Node::List { items: inner, .. } = item {
459 for child in inner.iter().skip(1) {
460 if let Node::List { items: grp, .. } = child {
461 let group: Vec<String> =
462 grp.iter().filter_map(atom_as_string).collect();
463 if !group.is_empty() {
464 net_tie_pad_groups.push(group);
465 }
466 }
467 }
468 }
469 }
470 Some("embedded_fonts") => embedded_fonts_present = true,
471 Some("embedded_files") => {
472 has_embedded_files = true;
473 embedded_file_count = list_child_head_count(item, "file");
474 }
475 Some("clearance") => clearance = second_atom_string(item),
476 Some("solder_mask_margin") => solder_mask_margin = second_atom_string(item),
477 Some("solder_paste_margin") => solder_paste_margin = second_atom_string(item),
478 Some("solder_paste_margin_ratio") => {
479 solder_paste_margin_ratio = second_atom_string(item)
480 }
481 Some("duplicate_pad_numbers_are_jumpers") => {
482 duplicate_pad_numbers_are_jumpers =
483 second_atom_string(item).and_then(|s| match s.as_str() {
484 "yes" => Some(true),
485 "no" => Some(false),
486 _ => None,
487 })
488 }
489 Some("pad") => {
490 pad_count += 1;
491 pads.push(parse_fp_pad(item));
492 }
493 Some("model") => {
494 model_count += 1;
495 models.push(parse_fp_model(item));
496 }
497 Some("zone") => {
498 zone_count += 1;
499 zones.push(parse_fp_zone(item));
500 }
501 Some("group") => {
502 group_count += 1;
503 groups.push(parse_fp_group(item));
504 }
505 Some("fp_line") => {
506 fp_line_count += 1;
507 graphic_count += 1;
508 graphics.push(parse_fp_graphic(item, "fp_line"));
509 }
510 Some("fp_rect") => {
511 fp_rect_count += 1;
512 graphic_count += 1;
513 graphics.push(parse_fp_graphic(item, "fp_rect"));
514 }
515 Some("fp_circle") => {
516 fp_circle_count += 1;
517 graphic_count += 1;
518 graphics.push(parse_fp_graphic(item, "fp_circle"));
519 }
520 Some("fp_arc") => {
521 fp_arc_count += 1;
522 graphic_count += 1;
523 graphics.push(parse_fp_graphic(item, "fp_arc"));
524 }
525 Some("fp_poly") => {
526 fp_poly_count += 1;
527 graphic_count += 1;
528 graphics.push(parse_fp_graphic(item, "fp_poly"));
529 }
530 Some("fp_curve") => {
531 fp_curve_count += 1;
532 graphic_count += 1;
533 graphics.push(parse_fp_graphic(item, "fp_curve"));
534 }
535 Some("fp_text") => {
536 fp_text_count += 1;
537 graphic_count += 1;
538 graphics.push(parse_fp_graphic(item, "fp_text"));
539 }
540 Some("fp_text_box") => {
541 fp_text_box_count += 1;
542 graphic_count += 1;
543 graphics.push(parse_fp_graphic(item, "fp_text_box"));
544 }
545 Some("dimension") => dimension_count += 1,
546 _ => {
547 if let Some(unknown) = UnknownNode::from_node(item) {
548 unknown_nodes.push(unknown);
549 }
550 }
551 }
552 }
553 }
554
555 FootprintAst {
556 lib_id,
557 version,
558 tedit,
559 generator,
560 generator_version,
561 layer,
562 descr,
563 tags,
564 property_count,
565 attr_present,
566 locked_present,
567 private_layers_present,
568 net_tie_pad_groups_present,
569 embedded_fonts_present,
570 has_embedded_files,
571 embedded_file_count,
572 clearance,
573 solder_mask_margin,
574 solder_paste_margin,
575 solder_paste_margin_ratio,
576 duplicate_pad_numbers_are_jumpers,
577 pad_count,
578 pads,
579 model_count,
580 models,
581 zone_count,
582 zones,
583 group_count,
584 groups,
585 fp_line_count,
586 fp_rect_count,
587 fp_circle_count,
588 fp_arc_count,
589 fp_poly_count,
590 fp_curve_count,
591 fp_text_count,
592 fp_text_box_count,
593 dimension_count,
594 graphic_count,
595 graphics,
596 attr,
597 locked,
598 placed,
599 private_layers,
600 net_tie_pad_groups,
601 reference,
602 value,
603 properties,
604 unknown_nodes,
605 }
606}
607
608fn parse_fp_pad(node: &Node) -> FpPad {
609 let Node::List { items, .. } = node else {
610 return FpPad {
611 number: None,
612 pad_type: None,
613 shape: None,
614 at: None,
615 rotation: None,
616 size: None,
617 layers: Vec::new(),
618 net: None,
619 drill: None,
620 uuid: None,
621 pin_function: None,
622 pin_type: None,
623 locked: false,
624 property: None,
625 remove_unused_layers: false,
626 keep_end_layers: false,
627 roundrect_rratio: None,
628 chamfer_ratio: None,
629 chamfer: Vec::new(),
630 die_length: None,
631 solder_mask_margin: None,
632 solder_paste_margin: None,
633 solder_paste_margin_ratio: None,
634 clearance: None,
635 zone_connect: None,
636 thermal_width: None,
637 thermal_gap: None,
638 custom_clearance: None,
639 custom_anchor: None,
640 custom_primitives: 0,
641 };
642 };
643 let number = items.get(1).and_then(atom_as_string);
644 let pad_type = items.get(2).and_then(atom_as_string);
645 let shape = items.get(3).and_then(atom_as_string);
646 let mut at = None;
647 let mut rotation = None;
648 let mut size = None;
649 let mut layers = Vec::new();
650 let mut net = None;
651 let mut drill = None;
652 let mut uuid = None;
653 let mut pin_function = None;
654 let mut pin_type = None;
655 let mut locked = items
656 .iter()
657 .any(|n| matches!(n, Node::Atom { atom: Atom::Symbol(s), .. } if s == "locked"));
658 let mut property = None;
659 let mut remove_unused_layers = false;
660 let mut keep_end_layers = false;
661 let mut roundrect_rratio = None;
662 let mut chamfer_ratio = None;
663 let mut chamfer = Vec::new();
664 let mut die_length = None;
665 let mut solder_mask_margin = None;
666 let mut solder_paste_margin = None;
667 let mut solder_paste_margin_ratio = None;
668 let mut clearance = None;
669 let mut zone_connect = None;
670 let mut thermal_width = None;
671 let mut thermal_gap = None;
672 let mut custom_clearance = None;
673 let mut custom_anchor = None;
674 let mut custom_primitives = 0usize;
675
676 for child in items.iter().skip(4) {
677 match head_of(child) {
678 Some("at") => {
679 let (xy, rot) = parse_fp_xy_and_angle(child);
680 at = xy;
681 rotation = rot;
682 }
683 Some("size") => size = parse_fp_xy(child),
684 Some("layers") => {
685 if let Node::List { items: inner, .. } = child {
686 layers = inner.iter().skip(1).filter_map(atom_as_string).collect();
687 }
688 }
689 Some("net") => {
690 if let Node::List { items: inner, .. } = child {
691 net = Some(FpPadNet {
692 code: inner
693 .get(1)
694 .and_then(atom_as_string)
695 .and_then(|s| s.parse().ok()),
696 name: inner.get(2).and_then(atom_as_string),
697 });
698 }
699 }
700 Some("drill") => drill = Some(parse_fp_drill(child)),
701 Some("uuid") => uuid = second_atom_string(child),
702 Some("pinfunction") => pin_function = second_atom_string(child),
703 Some("pintype") => pin_type = second_atom_string(child),
704 Some("locked") => locked = true,
705 Some("property") => property = second_atom_string(child),
706 Some("remove_unused_layer") | Some("remove_unused_layers") => {
707 remove_unused_layers = true
708 }
709 Some("keep_end_layers") => keep_end_layers = true,
710 Some("roundrect_rratio") => roundrect_rratio = second_atom_f64(child),
711 Some("chamfer_ratio") => chamfer_ratio = second_atom_f64(child),
712 Some("chamfer") => {
713 if let Node::List { items: inner, .. } = child {
714 chamfer = inner.iter().skip(1).filter_map(atom_as_string).collect();
715 }
716 }
717 Some("die_length") => die_length = second_atom_f64(child),
718 Some("solder_mask_margin") => solder_mask_margin = second_atom_f64(child),
719 Some("solder_paste_margin") => solder_paste_margin = second_atom_f64(child),
720 Some("solder_paste_margin_ratio") => solder_paste_margin_ratio = second_atom_f64(child),
721 Some("clearance") => clearance = second_atom_f64(child),
722 Some("zone_connect") => {
723 zone_connect = second_atom_string(child).and_then(|s| s.parse().ok())
724 }
725 Some("thermal_width") => thermal_width = second_atom_f64(child),
726 Some("thermal_gap") => thermal_gap = second_atom_f64(child),
727 Some("options") => {
728 if let Node::List { items: inner, .. } = child {
729 for opt in inner.iter().skip(1) {
730 match head_of(opt) {
731 Some("clearance") => custom_clearance = second_atom_string(opt),
732 Some("anchor") => custom_anchor = second_atom_string(opt),
733 _ => {}
734 }
735 }
736 }
737 }
738 Some("primitives") => {
739 if let Node::List { items: inner, .. } = child {
740 custom_primitives = inner.len().saturating_sub(1);
741 }
742 }
743 _ => {}
744 }
745 }
746 FpPad {
747 number,
748 pad_type,
749 shape,
750 at,
751 rotation,
752 size,
753 layers,
754 net,
755 drill,
756 uuid,
757 pin_function,
758 pin_type,
759 locked,
760 property,
761 remove_unused_layers,
762 keep_end_layers,
763 roundrect_rratio,
764 chamfer_ratio,
765 chamfer,
766 die_length,
767 solder_mask_margin,
768 solder_paste_margin,
769 solder_paste_margin_ratio,
770 clearance,
771 zone_connect,
772 thermal_width,
773 thermal_gap,
774 custom_clearance,
775 custom_anchor,
776 custom_primitives,
777 }
778}
779
780fn parse_fp_drill(node: &Node) -> FpPadDrill {
781 let Node::List { items, .. } = node else {
782 return FpPadDrill {
783 shape: None,
784 diameter: None,
785 width: None,
786 offset: None,
787 };
788 };
789 let mut shape = None;
790 let mut diameter = None;
791 let mut width = None;
792 let mut offset = None;
793 for child in items.iter().skip(1) {
794 match child {
795 Node::List { .. } => {
796 if matches!(head_of(child), Some("offset")) {
797 offset = parse_fp_xy(child);
798 }
799 }
800 Node::Atom { .. } => {
801 if let Some(value) = atom_as_f64(child) {
802 if diameter.is_none() {
803 diameter = Some(value);
804 } else if width.is_none() {
805 width = Some(value);
806 }
807 } else if let Some(token) = atom_as_string(child) {
808 shape = Some(token);
809 }
810 }
811 }
812 }
813 FpPadDrill {
814 shape,
815 diameter,
816 width,
817 offset,
818 }
819}
820
821fn parse_fp_graphic(node: &Node, token: &str) -> FpGraphic {
822 let mut layer = None;
823 let mut text = None;
824 let mut start = None;
825 let mut end = None;
826 let mut center = None;
827 let mut uuid = None;
828 let mut locked = false;
829 let mut width = None;
830 let mut stroke_type = None;
831 let mut fill_type = None;
832 let mut at = None;
833 let mut angle = None;
834 let mut font_size = None;
835 let mut font_thickness = None;
836 if let Node::List { items, .. } = node {
837 if matches!(token, "fp_text" | "fp_text_box") {
838 text = items.get(1).and_then(atom_as_string);
839 }
840 locked = items
841 .iter()
842 .any(|n| matches!(n, Node::Atom { atom: Atom::Symbol(s), .. } if s == "locked"));
843 for child in items.iter().skip(1) {
844 match head_of(child) {
845 Some("layer") => layer = second_atom_string(child),
846 Some("start") => start = parse_fp_xy(child),
847 Some("end") => end = parse_fp_xy(child),
848 Some("center") => center = parse_fp_xy(child),
849 Some("uuid") => uuid = second_atom_string(child),
850 Some("locked") => locked = true,
851 Some("width") => width = second_atom_f64(child),
852 Some("stroke") => {
853 if let Node::List { items: inner, .. } = child {
854 for s in inner.iter().skip(1) {
855 match head_of(s) {
856 Some("width") => width = second_atom_f64(s),
857 Some("type") => stroke_type = second_atom_string(s),
858 _ => {}
859 }
860 }
861 }
862 }
863 Some("fill") => {
864 if let Node::List { items: inner, .. } = child {
865 for f in inner.iter().skip(1) {
866 if head_of(f) == Some("type") {
867 fill_type = second_atom_string(f);
868 }
869 }
870 }
871 }
872 Some("at") => {
873 let (xy, rot) = parse_fp_xy_and_angle(child);
874 at = xy;
875 angle = rot;
876 }
877 Some("effects") => {
878 if let Node::List { items: inner, .. } = child {
879 for e in inner.iter().skip(1) {
880 if head_of(e) == Some("font") {
881 if let Node::List {
882 items: font_items, ..
883 } = e
884 {
885 for fi in font_items.iter().skip(1) {
886 match head_of(fi) {
887 Some("size") => font_size = parse_fp_xy(fi),
888 Some("thickness") => {
889 font_thickness = second_atom_f64(fi)
890 }
891 _ => {}
892 }
893 }
894 }
895 }
896 }
897 }
898 }
899 _ => {}
900 }
901 }
902 }
903 FpGraphic {
904 token: token.to_string(),
905 layer,
906 text,
907 start,
908 end,
909 center,
910 uuid,
911 locked,
912 width,
913 stroke_type,
914 fill_type,
915 at,
916 angle,
917 font_size,
918 font_thickness,
919 }
920}
921
922fn parse_fp_model(node: &Node) -> FpModel {
923 let Node::List { items, .. } = node else {
924 return FpModel {
925 path: None,
926 at: None,
927 scale: None,
928 rotate: None,
929 hide: false,
930 };
931 };
932 let path = items.get(1).and_then(atom_as_string);
933 let mut at = None;
934 let mut scale = None;
935 let mut rotate = None;
936 let hide = items
937 .iter()
938 .any(|n| matches!(n, Node::Atom { atom: Atom::Symbol(s), .. } if s == "hide"));
939 for child in items.iter().skip(2) {
940 match head_of(child) {
941 Some("at") | Some("offset") => at = parse_fp_model_xyz(child),
942 Some("scale") => scale = parse_fp_model_xyz(child),
943 Some("rotate") => rotate = parse_fp_model_xyz(child),
944 _ => {}
945 }
946 }
947 FpModel {
948 path,
949 at,
950 scale,
951 rotate,
952 hide,
953 }
954}
955
956fn parse_fp_model_xyz(node: &Node) -> Option<[f64; 3]> {
957 let Node::List { items, .. } = node else {
958 return None;
959 };
960 for child in items.iter().skip(1) {
961 if head_of(child) == Some("xyz") {
962 if let Node::List {
963 items: xyz_items, ..
964 } = child
965 {
966 let x = xyz_items.get(1).and_then(atom_as_f64)?;
967 let y = xyz_items.get(2).and_then(atom_as_f64)?;
968 let z = xyz_items.get(3).and_then(atom_as_f64)?;
969 return Some([x, y, z]);
970 }
971 }
972 }
973 None
974}
975
976fn parse_fp_zone(node: &Node) -> FpZone {
977 let mut net = None;
978 let mut net_name = None;
979 let mut name = None;
980 let mut layer = None;
981 let mut layers = Vec::new();
982 let mut hatch = None;
983 let mut fill_enabled = None;
984 let mut polygon_count = 0usize;
985 let mut filled_polygon_count = 0usize;
986 let mut has_keepout = false;
987 if let Node::List { items, .. } = node {
988 for child in items.iter().skip(1) {
989 match head_of(child) {
990 Some("net") => net = second_atom_string(child).and_then(|s| s.parse().ok()),
991 Some("net_name") => net_name = second_atom_string(child),
992 Some("name") => name = second_atom_string(child),
993 Some("layer") => layer = second_atom_string(child),
994 Some("layers") => {
995 if let Node::List { items: inner, .. } = child {
996 layers = inner.iter().skip(1).filter_map(atom_as_string).collect();
997 }
998 }
999 Some("hatch") => hatch = second_atom_string(child),
1000 Some("fill") => fill_enabled = second_atom_bool(child),
1001 Some("polygon") => polygon_count += 1,
1002 Some("filled_polygon") => filled_polygon_count += 1,
1003 Some("keepout") => has_keepout = true,
1004 _ => {}
1005 }
1006 }
1007 }
1008 FpZone {
1009 net,
1010 net_name,
1011 name,
1012 layer,
1013 layers,
1014 hatch,
1015 fill_enabled,
1016 polygon_count,
1017 filled_polygon_count,
1018 has_keepout,
1019 }
1020}
1021
1022fn parse_fp_group(node: &Node) -> FpGroup {
1023 let mut name = None;
1024 let mut group_id = None;
1025 let mut member_count = 0usize;
1026 if let Node::List { items, .. } = node {
1027 for child in items.iter().skip(1) {
1028 match head_of(child) {
1029 Some("name") => name = second_atom_string(child),
1030 Some("id") => group_id = second_atom_string(child),
1031 Some("members") => {
1032 if let Node::List { items: inner, .. } = child {
1033 member_count = inner.len().saturating_sub(1);
1034 }
1035 }
1036 _ => {}
1037 }
1038 }
1039 }
1040 FpGroup {
1041 name,
1042 group_id,
1043 member_count,
1044 }
1045}
1046
1047fn parse_fp_xy(node: &Node) -> Option<[f64; 2]> {
1048 let Node::List { items, .. } = node else {
1049 return None;
1050 };
1051 let x = items.get(1).and_then(atom_as_string)?.parse::<f64>().ok()?;
1052 let y = items.get(2).and_then(atom_as_string)?.parse::<f64>().ok()?;
1053 Some([x, y])
1054}
1055
1056fn parse_fp_xy_and_angle(node: &Node) -> (Option<[f64; 2]>, Option<f64>) {
1057 let Node::List { items, .. } = node else {
1058 return (None, None);
1059 };
1060 let x = items.get(1).and_then(atom_as_f64);
1061 let y = items.get(2).and_then(atom_as_f64);
1062 let rot = items.get(3).and_then(atom_as_f64);
1063 match (x, y) {
1064 (Some(x), Some(y)) => (Some([x, y]), rot),
1065 _ => (None, rot),
1066 }
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071 use std::path::PathBuf;
1072 use std::time::{SystemTime, UNIX_EPOCH};
1073
1074 use super::*;
1075
1076 fn tmp_file(name: &str) -> PathBuf {
1077 let nanos = SystemTime::now()
1078 .duration_since(UNIX_EPOCH)
1079 .expect("clock")
1080 .as_nanos();
1081 std::env::temp_dir().join(format!("{name}_{nanos}.kicad_mod"))
1082 }
1083
1084 #[test]
1085 fn read_footprint_and_preserve_lossless() {
1086 let path = tmp_file("footprint_read_ok");
1087 let src = "(footprint \"R_0603\" (version 20260101) (generator pcbnew))\n";
1088 fs::write(&path, src).expect("write fixture");
1089
1090 let doc = FootprintFile::read(&path).expect("read");
1091 assert_eq!(doc.ast().lib_id.as_deref(), Some("R_0603"));
1092 assert_eq!(doc.ast().version, Some(20260101));
1093 assert_eq!(doc.ast().generator.as_deref(), Some("pcbnew"));
1094 assert!(doc.ast().unknown_nodes.is_empty());
1095 assert_eq!(doc.cst().to_lossless_string(), src);
1096
1097 let _ = fs::remove_file(path);
1098 }
1099
1100 #[test]
1101 fn read_footprint_warns_on_future_version() {
1102 let path = tmp_file("footprint_future");
1103 fs::write(
1104 &path,
1105 "(footprint \"R\" (version 20270101) (generator pcbnew))\n",
1106 )
1107 .expect("write fixture");
1108
1109 let doc = FootprintFile::read(&path).expect("read");
1110 assert_eq!(doc.diagnostics().len(), 1);
1111
1112 let _ = fs::remove_file(path);
1113 }
1114
1115 #[test]
1116 fn read_footprint_warns_on_legacy_version() {
1117 let path = tmp_file("footprint_legacy");
1118 fs::write(
1119 &path,
1120 "(footprint \"R\" (version 20221018) (generator pcbnew))\n",
1121 )
1122 .expect("write fixture");
1123
1124 let doc = FootprintFile::read(&path).expect("read");
1125 assert_eq!(doc.diagnostics().len(), 1);
1126 assert_eq!(doc.diagnostics()[0].code, "legacy_format");
1127
1128 let _ = fs::remove_file(path);
1129 }
1130
1131 #[test]
1132 fn read_footprint_accepts_legacy_module_root() {
1133 let path = tmp_file("footprint_module_root");
1134 let src = "(module R_0603 (layer F.Cu) (tedit 5F0C7995) (attr smd))\n";
1135 fs::write(&path, src).expect("write fixture");
1136
1137 let doc = FootprintFile::read(&path).expect("read");
1138 assert_eq!(doc.ast().lib_id.as_deref(), Some("R_0603"));
1139 assert_eq!(doc.ast().tedit.as_deref(), Some("5F0C7995"));
1140 assert!(doc.ast().attr_present);
1141 assert_eq!(doc.diagnostics().len(), 1);
1142 assert_eq!(doc.diagnostics()[0].code, "legacy_root");
1143
1144 let _ = fs::remove_file(path);
1145 }
1146
1147 #[test]
1148 fn read_footprint_captures_unknown_nodes() {
1149 let path = tmp_file("footprint_unknown");
1150 let src =
1151 "(footprint \"R\" (version 20260101) (generator pcbnew) (future_shape foo bar))\n";
1152 fs::write(&path, src).expect("write fixture");
1153
1154 let doc = FootprintFile::read(&path).expect("read");
1155 assert_eq!(doc.ast().unknown_nodes.len(), 1);
1156 assert_eq!(
1157 doc.ast().unknown_nodes[0].head.as_deref(),
1158 Some("future_shape")
1159 );
1160
1161 let _ = fs::remove_file(path);
1162 }
1163
1164 #[test]
1165 fn read_footprint_parses_top_level_counts() {
1166 let path = tmp_file("footprint_counts");
1167 let src = "(footprint \"X\" (version 20260101) (generator pcbnew) (generator_version \"10.0\") (layer \"F.Cu\")\n (descr \"demo\")\n (tags \"a b\")\n (property \"Reference\" \"R?\")\n (property \"Value\" \"X\")\n (attr smd)\n (private_layers \"In1.Cu\")\n (net_tie_pad_groups \"1,2\")\n (solder_mask_margin 0.02)\n (solder_paste_margin -0.01)\n (solder_paste_margin_ratio -0.2)\n (duplicate_pad_numbers_are_jumpers yes)\n (fp_text reference \"R1\" (at 0 0) (layer \"F.SilkS\"))\n (fp_line (start 0 0) (end 1 1) (layer \"F.SilkS\"))\n (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\" \"F.Mask\"))\n (model \"foo.step\")\n (zone)\n (group (id \"g1\"))\n (dimension)\n)\n";
1168 fs::write(&path, src).expect("write fixture");
1169
1170 let doc = FootprintFile::read(&path).expect("read");
1171 assert_eq!(doc.ast().lib_id.as_deref(), Some("X"));
1172 assert_eq!(doc.ast().generator_version.as_deref(), Some("10.0"));
1173 assert_eq!(doc.ast().layer.as_deref(), Some("F.Cu"));
1174 assert_eq!(doc.ast().property_count, 2);
1175 assert!(doc.ast().attr_present);
1176 assert!(!doc.ast().locked_present);
1177 assert!(doc.ast().private_layers_present);
1178 assert!(doc.ast().net_tie_pad_groups_present);
1179 assert!(!doc.ast().embedded_fonts_present);
1180 assert!(!doc.ast().has_embedded_files);
1181 assert_eq!(doc.ast().embedded_file_count, 0);
1182 assert_eq!(doc.ast().clearance, None);
1183 assert_eq!(doc.ast().solder_mask_margin.as_deref(), Some("0.02"));
1184 assert_eq!(doc.ast().solder_paste_margin.as_deref(), Some("-0.01"));
1185 assert_eq!(doc.ast().solder_paste_margin_ratio.as_deref(), Some("-0.2"));
1186 assert_eq!(doc.ast().duplicate_pad_numbers_are_jumpers, Some(true));
1187 assert_eq!(doc.ast().fp_text_count, 1);
1188 assert_eq!(doc.ast().fp_line_count, 1);
1189 assert_eq!(doc.ast().graphic_count, 2);
1190 assert_eq!(doc.ast().pad_count, 1);
1191 assert_eq!(doc.ast().model_count, 1);
1192 assert_eq!(doc.ast().zone_count, 1);
1193 assert_eq!(doc.ast().group_count, 1);
1194 assert_eq!(doc.ast().dimension_count, 1);
1195 assert!(doc.ast().unknown_nodes.is_empty());
1196
1197 let _ = fs::remove_file(path);
1198 }
1199
1200 #[test]
1201 fn parses_embedded_fonts_regression() {
1202 let path = tmp_file("footprint_embedded_fonts");
1203 let src = "(footprint \"X\" (version 20260101) (generator pcbnew) (embedded_fonts no))\n";
1204 fs::write(&path, src).expect("write fixture");
1205
1206 let doc = FootprintFile::read(&path).expect("read");
1207 assert!(doc.ast().embedded_fonts_present);
1208 assert!(doc.ast().unknown_nodes.is_empty());
1209
1210 let _ = fs::remove_file(path);
1211 }
1212
1213 #[test]
1214 fn parses_locked_regression() {
1215 let path = tmp_file("footprint_locked");
1216 let src = "(footprint \"X\" (locked) (version 20260101) (generator pcbnew))\n";
1217 fs::write(&path, src).expect("write fixture");
1218
1219 let doc = FootprintFile::read(&path).expect("read");
1220 assert!(doc.ast().locked_present);
1221 assert!(doc.ast().unknown_nodes.is_empty());
1222
1223 let _ = fs::remove_file(path);
1224 }
1225
1226 #[test]
1227 fn parses_solder_margins_and_jumpers_regression() {
1228 let path = tmp_file("footprint_margins_jumpers");
1229 let src = "(footprint \"X\" (version 20260101) (generator pcbnew)\n (clearance 0.15)\n (solder_mask_margin 0.03)\n (solder_paste_margin -0.02)\n (solder_paste_margin_ratio -0.3)\n (duplicate_pad_numbers_are_jumpers no)\n)\n";
1230 fs::write(&path, src).expect("write fixture");
1231
1232 let doc = FootprintFile::read(&path).expect("read");
1233 assert_eq!(doc.ast().clearance.as_deref(), Some("0.15"));
1234 assert_eq!(doc.ast().solder_mask_margin.as_deref(), Some("0.03"));
1235 assert_eq!(doc.ast().solder_paste_margin.as_deref(), Some("-0.02"));
1236 assert_eq!(doc.ast().solder_paste_margin_ratio.as_deref(), Some("-0.3"));
1237 assert_eq!(doc.ast().duplicate_pad_numbers_are_jumpers, Some(false));
1238 assert!(doc.ast().unknown_nodes.is_empty());
1239
1240 let _ = fs::remove_file(path);
1241 }
1242
1243 #[test]
1244 fn parses_embedded_files_regression() {
1245 let path = tmp_file("footprint_embedded_files");
1246 let src = "(footprint \"X\" (version 20260101) (generator pcbnew)\n (embedded_files\n (file \"A\" \"base64\")\n (file \"B\" \"base64\")\n )\n)\n";
1247 fs::write(&path, src).expect("write fixture");
1248
1249 let doc = FootprintFile::read(&path).expect("read");
1250 assert!(doc.ast().has_embedded_files);
1251 assert_eq!(doc.ast().embedded_file_count, 2);
1252 assert!(doc.ast().unknown_nodes.is_empty());
1253
1254 let _ = fs::remove_file(path);
1255 }
1256
1257 #[test]
1258 fn edit_roundtrip_updates_core_fields_and_properties() {
1259 let path = tmp_file("footprint_edit_input");
1260 let src = "(footprint \"Old\" (version 20241229) (generator pcbnew) (layer \"F.Cu\")\n (property \"Reference\" \"R1\")\n (property \"Value\" \"10k\")\n (future_shape foo bar)\n)\n";
1261 fs::write(&path, src).expect("write fixture");
1262
1263 let mut doc = FootprintFile::read(&path).expect("read");
1264 doc.set_lib_id("New_Footprint")
1265 .set_version(20260101)
1266 .set_generator("kiutils")
1267 .set_generator_version("dev")
1268 .set_layer("B.Cu")
1269 .set_descr("demo footprint")
1270 .set_tags("r c passives")
1271 .set_reference("R99")
1272 .set_value("22k")
1273 .upsert_property("LCSC", "C1234")
1274 .remove_property("DoesNotExist");
1275
1276 let out = tmp_file("footprint_edit_output");
1277 doc.write(&out).expect("write");
1278 let written = fs::read_to_string(&out).expect("read out");
1279 assert!(written.contains("(future_shape foo bar)"));
1280 assert!(written.contains("(property \"LCSC\" \"C1234\")"));
1281
1282 let reread = FootprintFile::read(&out).expect("reread");
1283 assert_eq!(reread.ast().lib_id.as_deref(), Some("New_Footprint"));
1284 assert_eq!(reread.ast().version, Some(20260101));
1285 assert_eq!(reread.ast().generator.as_deref(), Some("kiutils"));
1286 assert_eq!(reread.ast().generator_version.as_deref(), Some("dev"));
1287 assert_eq!(reread.ast().layer.as_deref(), Some("B.Cu"));
1288 assert_eq!(reread.ast().descr.as_deref(), Some("demo footprint"));
1289 assert_eq!(reread.ast().tags.as_deref(), Some("r c passives"));
1290 assert_eq!(reread.ast().property_count, 3);
1291 assert_eq!(reread.ast().unknown_nodes.len(), 1);
1292
1293 let _ = fs::remove_file(path);
1294 let _ = fs::remove_file(out);
1295 }
1296
1297 #[test]
1298 fn remove_property_roundtrip_removes_entry() {
1299 let path = tmp_file("footprint_remove_property");
1300 let src = "(footprint \"X\" (version 20260101) (generator pcbnew)\n (property \"Reference\" \"R1\")\n (property \"Value\" \"10k\")\n)\n";
1301 fs::write(&path, src).expect("write fixture");
1302
1303 let mut doc = FootprintFile::read(&path).expect("read");
1304 doc.remove_property("Value");
1305
1306 let out = tmp_file("footprint_remove_property_out");
1307 doc.write(&out).expect("write");
1308 let reread = FootprintFile::read(&out).expect("reread");
1309 assert_eq!(reread.ast().property_count, 1);
1310
1311 let _ = fs::remove_file(path);
1312 let _ = fs::remove_file(out);
1313 }
1314
1315 #[test]
1316 fn no_op_setter_keeps_lossless_raw_unchanged() {
1317 let path = tmp_file("footprint_noop_setter");
1318 let src = "(footprint \"X\" (version 20260101) (generator pcbnew))\n";
1319 fs::write(&path, src).expect("write fixture");
1320
1321 let mut doc = FootprintFile::read(&path).expect("read");
1322 doc.set_version(20260101);
1323
1324 let out = tmp_file("footprint_noop_setter_out");
1325 doc.write(&out).expect("write");
1326 let written = fs::read_to_string(&out).expect("read out");
1327 assert_eq!(written, src);
1328
1329 let _ = fs::remove_file(path);
1330 let _ = fs::remove_file(out);
1331 }
1332}