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}