Skip to main content

cfs_synapse/
lib.rs

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