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