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}
37
38impl fmt::Display for Error {
39 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40 match self {
41 Error::Io(e) => write!(f, "{e}"),
42 Error::Parse(e) => write!(f, "{e}"),
43 Error::Codegen(e) => write!(f, "{e}"),
44 Error::Import(e) => write!(f, "{e}"),
45 }
46 }
47}
48
49impl StdError for Error {
50 fn source(&self) -> Option<&(dyn StdError + 'static)> {
51 match self {
52 Error::Io(e) => Some(e),
53 Error::Parse(e) => Some(e),
54 Error::Codegen(e) => Some(e),
55 Error::Import(_) => None,
56 }
57 }
58}
59
60impl From<std::io::Error> for Error {
61 fn from(value: std::io::Error) -> Self {
62 Error::Io(value)
63 }
64}
65
66impl From<pest::error::Error<synapse_parser::synapse::Rule>> for Error {
67 fn from(value: pest::error::Error<synapse_parser::synapse::Rule>) -> Self {
68 Error::Parse(Box::new(value))
69 }
70}
71
72impl From<synapse_codegen_cfs::CodegenError> for Error {
73 fn from(value: synapse_codegen_cfs::CodegenError) -> Self {
74 Error::Codegen(value)
75 }
76}
77
78pub fn generate_str(source: &str, lang: Lang) -> Result<String, Error> {
80 let file = synapse_parser::ast::parse(source)?;
81 generate_parsed(&file, lang)
82}
83
84pub fn generate_path(input: impl AsRef<Path>, lang: Lang) -> Result<String, Error> {
86 let graph = load_import_graph(input.as_ref())?;
87 validate_import_graph(&graph)?;
88 let units_by_path = units_by_path(&graph);
89 let root = graph
90 .units
91 .last()
92 .expect("import graph always contains the root input");
93 let imported_constants = imported_constants_for_unit(root, &units_by_path)?;
94 generate_parsed_with_constants(&root.file, lang, &imported_constants)
95}
96
97fn generate_parsed(file: &SynFile, lang: Lang) -> Result<String, Error> {
98 generate_parsed_with_constants(file, lang, &synapse_codegen_cfs::ResolvedConstants::new())
99}
100
101fn generate_parsed_with_constants(
102 file: &SynFile,
103 lang: Lang,
104 imported_constants: &synapse_codegen_cfs::ResolvedConstants,
105) -> Result<String, Error> {
106 let output = match lang {
107 Lang::C => synapse_codegen_cfs::try_generate_c_with_constants(file, imported_constants)?,
108 Lang::Rust => synapse_codegen_cfs::try_generate_rust_with_constants(
109 file,
110 &Default::default(),
111 imported_constants,
112 )?,
113 };
114 Ok(output)
115}
116
117pub fn generate_file(
122 input: impl AsRef<Path>,
123 out_dir: impl AsRef<Path>,
124 lang: Lang,
125) -> Result<PathBuf, Error> {
126 let input = input.as_ref();
127 let output = generate_path(input, lang)?;
128
129 let out_dir = out_dir.as_ref();
130 fs::create_dir_all(out_dir)?;
131
132 let stem = input.file_stem().ok_or_else(|| {
133 std::io::Error::new(
134 std::io::ErrorKind::InvalidInput,
135 format!("input file has no stem: {}", input.display()),
136 )
137 })?;
138 let out_path = out_dir.join(format!("{}.{}", stem.to_string_lossy(), lang.extension()));
139 fs::write(&out_path, output)?;
140 Ok(out_path)
141}
142
143pub fn generate_files(
149 input: impl AsRef<Path>,
150 out_dir: impl AsRef<Path>,
151 lang: Lang,
152) -> Result<Vec<PathBuf>, Error> {
153 let graph = load_import_graph(input.as_ref())?;
154 validate_import_graph(&graph)?;
155 let units_by_path = units_by_path(&graph);
156
157 let out_dir = out_dir.as_ref();
158 fs::create_dir_all(out_dir)?;
159
160 let mut written = Vec::new();
161 for unit in &graph.units {
162 let imported_constants = imported_constants_for_unit(unit, &units_by_path)?;
163 let output = generate_parsed_with_constants(&unit.file, lang, &imported_constants)?;
164 let out_path = output_path_for(&unit.path, out_dir, lang)?;
165 fs::write(&out_path, output)?;
166 written.push(out_path);
167 }
168 Ok(written)
169}
170
171#[derive(Debug)]
172struct ImportGraph {
173 units: Vec<ParsedUnit>,
174}
175
176#[derive(Debug)]
177struct ParsedUnit {
178 path: PathBuf,
179 file: SynFile,
180}
181
182fn load_import_graph(input: &Path) -> Result<ImportGraph, Error> {
183 let mut units = Vec::new();
184 let mut visited = HashSet::new();
185 let mut visiting = HashSet::new();
186 let mut stack = Vec::new();
187 load_import_unit(input, &mut units, &mut visited, &mut visiting, &mut stack)?;
188 Ok(ImportGraph { units })
189}
190
191fn load_import_unit(
192 input: &Path,
193 units: &mut Vec<ParsedUnit>,
194 visited: &mut HashSet<PathBuf>,
195 visiting: &mut HashSet<PathBuf>,
196 stack: &mut Vec<PathBuf>,
197) -> Result<(), Error> {
198 let path = canonicalize_import_path(input)?;
199 if visited.contains(&path) {
200 return Ok(());
201 }
202 if visiting.contains(&path) {
203 stack.push(path.clone());
204 let cycle = stack
205 .iter()
206 .map(|p| p.display().to_string())
207 .collect::<Vec<_>>()
208 .join(" -> ");
209 stack.pop();
210 return Err(Error::Import(format!("import cycle detected: {cycle}")));
211 }
212
213 visiting.insert(path.clone());
214 stack.push(path.clone());
215
216 let source = fs::read_to_string(&path)
217 .map_err(|e| Error::Import(format!("error reading import `{}`: {e}", path.display())))?;
218 let file = synapse_parser::ast::parse(&source)
219 .map_err(|e| Error::Import(format!("error parsing import `{}`:\n{e}", path.display())))?;
220
221 let base_dir = path.parent().unwrap_or_else(|| Path::new(""));
222 for item in &file.items {
223 if let Item::Import(import) = item {
224 load_import_unit(
225 &base_dir.join(&import.path),
226 units,
227 visited,
228 visiting,
229 stack,
230 )?;
231 }
232 }
233
234 stack.pop();
235 visiting.remove(&path);
236 visited.insert(path.clone());
237 units.push(ParsedUnit { path, file });
238 Ok(())
239}
240
241fn canonicalize_import_path(path: &Path) -> Result<PathBuf, Error> {
242 fs::canonicalize(path)
243 .map_err(|e| Error::Import(format!("error reading import `{}`: {e}", path.display())))
244}
245
246fn validate_import_graph(graph: &ImportGraph) -> Result<(), Error> {
247 let units_by_path = units_by_path(graph);
248 for unit in &graph.units {
249 validate_import_unit(unit, &units_by_path)?;
250 }
251 Ok(())
252}
253
254fn units_by_path(graph: &ImportGraph) -> HashMap<PathBuf, &ParsedUnit> {
255 graph
256 .units
257 .iter()
258 .map(|unit| (unit.path.clone(), unit))
259 .collect()
260}
261
262fn imported_constants_for_unit(
263 unit: &ParsedUnit,
264 units_by_path: &HashMap<PathBuf, &ParsedUnit>,
265) -> Result<synapse_codegen_cfs::ResolvedConstants, Error> {
266 let base_dir = unit.path.parent().unwrap_or_else(|| Path::new(""));
267 let mut constants = synapse_codegen_cfs::ResolvedConstants::new();
268
269 for item in &unit.file.items {
270 let Item::Import(import) = item else {
271 continue;
272 };
273
274 let import_path = canonicalize_import_path(&base_dir.join(&import.path))?;
275 let imported = units_by_path
276 .get(&import_path)
277 .expect("import graph loader parsed direct imports");
278 constants.extend(exported_constants_for_unit(imported, units_by_path)?);
279 }
280
281 Ok(constants)
282}
283
284fn exported_constants_for_unit(
285 unit: &ParsedUnit,
286 units_by_path: &HashMap<PathBuf, &ParsedUnit>,
287) -> Result<synapse_codegen_cfs::ResolvedConstants, Error> {
288 let imported_constants = imported_constants_for_unit(unit, units_by_path)?;
289 let resolved = synapse_codegen_cfs::resolve_integer_constants(&unit.file, &imported_constants);
290 let local_namespace = namespace(&unit.file);
291 let mut exported = synapse_codegen_cfs::ResolvedConstants::new();
292
293 for item in &unit.file.items {
294 let Item::Const(c) = item else {
295 continue;
296 };
297
298 let key = if local_namespace.is_empty() {
299 vec![c.name.clone()]
300 } else {
301 let mut qualified = local_namespace.clone();
302 qualified.push(c.name.clone());
303 qualified
304 };
305 if let Some(value) = resolved.get(&key) {
306 exported.insert(key, *value);
307 }
308 }
309
310 Ok(exported)
311}
312
313fn validate_import_unit(
314 unit: &ParsedUnit,
315 units_by_path: &HashMap<PathBuf, &ParsedUnit>,
316) -> Result<(), Error> {
317 let base_dir = unit.path.parent().unwrap_or_else(|| Path::new(""));
318 let local_namespace = namespace(&unit.file);
319 let mut symbols = local_symbols(&unit.file, &local_namespace);
320 let mut imported_type_suggestions = HashMap::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 let imported_namespace = namespace(&imported.file);
332 let imported_names = declared_names(&imported.file);
333 for name in &imported_names {
334 if !imported_namespace.is_empty() {
335 let mut qualified = imported_namespace.clone();
336 qualified.push(name.clone());
337 imported_type_suggestions.insert(name.clone(), qualified.join("::"));
338 }
339 }
340 symbols.extend(qualified_symbols(&imported_names, &imported_namespace));
341 }
342
343 validate_type_refs(&unit.file, &symbols, &imported_type_suggestions)
344}
345
346fn output_path_for(input: &Path, out_dir: &Path, lang: Lang) -> Result<PathBuf, Error> {
347 let stem = input.file_stem().ok_or_else(|| {
348 std::io::Error::new(
349 std::io::ErrorKind::InvalidInput,
350 format!("input file has no stem: {}", input.display()),
351 )
352 })?;
353 Ok(out_dir.join(format!("{}.{}", stem.to_string_lossy(), lang.extension())))
354}
355
356fn namespace(file: &SynFile) -> Vec<String> {
357 file.items
358 .iter()
359 .find_map(|item| match item {
360 Item::Namespace(ns) => Some(ns.name.clone()),
361 _ => None,
362 })
363 .unwrap_or_default()
364}
365
366fn local_symbols(file: &SynFile, namespace: &[String]) -> HashSet<Vec<String>> {
367 let mut symbols = HashSet::new();
368 for name in declared_names(file) {
369 symbols.insert(vec![name.clone()]);
370 if !namespace.is_empty() {
371 let mut qualified = namespace.to_vec();
372 qualified.push(name);
373 symbols.insert(qualified);
374 }
375 }
376 symbols
377}
378
379fn qualified_symbols(names: &[String], namespace: &[String]) -> HashSet<Vec<String>> {
380 let mut symbols = HashSet::new();
381 if namespace.is_empty() {
382 return symbols;
383 }
384 for name in names {
385 let mut qualified = namespace.to_vec();
386 qualified.push(name.clone());
387 symbols.insert(qualified);
388 }
389 symbols
390}
391
392fn declared_names(file: &SynFile) -> Vec<String> {
393 file.items
394 .iter()
395 .filter_map(|item| match item {
396 Item::Const(c) => Some(c.name.clone()),
397 Item::Enum(e) => Some(e.name.clone()),
398 Item::Struct(s) | Item::Table(s) => Some(s.name.clone()),
399 Item::Command(m) | Item::Telemetry(m) | Item::Message(m) => Some(m.name.clone()),
400 Item::Namespace(_) | Item::Import(_) => None,
401 })
402 .collect()
403}
404
405fn validate_type_refs(
406 file: &SynFile,
407 symbols: &HashSet<Vec<String>>,
408 imported_type_suggestions: &HashMap<String, String>,
409) -> Result<(), Error> {
410 for item in &file.items {
411 match item {
412 Item::Const(c) => {
413 validate_type_ref(&c.name, &c.ty.base, symbols, imported_type_suggestions)?
414 }
415 Item::Struct(s) | Item::Table(s) => {
416 validate_field_refs(&s.name, &s.fields, symbols, imported_type_suggestions)?
417 }
418 Item::Command(m) | Item::Telemetry(m) | Item::Message(m) => {
419 validate_field_refs(&m.name, &m.fields, symbols, imported_type_suggestions)?
420 }
421 Item::Namespace(_) | Item::Import(_) | Item::Enum(_) => {}
422 }
423 }
424 Ok(())
425}
426
427fn validate_field_refs(
428 container: &str,
429 fields: &[FieldDef],
430 symbols: &HashSet<Vec<String>>,
431 imported_type_suggestions: &HashMap<String, String>,
432) -> Result<(), Error> {
433 for field in fields {
434 validate_type_ref(
435 &format!("{container}.{}", field.name),
436 &field.ty.base,
437 symbols,
438 imported_type_suggestions,
439 )?;
440 }
441 Ok(())
442}
443
444fn validate_type_ref(
445 owner: &str,
446 base: &BaseType,
447 symbols: &HashSet<Vec<String>>,
448 imported_type_suggestions: &HashMap<String, String>,
449) -> Result<(), Error> {
450 let BaseType::Ref(segments) = base else {
451 return Ok(());
452 };
453 if symbols.contains(segments) {
454 return Ok(());
455 }
456 if segments.len() == 1 {
457 if let Some(suggestion) = imported_type_suggestions.get(&segments[0]) {
458 return Err(Error::Import(format!(
459 "imported type reference `{}` in `{owner}` must be namespace-qualified as `{suggestion}`",
460 segments[0]
461 )));
462 }
463 }
464 Err(Error::Import(format!(
465 "unresolved type reference `{}` in `{owner}`",
466 segments.join("::")
467 )))
468}
469
470pub fn generate_c_file(
472 input: impl AsRef<Path>,
473 out_dir: impl AsRef<Path>,
474) -> Result<PathBuf, Error> {
475 generate_file(input, out_dir, Lang::C)
476}
477
478pub fn generate_rust_file(
480 input: impl AsRef<Path>,
481 out_dir: impl AsRef<Path>,
482) -> Result<PathBuf, Error> {
483 generate_file(input, out_dir, Lang::Rust)
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use std::time::{SystemTime, UNIX_EPOCH};
490
491 #[test]
492 fn generate_c_from_string() {
493 let out = generate_str(
494 "@mid(0x1880)\n@cc(1)\ncommand SetMode { mode: u8 }",
495 Lang::C,
496 )
497 .unwrap();
498 assert!(out.contains("#define SET_MODE_MID 0x1880U"));
499 assert!(out.contains("#define SET_MODE_CC 1U"));
500 assert!(out.contains("CFE_MSG_CommandHeader_t Header;"));
501 }
502
503 #[test]
504 fn generate_rust_from_string() {
505 let out = generate_str("@mid(0x0801)\ntelemetry NavState { x: f64 }", Lang::Rust).unwrap();
506 assert!(out.contains("pub const NAV_STATE_MID: u16 = 0x0801;"));
507 assert!(out.contains("pub cfs_header: cfs_sys::CFE_MSG_TelemetryHeader_t,"));
508 }
509
510 #[test]
511 fn rejects_optional_fields() {
512 let err = generate_str(
513 "@mid(0x0801)\ntelemetry Status { error_code?: u32 }",
514 Lang::C,
515 )
516 .unwrap_err();
517 assert_eq!(
518 err.to_string(),
519 "optional field `Status.error_code` is not supported by cFS codegen yet"
520 );
521 }
522
523 #[test]
524 fn rejects_default_values() {
525 let err = generate_str("table Config { exposure_us: u32 = 10000 }", Lang::C).unwrap_err();
526 assert_eq!(
527 err.to_string(),
528 "default value for field `Config.exposure_us` is not supported by cFS codegen yet"
529 );
530 }
531
532 #[test]
533 fn rejects_enum_fields() {
534 let err = generate_str(
535 "enum CameraMode { Idle = 0 Streaming = 1 }\n@mid(0x0801)\ntelemetry Status { mode: CameraMode }",
536 Lang::C,
537 )
538 .unwrap_err();
539 assert_eq!(
540 err.to_string(),
541 "enum field `Status.mode` with type `CameraMode` needs an explicit integer representation for cFS codegen"
542 );
543 }
544
545 #[test]
546 fn rejects_unbounded_strings() {
547 let err = generate_str("struct Label { name: string }", Lang::C).unwrap_err();
548 assert_eq!(
549 err.to_string(),
550 "unbounded string field `Label.name` is not supported by cFS codegen; use `string[<=N]` or `string[N]`"
551 );
552 }
553
554 #[test]
555 fn rejects_legacy_message() {
556 let err = generate_str("@mid(0x0801)\nmessage Status { x: f32 }", Lang::C).unwrap_err();
557 assert_eq!(
558 err.to_string(),
559 "legacy message `Status` is not supported by cFS codegen; use `command` or `telemetry`"
560 );
561 }
562
563 #[test]
564 fn rejects_packet_without_mid() {
565 let err = generate_str("command SetMode { mode: u8 }", Lang::C).unwrap_err();
566 assert_eq!(
567 err.to_string(),
568 "packet `SetMode` is missing required `@mid(...)`"
569 );
570 }
571
572 #[test]
573 fn rejects_command_without_cc() {
574 let err = generate_str("@mid(0x1880)\ncommand SetMode { mode: u8 }", Lang::C).unwrap_err();
575 assert_eq!(
576 err.to_string(),
577 "command `SetMode` is missing required `@cc(...)`"
578 );
579 }
580
581 #[test]
582 fn rejects_mid_range_mismatch() {
583 let err = generate_str(
584 "@mid(0x0801)\n@cc(1)\ncommand SetMode { mode: u8 }",
585 Lang::C,
586 )
587 .unwrap_err();
588 assert_eq!(
589 err.to_string(),
590 "packet `SetMode` has MID `0x0801U`, expected command MID with bit 0x1000 set"
591 );
592 }
593
594 #[test]
595 fn rejects_dynamic_arrays() {
596 let err =
597 generate_str("@mid(0x0801)\ntelemetry Samples { values: f32[] }", Lang::C).unwrap_err();
598 assert_eq!(
599 err.to_string(),
600 "dynamic array field `Samples.values` with type `f32[]` is not supported by cFS codegen yet"
601 );
602 }
603
604 #[test]
605 fn rejects_non_string_bounded_arrays() {
606 let err = generate_str("table Buffer { bytes: u8[<=256] }", Lang::C).unwrap_err();
607 assert_eq!(
608 err.to_string(),
609 "bounded array field `Buffer.bytes` with type `u8[<=256]` is not supported by cFS codegen yet"
610 );
611 }
612
613 #[test]
614 fn generate_path_validates_imported_type_refs() {
615 let dir = test_dir("validates-imported-type-refs");
616 fs::write(
617 dir.join("std_msgs.syn"),
618 "namespace std_msgs\nstruct Header { seq: u32 }",
619 )
620 .unwrap();
621 let input = dir.join("camera.syn");
622 fs::write(
623 &input,
624 r#"namespace camera_app
625import "std_msgs.syn"
626@mid(0x0881)
627telemetry CameraStatus {
628 header: std_msgs::Header
629}
630"#,
631 )
632 .unwrap();
633
634 let out = generate_path(&input, Lang::C).unwrap();
635 assert!(out.contains("#include \"std_msgs.h\""));
636 assert!(out.contains("std_msgs_Header_t header;"));
637 }
638
639 #[test]
640 fn generate_path_resolves_imported_constants_in_attrs() {
641 let dir = test_dir("resolves-imported-constants-in-attrs");
642 fs::write(
643 dir.join("nav_ids.syn"),
644 "namespace nav_ids\nconst NAV_STATE_MID: u16 = 0x0801",
645 )
646 .unwrap();
647 let input = dir.join("nav.syn");
648 fs::write(
649 &input,
650 r#"namespace nav_app
651import "nav_ids.syn"
652@mid(nav_ids::NAV_STATE_MID)
653telemetry NavState {
654 x: f64
655}
656"#,
657 )
658 .unwrap();
659
660 let out = generate_path(&input, Lang::C).unwrap();
661 assert!(out.contains("#define NAV_STATE_MID 0x0801U"));
662 }
663
664 #[test]
665 fn generate_path_resolves_imported_alias_constants_in_attrs() {
666 let dir = test_dir("resolves-imported-alias-constants-in-attrs");
667 fs::write(
668 dir.join("mission_ids.syn"),
669 "namespace mission_ids\nconst NAV_CMD_MID: u16 = 0x1880",
670 )
671 .unwrap();
672 fs::write(
673 dir.join("nav_ids.syn"),
674 r#"namespace nav_ids
675import "mission_ids.syn"
676const SET_MODE_MID: u16 = mission_ids::NAV_CMD_MID
677const SET_MODE_CC: u16 = 2
678"#,
679 )
680 .unwrap();
681 let input = dir.join("nav.syn");
682 fs::write(
683 &input,
684 r#"namespace nav_app
685import "nav_ids.syn"
686@mid(nav_ids::SET_MODE_MID)
687@cc(nav_ids::SET_MODE_CC)
688command SetMode {
689 mode: u8
690}
691"#,
692 )
693 .unwrap();
694
695 let out = generate_path(&input, Lang::Rust).unwrap();
696 assert!(out.contains("pub const SET_MODE_MID: u16 = 0x1880;"));
697 assert!(out.contains("pub const SET_MODE_CC: u16 = 2;"));
698 }
699
700 #[test]
701 fn generate_path_rejects_transitive_only_constant_refs() {
702 let dir = test_dir("rejects-transitive-only-constant-refs");
703 fs::write(
704 dir.join("mission_ids.syn"),
705 "namespace mission_ids\nconst NAV_STATE_MID: u16 = 0x0801",
706 )
707 .unwrap();
708 fs::write(
709 dir.join("nav_ids.syn"),
710 r#"namespace nav_ids
711import "mission_ids.syn"
712const LOCAL_MID: u16 = mission_ids::NAV_STATE_MID
713"#,
714 )
715 .unwrap();
716 let input = dir.join("nav.syn");
717 fs::write(
718 &input,
719 r#"namespace nav_app
720import "nav_ids.syn"
721@mid(mission_ids::NAV_STATE_MID)
722telemetry NavState {
723 x: f64
724}
725"#,
726 )
727 .unwrap();
728
729 let err = generate_path(&input, Lang::C).unwrap_err();
730 assert_eq!(
731 err.to_string(),
732 "packet `NavState` has unresolved or non-integer `@mid(...)`; cFS codegen requires an integer, hex, local integer constant, or imported integer constant message ID"
733 );
734 }
735
736 #[test]
737 fn generate_path_rejects_unqualified_imported_type_refs() {
738 let dir = test_dir("rejects-unqualified-imported-type-refs");
739 fs::write(
740 dir.join("std_msgs.syn"),
741 "namespace std_msgs\nstruct Header { seq: u32 }",
742 )
743 .unwrap();
744 let input = dir.join("camera.syn");
745 fs::write(
746 &input,
747 r#"namespace camera_app
748import "std_msgs.syn"
749@mid(0x0881)
750telemetry CameraStatus {
751 header: Header
752}
753"#,
754 )
755 .unwrap();
756
757 let err = generate_path(&input, Lang::C).unwrap_err();
758 assert_eq!(
759 err.to_string(),
760 "imported type reference `Header` in `CameraStatus.header` must be namespace-qualified as `std_msgs::Header`"
761 );
762 }
763
764 #[test]
765 fn generate_path_rejects_missing_import_file() {
766 let dir = test_dir("missing-import");
767 let input = dir.join("camera.syn");
768 fs::write(&input, r#"import "missing.syn""#).unwrap();
769
770 let err = generate_path(&input, Lang::C).unwrap_err();
771 assert!(err.to_string().contains("error reading import"));
772 assert!(err.to_string().contains("missing.syn"));
773 }
774
775 #[test]
776 fn generate_path_rejects_unresolved_type_ref() {
777 let dir = test_dir("unresolved-type-ref");
778 let input = dir.join("camera.syn");
779 fs::write(
780 &input,
781 "@mid(0x0881)\ntelemetry CameraStatus { header: std_msgs::Header }",
782 )
783 .unwrap();
784
785 let err = generate_path(&input, Lang::C).unwrap_err();
786 assert_eq!(
787 err.to_string(),
788 "unresolved type reference `std_msgs::Header` in `CameraStatus.header`"
789 );
790 }
791
792 #[test]
793 fn generate_path_validates_transitive_imports() {
794 let dir = test_dir("validates-transitive-imports");
795 fs::write(
796 dir.join("time.syn"),
797 "namespace time\nstruct Time { sec: u32 }",
798 )
799 .unwrap();
800 fs::write(
801 dir.join("std_msgs.syn"),
802 r#"namespace std_msgs
803import "time.syn"
804struct Header { stamp: time::Time }
805"#,
806 )
807 .unwrap();
808 let input = dir.join("camera.syn");
809 fs::write(
810 &input,
811 r#"namespace camera_app
812import "std_msgs.syn"
813@mid(0x0881)
814telemetry CameraStatus {
815 header: std_msgs::Header
816}
817"#,
818 )
819 .unwrap();
820
821 let out = generate_path(&input, Lang::C).unwrap();
822 assert!(out.contains("#include \"std_msgs.h\""));
823 assert!(out.contains("std_msgs_Header_t header;"));
824 }
825
826 #[test]
827 fn generate_path_rejects_missing_transitive_import_file() {
828 let dir = test_dir("missing-transitive-import");
829 fs::write(
830 dir.join("std_msgs.syn"),
831 r#"namespace std_msgs
832import "missing_time.syn"
833struct Header { seq: u32 }
834"#,
835 )
836 .unwrap();
837 let input = dir.join("camera.syn");
838 fs::write(
839 &input,
840 r#"namespace camera_app
841import "std_msgs.syn"
842@mid(0x0881)
843telemetry CameraStatus {
844 header: std_msgs::Header
845}
846"#,
847 )
848 .unwrap();
849
850 let err = generate_path(&input, Lang::C).unwrap_err();
851 assert!(err.to_string().contains("error reading import"));
852 assert!(err.to_string().contains("missing_time.syn"));
853 }
854
855 #[test]
856 fn generate_path_rejects_references_to_transitive_only_imports() {
857 let dir = test_dir("rejects-transitive-only-import-reference");
858 fs::write(
859 dir.join("time.syn"),
860 "namespace time\nstruct Time { sec: u32 }",
861 )
862 .unwrap();
863 fs::write(
864 dir.join("std_msgs.syn"),
865 r#"namespace std_msgs
866import "time.syn"
867struct Header { stamp: time::Time }
868"#,
869 )
870 .unwrap();
871 let input = dir.join("camera.syn");
872 fs::write(
873 &input,
874 r#"namespace camera_app
875import "std_msgs.syn"
876@mid(0x0881)
877telemetry CameraStatus {
878 stamp: time::Time
879}
880"#,
881 )
882 .unwrap();
883
884 let err = generate_path(&input, Lang::C).unwrap_err();
885 assert_eq!(
886 err.to_string(),
887 "unresolved type reference `time::Time` in `CameraStatus.stamp`"
888 );
889 }
890
891 #[test]
892 fn generate_files_writes_import_closure_in_dependency_order() {
893 let dir = test_dir("generate-files-import-closure");
894 fs::write(
895 dir.join("time.syn"),
896 "namespace time\nstruct Time { sec: u32 }",
897 )
898 .unwrap();
899 fs::write(
900 dir.join("std_msgs.syn"),
901 r#"namespace std_msgs
902import "time.syn"
903struct Header { stamp: time::Time }
904"#,
905 )
906 .unwrap();
907 let input = dir.join("camera.syn");
908 fs::write(
909 &input,
910 r#"namespace camera_app
911import "std_msgs.syn"
912@mid(0x0881)
913telemetry CameraStatus {
914 header: std_msgs::Header
915}
916"#,
917 )
918 .unwrap();
919
920 let out_dir = dir.join("generated");
921 let written = generate_files(&input, &out_dir, Lang::C).unwrap();
922 let names = written
923 .iter()
924 .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
925 .collect::<Vec<_>>();
926 assert_eq!(names, ["time.h", "std_msgs.h", "camera.h"]);
927 assert!(
928 fs::read_to_string(out_dir.join("std_msgs.h"))
929 .unwrap()
930 .contains("#include \"time.h\"")
931 );
932 assert!(
933 fs::read_to_string(out_dir.join("camera.h"))
934 .unwrap()
935 .contains("#include \"std_msgs.h\"")
936 );
937 }
938
939 #[test]
940 fn generate_path_rejects_import_cycles() {
941 let dir = test_dir("rejects-import-cycles");
942 fs::write(
943 dir.join("a.syn"),
944 r#"namespace a
945import "b.syn"
946struct A { b: b::B }
947"#,
948 )
949 .unwrap();
950 fs::write(
951 dir.join("b.syn"),
952 r#"namespace b
953import "a.syn"
954struct B { a: a::A }
955"#,
956 )
957 .unwrap();
958
959 let err = generate_path(dir.join("a.syn"), Lang::C).unwrap_err();
960 assert!(err.to_string().contains("import cycle detected"));
961 assert!(err.to_string().contains("a.syn"));
962 assert!(err.to_string().contains("b.syn"));
963 }
964
965 fn test_dir(name: &str) -> PathBuf {
966 let stamp = SystemTime::now()
967 .duration_since(UNIX_EPOCH)
968 .unwrap()
969 .as_nanos();
970 let dir =
971 std::env::temp_dir().join(format!("synapse-{name}-{}-{stamp}", std::process::id()));
972 fs::create_dir_all(&dir).unwrap();
973 dir
974 }
975}