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