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