1mod docs;
2mod errors;
3mod registry;
4
5use std::{
6 collections::{HashMap, HashSet},
7 fs,
8 path::{Path, PathBuf},
9};
10
11use synapse_parser::ast::{BaseType, FieldDef, Item, SynFile};
12
13pub use errors::Error;
14pub use synapse_codegen_cfs::{CfsOptions, MsgIdLayout};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Lang {
19 C,
21 Rust,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum RegistryFormat {
28 Json,
30 Csv,
32}
33
34impl Lang {
35 pub fn extension(self) -> &'static str {
37 match self {
38 Lang::C => "h",
39 Lang::Rust => "rs",
40 }
41 }
42}
43
44pub fn generate_str(source: &str, lang: Lang) -> Result<String, Error> {
46 generate_str_with_options(source, lang, &CfsOptions::default())
47}
48
49pub fn generate_str_with_options(
51 source: &str,
52 lang: Lang,
53 options: &CfsOptions,
54) -> Result<String, Error> {
55 let file = synapse_parser::ast::parse(source)?;
56 generate_parsed(&file, lang, options)
57}
58
59pub fn check_str(source: &str) -> Result<(), Error> {
61 check_str_with_options(source, &CfsOptions::default())
62}
63
64pub fn check_str_with_options(source: &str, options: &CfsOptions) -> Result<(), Error> {
66 let file = synapse_parser::ast::parse(source)?;
67 synapse_codegen_cfs::validate_cfs_with_options(&file, options)?;
68 Ok(())
69}
70
71pub fn check_path(input: impl AsRef<Path>) -> Result<(), Error> {
73 check_paths([input.as_ref()])
74}
75
76pub fn check_path_with_options(input: impl AsRef<Path>, options: &CfsOptions) -> Result<(), Error> {
78 check_paths_with_options([input.as_ref()], options)
79}
80
81pub fn check_paths<I, P>(inputs: I) -> Result<(), Error>
87where
88 I: IntoIterator<Item = P>,
89 P: AsRef<Path>,
90{
91 check_paths_with_options(inputs, &CfsOptions::default())
92}
93
94pub fn check_paths_with_options<I, P>(inputs: I, options: &CfsOptions) -> Result<(), Error>
96where
97 I: IntoIterator<Item = P>,
98 P: AsRef<Path>,
99{
100 let inputs = collect_input_paths(inputs, "check")?;
101 let graph = load_import_graphs(&inputs)?;
102 validate_cfs_graph(&graph, options)?;
103 Ok(())
104}
105
106pub fn generate_docs<I, P>(inputs: I) -> Result<String, Error>
108where
109 I: IntoIterator<Item = P>,
110 P: AsRef<Path>,
111{
112 generate_docs_with_options(inputs, &CfsOptions::default())
113}
114
115pub fn generate_docs_with_options<I, P>(inputs: I, options: &CfsOptions) -> Result<String, Error>
117where
118 I: IntoIterator<Item = P>,
119 P: AsRef<Path>,
120{
121 let inputs = collect_input_paths(inputs, "doc")?;
122 let graph = load_import_graphs(&inputs)?;
123 validate_cfs_graph(&graph, options)?;
124 let units_by_path = units_by_path(&graph);
125
126 docs::render_html_docs(&graph, &units_by_path, options)
127}
128
129pub fn write_docs<I, P>(inputs: I, out_dir: impl AsRef<Path>) -> Result<PathBuf, Error>
131where
132 I: IntoIterator<Item = P>,
133 P: AsRef<Path>,
134{
135 write_docs_with_options(inputs, out_dir, &CfsOptions::default())
136}
137
138pub fn write_docs_with_options<I, P>(
140 inputs: I,
141 out_dir: impl AsRef<Path>,
142 options: &CfsOptions,
143) -> Result<PathBuf, Error>
144where
145 I: IntoIterator<Item = P>,
146 P: AsRef<Path>,
147{
148 let output = generate_docs_with_options(inputs, options)?;
149 let out_dir = out_dir.as_ref();
150 fs::create_dir_all(out_dir)?;
151 let out_path = out_dir.join("index.html");
152 fs::write(&out_path, output)?;
153 Ok(out_path)
154}
155
156pub fn generate_registry<I, P>(inputs: I, format: RegistryFormat) -> Result<String, Error>
158where
159 I: IntoIterator<Item = P>,
160 P: AsRef<Path>,
161{
162 generate_registry_with_options(inputs, format, &CfsOptions::default())
163}
164
165pub fn generate_registry_with_options<I, P>(
167 inputs: I,
168 format: RegistryFormat,
169 options: &CfsOptions,
170) -> Result<String, Error>
171where
172 I: IntoIterator<Item = P>,
173 P: AsRef<Path>,
174{
175 let inputs = collect_input_paths(inputs, "registry")?;
176 let graph = load_import_graphs(&inputs)?;
177 validate_cfs_graph(&graph, options)?;
178 let units_by_path = units_by_path(&graph);
179
180 registry::render_registry(&graph, &units_by_path, format, options)
181}
182
183pub fn write_registry<I, P>(
185 inputs: I,
186 output: impl AsRef<Path>,
187 format: RegistryFormat,
188) -> Result<PathBuf, Error>
189where
190 I: IntoIterator<Item = P>,
191 P: AsRef<Path>,
192{
193 write_registry_with_options(inputs, output, format, &CfsOptions::default())
194}
195
196pub fn write_registry_with_options<I, P>(
198 inputs: I,
199 output: impl AsRef<Path>,
200 format: RegistryFormat,
201 options: &CfsOptions,
202) -> Result<PathBuf, Error>
203where
204 I: IntoIterator<Item = P>,
205 P: AsRef<Path>,
206{
207 let registry = generate_registry_with_options(inputs, format, options)?;
208 let output = output.as_ref();
209 if let Some(parent) = output.parent() {
210 if !parent.as_os_str().is_empty() {
211 fs::create_dir_all(parent)?;
212 }
213 }
214 fs::write(output, registry)?;
215 Ok(output.to_path_buf())
216}
217
218pub fn generate_path(input: impl AsRef<Path>, lang: Lang) -> Result<String, Error> {
220 generate_path_with_options(input, lang, &CfsOptions::default())
221}
222
223pub fn generate_path_with_options(
225 input: impl AsRef<Path>,
226 lang: Lang,
227 options: &CfsOptions,
228) -> Result<String, Error> {
229 let graph = load_import_graph(input.as_ref())?;
230 validate_import_graph(&graph)?;
231 let units_by_path = units_by_path(&graph);
232 let root = graph
233 .units
234 .last()
235 .expect("import graph always contains the root input");
236 let imported_constants = imported_constants_for_unit(root, &units_by_path)?;
237 generate_parsed_with_constants(&root.file, lang, &imported_constants, options)
238}
239
240fn generate_parsed(file: &SynFile, lang: Lang, options: &CfsOptions) -> Result<String, Error> {
241 generate_parsed_with_constants(
242 file,
243 lang,
244 &synapse_codegen_cfs::ResolvedConstants::new(),
245 options,
246 )
247}
248
249fn collect_input_paths<I, P>(inputs: I, command: &str) -> Result<Vec<PathBuf>, Error>
250where
251 I: IntoIterator<Item = P>,
252 P: AsRef<Path>,
253{
254 let inputs = inputs
255 .into_iter()
256 .map(|input| input.as_ref().to_path_buf())
257 .collect::<Vec<_>>();
258 if inputs.is_empty() {
259 return Err(Error::Mission(format!(
260 "{command} requires at least one input .syn file"
261 )));
262 }
263 Ok(inputs)
264}
265
266fn validate_cfs_graph(graph: &ImportGraph, options: &CfsOptions) -> Result<(), Error> {
267 validate_import_graph(graph)?;
268 let units_by_path = units_by_path(graph);
269 validate_cfs_units(graph, &units_by_path, options)?;
270 validate_mission_registry(graph, &units_by_path, options)
271}
272
273fn validate_cfs_units(
274 graph: &ImportGraph,
275 units_by_path: &HashMap<PathBuf, &ParsedUnit>,
276 options: &CfsOptions,
277) -> Result<(), Error> {
278 for unit in &graph.units {
279 let imported_constants = imported_constants_for_unit(unit, units_by_path)?;
280 synapse_codegen_cfs::validate_cfs_with_constants_and_options(
281 &unit.file,
282 &imported_constants,
283 options,
284 )?;
285 }
286 Ok(())
287}
288
289fn generate_parsed_with_constants(
290 file: &SynFile,
291 lang: Lang,
292 imported_constants: &synapse_codegen_cfs::ResolvedConstants,
293 options: &CfsOptions,
294) -> Result<String, Error> {
295 let output = match lang {
296 Lang::C => synapse_codegen_cfs::try_generate_c_with_constants_and_options(
297 file,
298 imported_constants,
299 options,
300 )?,
301 Lang::Rust => synapse_codegen_cfs::try_generate_rust_with_constants_and_options(
302 file,
303 &Default::default(),
304 imported_constants,
305 options,
306 )?,
307 };
308 Ok(output)
309}
310
311pub fn generate_file(
316 input: impl AsRef<Path>,
317 out_dir: impl AsRef<Path>,
318 lang: Lang,
319) -> Result<PathBuf, Error> {
320 let input = input.as_ref();
321 generate_file_with_options(input, out_dir, lang, &CfsOptions::default())
322}
323
324pub fn generate_file_with_options(
326 input: impl AsRef<Path>,
327 out_dir: impl AsRef<Path>,
328 lang: Lang,
329 options: &CfsOptions,
330) -> Result<PathBuf, Error> {
331 let input = input.as_ref();
332 let output = generate_path_with_options(input, lang, options)?;
333
334 let out_dir = out_dir.as_ref();
335 fs::create_dir_all(out_dir)?;
336
337 let stem = input.file_stem().ok_or_else(|| {
338 std::io::Error::new(
339 std::io::ErrorKind::InvalidInput,
340 format!("input file has no stem: {}", input.display()),
341 )
342 })?;
343 let out_path = out_dir.join(format!("{}.{}", stem.to_string_lossy(), lang.extension()));
344 fs::write(&out_path, output)?;
345 Ok(out_path)
346}
347
348pub fn generate_files(
354 input: impl AsRef<Path>,
355 out_dir: impl AsRef<Path>,
356 lang: Lang,
357) -> Result<Vec<PathBuf>, Error> {
358 generate_files_with_options(input, out_dir, lang, &CfsOptions::default())
359}
360
361pub fn generate_files_with_options(
363 input: impl AsRef<Path>,
364 out_dir: impl AsRef<Path>,
365 lang: Lang,
366 options: &CfsOptions,
367) -> Result<Vec<PathBuf>, Error> {
368 let graph = load_import_graph(input.as_ref())?;
369 validate_import_graph(&graph)?;
370 let units_by_path = units_by_path(&graph);
371
372 let out_dir = out_dir.as_ref();
373 fs::create_dir_all(out_dir)?;
374
375 let mut written = Vec::new();
376 for unit in &graph.units {
377 written.push(write_generated_unit(
378 unit,
379 &units_by_path,
380 out_dir,
381 lang,
382 options,
383 )?);
384 }
385 Ok(written)
386}
387
388fn write_generated_unit(
389 unit: &ParsedUnit,
390 units_by_path: &HashMap<PathBuf, &ParsedUnit>,
391 out_dir: &Path,
392 lang: Lang,
393 options: &CfsOptions,
394) -> Result<PathBuf, Error> {
395 let imported_constants = imported_constants_for_unit(unit, units_by_path)?;
396 let output = generate_parsed_with_constants(&unit.file, lang, &imported_constants, options)?;
397 let out_path = output_path_for(&unit.path, out_dir, lang)?;
398 fs::write(&out_path, output)?;
399 Ok(out_path)
400}
401
402#[derive(Debug)]
403struct ImportGraph {
404 units: Vec<ParsedUnit>,
405}
406
407#[derive(Debug)]
408struct ParsedUnit {
409 path: PathBuf,
410 file: SynFile,
411}
412
413fn load_import_graph(input: &Path) -> Result<ImportGraph, Error> {
414 load_import_graphs(&[input.to_path_buf()])
415}
416
417fn load_import_graphs(inputs: &[PathBuf]) -> Result<ImportGraph, Error> {
418 let mut units = Vec::new();
419 let mut visited = HashSet::new();
420 let mut visiting = HashSet::new();
421 let mut stack = Vec::new();
422 for input in inputs {
423 load_import_unit(input, &mut units, &mut visited, &mut visiting, &mut stack)?;
424 }
425 Ok(ImportGraph { units })
426}
427
428fn load_import_unit(
429 input: &Path,
430 units: &mut Vec<ParsedUnit>,
431 visited: &mut HashSet<PathBuf>,
432 visiting: &mut HashSet<PathBuf>,
433 stack: &mut Vec<PathBuf>,
434) -> Result<(), Error> {
435 let path = canonicalize_import_path(input)?;
436 if visited.contains(&path) {
437 return Ok(());
438 }
439 reject_import_cycle(&path, visiting, stack)?;
440
441 visiting.insert(path.clone());
442 stack.push(path.clone());
443
444 let file = parse_import_file(&path)?;
445
446 let base_dir = path.parent().unwrap_or_else(|| Path::new(""));
447 load_child_imports(&file, base_dir, units, visited, visiting, stack)?;
448
449 stack.pop();
450 visiting.remove(&path);
451 visited.insert(path.clone());
452 units.push(ParsedUnit { path, file });
453 Ok(())
454}
455
456fn reject_import_cycle(
457 path: &Path,
458 visiting: &HashSet<PathBuf>,
459 stack: &mut Vec<PathBuf>,
460) -> Result<(), Error> {
461 if !visiting.contains(path) {
462 return Ok(());
463 }
464
465 stack.push(path.to_path_buf());
466 let cycle = stack
467 .iter()
468 .map(|p| p.display().to_string())
469 .collect::<Vec<_>>()
470 .join(" -> ");
471 stack.pop();
472 Err(Error::Import(format!("import cycle detected: {cycle}")))
473}
474
475fn parse_import_file(path: &Path) -> Result<SynFile, Error> {
476 let source = fs::read_to_string(path)
477 .map_err(|e| Error::Import(format!("error reading import `{}`: {e}", path.display())))?;
478 synapse_parser::ast::parse(&source)
479 .map_err(|e| Error::Import(format!("error parsing import `{}`:\n{e}", path.display())))
480}
481
482fn load_child_imports(
483 file: &SynFile,
484 base_dir: &Path,
485 units: &mut Vec<ParsedUnit>,
486 visited: &mut HashSet<PathBuf>,
487 visiting: &mut HashSet<PathBuf>,
488 stack: &mut Vec<PathBuf>,
489) -> Result<(), Error> {
490 for item in &file.items {
491 if let Item::Import(import) = item {
492 load_import_unit(
493 &base_dir.join(&import.path),
494 units,
495 visited,
496 visiting,
497 stack,
498 )?;
499 }
500 }
501 Ok(())
502}
503
504fn canonicalize_import_path(path: &Path) -> Result<PathBuf, Error> {
505 fs::canonicalize(path)
506 .map_err(|e| Error::Import(format!("error reading import `{}`: {e}", path.display())))
507}
508
509fn validate_import_graph(graph: &ImportGraph) -> Result<(), Error> {
510 let units_by_path = units_by_path(graph);
511 for unit in &graph.units {
512 validate_import_unit(unit, &units_by_path)?;
513 }
514 Ok(())
515}
516
517fn units_by_path(graph: &ImportGraph) -> HashMap<PathBuf, &ParsedUnit> {
518 graph
519 .units
520 .iter()
521 .map(|unit| (unit.path.clone(), unit))
522 .collect()
523}
524
525fn imported_constants_for_unit(
526 unit: &ParsedUnit,
527 units_by_path: &HashMap<PathBuf, &ParsedUnit>,
528) -> Result<synapse_codegen_cfs::ResolvedConstants, Error> {
529 let base_dir = unit.path.parent().unwrap_or_else(|| Path::new(""));
530 let mut constants = synapse_codegen_cfs::ResolvedConstants::new();
531
532 for item in &unit.file.items {
533 let Item::Import(import) = item else {
534 continue;
535 };
536
537 let import_path = canonicalize_import_path(&base_dir.join(&import.path))?;
538 let imported = units_by_path
539 .get(&import_path)
540 .expect("import graph loader parsed direct imports");
541 constants.extend(exported_constants_for_unit(imported, units_by_path)?);
542 }
543
544 Ok(constants)
545}
546
547fn exported_constants_for_unit(
548 unit: &ParsedUnit,
549 units_by_path: &HashMap<PathBuf, &ParsedUnit>,
550) -> Result<synapse_codegen_cfs::ResolvedConstants, Error> {
551 let imported_constants = imported_constants_for_unit(unit, units_by_path)?;
552 let resolved = synapse_codegen_cfs::resolve_integer_constants(&unit.file, &imported_constants);
553 let local_namespace = namespace(&unit.file);
554 let mut exported = synapse_codegen_cfs::ResolvedConstants::new();
555
556 for item in &unit.file.items {
557 let Item::Const(c) = item else {
558 continue;
559 };
560
561 let key = if local_namespace.is_empty() {
562 vec![c.name.clone()]
563 } else {
564 let mut qualified = local_namespace.clone();
565 qualified.push(c.name.clone());
566 qualified
567 };
568 if let Some(value) = resolved.get(&key) {
569 exported.insert(key, *value);
570 }
571 }
572
573 Ok(exported)
574}
575
576fn validate_import_unit(
577 unit: &ParsedUnit,
578 units_by_path: &HashMap<PathBuf, &ParsedUnit>,
579) -> Result<(), Error> {
580 let base_dir = unit.path.parent().unwrap_or_else(|| Path::new(""));
581 let local_namespace = namespace(&unit.file);
582 let mut symbols = local_symbols(&unit.file, &local_namespace);
583 let mut imported_type_suggestions = HashMap::new();
584
585 for item in &unit.file.items {
586 let Item::Import(import) = item else {
587 continue;
588 };
589
590 let import_path = canonicalize_import_path(&base_dir.join(&import.path))?;
591 let imported = units_by_path
592 .get(&import_path)
593 .expect("import graph loader parsed direct imports");
594 let imported_namespace = namespace(&imported.file);
595 let imported_names = declared_names(&imported.file);
596 for name in &imported_names {
597 if !imported_namespace.is_empty() {
598 let mut qualified = imported_namespace.clone();
599 qualified.push(name.clone());
600 imported_type_suggestions.insert(name.clone(), qualified.join("::"));
601 }
602 }
603 symbols.extend(qualified_symbols(&imported_names, &imported_namespace));
604 }
605
606 validate_type_refs(&unit.file, &symbols, &imported_type_suggestions)
607}
608
609#[derive(Debug, Clone)]
610struct MissionPacket {
611 path: PathBuf,
612 namespace: Vec<String>,
613 name: String,
614 kind: synapse_codegen_cfs::CfsPacketKind,
615 mid: u64,
616 cc: Option<u64>,
617}
618
619fn validate_mission_registry(
620 graph: &ImportGraph,
621 units_by_path: &HashMap<PathBuf, &ParsedUnit>,
622 options: &CfsOptions,
623) -> Result<(), Error> {
624 let mut telemetry_mids = HashMap::<u64, MissionPacket>::new();
625 let mut command_codes = HashMap::<(u64, u64), MissionPacket>::new();
626
627 for unit in &graph.units {
628 let imported_constants = imported_constants_for_unit(unit, units_by_path)?;
629 let packets = synapse_codegen_cfs::collect_cfs_packets_with_constants_and_options(
630 &unit.file,
631 &imported_constants,
632 options,
633 )?;
634
635 for packet in packets {
636 let packet = mission_packet(unit, packet);
637 register_mission_packet(&packet, &mut telemetry_mids, &mut command_codes)?;
638 }
639 }
640
641 Ok(())
642}
643
644fn mission_packet(unit: &ParsedUnit, packet: synapse_codegen_cfs::CfsPacket) -> MissionPacket {
645 MissionPacket {
646 path: unit.path.clone(),
647 namespace: packet.namespace,
648 name: packet.name,
649 kind: packet.kind,
650 mid: packet.mid,
651 cc: packet.cc,
652 }
653}
654
655fn register_mission_packet(
656 packet: &MissionPacket,
657 telemetry_mids: &mut HashMap<u64, MissionPacket>,
658 command_codes: &mut HashMap<(u64, u64), MissionPacket>,
659) -> Result<(), Error> {
660 match packet.kind {
661 synapse_codegen_cfs::CfsPacketKind::Telemetry => {
662 register_mission_telemetry(packet, telemetry_mids)
663 }
664 synapse_codegen_cfs::CfsPacketKind::Command => {
665 register_mission_command(packet, command_codes)
666 }
667 }
668}
669
670fn register_mission_telemetry(
671 packet: &MissionPacket,
672 telemetry_mids: &mut HashMap<u64, MissionPacket>,
673) -> Result<(), Error> {
674 if let Some(first) = telemetry_mids.insert(packet.mid, packet.clone()) {
675 return Err(Error::Mission(format!(
676 "duplicate telemetry MID `{}` across mission packets `{}` ({}) and `{}` ({})",
677 format_mid(packet.mid),
678 packet_name(&first),
679 first.path.display(),
680 packet_name(packet),
681 packet.path.display()
682 )));
683 }
684 Ok(())
685}
686
687fn register_mission_command(
688 packet: &MissionPacket,
689 command_codes: &mut HashMap<(u64, u64), MissionPacket>,
690) -> Result<(), Error> {
691 let cc = packet
692 .cc
693 .expect("cFS packet collector resolves command codes");
694 if let Some(first) = command_codes.insert((packet.mid, cc), packet.clone()) {
695 return Err(Error::Mission(format!(
696 "duplicate command MID/CC pair `{}`/`{}` across mission packets `{}` ({}) and `{}` ({})",
697 format_mid(packet.mid),
698 cc,
699 packet_name(&first),
700 first.path.display(),
701 packet_name(packet),
702 packet.path.display()
703 )));
704 }
705 Ok(())
706}
707
708fn packet_name(packet: &MissionPacket) -> String {
709 if packet.namespace.is_empty() {
710 packet.name.clone()
711 } else {
712 let mut segments = packet.namespace.clone();
713 segments.push(packet.name.clone());
714 segments.join("::")
715 }
716}
717
718pub(crate) fn format_mid(mid: u64) -> String {
719 format!("0x{mid:04X}")
720}
721
722fn output_path_for(input: &Path, out_dir: &Path, lang: Lang) -> Result<PathBuf, Error> {
723 let stem = input.file_stem().ok_or_else(|| {
724 std::io::Error::new(
725 std::io::ErrorKind::InvalidInput,
726 format!("input file has no stem: {}", input.display()),
727 )
728 })?;
729 Ok(out_dir.join(format!("{}.{}", stem.to_string_lossy(), lang.extension())))
730}
731
732fn namespace(file: &SynFile) -> Vec<String> {
733 file.items
734 .iter()
735 .find_map(|item| match item {
736 Item::Namespace(ns) => Some(ns.name.clone()),
737 _ => None,
738 })
739 .unwrap_or_default()
740}
741
742fn local_symbols(file: &SynFile, namespace: &[String]) -> HashSet<Vec<String>> {
743 let mut symbols = HashSet::new();
744 for name in declared_names(file) {
745 symbols.insert(vec![name.clone()]);
746 if !namespace.is_empty() {
747 let mut qualified = namespace.to_vec();
748 qualified.push(name);
749 symbols.insert(qualified);
750 }
751 }
752 symbols
753}
754
755fn qualified_symbols(names: &[String], namespace: &[String]) -> HashSet<Vec<String>> {
756 let mut symbols = HashSet::new();
757 if namespace.is_empty() {
758 return symbols;
759 }
760 for name in names {
761 let mut qualified = namespace.to_vec();
762 qualified.push(name.clone());
763 symbols.insert(qualified);
764 }
765 symbols
766}
767
768fn declared_names(file: &SynFile) -> Vec<String> {
769 file.items
770 .iter()
771 .filter_map(|item| match item {
772 Item::Const(c) => Some(c.name.clone()),
773 Item::Enum(e) => Some(e.name.clone()),
774 Item::Struct(s) | Item::Table(s) => Some(s.name.clone()),
775 Item::Command(m) | Item::Telemetry(m) | Item::Message(m) => Some(m.name.clone()),
776 Item::Namespace(_) | Item::Import(_) => None,
777 })
778 .collect()
779}
780
781fn validate_type_refs(
782 file: &SynFile,
783 symbols: &HashSet<Vec<String>>,
784 imported_type_suggestions: &HashMap<String, String>,
785) -> Result<(), Error> {
786 for item in &file.items {
787 validate_item_type_refs(item, symbols, imported_type_suggestions)?;
788 }
789 Ok(())
790}
791
792fn validate_item_type_refs(
793 item: &Item,
794 symbols: &HashSet<Vec<String>>,
795 imported_type_suggestions: &HashMap<String, String>,
796) -> Result<(), Error> {
797 match item {
798 Item::Const(c) => {
799 validate_type_ref(&c.name, &c.ty.base, symbols, imported_type_suggestions)
800 }
801 Item::Struct(s) | Item::Table(s) => {
802 validate_field_refs(&s.name, &s.fields, symbols, imported_type_suggestions)
803 }
804 Item::Command(m) | Item::Telemetry(m) | Item::Message(m) => {
805 validate_field_refs(&m.name, &m.fields, symbols, imported_type_suggestions)
806 }
807 Item::Namespace(_) | Item::Import(_) | Item::Enum(_) => Ok(()),
808 }
809}
810
811fn validate_field_refs(
812 container: &str,
813 fields: &[FieldDef],
814 symbols: &HashSet<Vec<String>>,
815 imported_type_suggestions: &HashMap<String, String>,
816) -> Result<(), Error> {
817 for field in fields {
818 validate_type_ref(
819 &format!("{container}.{}", field.name),
820 &field.ty.base,
821 symbols,
822 imported_type_suggestions,
823 )?;
824 }
825 Ok(())
826}
827
828fn validate_type_ref(
829 owner: &str,
830 base: &BaseType,
831 symbols: &HashSet<Vec<String>>,
832 imported_type_suggestions: &HashMap<String, String>,
833) -> Result<(), Error> {
834 let BaseType::Ref(segments) = base else {
835 return Ok(());
836 };
837 if symbols.contains(segments) {
838 return Ok(());
839 }
840 if segments.len() == 1 {
841 if let Some(suggestion) = imported_type_suggestions.get(&segments[0]) {
842 return Err(Error::Import(format!(
843 "imported type reference `{}` in `{owner}` must be namespace-qualified as `{suggestion}`",
844 segments[0]
845 )));
846 }
847 }
848 Err(Error::Import(format!(
849 "unresolved type reference `{}` in `{owner}`",
850 segments.join("::")
851 )))
852}
853
854pub fn generate_c_file(
856 input: impl AsRef<Path>,
857 out_dir: impl AsRef<Path>,
858) -> Result<PathBuf, Error> {
859 generate_file(input, out_dir, Lang::C)
860}
861
862pub fn generate_rust_file(
864 input: impl AsRef<Path>,
865 out_dir: impl AsRef<Path>,
866) -> Result<PathBuf, Error> {
867 generate_file(input, out_dir, Lang::Rust)
868}
869
870#[cfg(test)]
871mod tests {
872 use super::*;
873 use std::time::{SystemTime, UNIX_EPOCH};
874
875 #[test]
876 fn generate_c_from_string() {
877 let out = generate_str(
878 "@mid(0x1880)\n@cc(1)\ncommand SetMode { mode: u8 }",
879 Lang::C,
880 )
881 .unwrap();
882 assert!(out.contains("#define SET_MODE_MID 0x1880U"));
883 assert!(out.contains("#define SET_MODE_CC 1U"));
884 assert!(out.contains("CFE_MSG_CommandHeader_t Header;"));
885 }
886
887 #[test]
888 fn generate_rust_from_string() {
889 let out = generate_str("@mid(0x0801)\ntelemetry NavState { x: f64 }", Lang::Rust).unwrap();
890 assert!(out.contains("pub const NAV_STATE_MID: u16 = 0x0801;"));
891 assert!(out.contains("pub cfs_header: cfs_sys::CFE_MSG_TelemetryHeader_t,"));
892 }
893
894 #[test]
895 fn rejects_optional_fields() {
896 let err = generate_str(
897 "@mid(0x0801)\ntelemetry Status { error_code?: u32 }",
898 Lang::C,
899 )
900 .unwrap_err();
901 assert_eq!(
902 err.to_string(),
903 "optional field `Status.error_code` is not supported by cFS codegen yet"
904 );
905 }
906
907 #[test]
908 fn check_str_validates_cfs_codegen_support() {
909 let err = check_str("@mid(0x0801)\ntelemetry Status { error_code?: u32 }").unwrap_err();
910 assert_eq!(
911 err.to_string(),
912 "optional field `Status.error_code` is not supported by cFS codegen yet"
913 );
914 }
915
916 #[test]
917 fn rejects_default_values() {
918 let err = generate_str("table Config { exposure_us: u32 = 10000 }", Lang::C).unwrap_err();
919 assert_eq!(
920 err.to_string(),
921 "default value for field `Config.exposure_us` is not supported by cFS codegen yet"
922 );
923 }
924
925 #[test]
926 fn rejects_enum_fields() {
927 let err = generate_str(
928 "enum CameraMode { Idle = 0 Streaming = 1 }\n@mid(0x0801)\ntelemetry Status { mode: CameraMode }",
929 Lang::C,
930 )
931 .unwrap_err();
932 assert_eq!(
933 err.to_string(),
934 "enum field `Status.mode` with type `CameraMode` needs an explicit integer representation for cFS codegen"
935 );
936 }
937
938 #[test]
939 fn rejects_unbounded_strings() {
940 let err = generate_str("struct Label { name: string }", Lang::C).unwrap_err();
941 assert_eq!(
942 err.to_string(),
943 "unbounded string field `Label.name` is not supported by cFS codegen; use `string[<=N]` or `string[N]`"
944 );
945 }
946
947 #[test]
948 fn rejects_legacy_message() {
949 let err = generate_str("@mid(0x0801)\nmessage Status { x: f32 }", Lang::C).unwrap_err();
950 assert_eq!(
951 err.to_string(),
952 "legacy message `Status` is not supported by cFS codegen; use `command` or `telemetry`"
953 );
954 }
955
956 #[test]
957 fn rejects_packet_without_mid() {
958 let err = generate_str("command SetMode { mode: u8 }", Lang::C).unwrap_err();
959 assert_eq!(
960 err.to_string(),
961 "packet `SetMode` is missing required `@mid(...)`"
962 );
963 }
964
965 #[test]
966 fn rejects_command_without_cc() {
967 let err = generate_str("@mid(0x1880)\ncommand SetMode { mode: u8 }", Lang::C).unwrap_err();
968 assert_eq!(
969 err.to_string(),
970 "command `SetMode` is missing required `@cc(...)`"
971 );
972 }
973
974 #[test]
975 fn rejects_mid_range_mismatch() {
976 let err = generate_str(
977 "@mid(0x0801)\n@cc(1)\ncommand SetMode { mode: u8 }",
978 Lang::C,
979 )
980 .unwrap_err();
981 assert_eq!(
982 err.to_string(),
983 "packet `SetMode` has MID `0x0801U`, expected command MID with bit 0x1000 set"
984 );
985 }
986
987 #[test]
988 fn rejects_dynamic_arrays() {
989 let err =
990 generate_str("@mid(0x0801)\ntelemetry Samples { values: f32[] }", Lang::C).unwrap_err();
991 assert_eq!(
992 err.to_string(),
993 "dynamic array field `Samples.values` with type `f32[]` is not supported by cFS codegen yet"
994 );
995 }
996
997 #[test]
998 fn rejects_non_string_bounded_arrays() {
999 let err = generate_str("table Buffer { bytes: u8[<=256] }", Lang::C).unwrap_err();
1000 assert_eq!(
1001 err.to_string(),
1002 "bounded array field `Buffer.bytes` with type `u8[<=256]` is not supported by cFS codegen yet"
1003 );
1004 }
1005
1006 #[test]
1007 fn generate_path_validates_imported_type_refs() {
1008 let dir = test_dir("validates-imported-type-refs");
1009 fs::write(
1010 dir.join("std_msgs.syn"),
1011 "namespace std_msgs\nstruct Header { seq: u32 }",
1012 )
1013 .unwrap();
1014 let input = dir.join("camera.syn");
1015 fs::write(
1016 &input,
1017 r#"namespace camera_app
1018import "std_msgs.syn"
1019@mid(0x0881)
1020telemetry CameraStatus {
1021 header: std_msgs::Header
1022}
1023"#,
1024 )
1025 .unwrap();
1026
1027 let out = generate_path(&input, Lang::C).unwrap();
1028 assert!(out.contains("#include \"std_msgs.h\""));
1029 assert!(out.contains("std_msgs_Header_t header;"));
1030 }
1031
1032 #[test]
1033 fn check_path_validates_imported_codegen_support() {
1034 let dir = test_dir("check-validates-imported-codegen-support");
1035 fs::write(
1036 dir.join("bad.syn"),
1037 "namespace bad\nstruct Unsupported { count?: u32 }",
1038 )
1039 .unwrap();
1040 let input = dir.join("root.syn");
1041 fs::write(
1042 &input,
1043 r#"namespace root
1044import "bad.syn"
1045struct Root { unsupported: bad::Unsupported }
1046"#,
1047 )
1048 .unwrap();
1049
1050 let err = check_path(&input).unwrap_err();
1051 assert_eq!(
1052 err.to_string(),
1053 "optional field `Unsupported.count` is not supported by cFS codegen yet"
1054 );
1055 }
1056
1057 #[test]
1058 fn check_paths_rejects_duplicate_telemetry_mids_across_roots() {
1059 let dir = test_dir("check-paths-duplicate-telemetry-mids");
1060 let nav = dir.join("nav.syn");
1061 fs::write(
1062 &nav,
1063 "namespace nav_app\n@mid(0x0801)\ntelemetry NavState { x: f64 }",
1064 )
1065 .unwrap();
1066 let payload = dir.join("payload.syn");
1067 fs::write(
1068 &payload,
1069 "namespace payload_app\n@mid(0x0801)\ntelemetry PayloadStatus { temp: f32 }",
1070 )
1071 .unwrap();
1072
1073 let err = check_paths([&nav, &payload]).unwrap_err();
1074 let msg = err.to_string();
1075 assert!(msg.contains("duplicate telemetry MID `0x0801`"));
1076 assert!(msg.contains("nav_app::NavState"));
1077 assert!(msg.contains("payload_app::PayloadStatus"));
1078 }
1079
1080 #[test]
1081 fn check_paths_rejects_duplicate_command_mid_cc_pairs_across_roots() {
1082 let dir = test_dir("check-paths-duplicate-command-codes");
1083 let camera = dir.join("camera.syn");
1084 fs::write(
1085 &camera,
1086 "namespace camera_app\n@mid(0x1880)\n@cc(1)\ncommand SetMode { mode: u8 }",
1087 )
1088 .unwrap();
1089 let radio = dir.join("radio.syn");
1090 fs::write(
1091 &radio,
1092 "namespace radio_app\n@mid(0x1880)\n@cc(1)\ncommand SetMode { mode: u8 }",
1093 )
1094 .unwrap();
1095
1096 let err = check_paths([&camera, &radio]).unwrap_err();
1097 let msg = err.to_string();
1098 assert!(msg.contains("duplicate command MID/CC pair `0x1880`/`1`"));
1099 assert!(msg.contains("camera_app::SetMode"));
1100 assert!(msg.contains("radio_app::SetMode"));
1101 }
1102
1103 #[test]
1104 fn check_paths_allows_shared_command_mid_with_distinct_command_codes() {
1105 let dir = test_dir("check-paths-shared-command-mid");
1106 let camera = dir.join("camera.syn");
1107 fs::write(
1108 &camera,
1109 "namespace camera_app\n@mid(0x1880)\n@cc(1)\ncommand SetMode { mode: u8 }",
1110 )
1111 .unwrap();
1112 let radio = dir.join("radio.syn");
1113 fs::write(
1114 &radio,
1115 "namespace radio_app\n@mid(0x1880)\n@cc(2)\ncommand SetMode { mode: u8 }",
1116 )
1117 .unwrap();
1118
1119 check_paths([&camera, &radio]).unwrap();
1120 }
1121
1122 #[test]
1123 fn generate_docs_includes_packet_ids_fields_and_docs() {
1124 let dir = test_dir("generate-docs");
1125 fs::write(
1126 dir.join("mission_ids.syn"),
1127 "namespace mission_ids\nconst NAV_STATE_MID: u16 = 0x0801",
1128 )
1129 .unwrap();
1130 let input = dir.join("nav.syn");
1131 fs::write(
1132 &input,
1133 r#"namespace nav_app
1134import "mission_ids.syn"
1135/// Navigation mode.
1136enum u8 NavMode {
1137 /// Tracking target attitude.
1138 Track = 1
1139}
1140/// Navigation state estimate.
1141@mid(mission_ids::NAV_STATE_MID)
1142telemetry NavState {
1143 /// Current mode.
1144 mode: NavMode
1145}
1146"#,
1147 )
1148 .unwrap();
1149
1150 let html = generate_docs([&input]).unwrap();
1151 assert!(html.contains("Synapse Message Documentation"));
1152 assert!(html.contains("nav_app"));
1153 assert!(html.contains("NavState"));
1154 assert!(html.contains("0x0801"));
1155 assert!(html.contains("Current mode."));
1156 assert!(html.contains("NavMode"));
1157 assert!(html.contains("id=\"doc-search\""));
1158 assert!(html.contains("data-search="));
1159 assert!(html.contains("href=\"#"));
1160 assert!(!html.contains("file://"));
1161 assert!(!html.contains(&dir.display().to_string()));
1162 assert!(html.contains(">Source</a>"));
1163 }
1164
1165 #[test]
1166 fn write_docs_writes_index_html() {
1167 let dir = test_dir("write-docs");
1168 let input = dir.join("status.syn");
1169 fs::write(
1170 &input,
1171 "namespace status_app\n@mid(0x0801)\ntelemetry Status { count: u32 }",
1172 )
1173 .unwrap();
1174
1175 let out_dir = dir.join("site");
1176 let out_path = write_docs([&input], &out_dir).unwrap();
1177 assert_eq!(out_path, out_dir.join("index.html"));
1178 assert!(fs::read_to_string(out_path).unwrap().contains("status_app"));
1179 }
1180
1181 #[test]
1182 fn generate_registry_json_includes_packet_facts() {
1183 let dir = test_dir("generate-registry-json");
1184 fs::write(
1185 dir.join("mission_ids.syn"),
1186 "namespace mission_ids\nconst CMD_MID: u16 = 0x1880\nconst SET_MODE_CC: u16 = 2",
1187 )
1188 .unwrap();
1189 let input = dir.join("camera.syn");
1190 fs::write(
1191 &input,
1192 r#"namespace camera_app
1193import "mission_ids.syn"
1194@mid(mission_ids::CMD_MID)
1195@cc(mission_ids::SET_MODE_CC)
1196command SetMode {
1197 mode: u8
1198}
1199"#,
1200 )
1201 .unwrap();
1202
1203 let json = generate_registry([&input], RegistryFormat::Json).unwrap();
1204 assert!(json.contains("\"packets\""));
1205 assert!(json.contains("\"namespace\": \"camera_app\""));
1206 assert!(json.contains("\"qualified_name\": \"camera_app::SetMode\""));
1207 assert!(json.contains("\"kind\": \"command\""));
1208 assert!(json.contains("\"mid\": 6272"));
1209 assert!(json.contains("\"mid_hex\": \"0x1880\""));
1210 assert!(json.contains("\"cc\": 2"));
1211 }
1212
1213 #[test]
1214 fn write_registry_csv_writes_packet_rows() {
1215 let dir = test_dir("write-registry-csv");
1216 let input = dir.join("status.syn");
1217 fs::write(
1218 &input,
1219 "namespace status_app\n@mid(0x0801)\ntelemetry Status { count: u32 }",
1220 )
1221 .unwrap();
1222 let output = dir.join("registry.csv");
1223
1224 let out_path = write_registry([&input], &output, RegistryFormat::Csv).unwrap();
1225 assert_eq!(out_path, output);
1226 let csv = fs::read_to_string(out_path).unwrap();
1227 assert!(csv.starts_with("namespace,name,qualified_name,kind,source,mid,mid_hex,cc"));
1228 assert!(csv.contains("\"status_app\",\"Status\",\"status_app::Status\",\"telemetry\""));
1229 assert!(csv.contains("\"2049\",\"0x0801\""));
1230 }
1231
1232 #[test]
1233 fn generate_path_resolves_imported_constants_in_attrs() {
1234 let dir = test_dir("resolves-imported-constants-in-attrs");
1235 fs::write(
1236 dir.join("nav_ids.syn"),
1237 "namespace nav_ids\nconst NAV_STATE_MID: u16 = 0x0801",
1238 )
1239 .unwrap();
1240 let input = dir.join("nav.syn");
1241 fs::write(
1242 &input,
1243 r#"namespace nav_app
1244import "nav_ids.syn"
1245@mid(nav_ids::NAV_STATE_MID)
1246telemetry NavState {
1247 x: f64
1248}
1249"#,
1250 )
1251 .unwrap();
1252
1253 let out = generate_path(&input, Lang::C).unwrap();
1254 assert!(out.contains("#define NAV_STATE_MID 0x0801U"));
1255 }
1256
1257 #[test]
1258 fn generate_path_resolves_imported_alias_constants_in_attrs() {
1259 let dir = test_dir("resolves-imported-alias-constants-in-attrs");
1260 fs::write(
1261 dir.join("mission_ids.syn"),
1262 "namespace mission_ids\nconst NAV_CMD_MID: u16 = 0x1880",
1263 )
1264 .unwrap();
1265 fs::write(
1266 dir.join("nav_ids.syn"),
1267 r#"namespace nav_ids
1268import "mission_ids.syn"
1269const SET_MODE_MID: u16 = mission_ids::NAV_CMD_MID
1270const SET_MODE_CC: u16 = 2
1271"#,
1272 )
1273 .unwrap();
1274 let input = dir.join("nav.syn");
1275 fs::write(
1276 &input,
1277 r#"namespace nav_app
1278import "nav_ids.syn"
1279@mid(nav_ids::SET_MODE_MID)
1280@cc(nav_ids::SET_MODE_CC)
1281command SetMode {
1282 mode: u8
1283}
1284"#,
1285 )
1286 .unwrap();
1287
1288 let out = generate_path(&input, Lang::Rust).unwrap();
1289 assert!(out.contains("pub const SET_MODE_MID: u16 = 0x1880;"));
1290 assert!(out.contains("pub const SET_MODE_CC: u16 = 2;"));
1291 }
1292
1293 #[test]
1294 fn generate_path_rejects_transitive_only_constant_refs() {
1295 let dir = test_dir("rejects-transitive-only-constant-refs");
1296 fs::write(
1297 dir.join("mission_ids.syn"),
1298 "namespace mission_ids\nconst NAV_STATE_MID: u16 = 0x0801",
1299 )
1300 .unwrap();
1301 fs::write(
1302 dir.join("nav_ids.syn"),
1303 r#"namespace nav_ids
1304import "mission_ids.syn"
1305const LOCAL_MID: u16 = mission_ids::NAV_STATE_MID
1306"#,
1307 )
1308 .unwrap();
1309 let input = dir.join("nav.syn");
1310 fs::write(
1311 &input,
1312 r#"namespace nav_app
1313import "nav_ids.syn"
1314@mid(mission_ids::NAV_STATE_MID)
1315telemetry NavState {
1316 x: f64
1317}
1318"#,
1319 )
1320 .unwrap();
1321
1322 let err = generate_path(&input, Lang::C).unwrap_err();
1323 assert_eq!(
1324 err.to_string(),
1325 "packet `NavState` has unresolved or non-integer `@mid(...)`; cFS codegen requires an integer, hex, local integer constant, or imported integer constant message ID"
1326 );
1327 }
1328
1329 #[test]
1330 fn generate_path_rejects_unqualified_imported_type_refs() {
1331 let dir = test_dir("rejects-unqualified-imported-type-refs");
1332 fs::write(
1333 dir.join("std_msgs.syn"),
1334 "namespace std_msgs\nstruct Header { seq: u32 }",
1335 )
1336 .unwrap();
1337 let input = dir.join("camera.syn");
1338 fs::write(
1339 &input,
1340 r#"namespace camera_app
1341import "std_msgs.syn"
1342@mid(0x0881)
1343telemetry CameraStatus {
1344 header: Header
1345}
1346"#,
1347 )
1348 .unwrap();
1349
1350 let err = generate_path(&input, Lang::C).unwrap_err();
1351 assert_eq!(
1352 err.to_string(),
1353 "imported type reference `Header` in `CameraStatus.header` must be namespace-qualified as `std_msgs::Header`"
1354 );
1355 }
1356
1357 #[test]
1358 fn generate_path_rejects_missing_import_file() {
1359 let dir = test_dir("missing-import");
1360 let input = dir.join("camera.syn");
1361 fs::write(&input, r#"import "missing.syn""#).unwrap();
1362
1363 let err = generate_path(&input, Lang::C).unwrap_err();
1364 assert!(err.to_string().contains("error reading import"));
1365 assert!(err.to_string().contains("missing.syn"));
1366 }
1367
1368 #[test]
1369 fn generate_path_rejects_unresolved_type_ref() {
1370 let dir = test_dir("unresolved-type-ref");
1371 let input = dir.join("camera.syn");
1372 fs::write(
1373 &input,
1374 "@mid(0x0881)\ntelemetry CameraStatus { header: std_msgs::Header }",
1375 )
1376 .unwrap();
1377
1378 let err = generate_path(&input, Lang::C).unwrap_err();
1379 assert_eq!(
1380 err.to_string(),
1381 "unresolved type reference `std_msgs::Header` in `CameraStatus.header`"
1382 );
1383 }
1384
1385 #[test]
1386 fn generate_path_validates_transitive_imports() {
1387 let dir = test_dir("validates-transitive-imports");
1388 fs::write(
1389 dir.join("time.syn"),
1390 "namespace time\nstruct Time { sec: u32 }",
1391 )
1392 .unwrap();
1393 fs::write(
1394 dir.join("std_msgs.syn"),
1395 r#"namespace std_msgs
1396import "time.syn"
1397struct Header { stamp: time::Time }
1398"#,
1399 )
1400 .unwrap();
1401 let input = dir.join("camera.syn");
1402 fs::write(
1403 &input,
1404 r#"namespace camera_app
1405import "std_msgs.syn"
1406@mid(0x0881)
1407telemetry CameraStatus {
1408 header: std_msgs::Header
1409}
1410"#,
1411 )
1412 .unwrap();
1413
1414 let out = generate_path(&input, Lang::C).unwrap();
1415 assert!(out.contains("#include \"std_msgs.h\""));
1416 assert!(out.contains("std_msgs_Header_t header;"));
1417 }
1418
1419 #[test]
1420 fn generate_path_rejects_missing_transitive_import_file() {
1421 let dir = test_dir("missing-transitive-import");
1422 fs::write(
1423 dir.join("std_msgs.syn"),
1424 r#"namespace std_msgs
1425import "missing_time.syn"
1426struct Header { seq: u32 }
1427"#,
1428 )
1429 .unwrap();
1430 let input = dir.join("camera.syn");
1431 fs::write(
1432 &input,
1433 r#"namespace camera_app
1434import "std_msgs.syn"
1435@mid(0x0881)
1436telemetry CameraStatus {
1437 header: std_msgs::Header
1438}
1439"#,
1440 )
1441 .unwrap();
1442
1443 let err = generate_path(&input, Lang::C).unwrap_err();
1444 assert!(err.to_string().contains("error reading import"));
1445 assert!(err.to_string().contains("missing_time.syn"));
1446 }
1447
1448 #[test]
1449 fn generate_path_rejects_references_to_transitive_only_imports() {
1450 let dir = test_dir("rejects-transitive-only-import-reference");
1451 fs::write(
1452 dir.join("time.syn"),
1453 "namespace time\nstruct Time { sec: u32 }",
1454 )
1455 .unwrap();
1456 fs::write(
1457 dir.join("std_msgs.syn"),
1458 r#"namespace std_msgs
1459import "time.syn"
1460struct Header { stamp: time::Time }
1461"#,
1462 )
1463 .unwrap();
1464 let input = dir.join("camera.syn");
1465 fs::write(
1466 &input,
1467 r#"namespace camera_app
1468import "std_msgs.syn"
1469@mid(0x0881)
1470telemetry CameraStatus {
1471 stamp: time::Time
1472}
1473"#,
1474 )
1475 .unwrap();
1476
1477 let err = generate_path(&input, Lang::C).unwrap_err();
1478 assert_eq!(
1479 err.to_string(),
1480 "unresolved type reference `time::Time` in `CameraStatus.stamp`"
1481 );
1482 }
1483
1484 #[test]
1485 fn generate_files_writes_import_closure_in_dependency_order() {
1486 let dir = test_dir("generate-files-import-closure");
1487 fs::write(
1488 dir.join("time.syn"),
1489 "namespace time\nstruct Time { sec: u32 }",
1490 )
1491 .unwrap();
1492 fs::write(
1493 dir.join("std_msgs.syn"),
1494 r#"namespace std_msgs
1495import "time.syn"
1496struct Header { stamp: time::Time }
1497"#,
1498 )
1499 .unwrap();
1500 let input = dir.join("camera.syn");
1501 fs::write(
1502 &input,
1503 r#"namespace camera_app
1504import "std_msgs.syn"
1505@mid(0x0881)
1506telemetry CameraStatus {
1507 header: std_msgs::Header
1508}
1509"#,
1510 )
1511 .unwrap();
1512
1513 let out_dir = dir.join("generated");
1514 let written = generate_files(&input, &out_dir, Lang::C).unwrap();
1515 let names = written
1516 .iter()
1517 .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
1518 .collect::<Vec<_>>();
1519 assert_eq!(names, ["time.h", "std_msgs.h", "camera.h"]);
1520 assert!(
1521 fs::read_to_string(out_dir.join("std_msgs.h"))
1522 .unwrap()
1523 .contains("#include \"time.h\"")
1524 );
1525 assert!(
1526 fs::read_to_string(out_dir.join("camera.h"))
1527 .unwrap()
1528 .contains("#include \"std_msgs.h\"")
1529 );
1530 }
1531
1532 #[test]
1533 fn generate_path_rejects_import_cycles() {
1534 let dir = test_dir("rejects-import-cycles");
1535 fs::write(
1536 dir.join("a.syn"),
1537 r#"namespace a
1538import "b.syn"
1539struct A { b: b::B }
1540"#,
1541 )
1542 .unwrap();
1543 fs::write(
1544 dir.join("b.syn"),
1545 r#"namespace b
1546import "a.syn"
1547struct B { a: a::A }
1548"#,
1549 )
1550 .unwrap();
1551
1552 let err = generate_path(dir.join("a.syn"), Lang::C).unwrap_err();
1553 assert!(err.to_string().contains("import cycle detected"));
1554 assert!(err.to_string().contains("a.syn"));
1555 assert!(err.to_string().contains("b.syn"));
1556 }
1557
1558 fn test_dir(name: &str) -> PathBuf {
1559 let stamp = SystemTime::now()
1560 .duration_since(UNIX_EPOCH)
1561 .unwrap()
1562 .as_nanos();
1563 let dir =
1564 std::env::temp_dir().join(format!("synapse-{name}-{}-{stamp}", std::process::id()));
1565 fs::create_dir_all(&dir).unwrap();
1566 dir
1567 }
1568}