Skip to main content

kiutils_kicad/
schematic.rs

1use std::fs;
2use std::path::Path;
3
4use kiutils_sexpr::{parse_one, Atom, CstDocument, Node};
5
6use crate::diagnostic::Diagnostic;
7use crate::sections::{parse_paper, parse_title_block, ParsedPaper, ParsedTitleBlock};
8use crate::sexpr_edit::{
9    atom_quoted, atom_symbol, ensure_root_head_any, find_property_index, mutate_root_and_refresh,
10    paper_standard_node, paper_user_node, remove_property, upsert_node,
11    upsert_property_preserve_tail, upsert_scalar, upsert_section_child_scalar,
12};
13use crate::sexpr_utils::{
14    atom_as_f64, atom_as_string, head_of, list_child_head_count, second_atom_bool, second_atom_f64,
15    second_atom_i32, second_atom_string,
16};
17use crate::version_diag::collect_version_diagnostics;
18use crate::{Error, UnknownNode, WriteMode};
19
20#[derive(Debug, Clone, PartialEq)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
22pub struct SchematicPaper {
23    pub kind: Option<String>,
24    pub width: Option<f64>,
25    pub height: Option<f64>,
26    pub orientation: Option<String>,
27}
28
29impl From<ParsedPaper> for SchematicPaper {
30    fn from(value: ParsedPaper) -> Self {
31        Self {
32            kind: value.kind,
33            width: value.width,
34            height: value.height,
35            orientation: value.orientation,
36        }
37    }
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
42pub struct SchematicTitleBlock {
43    pub title: Option<String>,
44    pub date: Option<String>,
45    pub revision: Option<String>,
46    pub company: Option<String>,
47    pub comments: Vec<String>,
48}
49
50impl From<ParsedTitleBlock> for SchematicTitleBlock {
51    fn from(value: ParsedTitleBlock) -> Self {
52        Self {
53            title: value.title,
54            date: value.date,
55            revision: value.revision,
56            company: value.company,
57            comments: value.comments,
58        }
59    }
60}
61
62/// Symbol instance details embedded in a schematic.
63#[derive(Debug, Clone, PartialEq, Eq)]
64#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
65pub struct SchematicSymbolInfo {
66    pub reference: Option<String>,
67    pub lib_id: Option<String>,
68    pub value: Option<String>,
69    pub footprint: Option<String>,
70    /// All properties as (key, value) pairs.
71    pub properties: Vec<(String, String)>,
72}
73
74#[derive(Debug, Clone, PartialEq)]
75#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
76pub struct SchematicJunction {
77    pub at: Option<[f64; 2]>,
78    pub diameter: Option<f64>,
79    pub color: Option<String>,
80    pub uuid: Option<String>,
81}
82
83#[derive(Debug, Clone, PartialEq)]
84#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
85pub struct SchematicNoConnect {
86    pub at: Option<[f64; 2]>,
87    pub uuid: Option<String>,
88}
89
90#[derive(Debug, Clone, PartialEq)]
91#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
92pub struct SchematicWire {
93    pub points: Vec<[f64; 2]>,
94    pub uuid: Option<String>,
95    pub stroke_width: Option<f64>,
96    pub stroke_type: Option<String>,
97}
98
99#[derive(Debug, Clone, PartialEq)]
100#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
101pub struct SchematicBus {
102    pub points: Vec<[f64; 2]>,
103    pub uuid: Option<String>,
104    pub stroke_width: Option<f64>,
105    pub stroke_type: Option<String>,
106}
107
108#[derive(Debug, Clone, PartialEq)]
109#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
110pub struct SchematicBusEntry {
111    pub at: Option<[f64; 2]>,
112    pub size: Option<[f64; 2]>,
113    pub uuid: Option<String>,
114}
115
116#[derive(Debug, Clone, PartialEq)]
117#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
118pub struct SchematicBusAlias {
119    pub name: Option<String>,
120    pub members: Vec<String>,
121}
122
123#[derive(Debug, Clone, PartialEq)]
124#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
125pub struct SchematicNetclassFlag {
126    pub text: Option<String>,
127    pub at: Option<[f64; 2]>,
128    pub angle: Option<f64>,
129    pub uuid: Option<String>,
130}
131
132#[derive(Debug, Clone, PartialEq)]
133#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
134pub struct SchematicPolyline {
135    pub points: Vec<[f64; 2]>,
136    pub uuid: Option<String>,
137}
138
139#[derive(Debug, Clone, PartialEq)]
140#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
141pub struct SchematicRectangle {
142    pub start: Option<[f64; 2]>,
143    pub end: Option<[f64; 2]>,
144    pub uuid: Option<String>,
145}
146
147#[derive(Debug, Clone, PartialEq)]
148#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
149pub struct SchematicCircle {
150    pub center: Option<[f64; 2]>,
151    pub end: Option<[f64; 2]>,
152    pub uuid: Option<String>,
153}
154
155#[derive(Debug, Clone, PartialEq)]
156#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
157pub struct SchematicArc {
158    pub start: Option<[f64; 2]>,
159    pub mid: Option<[f64; 2]>,
160    pub end: Option<[f64; 2]>,
161    pub uuid: Option<String>,
162}
163
164#[derive(Debug, Clone, PartialEq)]
165#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
166pub struct SchematicRuleArea {
167    pub name: Option<String>,
168    pub points: Vec<[f64; 2]>,
169    pub uuid: Option<String>,
170}
171
172#[derive(Debug, Clone, PartialEq)]
173#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
174pub struct SchematicText {
175    pub text: Option<String>,
176    pub at: Option<[f64; 2]>,
177    pub angle: Option<f64>,
178    pub uuid: Option<String>,
179}
180
181#[derive(Debug, Clone, PartialEq)]
182#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
183pub struct SchematicLabel {
184    pub label_type: String,
185    pub text: Option<String>,
186    pub at: Option<[f64; 2]>,
187    pub angle: Option<f64>,
188    pub uuid: Option<String>,
189    pub shape: Option<String>,
190}
191
192#[derive(Debug, Clone, PartialEq)]
193#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
194pub struct SchematicSymbol {
195    pub lib_id: Option<String>,
196    pub at: Option<[f64; 2]>,
197    pub angle: Option<f64>,
198    pub mirror: Option<String>,
199    pub unit: Option<i32>,
200    pub uuid: Option<String>,
201    pub in_bom: Option<bool>,
202    pub on_board: Option<bool>,
203    pub dnp: bool,
204    pub fields_autoplaced: bool,
205    pub properties: Vec<(String, String)>,
206    pub pin_count: usize,
207    pub reference: Option<String>,
208    pub value: Option<String>,
209}
210
211#[derive(Debug, Clone, PartialEq)]
212#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
213pub struct SchematicSheet {
214    pub at: Option<[f64; 2]>,
215    pub size: Option<[f64; 2]>,
216    pub uuid: Option<String>,
217    pub fields_autoplaced: bool,
218    pub name: Option<String>,
219    pub filename: Option<String>,
220    pub pin_count: usize,
221    pub properties: Vec<(String, String)>,
222}
223
224#[derive(Debug, Clone, PartialEq)]
225#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
226pub struct SchematicImage {
227    pub at: Option<[f64; 2]>,
228    pub scale: Option<f64>,
229    pub uuid: Option<String>,
230}
231
232#[derive(Debug, Clone, PartialEq)]
233#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
234pub struct SchematicSymbolInstance {
235    pub path: Option<String>,
236    pub reference: Option<String>,
237    pub unit: Option<i32>,
238    pub value: Option<String>,
239    pub footprint: Option<String>,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq)]
243#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
244pub struct SchematicSheetInstance {
245    pub path: Option<String>,
246    pub page: Option<String>,
247}
248
249#[derive(Debug, Clone, PartialEq)]
250#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
251pub struct SchematicAst {
252    pub version: Option<i32>,
253    pub generator: Option<String>,
254    pub generator_version: Option<String>,
255    pub uuid: Option<String>,
256    pub has_paper: bool,
257    pub paper: Option<SchematicPaper>,
258    pub has_title_block: bool,
259    pub title_block: Option<SchematicTitleBlock>,
260    pub has_lib_symbols: bool,
261    pub embedded_fonts: Option<bool>,
262    pub lib_symbol_count: usize,
263    pub symbol_count: usize,
264    pub symbols: Vec<SchematicSymbol>,
265    pub sheet_count: usize,
266    pub sheets: Vec<SchematicSheet>,
267    pub junction_count: usize,
268    pub junctions: Vec<SchematicJunction>,
269    pub no_connect_count: usize,
270    pub no_connects: Vec<SchematicNoConnect>,
271    pub bus_entry_count: usize,
272    pub bus_entries: Vec<SchematicBusEntry>,
273    pub bus_alias_count: usize,
274    pub bus_aliases: Vec<SchematicBusAlias>,
275    pub wire_count: usize,
276    pub wires: Vec<SchematicWire>,
277    pub bus_count: usize,
278    pub buses: Vec<SchematicBus>,
279    pub image_count: usize,
280    pub images: Vec<SchematicImage>,
281    pub text_count: usize,
282    pub texts: Vec<SchematicText>,
283    pub text_box_count: usize,
284    pub label_count: usize,
285    pub labels: Vec<SchematicLabel>,
286    pub global_label_count: usize,
287    // global labels go into labels vec with label_type
288    pub hierarchical_label_count: usize,
289    // hierarchical labels go into labels vec with label_type
290    pub netclass_flag_count: usize,
291    pub netclass_flags: Vec<SchematicNetclassFlag>,
292    pub polyline_count: usize,
293    pub polylines: Vec<SchematicPolyline>,
294    pub rectangle_count: usize,
295    pub rectangles: Vec<SchematicRectangle>,
296    pub circle_count: usize,
297    pub circles: Vec<SchematicCircle>,
298    pub arc_count: usize,
299    pub arcs: Vec<SchematicArc>,
300    pub rule_area_count: usize,
301    pub rule_areas: Vec<SchematicRuleArea>,
302    pub sheet_instance_count: usize,
303    pub sheet_instances: Vec<SchematicSheetInstance>,
304    pub symbol_instance_count: usize,
305    pub symbol_instances_parsed: Vec<SchematicSymbolInstance>,
306    pub unknown_nodes: Vec<UnknownNode>,
307}
308
309#[derive(Debug, Clone)]
310pub struct SchematicDocument {
311    ast: SchematicAst,
312    cst: CstDocument,
313    diagnostics: Vec<Diagnostic>,
314    ast_dirty: bool,
315}
316
317impl SchematicDocument {
318    pub fn ast(&self) -> &SchematicAst {
319        &self.ast
320    }
321
322    pub fn ast_mut(&mut self) -> &mut SchematicAst {
323        self.ast_dirty = true;
324        &mut self.ast
325    }
326
327    pub fn cst(&self) -> &CstDocument {
328        &self.cst
329    }
330
331    pub fn diagnostics(&self) -> &[Diagnostic] {
332        &self.diagnostics
333    }
334
335    pub fn set_version(&mut self, version: i32) -> &mut Self {
336        self.mutate_root_items(|items| {
337            upsert_scalar(items, "version", atom_symbol(version.to_string()), 1)
338        })
339    }
340
341    pub fn set_generator<S: Into<String>>(&mut self, generator: S) -> &mut Self {
342        self.mutate_root_items(|items| {
343            upsert_scalar(items, "generator", atom_quoted(generator.into()), 1)
344        })
345    }
346
347    pub fn set_generator_version<S: Into<String>>(&mut self, generator_version: S) -> &mut Self {
348        self.mutate_root_items(|items| {
349            upsert_scalar(
350                items,
351                "generator_version",
352                atom_quoted(generator_version.into()),
353                1,
354            )
355        })
356    }
357
358    pub fn set_uuid<S: Into<String>>(&mut self, uuid: S) -> &mut Self {
359        self.mutate_root_items(|items| upsert_scalar(items, "uuid", atom_quoted(uuid.into()), 1))
360    }
361
362    pub fn set_paper_standard<S: Into<String>>(
363        &mut self,
364        kind: S,
365        orientation: Option<&str>,
366    ) -> &mut Self {
367        let node = paper_standard_node(kind.into(), orientation.map(|v| v.to_string()));
368        self.mutate_root_items(|items| upsert_node(items, "paper", node, 1))
369    }
370
371    pub fn set_paper_user(
372        &mut self,
373        width: f64,
374        height: f64,
375        orientation: Option<&str>,
376    ) -> &mut Self {
377        let node = paper_user_node(width, height, orientation.map(|v| v.to_string()));
378        self.mutate_root_items(|items| upsert_node(items, "paper", node, 1))
379    }
380
381    pub fn set_title<S: Into<String>>(&mut self, title: S) -> &mut Self {
382        self.mutate_root_items(|items| {
383            upsert_section_child_scalar(items, "title_block", 1, "title", atom_quoted(title.into()))
384        })
385    }
386
387    pub fn set_date<S: Into<String>>(&mut self, date: S) -> &mut Self {
388        self.mutate_root_items(|items| {
389            upsert_section_child_scalar(items, "title_block", 1, "date", atom_quoted(date.into()))
390        })
391    }
392
393    pub fn set_revision<S: Into<String>>(&mut self, revision: S) -> &mut Self {
394        self.mutate_root_items(|items| {
395            upsert_section_child_scalar(
396                items,
397                "title_block",
398                1,
399                "rev",
400                atom_quoted(revision.into()),
401            )
402        })
403    }
404
405    pub fn set_company<S: Into<String>>(&mut self, company: S) -> &mut Self {
406        self.mutate_root_items(|items| {
407            upsert_section_child_scalar(
408                items,
409                "title_block",
410                1,
411                "company",
412                atom_quoted(company.into()),
413            )
414        })
415    }
416
417    pub fn set_embedded_fonts(&mut self, enabled: bool) -> &mut Self {
418        let value = if enabled { "yes" } else { "no" };
419        self.mutate_root_items(|items| {
420            upsert_scalar(items, "embedded_fonts", atom_symbol(value.to_string()), 1)
421        })
422    }
423
424    /// Return filenames of sub-sheets referenced by `(sheet ...)` nodes.
425    ///
426    /// The filenames come from the `Sheetfile` property on each sheet node
427    /// and are relative to the directory containing this schematic.
428    pub fn sheet_filenames(&self) -> Vec<String> {
429        let items = match self.cst.nodes.first() {
430            Some(Node::List { items, .. }) => items,
431            _ => return Vec::new(),
432        };
433        items
434            .iter()
435            .skip(1)
436            .filter(|node| head_of(node) == Some("sheet"))
437            .filter_map(|node| {
438                let Node::List {
439                    items: sheet_items, ..
440                } = node
441                else {
442                    return None;
443                };
444                // Look for (property "Sheetfile" "filename.kicad_sch" ...)
445                find_property_index(sheet_items, "Sheetfile", 1).and_then(|idx| {
446                    if let Some(Node::List {
447                        items: prop_items, ..
448                    }) = sheet_items.get(idx)
449                    {
450                        prop_items.get(2).and_then(atom_as_string)
451                    } else {
452                        None
453                    }
454                })
455            })
456            .collect()
457    }
458
459    /// Return info for all symbol instances in the schematic.
460    pub fn symbol_instances(&self) -> Vec<SchematicSymbolInfo> {
461        let items = match self.cst.nodes.first() {
462            Some(Node::List { items, .. }) => items,
463            _ => return Vec::new(),
464        };
465        items
466            .iter()
467            .skip(1)
468            .filter(|node| head_of(node) == Some("symbol"))
469            .map(parse_schematic_symbol_info)
470            .collect()
471    }
472
473    /// Upsert a property on every symbol instance matching `reference`.
474    pub fn upsert_symbol_instance_property<R: Into<String>, K: Into<String>, V: Into<String>>(
475        &mut self,
476        reference: R,
477        key: K,
478        value: V,
479    ) -> &mut Self {
480        let reference = reference.into();
481        let key = key.into();
482        let value = value.into();
483        self.mutate_root_items(|items| {
484            let indices = find_schematic_symbol_indices_by_reference(items, &reference);
485            let mut changed = false;
486            for idx in indices {
487                if let Some(Node::List {
488                    items: sym_items, ..
489                }) = items.get_mut(idx)
490                {
491                    if upsert_property_preserve_tail(sym_items, &key, &value, 1) {
492                        changed = true;
493                    }
494                }
495            }
496            changed
497        })
498    }
499
500    /// Remove a property from every symbol instance matching `reference`.
501    pub fn remove_symbol_instance_property<R: Into<String>, K: Into<String>>(
502        &mut self,
503        reference: R,
504        key: K,
505    ) -> &mut Self {
506        let reference = reference.into();
507        let key = key.into();
508        self.mutate_root_items(|items| {
509            let indices = find_schematic_symbol_indices_by_reference(items, &reference);
510            let mut changed = false;
511            for idx in indices {
512                if let Some(Node::List {
513                    items: sym_items, ..
514                }) = items.get_mut(idx)
515                {
516                    if remove_property(sym_items, &key, 1) {
517                        changed = true;
518                    }
519                }
520            }
521            changed
522        })
523    }
524
525    pub fn write<P: AsRef<Path>>(&self, path: P) -> Result<(), Error> {
526        self.write_mode(path, WriteMode::Lossless)
527    }
528
529    pub fn write_mode<P: AsRef<Path>>(&self, path: P, mode: WriteMode) -> Result<(), Error> {
530        if self.ast_dirty {
531            return Err(Error::Validation(
532                "ast_mut changes are not serializable; use document setter APIs".to_string(),
533            ));
534        }
535        match mode {
536            WriteMode::Lossless => fs::write(path, self.cst.to_lossless_string())?,
537            WriteMode::Canonical => fs::write(path, self.cst.to_canonical_string())?,
538        }
539        Ok(())
540    }
541
542    fn mutate_root_items<F>(&mut self, mutate: F) -> &mut Self
543    where
544        F: FnOnce(&mut Vec<Node>) -> bool,
545    {
546        mutate_root_and_refresh(
547            &mut self.cst,
548            &mut self.ast,
549            &mut self.diagnostics,
550            mutate,
551            parse_ast,
552            |_cst, ast| collect_version_diagnostics(ast.version),
553        );
554        self.ast_dirty = false;
555        self
556    }
557}
558
559pub struct SchematicFile;
560
561impl SchematicFile {
562    pub fn read<P: AsRef<Path>>(path: P) -> Result<SchematicDocument, Error> {
563        let raw = fs::read_to_string(path)?;
564        let cst = parse_one(&raw)?;
565        ensure_root_head_any(&cst, &["kicad_sch"])?;
566        let ast = parse_ast(&cst);
567        let diagnostics = collect_version_diagnostics(ast.version);
568        Ok(SchematicDocument {
569            ast,
570            cst,
571            diagnostics,
572            ast_dirty: false,
573        })
574    }
575}
576
577/// Find indices of root-level `(symbol ...)` nodes whose "Reference" property matches.
578fn find_schematic_symbol_indices_by_reference(items: &[Node], reference: &str) -> Vec<usize> {
579    items
580        .iter()
581        .enumerate()
582        .skip(1)
583        .filter(|(_, node)| {
584            if head_of(node) != Some("symbol") {
585                return false;
586            }
587            let Node::List {
588                items: sym_items, ..
589            } = node
590            else {
591                return false;
592            };
593            if let Some(prop_idx) = find_property_index(sym_items, "Reference", 1) {
594                if let Some(Node::List {
595                    items: prop_items, ..
596                }) = sym_items.get(prop_idx)
597                {
598                    return prop_items.get(2).and_then(atom_as_string).as_deref()
599                        == Some(reference);
600                }
601            }
602            false
603        })
604        .map(|(idx, _)| idx)
605        .collect()
606}
607
608/// Extract property value from a symbol node's items.
609fn get_property_value(sym_items: &[Node], key: &str) -> Option<String> {
610    find_property_index(sym_items, key, 1).and_then(|idx| {
611        if let Some(Node::List {
612            items: prop_items, ..
613        }) = sym_items.get(idx)
614        {
615            prop_items.get(2).and_then(atom_as_string)
616        } else {
617            None
618        }
619    })
620}
621
622fn parse_schematic_symbol_info(node: &Node) -> SchematicSymbolInfo {
623    let Node::List { items, .. } = node else {
624        return SchematicSymbolInfo {
625            reference: None,
626            lib_id: None,
627            value: None,
628            footprint: None,
629            properties: Vec::new(),
630        };
631    };
632
633    let lib_id = items
634        .iter()
635        .skip(1)
636        .find(|n| head_of(n) == Some("lib_id"))
637        .and_then(second_atom_string);
638
639    let reference = get_property_value(items, "Reference");
640    let value = get_property_value(items, "Value");
641    let footprint = get_property_value(items, "Footprint");
642
643    let properties: Vec<(String, String)> = items
644        .iter()
645        .skip(1)
646        .filter(|n| head_of(n) == Some("property"))
647        .filter_map(|n| {
648            let Node::List {
649                items: prop_items, ..
650            } = n
651            else {
652                return None;
653            };
654            let key = prop_items.get(1).and_then(atom_as_string)?;
655            let val = prop_items
656                .get(2)
657                .and_then(atom_as_string)
658                .unwrap_or_default();
659            Some((key, val))
660        })
661        .collect();
662
663    SchematicSymbolInfo {
664        reference,
665        lib_id,
666        value,
667        footprint,
668        properties,
669    }
670}
671
672fn parse_xy2(node: &Node) -> Option<[f64; 2]> {
673    let Node::List { items, .. } = node else {
674        return None;
675    };
676    let x = items.get(1).and_then(atom_as_f64)?;
677    let y = items.get(2).and_then(atom_as_f64)?;
678    Some([x, y])
679}
680
681fn parse_at_and_angle(node: &Node) -> (Option<[f64; 2]>, Option<f64>) {
682    let Node::List { items, .. } = node else {
683        return (None, None);
684    };
685    let at = match (
686        items.get(1).and_then(atom_as_f64),
687        items.get(2).and_then(atom_as_f64),
688    ) {
689        (Some(x), Some(y)) => Some([x, y]),
690        _ => None,
691    };
692    let angle = items.get(3).and_then(atom_as_f64);
693    (at, angle)
694}
695
696fn parse_pts(node: &Node) -> Vec<[f64; 2]> {
697    let Node::List { items, .. } = node else {
698        return Vec::new();
699    };
700    items
701        .iter()
702        .skip(1)
703        .filter(|n| head_of(n) == Some("xy"))
704        .filter_map(parse_xy2)
705        .collect()
706}
707
708fn parse_property_pairs(items: &[Node]) -> Vec<(String, String)> {
709    items
710        .iter()
711        .skip(1)
712        .filter(|n| head_of(n) == Some("property"))
713        .filter_map(|n| {
714            let Node::List {
715                items: prop_items, ..
716            } = n
717            else {
718                return None;
719            };
720            let key = prop_items.get(1).and_then(atom_as_string)?;
721            let val = prop_items
722                .get(2)
723                .and_then(atom_as_string)
724                .unwrap_or_default();
725            Some((key, val))
726        })
727        .collect()
728}
729
730fn parse_stroke(node: &Node) -> (Option<f64>, Option<String>) {
731    let mut width = None;
732    let mut stroke_type = None;
733    let Node::List { items, .. } = node else {
734        return (width, stroke_type);
735    };
736    for child in items.iter().skip(1) {
737        match head_of(child) {
738            Some("width") => width = second_atom_f64(child),
739            Some("type") => stroke_type = second_atom_string(child),
740            _ => {}
741        }
742    }
743    (width, stroke_type)
744}
745
746fn parse_junction(node: &Node) -> SchematicJunction {
747    let mut at = None;
748    let mut diameter = None;
749    let mut color = None;
750    let mut uuid = None;
751    if let Node::List { items, .. } = node {
752        for child in items.iter().skip(1) {
753            match head_of(child) {
754                Some("at") => at = parse_xy2(child),
755                Some("diameter") => diameter = second_atom_f64(child),
756                Some("color") => {
757                    if let Node::List {
758                        items: color_items, ..
759                    } = child
760                    {
761                        let parts: Vec<String> = color_items
762                            .iter()
763                            .skip(1)
764                            .filter_map(atom_as_string)
765                            .collect();
766                        if !parts.is_empty() {
767                            color = Some(parts.join(" "));
768                        }
769                    }
770                }
771                Some("uuid") => uuid = second_atom_string(child),
772                _ => {}
773            }
774        }
775    }
776    SchematicJunction {
777        at,
778        diameter,
779        color,
780        uuid,
781    }
782}
783
784fn parse_no_connect(node: &Node) -> SchematicNoConnect {
785    let mut at = None;
786    let mut uuid = None;
787    if let Node::List { items, .. } = node {
788        for child in items.iter().skip(1) {
789            match head_of(child) {
790                Some("at") => at = parse_xy2(child),
791                Some("uuid") => uuid = second_atom_string(child),
792                _ => {}
793            }
794        }
795    }
796    SchematicNoConnect { at, uuid }
797}
798
799fn parse_wire(node: &Node) -> SchematicWire {
800    let mut points = Vec::new();
801    let mut uuid = None;
802    let mut stroke_width = None;
803    let mut stroke_type = None;
804    if let Node::List { items, .. } = node {
805        for child in items.iter().skip(1) {
806            match head_of(child) {
807                Some("pts") => points = parse_pts(child),
808                Some("uuid") => uuid = second_atom_string(child),
809                Some("stroke") => (stroke_width, stroke_type) = parse_stroke(child),
810                _ => {}
811            }
812        }
813    }
814    SchematicWire {
815        points,
816        uuid,
817        stroke_width,
818        stroke_type,
819    }
820}
821
822fn parse_bus(node: &Node) -> SchematicBus {
823    let mut points = Vec::new();
824    let mut uuid = None;
825    let mut stroke_width = None;
826    let mut stroke_type = None;
827    if let Node::List { items, .. } = node {
828        for child in items.iter().skip(1) {
829            match head_of(child) {
830                Some("pts") => points = parse_pts(child),
831                Some("uuid") => uuid = second_atom_string(child),
832                Some("stroke") => (stroke_width, stroke_type) = parse_stroke(child),
833                _ => {}
834            }
835        }
836    }
837    SchematicBus {
838        points,
839        uuid,
840        stroke_width,
841        stroke_type,
842    }
843}
844
845fn parse_bus_entry(node: &Node) -> SchematicBusEntry {
846    let mut at = None;
847    let mut size = None;
848    let mut uuid = None;
849    if let Node::List { items, .. } = node {
850        for child in items.iter().skip(1) {
851            match head_of(child) {
852                Some("at") => at = parse_xy2(child),
853                Some("size") => size = parse_xy2(child),
854                Some("uuid") => uuid = second_atom_string(child),
855                _ => {}
856            }
857        }
858    }
859    SchematicBusEntry { at, size, uuid }
860}
861
862fn parse_bus_alias(node: &Node) -> SchematicBusAlias {
863    let mut name = None;
864    let mut members = Vec::new();
865    if let Node::List { items, .. } = node {
866        for child in items.iter().skip(1) {
867            match head_of(child) {
868                Some("name") => name = second_atom_string(child),
869                Some("members") => {
870                    if let Node::List {
871                        items: member_items,
872                        ..
873                    } = child
874                    {
875                        members = member_items
876                            .iter()
877                            .skip(1)
878                            .filter_map(atom_as_string)
879                            .collect();
880                    }
881                }
882                _ => {}
883            }
884        }
885    }
886    SchematicBusAlias { name, members }
887}
888
889fn parse_netclass_flag(node: &Node) -> SchematicNetclassFlag {
890    let mut text = second_atom_string(node);
891    let mut at = None;
892    let mut angle = None;
893    let mut uuid = None;
894    if let Node::List { items, .. } = node {
895        for child in items.iter().skip(1) {
896            match head_of(child) {
897                Some("at") => (at, angle) = parse_at_and_angle(child),
898                Some("uuid") => uuid = second_atom_string(child),
899                _ => {}
900            }
901        }
902        if text.is_none() {
903            text = items.get(1).and_then(atom_as_string);
904        }
905    }
906    SchematicNetclassFlag {
907        text,
908        at,
909        angle,
910        uuid,
911    }
912}
913
914fn parse_polyline(node: &Node) -> SchematicPolyline {
915    let mut points = Vec::new();
916    let mut uuid = None;
917    if let Node::List { items, .. } = node {
918        for child in items.iter().skip(1) {
919            match head_of(child) {
920                Some("pts") => points = parse_pts(child),
921                Some("uuid") => uuid = second_atom_string(child),
922                _ => {}
923            }
924        }
925    }
926    SchematicPolyline { points, uuid }
927}
928
929fn parse_rectangle(node: &Node) -> SchematicRectangle {
930    let mut start = None;
931    let mut end = None;
932    let mut uuid = None;
933    if let Node::List { items, .. } = node {
934        for child in items.iter().skip(1) {
935            match head_of(child) {
936                Some("start") => start = parse_xy2(child),
937                Some("end") => end = parse_xy2(child),
938                Some("uuid") => uuid = second_atom_string(child),
939                _ => {}
940            }
941        }
942    }
943    SchematicRectangle { start, end, uuid }
944}
945
946fn parse_circle(node: &Node) -> SchematicCircle {
947    let mut center = None;
948    let mut end = None;
949    let mut uuid = None;
950    if let Node::List { items, .. } = node {
951        for child in items.iter().skip(1) {
952            match head_of(child) {
953                Some("center") => center = parse_xy2(child),
954                Some("end") => end = parse_xy2(child),
955                Some("uuid") => uuid = second_atom_string(child),
956                _ => {}
957            }
958        }
959    }
960    SchematicCircle { center, end, uuid }
961}
962
963fn parse_arc(node: &Node) -> SchematicArc {
964    let mut start = None;
965    let mut mid = None;
966    let mut end = None;
967    let mut uuid = None;
968    if let Node::List { items, .. } = node {
969        for child in items.iter().skip(1) {
970            match head_of(child) {
971                Some("start") => start = parse_xy2(child),
972                Some("mid") => mid = parse_xy2(child),
973                Some("end") => end = parse_xy2(child),
974                Some("uuid") => uuid = second_atom_string(child),
975                _ => {}
976            }
977        }
978    }
979    SchematicArc {
980        start,
981        mid,
982        end,
983        uuid,
984    }
985}
986
987fn parse_rule_area(node: &Node) -> SchematicRuleArea {
988    let mut name = None;
989    let mut points = Vec::new();
990    let mut uuid = None;
991    if let Node::List { items, .. } = node {
992        for child in items.iter().skip(1) {
993            match head_of(child) {
994                Some("name") => name = second_atom_string(child),
995                Some("pts") => points = parse_pts(child),
996                Some("uuid") => uuid = second_atom_string(child),
997                _ => {}
998            }
999        }
1000    }
1001    SchematicRuleArea { name, points, uuid }
1002}
1003
1004fn parse_text(node: &Node) -> SchematicText {
1005    let mut text = second_atom_string(node);
1006    let mut at = None;
1007    let mut angle = None;
1008    let mut uuid = None;
1009    if let Node::List { items, .. } = node {
1010        for child in items.iter().skip(1) {
1011            match head_of(child) {
1012                Some("at") => (at, angle) = parse_at_and_angle(child),
1013                Some("uuid") => uuid = second_atom_string(child),
1014                _ => {}
1015            }
1016        }
1017        if text.is_none() {
1018            text = items.get(1).and_then(atom_as_string);
1019        }
1020    }
1021    SchematicText {
1022        text,
1023        at,
1024        angle,
1025        uuid,
1026    }
1027}
1028
1029fn parse_label(node: &Node, label_type: &str) -> SchematicLabel {
1030    let mut text = second_atom_string(node);
1031    let mut at = None;
1032    let mut angle = None;
1033    let mut uuid = None;
1034    let mut shape = None;
1035    if let Node::List { items, .. } = node {
1036        for child in items.iter().skip(1) {
1037            match head_of(child) {
1038                Some("at") => (at, angle) = parse_at_and_angle(child),
1039                Some("uuid") => uuid = second_atom_string(child),
1040                Some("shape") => shape = second_atom_string(child),
1041                _ => {}
1042            }
1043        }
1044        if text.is_none() {
1045            text = items.get(1).and_then(atom_as_string);
1046        }
1047    }
1048    SchematicLabel {
1049        label_type: label_type.to_string(),
1050        text,
1051        at,
1052        angle,
1053        uuid,
1054        shape,
1055    }
1056}
1057
1058fn parse_symbol(node: &Node) -> SchematicSymbol {
1059    let mut lib_id = None;
1060    let mut at = None;
1061    let mut angle = None;
1062    let mut mirror = None;
1063    let mut unit = None;
1064    let mut uuid = None;
1065    let mut in_bom = None;
1066    let mut on_board = None;
1067    let mut dnp = false;
1068    let mut fields_autoplaced = false;
1069    let mut pin_count = 0usize;
1070    let mut reference = None;
1071    let mut value = None;
1072    let mut properties = Vec::new();
1073
1074    if let Node::List { items, .. } = node {
1075        pin_count = list_child_head_count(node, "pin");
1076        reference = get_property_value(items, "Reference");
1077        value = get_property_value(items, "Value");
1078        properties = parse_property_pairs(items);
1079
1080        for child in items.iter().skip(1) {
1081            match head_of(child) {
1082                Some("lib_id") => lib_id = second_atom_string(child),
1083                Some("at") => (at, angle) = parse_at_and_angle(child),
1084                Some("mirror") => mirror = second_atom_string(child),
1085                Some("unit") => unit = second_atom_i32(child),
1086                Some("uuid") => uuid = second_atom_string(child),
1087                Some("in_bom") => in_bom = second_atom_bool(child),
1088                Some("on_board") => on_board = second_atom_bool(child),
1089                Some("dnp") => dnp = second_atom_bool(child).unwrap_or(true),
1090                Some("fields_autoplaced") => fields_autoplaced = true,
1091                _ => {
1092                    if matches!(
1093                        child,
1094                        Node::Atom {
1095                            atom: Atom::Symbol(s),
1096                            ..
1097                        } if s == "fields_autoplaced"
1098                    ) {
1099                        fields_autoplaced = true;
1100                    }
1101                }
1102            }
1103        }
1104    }
1105
1106    SchematicSymbol {
1107        lib_id,
1108        at,
1109        angle,
1110        mirror,
1111        unit,
1112        uuid,
1113        in_bom,
1114        on_board,
1115        dnp,
1116        fields_autoplaced,
1117        properties,
1118        pin_count,
1119        reference,
1120        value,
1121    }
1122}
1123
1124fn parse_sheet(node: &Node) -> SchematicSheet {
1125    let mut at = None;
1126    let mut size = None;
1127    let mut uuid = None;
1128    let mut fields_autoplaced = false;
1129    let mut pin_count = 0usize;
1130    let mut properties = Vec::new();
1131    if let Node::List { items, .. } = node {
1132        pin_count = list_child_head_count(node, "pin");
1133        properties = parse_property_pairs(items);
1134        for child in items.iter().skip(1) {
1135            match head_of(child) {
1136                Some("at") => at = parse_xy2(child),
1137                Some("size") => size = parse_xy2(child),
1138                Some("uuid") => uuid = second_atom_string(child),
1139                Some("fields_autoplaced") => fields_autoplaced = true,
1140                _ => {
1141                    if matches!(
1142                        child,
1143                        Node::Atom {
1144                            atom: Atom::Symbol(s),
1145                            ..
1146                        } if s == "fields_autoplaced"
1147                    ) {
1148                        fields_autoplaced = true;
1149                    }
1150                }
1151            }
1152        }
1153    }
1154    let name = properties
1155        .iter()
1156        .find(|(k, _)| k == "Sheetname")
1157        .map(|(_, v)| v.clone());
1158    let filename = properties
1159        .iter()
1160        .find(|(k, _)| k == "Sheetfile")
1161        .map(|(_, v)| v.clone());
1162
1163    SchematicSheet {
1164        at,
1165        size,
1166        uuid,
1167        fields_autoplaced,
1168        name,
1169        filename,
1170        pin_count,
1171        properties,
1172    }
1173}
1174
1175fn parse_image(node: &Node) -> SchematicImage {
1176    let mut at = None;
1177    let mut scale = None;
1178    let mut uuid = None;
1179    if let Node::List { items, .. } = node {
1180        for child in items.iter().skip(1) {
1181            match head_of(child) {
1182                Some("at") => at = parse_xy2(child),
1183                Some("scale") => scale = second_atom_f64(child),
1184                Some("uuid") => uuid = second_atom_string(child),
1185                _ => {}
1186            }
1187        }
1188    }
1189    SchematicImage { at, scale, uuid }
1190}
1191
1192fn parse_sheet_instance(node: &Node) -> SchematicSheetInstance {
1193    let Node::List { items, .. } = node else {
1194        return SchematicSheetInstance {
1195            path: None,
1196            page: None,
1197        };
1198    };
1199
1200    let path = items.get(1).and_then(atom_as_string);
1201    let page = items
1202        .iter()
1203        .skip(2)
1204        .find(|n| head_of(n) == Some("page"))
1205        .and_then(second_atom_string);
1206
1207    SchematicSheetInstance { path, page }
1208}
1209
1210fn parse_symbol_instance(node: &Node) -> SchematicSymbolInstance {
1211    let Node::List { items, .. } = node else {
1212        return SchematicSymbolInstance {
1213            path: None,
1214            reference: None,
1215            unit: None,
1216            value: None,
1217            footprint: None,
1218        };
1219    };
1220
1221    let mut reference = None;
1222    let mut unit = None;
1223    let mut value = None;
1224    let mut footprint = None;
1225
1226    for child in items.iter().skip(2) {
1227        match head_of(child) {
1228            Some("reference") => reference = second_atom_string(child),
1229            Some("unit") => unit = second_atom_i32(child),
1230            Some("value") => value = second_atom_string(child),
1231            Some("footprint") => footprint = second_atom_string(child),
1232            _ => {}
1233        }
1234    }
1235
1236    SchematicSymbolInstance {
1237        path: items.get(1).and_then(atom_as_string),
1238        reference,
1239        unit,
1240        value,
1241        footprint,
1242    }
1243}
1244
1245fn parse_ast(cst: &CstDocument) -> SchematicAst {
1246    let mut version = None;
1247    let mut generator = None;
1248    let mut generator_version = None;
1249    let mut uuid = None;
1250    let mut has_paper = false;
1251    let mut paper = None;
1252    let mut has_title_block = false;
1253    let mut title_block = None;
1254    let mut has_lib_symbols = false;
1255    let mut embedded_fonts = None;
1256    let mut lib_symbol_count = 0usize;
1257    let mut symbol_count = 0usize;
1258    let mut symbols = Vec::new();
1259    let mut sheet_count = 0usize;
1260    let mut sheets = Vec::new();
1261    let mut junction_count = 0usize;
1262    let mut junctions = Vec::new();
1263    let mut no_connect_count = 0usize;
1264    let mut no_connects = Vec::new();
1265    let mut bus_entry_count = 0usize;
1266    let mut bus_entries = Vec::new();
1267    let mut bus_alias_count = 0usize;
1268    let mut bus_aliases = Vec::new();
1269    let mut wire_count = 0usize;
1270    let mut wires = Vec::new();
1271    let mut bus_count = 0usize;
1272    let mut buses = Vec::new();
1273    let mut image_count = 0usize;
1274    let mut images = Vec::new();
1275    let mut text_count = 0usize;
1276    let mut texts = Vec::new();
1277    let mut text_box_count = 0usize;
1278    let mut label_count = 0usize;
1279    let mut labels = Vec::new();
1280    let mut global_label_count = 0usize;
1281    let mut hierarchical_label_count = 0usize;
1282    let mut netclass_flag_count = 0usize;
1283    let mut netclass_flags = Vec::new();
1284    let mut polyline_count = 0usize;
1285    let mut polylines = Vec::new();
1286    let mut rectangle_count = 0usize;
1287    let mut rectangles = Vec::new();
1288    let mut circle_count = 0usize;
1289    let mut circles = Vec::new();
1290    let mut arc_count = 0usize;
1291    let mut arcs = Vec::new();
1292    let mut rule_area_count = 0usize;
1293    let mut rule_areas = Vec::new();
1294    let mut sheet_instance_count = 0usize;
1295    let mut sheet_instances = Vec::new();
1296    let mut symbol_instance_count = 0usize;
1297    let mut symbol_instances_parsed = Vec::new();
1298    let mut unknown_nodes = Vec::new();
1299
1300    if let Some(Node::List { items, .. }) = cst.nodes.first() {
1301        for item in items.iter().skip(1) {
1302            match head_of(item) {
1303                Some("version") => version = second_atom_i32(item),
1304                Some("generator") => generator = second_atom_string(item),
1305                Some("generator_version") => generator_version = second_atom_string(item),
1306                Some("uuid") => uuid = second_atom_string(item),
1307                Some("paper") => {
1308                    has_paper = true;
1309                    paper = Some(parse_paper(item).into());
1310                }
1311                Some("title_block") => {
1312                    has_title_block = true;
1313                    title_block = Some(parse_title_block(item).into());
1314                }
1315                Some("lib_symbols") => {
1316                    has_lib_symbols = true;
1317                    lib_symbol_count = list_child_head_count(item, "symbol");
1318                }
1319                Some("symbol") => {
1320                    symbol_count += 1;
1321                    symbols.push(parse_symbol(item));
1322                }
1323                Some("sheet") => {
1324                    sheet_count += 1;
1325                    sheets.push(parse_sheet(item));
1326                }
1327                Some("junction") => {
1328                    junction_count += 1;
1329                    junctions.push(parse_junction(item));
1330                }
1331                Some("no_connect") => {
1332                    no_connect_count += 1;
1333                    no_connects.push(parse_no_connect(item));
1334                }
1335                Some("bus_entry") => {
1336                    bus_entry_count += 1;
1337                    bus_entries.push(parse_bus_entry(item));
1338                }
1339                Some("bus_alias") => {
1340                    bus_alias_count += 1;
1341                    bus_aliases.push(parse_bus_alias(item));
1342                }
1343                Some("wire") => {
1344                    wire_count += 1;
1345                    wires.push(parse_wire(item));
1346                }
1347                Some("bus") => {
1348                    bus_count += 1;
1349                    buses.push(parse_bus(item));
1350                }
1351                Some("image") => {
1352                    image_count += 1;
1353                    images.push(parse_image(item));
1354                }
1355                Some("text") => {
1356                    text_count += 1;
1357                    texts.push(parse_text(item));
1358                }
1359                Some("text_box") => text_box_count += 1,
1360                Some("label") => {
1361                    label_count += 1;
1362                    labels.push(parse_label(item, "label"));
1363                }
1364                Some("global_label") => {
1365                    global_label_count += 1;
1366                    labels.push(parse_label(item, "global_label"));
1367                }
1368                Some("hierarchical_label") => {
1369                    hierarchical_label_count += 1;
1370                    labels.push(parse_label(item, "hierarchical_label"));
1371                }
1372                Some("netclass_flag") => {
1373                    netclass_flag_count += 1;
1374                    netclass_flags.push(parse_netclass_flag(item));
1375                }
1376                Some("polyline") => {
1377                    polyline_count += 1;
1378                    polylines.push(parse_polyline(item));
1379                }
1380                Some("rectangle") => {
1381                    rectangle_count += 1;
1382                    rectangles.push(parse_rectangle(item));
1383                }
1384                Some("circle") => {
1385                    circle_count += 1;
1386                    circles.push(parse_circle(item));
1387                }
1388                Some("arc") => {
1389                    arc_count += 1;
1390                    arcs.push(parse_arc(item));
1391                }
1392                Some("rule_area") => {
1393                    rule_area_count += 1;
1394                    rule_areas.push(parse_rule_area(item));
1395                }
1396                Some("sheet_instances") => {
1397                    sheet_instance_count = list_child_head_count(item, "path");
1398                    if let Node::List {
1399                        items: section_items,
1400                        ..
1401                    } = item
1402                    {
1403                        sheet_instances = section_items
1404                            .iter()
1405                            .skip(1)
1406                            .filter(|n| head_of(n) == Some("path"))
1407                            .map(parse_sheet_instance)
1408                            .collect();
1409                    }
1410                }
1411                Some("symbol_instances") => {
1412                    symbol_instance_count = list_child_head_count(item, "path");
1413                    if let Node::List {
1414                        items: section_items,
1415                        ..
1416                    } = item
1417                    {
1418                        symbol_instances_parsed = section_items
1419                            .iter()
1420                            .skip(1)
1421                            .filter(|n| head_of(n) == Some("path"))
1422                            .map(parse_symbol_instance)
1423                            .collect();
1424                    }
1425                }
1426                Some("embedded_fonts") => {
1427                    embedded_fonts = second_atom_bool(item);
1428                }
1429                _ => {
1430                    if let Some(unknown) = UnknownNode::from_node(item) {
1431                        unknown_nodes.push(unknown);
1432                    }
1433                }
1434            }
1435        }
1436    }
1437
1438    SchematicAst {
1439        version,
1440        generator,
1441        generator_version,
1442        uuid,
1443        has_paper,
1444        paper,
1445        has_title_block,
1446        title_block,
1447        has_lib_symbols,
1448        embedded_fonts,
1449        lib_symbol_count,
1450        symbol_count,
1451        symbols,
1452        sheet_count,
1453        sheets,
1454        junction_count,
1455        junctions,
1456        no_connect_count,
1457        no_connects,
1458        bus_entry_count,
1459        bus_entries,
1460        bus_alias_count,
1461        bus_aliases,
1462        wire_count,
1463        wires,
1464        bus_count,
1465        buses,
1466        image_count,
1467        images,
1468        text_count,
1469        texts,
1470        text_box_count,
1471        label_count,
1472        labels,
1473        global_label_count,
1474        hierarchical_label_count,
1475        netclass_flag_count,
1476        netclass_flags,
1477        polyline_count,
1478        polylines,
1479        rectangle_count,
1480        rectangles,
1481        circle_count,
1482        circles,
1483        arc_count,
1484        arcs,
1485        rule_area_count,
1486        rule_areas,
1487        sheet_instance_count,
1488        sheet_instances,
1489        symbol_instance_count,
1490        symbol_instances_parsed,
1491        unknown_nodes,
1492    }
1493}
1494
1495#[cfg(test)]
1496mod tests {
1497    use std::path::PathBuf;
1498    use std::time::{SystemTime, UNIX_EPOCH};
1499
1500    use super::*;
1501
1502    fn tmp_file(name: &str) -> PathBuf {
1503        let nanos = SystemTime::now()
1504            .duration_since(UNIX_EPOCH)
1505            .expect("clock")
1506            .as_nanos();
1507        std::env::temp_dir().join(format!("{name}_{nanos}.kicad_sch"))
1508    }
1509
1510    #[test]
1511    fn read_schematic_and_preserve_lossless() {
1512        let path = tmp_file("sch_read_ok");
1513        let src = "(kicad_sch (version 20250114) (generator \"eeschema\") (generator_version \"9.0\") (uuid \"u-1\") (paper \"A4\") (title_block (title \"Demo\") (date \"2026-02-25\") (comment 2 \"c2\") (comment 1 \"c1\")) (lib_symbols (symbol \"Lib:R\")) (symbol (lib_id \"Lib:R\")) (wire (pts (xy 0 0) (xy 1 1))) (sheet_instances (path \"/\" (page \"1\"))) (embedded_fonts no))\n";
1514        fs::write(&path, src).expect("write fixture");
1515
1516        let doc = SchematicFile::read(&path).expect("read");
1517        assert_eq!(doc.ast().version, Some(20250114));
1518        assert_eq!(doc.ast().generator.as_deref(), Some("eeschema"));
1519        assert_eq!(doc.ast().generator_version.as_deref(), Some("9.0"));
1520        assert_eq!(doc.ast().uuid.as_deref(), Some("u-1"));
1521        assert_eq!(
1522            doc.ast().paper.as_ref().and_then(|p| p.kind.clone()),
1523            Some("A4".to_string())
1524        );
1525        assert_eq!(doc.ast().lib_symbol_count, 1);
1526        assert_eq!(doc.ast().symbol_count, 1);
1527        assert_eq!(doc.ast().wire_count, 1);
1528        assert_eq!(doc.ast().sheet_instance_count, 1);
1529        assert_eq!(doc.ast().embedded_fonts, Some(false));
1530        assert_eq!(doc.cst().to_lossless_string(), src);
1531
1532        let _ = fs::remove_file(path);
1533    }
1534
1535    #[test]
1536    fn captures_unknown_nodes_roundtrip() {
1537        let path = tmp_file("sch_unknown");
1538        let src = "(kicad_sch (version 20250114) (generator \"eeschema\") (future_block 1 2) (symbol (lib_id \"Device:R\")))\n";
1539        fs::write(&path, src).expect("write fixture");
1540
1541        let doc = SchematicFile::read(&path).expect("read");
1542        assert_eq!(doc.ast().unknown_nodes.len(), 1);
1543
1544        let out = tmp_file("sch_unknown_out");
1545        doc.write(&out).expect("write");
1546        let got = fs::read_to_string(&out).expect("read out");
1547        assert_eq!(got, src);
1548
1549        let _ = fs::remove_file(path);
1550        let _ = fs::remove_file(out);
1551    }
1552
1553    #[test]
1554    fn edit_roundtrip_updates_core_fields() {
1555        let path = tmp_file("sch_edit");
1556        let src = "(kicad_sch (version 20241229) (generator \"eeschema\") (paper \"A4\") (title_block (title \"Old\") (date \"2025-01-01\") (rev \"A\") (company \"OldCo\")) (future_token 1 2))\n";
1557        fs::write(&path, src).expect("write fixture");
1558
1559        let mut doc = SchematicFile::read(&path).expect("read");
1560        doc.set_version(20260101)
1561            .set_generator("kiutils")
1562            .set_generator_version("dev")
1563            .set_uuid("uuid-new")
1564            .set_paper_user(297.0, 210.0, Some("landscape"))
1565            .set_title("New")
1566            .set_date("2026-02-25")
1567            .set_revision("B")
1568            .set_company("Lords")
1569            .set_embedded_fonts(true);
1570
1571        let out = tmp_file("sch_edit_out");
1572        doc.write(&out).expect("write");
1573        let reread = SchematicFile::read(&out).expect("reread");
1574
1575        assert_eq!(reread.ast().version, Some(20260101));
1576        assert_eq!(reread.ast().generator.as_deref(), Some("kiutils"));
1577        assert_eq!(reread.ast().generator_version.as_deref(), Some("dev"));
1578        assert_eq!(reread.ast().uuid.as_deref(), Some("uuid-new"));
1579        assert_eq!(
1580            reread.ast().paper.as_ref().and_then(|p| p.kind.clone()),
1581            Some("User".to_string())
1582        );
1583        assert_eq!(
1584            reread.ast().paper.as_ref().and_then(|p| p.width),
1585            Some(297.0)
1586        );
1587        assert_eq!(
1588            reread.ast().paper.as_ref().and_then(|p| p.height),
1589            Some(210.0)
1590        );
1591        assert_eq!(reread.ast().embedded_fonts, Some(true));
1592        assert_eq!(reread.ast().unknown_nodes.len(), 1);
1593        assert_eq!(
1594            reread
1595                .ast()
1596                .title_block
1597                .as_ref()
1598                .and_then(|t| t.title.clone()),
1599            Some("New".to_string())
1600        );
1601
1602        let _ = fs::remove_file(path);
1603        let _ = fs::remove_file(out);
1604    }
1605}