Skip to main content

kiutils_kicad/
lib_table.rs

1use std::fs;
2use std::path::Path;
3
4use kiutils_sexpr::{parse_one, CstDocument, Node};
5
6use crate::diagnostic::Diagnostic;
7use crate::sexpr_edit::{
8    atom_quoted, atom_symbol, ensure_root_head_any, list_node, mutate_root_and_refresh,
9    upsert_scalar,
10};
11use crate::sexpr_utils::{atom_as_string, head_of, second_atom_i32};
12use crate::{Error, UnknownNode, WriteMode};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
16pub enum LibTableKind {
17    Footprint,
18    Symbol,
19}
20
21impl LibTableKind {
22    fn root_token(self) -> &'static str {
23        match self {
24            Self::Footprint => "fp_lib_table",
25            Self::Symbol => "sym_lib_table",
26        }
27    }
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
32pub struct LibTableLibrarySummary {
33    pub name: Option<String>,
34    pub library_type: Option<String>,
35    pub uri: Option<String>,
36    pub options: Option<String>,
37    pub descr: Option<String>,
38    pub disabled: bool,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
43pub struct LibTableAst {
44    pub kind: LibTableKind,
45    pub version: Option<i32>,
46    pub libraries: Vec<LibTableLibrarySummary>,
47    pub library_count: usize,
48    pub disabled_library_count: usize,
49    pub unknown_nodes: Vec<UnknownNode>,
50}
51
52pub type FpLibTableAst = LibTableAst;
53pub type SymLibTableAst = LibTableAst;
54
55#[derive(Debug, Clone)]
56pub struct LibTableDocument {
57    ast: LibTableAst,
58    cst: CstDocument,
59    diagnostics: Vec<Diagnostic>,
60    ast_dirty: bool,
61}
62
63pub type FpLibTableDocument = LibTableDocument;
64pub type SymLibTableDocument = LibTableDocument;
65
66impl LibTableDocument {
67    pub fn ast(&self) -> &LibTableAst {
68        &self.ast
69    }
70
71    pub fn ast_mut(&mut self) -> &mut LibTableAst {
72        self.ast_dirty = true;
73        &mut self.ast
74    }
75
76    pub fn cst(&self) -> &CstDocument {
77        &self.cst
78    }
79
80    pub fn diagnostics(&self) -> &[Diagnostic] {
81        &self.diagnostics
82    }
83
84    pub fn set_version(&mut self, version: i32) -> &mut Self {
85        self.mutate_root_items(|items| {
86            upsert_scalar(items, "version", atom_symbol(version.to_string()), 1)
87        })
88    }
89
90    pub fn add_library<N: Into<String>, U: Into<String>>(&mut self, name: N, uri: U) -> &mut Self {
91        let node = lib_node(LibNodeInput {
92            name: name.into(),
93            library_type: "KiCad".to_string(),
94            uri: uri.into(),
95            options: "".to_string(),
96            descr: "".to_string(),
97            disabled: false,
98        });
99        self.mutate_root_items(|items| {
100            items.push(node);
101            true
102        })
103    }
104
105    pub fn rename_library<S: Into<String>>(&mut self, from: &str, to: S) -> &mut Self {
106        let from = from.to_string();
107        let to = to.into();
108        self.mutate_root_items(|items| {
109            let Some(idx) = find_library_index(items, &from) else {
110                return false;
111            };
112            let Some(Node::List {
113                items: lib_items, ..
114            }) = items.get_mut(idx)
115            else {
116                return false;
117            };
118            if let Some(name_idx) = lib_items.iter().position(|n| head_of(n) == Some("name")) {
119                let Some(Node::List {
120                    items: name_items, ..
121                }) = lib_items.get_mut(name_idx)
122                else {
123                    return false;
124                };
125                if name_items.len() > 1 {
126                    let next = atom_quoted(to);
127                    if name_items[1] == next {
128                        false
129                    } else {
130                        name_items[1] = next;
131                        true
132                    }
133                } else {
134                    false
135                }
136            } else {
137                lib_items.insert(1, list_node2("name".to_string(), atom_quoted(to)));
138                true
139            }
140        })
141    }
142
143    pub fn remove_library(&mut self, name: &str) -> &mut Self {
144        let name = name.to_string();
145        self.mutate_root_items(|items| {
146            if let Some(idx) = find_library_index(items, &name) {
147                items.remove(idx);
148                true
149            } else {
150                false
151            }
152        })
153    }
154
155    pub fn upsert_library_uri<N: AsRef<str>, U: Into<String>>(
156        &mut self,
157        name: N,
158        uri: U,
159    ) -> &mut Self {
160        let name = name.as_ref().to_string();
161        let uri = uri.into();
162        self.mutate_root_items(|items| {
163            let Some(idx) = find_library_index(items, &name) else {
164                items.push(lib_node(LibNodeInput {
165                    name,
166                    library_type: "KiCad".to_string(),
167                    uri,
168                    options: "".to_string(),
169                    descr: "".to_string(),
170                    disabled: false,
171                }));
172                return true;
173            };
174
175            let Some(Node::List {
176                items: lib_items, ..
177            }) = items.get_mut(idx)
178            else {
179                return false;
180            };
181            upsert_scalar(lib_items, "uri", atom_quoted(uri), 1)
182        })
183    }
184
185    pub fn write<P: AsRef<Path>>(&self, path: P) -> Result<(), Error> {
186        self.write_mode(path, WriteMode::Lossless)
187    }
188
189    pub fn write_mode<P: AsRef<Path>>(&self, path: P, mode: WriteMode) -> Result<(), Error> {
190        if self.ast_dirty {
191            return Err(Error::Validation(
192                "ast_mut changes are not serializable; use document setter APIs".to_string(),
193            ));
194        }
195        match mode {
196            WriteMode::Lossless => fs::write(path, self.cst.to_lossless_string())?,
197            WriteMode::Canonical => fs::write(path, self.cst.to_canonical_string())?,
198        }
199        Ok(())
200    }
201
202    fn mutate_root_items<F>(&mut self, mutate: F) -> &mut Self
203    where
204        F: FnOnce(&mut Vec<Node>) -> bool,
205    {
206        let kind = self.ast.kind;
207        mutate_root_and_refresh(
208            &mut self.cst,
209            &mut self.ast,
210            &mut self.diagnostics,
211            mutate,
212            |cst| parse_ast(cst, kind),
213            |_cst, _ast| Vec::new(),
214        );
215        self.ast_dirty = false;
216        self
217    }
218}
219
220pub struct FpLibTableFile;
221pub struct SymLibTableFile;
222
223impl FpLibTableFile {
224    pub fn read<P: AsRef<Path>>(path: P) -> Result<FpLibTableDocument, Error> {
225        read_kind(path, LibTableKind::Footprint)
226    }
227}
228
229impl SymLibTableFile {
230    pub fn read<P: AsRef<Path>>(path: P) -> Result<SymLibTableDocument, Error> {
231        read_kind(path, LibTableKind::Symbol)
232    }
233}
234
235fn read_kind<P: AsRef<Path>>(path: P, kind: LibTableKind) -> Result<LibTableDocument, Error> {
236    let raw = fs::read_to_string(path)?;
237    let cst = parse_one(&raw)?;
238    ensure_root_head_any(&cst, &[kind.root_token()])?;
239    let ast = parse_ast(&cst, kind);
240    Ok(LibTableDocument {
241        ast,
242        cst,
243        diagnostics: Vec::new(),
244        ast_dirty: false,
245    })
246}
247
248fn parse_ast(cst: &CstDocument, kind: LibTableKind) -> LibTableAst {
249    let mut version = None;
250    let mut libraries = Vec::new();
251    let mut unknown_nodes = Vec::new();
252
253    if let Some(Node::List { items, .. }) = cst.nodes.first() {
254        for item in items.iter().skip(1) {
255            match head_of(item) {
256                Some("version") => version = second_atom_i32(item),
257                Some("lib") => libraries.push(parse_library_summary(item)),
258                _ => {
259                    if let Some(unknown) = UnknownNode::from_node(item) {
260                        unknown_nodes.push(unknown);
261                    }
262                }
263            }
264        }
265    }
266
267    let library_count = libraries.len();
268    let disabled_library_count = libraries.iter().filter(|l| l.disabled).count();
269
270    LibTableAst {
271        kind,
272        version,
273        libraries,
274        library_count,
275        disabled_library_count,
276        unknown_nodes,
277    }
278}
279
280fn parse_library_summary(node: &Node) -> LibTableLibrarySummary {
281    let mut name = None;
282    let mut library_type = None;
283    let mut uri = None;
284    let mut options = None;
285    let mut descr = None;
286    let mut disabled = false;
287
288    if let Node::List { items, .. } = node {
289        for child in items.iter().skip(1) {
290            match head_of(child) {
291                Some("name") => name = second_atom_string(child),
292                Some("type") => library_type = second_atom_string(child),
293                Some("uri") => uri = second_atom_string(child),
294                Some("options") => options = second_atom_string(child),
295                Some("descr") => descr = second_atom_string(child),
296                Some("disabled") => disabled = true,
297                _ => {}
298            }
299        }
300    }
301
302    LibTableLibrarySummary {
303        name,
304        library_type,
305        uri,
306        options,
307        descr,
308        disabled,
309    }
310}
311
312fn second_atom_string(node: &Node) -> Option<String> {
313    match node {
314        Node::List { items, .. } => items.get(1).and_then(atom_as_string),
315        _ => None,
316    }
317}
318
319fn find_library_index(items: &[Node], name: &str) -> Option<usize> {
320    items
321        .iter()
322        .enumerate()
323        .skip(1)
324        .find(|(_, node)| {
325            if head_of(node) != Some("lib") {
326                return false;
327            }
328            match node {
329                Node::List {
330                    items: lib_items, ..
331                } => {
332                    lib_items
333                        .iter()
334                        .find(|n| head_of(n) == Some("name"))
335                        .and_then(second_atom_string)
336                        .as_deref()
337                        == Some(name)
338                }
339                _ => false,
340            }
341        })
342        .map(|(idx, _)| idx)
343}
344
345struct LibNodeInput {
346    name: String,
347    library_type: String,
348    uri: String,
349    options: String,
350    descr: String,
351    disabled: bool,
352}
353
354fn list_node2(head: String, value: Node) -> Node {
355    list_node(vec![atom_symbol(head), value])
356}
357
358fn lib_node(input: LibNodeInput) -> Node {
359    let mut items = vec![atom_symbol("lib".to_string())];
360    items.push(list_node2("name".to_string(), atom_quoted(input.name)));
361    items.push(list_node2(
362        "type".to_string(),
363        atom_quoted(input.library_type),
364    ));
365    items.push(list_node2("uri".to_string(), atom_quoted(input.uri)));
366    items.push(list_node2(
367        "options".to_string(),
368        atom_quoted(input.options),
369    ));
370    items.push(list_node2("descr".to_string(), atom_quoted(input.descr)));
371    if input.disabled {
372        items.push(list_node(vec![atom_symbol("disabled".to_string())]));
373    }
374    list_node(items)
375}
376
377#[cfg(test)]
378mod tests {
379    use std::path::PathBuf;
380    use std::time::{SystemTime, UNIX_EPOCH};
381
382    use super::*;
383
384    fn tmp_file(name: &str) -> PathBuf {
385        let nanos = SystemTime::now()
386            .duration_since(UNIX_EPOCH)
387            .expect("clock")
388            .as_nanos();
389        std::env::temp_dir().join(format!("{name}_{nanos}.table"))
390    }
391
392    #[test]
393    fn read_fp_lib_table() {
394        let path = tmp_file("fplib_ok");
395        let src = "(fp_lib_table\n  (version 7)\n  (lib (name \"A\") (type \"KiCad\") (uri \"x\") (options \"\") (descr \"\"))\n)\n";
396        fs::write(&path, src).expect("write fixture");
397
398        let doc = FpLibTableFile::read(&path).expect("read");
399        assert_eq!(doc.ast().kind, LibTableKind::Footprint);
400        assert_eq!(doc.ast().version, Some(7));
401        assert_eq!(doc.ast().library_count, 1);
402        assert!(doc.ast().unknown_nodes.is_empty());
403
404        let _ = fs::remove_file(path);
405    }
406
407    #[test]
408    fn read_sym_lib_table() {
409        let path = tmp_file("symlib_ok");
410        let src = "(sym_lib_table\n  (version 7)\n  (lib (name \"S\") (type \"KiCad\") (uri \"y\") (options \"\") (descr \"\"))\n)\n";
411        fs::write(&path, src).expect("write fixture");
412
413        let doc = SymLibTableFile::read(&path).expect("read");
414        assert_eq!(doc.ast().kind, LibTableKind::Symbol);
415        assert_eq!(doc.ast().version, Some(7));
416        assert_eq!(doc.ast().library_count, 1);
417        assert!(doc.ast().unknown_nodes.is_empty());
418
419        let _ = fs::remove_file(path);
420    }
421
422    #[test]
423    fn read_fp_lib_table_captures_unknown() {
424        let path = tmp_file("fplib_unknown");
425        let src = "(fp_lib_table\n  (lib (name \"A\") (type \"KiCad\") (uri \"x\") (options \"\") (descr \"\"))\n  (unknown_table_item 1)\n)\n";
426        fs::write(&path, src).expect("write fixture");
427
428        let doc = FpLibTableFile::read(&path).expect("read");
429        assert_eq!(doc.ast().unknown_nodes.len(), 1);
430        assert_eq!(
431            doc.ast().unknown_nodes[0].head.as_deref(),
432            Some("unknown_table_item")
433        );
434
435        let _ = fs::remove_file(path);
436    }
437
438    #[test]
439    fn edit_roundtrip_renames_and_adds_library() {
440        let path = tmp_file("fplib_edit");
441        let src = "(fp_lib_table (version 7) (lib (name \"A\") (type \"KiCad\") (uri \"x\") (options \"\") (descr \"\")))\n";
442        fs::write(&path, src).expect("write fixture");
443
444        let mut doc = FpLibTableFile::read(&path).expect("read");
445        doc.rename_library("A", "B")
446            .add_library("C", "${KIPRJMOD}/C");
447        let out = tmp_file("fplib_edit_out");
448        doc.write(&out).expect("write");
449        let reread = FpLibTableFile::read(&out).expect("reread");
450        assert_eq!(reread.ast().library_count, 2);
451        assert_eq!(
452            reread.ast().libraries.first().and_then(|l| l.name.clone()),
453            Some("B".to_string())
454        );
455        assert_eq!(
456            reread.ast().libraries.get(1).and_then(|l| l.name.clone()),
457            Some("C".to_string())
458        );
459
460        let _ = fs::remove_file(path);
461        let _ = fs::remove_file(out);
462    }
463
464    #[test]
465    fn upsert_library_uri_replaces_existing_uri() {
466        let path = tmp_file("fplib_upsert_existing");
467        let src = "(fp_lib_table (version 7) (lib (name \"A\") (type \"Legacy\") (uri \"x\") (options \"opt=1\") (descr \"legacy\") (disabled)))\n";
468        fs::write(&path, src).expect("write fixture");
469
470        let mut doc = FpLibTableFile::read(&path).expect("read");
471        doc.upsert_library_uri("A", "${KIPRJMOD}/A.pretty");
472        assert_eq!(doc.ast().library_count, 1);
473        assert_eq!(
474            doc.ast().libraries[0].library_type.as_deref(),
475            Some("Legacy")
476        );
477        assert_eq!(
478            doc.ast().libraries[0].uri.as_deref(),
479            Some("${KIPRJMOD}/A.pretty")
480        );
481        assert_eq!(doc.ast().libraries[0].options.as_deref(), Some("opt=1"));
482        assert_eq!(doc.ast().libraries[0].descr.as_deref(), Some("legacy"));
483        assert!(doc.ast().libraries[0].disabled);
484
485        let _ = fs::remove_file(path);
486    }
487
488    #[test]
489    fn upsert_library_uri_adds_when_missing() {
490        let path = tmp_file("fplib_upsert_missing");
491        let src = "(fp_lib_table (version 7))\n";
492        fs::write(&path, src).expect("write fixture");
493
494        let mut doc = FpLibTableFile::read(&path).expect("read");
495        doc.upsert_library_uri("A", "${KIPRJMOD}/A.pretty");
496        assert_eq!(doc.ast().library_count, 1);
497        assert_eq!(doc.ast().libraries[0].name.as_deref(), Some("A"));
498        assert_eq!(
499            doc.ast().libraries[0].uri.as_deref(),
500            Some("${KIPRJMOD}/A.pretty")
501        );
502
503        let _ = fs::remove_file(path);
504    }
505}