Skip to main content

cfs_synapse/
lib.rs

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