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