1use std::{collections::HashMap, error::Error as StdError, fmt};
2
3use synapse_parser::ast::{
4 ArraySuffix, Attribute, BaseType, ConstDecl, EnumDef, FieldDef, Item, Literal, MessageDef,
5 PacketKind, PrimitiveType, StructDef, SynFile, TypeExpr,
6};
7
8pub const GENERATED_BANNER: &str = "Generated by Synapse. Do not edit directly.";
12
13pub const PREAMBLE: &str = "\
15/* Generated by Synapse. Do not edit directly. */
16#pragma once
17#include \"cfe.h\"
18
19";
20
21pub type ResolvedConstants = HashMap<Vec<String>, u64>;
23
24pub struct RustOptions<'a> {
26 pub cfs_module: &'a str,
30 pub tlm_header: &'a str,
32 pub cmd_header: &'a str,
34}
35
36impl Default for RustOptions<'_> {
37 fn default() -> Self {
38 RustOptions {
39 cfs_module: "cfs_sys",
40 tlm_header: "CFE_MSG_TelemetryHeader_t",
41 cmd_header: "CFE_MSG_CommandHeader_t",
42 }
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum CodegenError {
49 OptionalFieldUnsupported { container: String, field: String },
51 DefaultValueUnsupported { container: String, field: String },
53 EnumFieldUnsupported {
55 container: String,
56 field: String,
57 ty: String,
58 },
59 EnumRepresentationUnsupported { enum_name: String, repr: String },
61 EnumVariantValueRequired { enum_name: String, variant: String },
63 EnumVariantValueOutOfRange {
65 enum_name: String,
66 variant: String,
67 value: i64,
68 repr: String,
69 },
70 UnboundedStringUnsupported { container: String, field: String },
72 LegacyMessageUnsupported { packet: String },
74 MissingMid { packet: String },
76 MessageIdUnsupported { item: String },
78 MessageIdValueUnsupported { packet: String },
80 MissingCommandCode { packet: String },
82 CommandCodeUnsupported { item: String },
84 CommandCodeValueUnsupported { packet: String },
86 DuplicateMid {
88 mid: String,
89 first_packet: String,
90 second_packet: String,
91 },
92 DuplicateCommandCode {
94 mid: String,
95 cc: String,
96 first_packet: String,
97 second_packet: String,
98 },
99 MidRangeMismatch {
101 packet: String,
102 mid: String,
103 expected: &'static str,
104 },
105 DynamicArrayUnsupported {
107 container: String,
108 field: String,
109 ty: String,
110 },
111 BoundedArrayUnsupported {
113 container: String,
114 field: String,
115 ty: String,
116 },
117}
118
119impl fmt::Display for CodegenError {
120 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121 match self {
122 CodegenError::OptionalFieldUnsupported { container, field } => write!(
123 f,
124 "optional field `{container}.{field}` is not supported by cFS codegen yet"
125 ),
126 CodegenError::DefaultValueUnsupported { container, field } => write!(
127 f,
128 "default value for field `{container}.{field}` is not supported by cFS codegen yet"
129 ),
130 CodegenError::EnumFieldUnsupported {
131 container,
132 field,
133 ty,
134 } => write!(
135 f,
136 "enum field `{container}.{field}` with type `{ty}` needs an explicit integer representation for cFS codegen"
137 ),
138 CodegenError::EnumRepresentationUnsupported { enum_name, repr } => write!(
139 f,
140 "enum `{enum_name}` uses unsupported representation `{repr}`; cFS codegen supports integer enum representations"
141 ),
142 CodegenError::EnumVariantValueRequired { enum_name, variant } => write!(
143 f,
144 "enum `{enum_name}` variant `{variant}` needs an explicit value for cFS codegen"
145 ),
146 CodegenError::EnumVariantValueOutOfRange {
147 enum_name,
148 variant,
149 value,
150 repr,
151 } => write!(
152 f,
153 "enum `{enum_name}` variant `{variant}` value `{value}` does not fit `{repr}`"
154 ),
155 CodegenError::UnboundedStringUnsupported { container, field } => write!(
156 f,
157 "unbounded string field `{container}.{field}` is not supported by cFS codegen; use `string[<=N]` or `string[N]`"
158 ),
159 CodegenError::LegacyMessageUnsupported { packet } => write!(
160 f,
161 "legacy message `{packet}` is not supported by cFS codegen; use `command` or `telemetry`"
162 ),
163 CodegenError::MissingMid { packet } => {
164 write!(f, "packet `{packet}` is missing required `@mid(...)`")
165 }
166 CodegenError::MessageIdUnsupported { item } => write!(
167 f,
168 "`@mid(...)` is only supported on command and telemetry packets, found on `{item}`"
169 ),
170 CodegenError::MessageIdValueUnsupported { packet } => write!(
171 f,
172 "packet `{packet}` has unresolved or non-integer `@mid(...)`; cFS codegen requires an integer, hex, local integer constant, or imported integer constant message ID"
173 ),
174 CodegenError::MissingCommandCode { packet } => {
175 write!(f, "command `{packet}` is missing required `@cc(...)`")
176 }
177 CodegenError::CommandCodeUnsupported { item } => write!(
178 f,
179 "`@cc(...)` is only supported on command packets, found on `{item}`"
180 ),
181 CodegenError::CommandCodeValueUnsupported { packet } => write!(
182 f,
183 "command `{packet}` has unresolved or non-integer `@cc(...)`; cFS codegen requires an integer, hex, or local integer constant command code"
184 ),
185 CodegenError::DuplicateMid {
186 mid,
187 first_packet,
188 second_packet,
189 } => write!(
190 f,
191 "duplicate MID `{mid}` used by packets `{first_packet}` and `{second_packet}`"
192 ),
193 CodegenError::DuplicateCommandCode {
194 mid,
195 cc,
196 first_packet,
197 second_packet,
198 } => write!(
199 f,
200 "duplicate command MID/CC pair `{mid}`/`{cc}` used by packets `{first_packet}` and `{second_packet}`"
201 ),
202 CodegenError::MidRangeMismatch {
203 packet,
204 mid,
205 expected,
206 } => write!(f, "packet `{packet}` has MID `{mid}`, expected {expected}"),
207 CodegenError::DynamicArrayUnsupported {
208 container,
209 field,
210 ty,
211 } => write!(
212 f,
213 "dynamic array field `{container}.{field}` with type `{ty}` is not supported by cFS codegen yet"
214 ),
215 CodegenError::BoundedArrayUnsupported {
216 container,
217 field,
218 ty,
219 } => write!(
220 f,
221 "bounded array field `{container}.{field}` with type `{ty}` is not supported by cFS codegen yet"
222 ),
223 }
224 }
225}
226
227impl StdError for CodegenError {}
228
229pub fn generate_c(file: &SynFile) -> String {
231 try_generate_c(file).expect("parsed Synapse file is not supported by cFS C codegen")
232}
233
234pub fn try_generate_c(file: &SynFile) -> Result<String, CodegenError> {
236 try_generate_c_with_constants(file, &ResolvedConstants::new())
237}
238
239pub fn try_generate_c_with_constants(
241 file: &SynFile,
242 imported_constants: &ResolvedConstants,
243) -> Result<String, CodegenError> {
244 let constants = const_context(file, imported_constants);
245 validate_supported(file, &constants)?;
246 let mut out = String::from(PREAMBLE);
247 emit_c_imports(file, &mut out);
248 emit_items(file, &mut out, &constants);
249 Ok(out)
250}
251
252pub fn generate_rust(file: &SynFile, opts: &RustOptions) -> String {
258 try_generate_rust(file, opts).expect("parsed Synapse file is not supported by cFS Rust codegen")
259}
260
261pub fn try_generate_rust(file: &SynFile, opts: &RustOptions) -> Result<String, CodegenError> {
263 try_generate_rust_with_constants(file, opts, &ResolvedConstants::new())
264}
265
266pub fn try_generate_rust_with_constants(
268 file: &SynFile,
269 opts: &RustOptions,
270 imported_constants: &ResolvedConstants,
271) -> Result<String, CodegenError> {
272 let constants = const_context(file, imported_constants);
273 validate_supported(file, &constants)?;
274 let mut out = format!("// {GENERATED_BANNER}\n\n");
275 emit_rust_imports(file, &mut out);
276 emit_rust_items(file, opts, &mut out, &constants);
277 Ok(out)
278}
279
280pub fn resolve_integer_constants(
282 file: &SynFile,
283 imported_constants: &ResolvedConstants,
284) -> ResolvedConstants {
285 let constants = const_context(file, imported_constants);
286 constants.resolved_local_constants()
287}
288
289fn validate_supported(file: &SynFile, constants: &ConstContext<'_>) -> Result<(), CodegenError> {
292 let enum_defs = enum_defs(file);
293 let mut telemetry_mids = HashMap::new();
294 let mut command_codes = HashMap::new();
295 for item in &file.items {
296 match item {
297 Item::Struct(s) | Item::Table(s) => {
298 validate_plain_item_attrs(&s.name, &s.attrs)?;
299 validate_fields(&s.name, &s.fields, &enum_defs)?
300 }
301 Item::Command(m) | Item::Telemetry(m) => {
302 validate_packet(m, constants, &mut telemetry_mids, &mut command_codes)?;
303 validate_fields(&m.name, &m.fields, &enum_defs)?
304 }
305 Item::Message(m) => {
306 return Err(CodegenError::LegacyMessageUnsupported {
307 packet: m.name.clone(),
308 });
309 }
310 Item::Enum(e) => validate_enum(e)?,
311 Item::Namespace(_) | Item::Import(_) | Item::Const(_) => {}
312 }
313 }
314 Ok(())
315}
316
317struct ConstContext<'a> {
318 local_defs: HashMap<Vec<String>, &'a ConstDecl>,
319 imported_values: &'a ResolvedConstants,
320}
321
322fn const_context<'a>(
323 file: &'a SynFile,
324 imported_values: &'a ResolvedConstants,
325) -> ConstContext<'a> {
326 let namespace = file_namespace(file);
327 let mut local_defs = HashMap::new();
328 for item in &file.items {
329 if let Item::Const(c) = item {
330 local_defs.insert(vec![c.name.clone()], c);
331 if !namespace.is_empty() {
332 let mut qualified = namespace.clone();
333 qualified.push(c.name.clone());
334 local_defs.insert(qualified, c);
335 }
336 }
337 }
338 ConstContext {
339 local_defs,
340 imported_values,
341 }
342}
343
344impl ConstContext<'_> {
345 fn resolved_local_constants(&self) -> ResolvedConstants {
346 self.local_defs
347 .keys()
348 .filter_map(|segments| {
349 resolve_ident_to_u64(segments, self).map(|value| (segments.clone(), value))
350 })
351 .collect()
352 }
353
354 fn is_local_bare_ident(&self, segments: &[String]) -> bool {
355 segments.len() == 1 && self.local_defs.contains_key(segments)
356 }
357}
358
359fn file_namespace(file: &SynFile) -> Vec<String> {
360 file.items
361 .iter()
362 .find_map(|item| match item {
363 Item::Namespace(ns) => Some(ns.name.clone()),
364 _ => None,
365 })
366 .unwrap_or_default()
367}
368
369fn enum_defs(file: &SynFile) -> HashMap<String, &EnumDef> {
370 file.items
371 .iter()
372 .filter_map(|item| match item {
373 Item::Enum(e) => Some((e.name.clone(), e)),
374 _ => None,
375 })
376 .collect()
377}
378
379fn validate_enum(e: &EnumDef) -> Result<(), CodegenError> {
380 let Some(repr) = e.repr else {
381 return Ok(());
382 };
383 let Some((min, max)) = enum_repr_range(repr) else {
384 return Err(CodegenError::EnumRepresentationUnsupported {
385 enum_name: e.name.clone(),
386 repr: primitive_name(repr).to_string(),
387 });
388 };
389
390 for variant in &e.variants {
391 let value = variant
392 .value
393 .ok_or_else(|| CodegenError::EnumVariantValueRequired {
394 enum_name: e.name.clone(),
395 variant: variant.name.clone(),
396 })?;
397 if value < min || value > max {
398 return Err(CodegenError::EnumVariantValueOutOfRange {
399 enum_name: e.name.clone(),
400 variant: variant.name.clone(),
401 value,
402 repr: primitive_name(repr).to_string(),
403 });
404 }
405 }
406 Ok(())
407}
408
409fn enum_repr_range(repr: PrimitiveType) -> Option<(i64, i64)> {
410 match repr {
411 PrimitiveType::I8 => Some((i8::MIN as i64, i8::MAX as i64)),
412 PrimitiveType::I16 => Some((i16::MIN as i64, i16::MAX as i64)),
413 PrimitiveType::I32 => Some((i32::MIN as i64, i32::MAX as i64)),
414 PrimitiveType::I64 => Some((i64::MIN, i64::MAX)),
415 PrimitiveType::U8 => Some((0, u8::MAX as i64)),
416 PrimitiveType::U16 => Some((0, u16::MAX as i64)),
417 PrimitiveType::U32 => Some((0, u32::MAX as i64)),
418 PrimitiveType::U64 => Some((0, i64::MAX)),
419 PrimitiveType::F32 | PrimitiveType::F64 | PrimitiveType::Bool | PrimitiveType::Bytes => {
420 None
421 }
422 }
423}
424
425fn validate_packet(
426 packet: &MessageDef,
427 constants: &ConstContext<'_>,
428 telemetry_mids: &mut HashMap<u64, String>,
429 command_codes: &mut HashMap<(u64, u64), String>,
430) -> Result<(), CodegenError> {
431 let Some(mid) = find_mid_attr(&packet.attrs) else {
432 return Err(CodegenError::MissingMid {
433 packet: packet.name.clone(),
434 });
435 };
436
437 let cc = find_cc_attr(&packet.attrs);
438 match packet.kind {
439 PacketKind::Command if cc.is_none() => {
440 return Err(CodegenError::MissingCommandCode {
441 packet: packet.name.clone(),
442 });
443 }
444 PacketKind::Telemetry if cc.is_some() => {
445 return Err(CodegenError::CommandCodeUnsupported {
446 item: packet.name.clone(),
447 });
448 }
449 PacketKind::Command | PacketKind::Telemetry | PacketKind::Message => {}
450 }
451 let cc_value = if packet.kind == PacketKind::Command {
452 let cc = cc.expect("command code was checked above");
453 Some(resolve_literal_to_u64(cc, constants).ok_or_else(|| {
454 CodegenError::CommandCodeValueUnsupported {
455 packet: packet.name.clone(),
456 }
457 })?)
458 } else {
459 None
460 };
461
462 let value = resolve_literal_to_u64(mid, constants).ok_or_else(|| {
463 CodegenError::MessageIdValueUnsupported {
464 packet: packet.name.clone(),
465 }
466 })?;
467
468 validate_mid_range(packet, value, mid, constants)?;
469 match packet.kind {
470 PacketKind::Command => {
471 let cc = cc.expect("command code was checked above");
472 let cc_value = cc_value.expect("command code value was checked above");
473 if let Some(first_packet) = command_codes.insert((value, cc_value), packet.name.clone())
474 {
475 return Err(CodegenError::DuplicateCommandCode {
476 mid: literal_mid_str(mid, constants),
477 cc: literal_cc_str(cc, constants),
478 first_packet,
479 second_packet: packet.name.clone(),
480 });
481 }
482 }
483 PacketKind::Telemetry => {
484 if let Some(first_packet) = telemetry_mids.insert(value, packet.name.clone()) {
485 return Err(CodegenError::DuplicateMid {
486 mid: literal_mid_str(mid, constants),
487 first_packet,
488 second_packet: packet.name.clone(),
489 });
490 }
491 }
492 PacketKind::Message => {}
493 }
494
495 Ok(())
496}
497
498fn validate_plain_item_attrs(item_name: &str, attrs: &[Attribute]) -> Result<(), CodegenError> {
499 if find_mid_attr(attrs).is_some() {
500 return Err(CodegenError::MessageIdUnsupported {
501 item: item_name.to_string(),
502 });
503 }
504 if find_cc_attr(attrs).is_some() {
505 return Err(CodegenError::CommandCodeUnsupported {
506 item: item_name.to_string(),
507 });
508 }
509 Ok(())
510}
511
512fn validate_mid_range(
513 packet: &MessageDef,
514 value: u64,
515 mid: &Literal,
516 constants: &ConstContext<'_>,
517) -> Result<(), CodegenError> {
518 let command_bit_set = (value & 0x1000) != 0;
519 let expected = match packet.kind {
520 PacketKind::Command if !command_bit_set => Some("command MID with bit 0x1000 set"),
521 PacketKind::Telemetry if command_bit_set => Some("telemetry MID with bit 0x1000 clear"),
522 PacketKind::Command | PacketKind::Telemetry | PacketKind::Message => None,
523 };
524
525 if let Some(expected) = expected {
526 return Err(CodegenError::MidRangeMismatch {
527 packet: packet.name.clone(),
528 mid: literal_mid_str(mid, constants),
529 expected,
530 });
531 }
532
533 Ok(())
534}
535
536fn validate_fields(
537 container: &str,
538 fields: &[FieldDef],
539 enum_defs: &HashMap<String, &EnumDef>,
540) -> Result<(), CodegenError> {
541 for field in fields {
542 if field.optional {
543 return Err(CodegenError::OptionalFieldUnsupported {
544 container: container.to_string(),
545 field: field.name.clone(),
546 });
547 }
548 if field.default.is_some() {
549 return Err(CodegenError::DefaultValueUnsupported {
550 container: container.to_string(),
551 field: field.name.clone(),
552 });
553 }
554 if field.ty.base == BaseType::String && field.ty.array.is_none() {
555 return Err(CodegenError::UnboundedStringUnsupported {
556 container: container.to_string(),
557 field: field.name.clone(),
558 });
559 }
560 if let BaseType::Ref(segments) = &field.ty.base {
561 if let Some(e) = segments
562 .last()
563 .and_then(|name| enum_defs.get(name.as_str()))
564 {
565 if e.repr.is_none() {
566 return Err(CodegenError::EnumFieldUnsupported {
567 container: container.to_string(),
568 field: field.name.clone(),
569 ty: segments.join("::"),
570 });
571 }
572 }
573 }
574 match &field.ty.array {
575 Some(ArraySuffix::Dynamic) => {
576 return Err(CodegenError::DynamicArrayUnsupported {
577 container: container.to_string(),
578 field: field.name.clone(),
579 ty: type_expr_display(&field.ty),
580 });
581 }
582 Some(ArraySuffix::Bounded(_)) if field.ty.base != BaseType::String => {
583 return Err(CodegenError::BoundedArrayUnsupported {
584 container: container.to_string(),
585 field: field.name.clone(),
586 ty: type_expr_display(&field.ty),
587 });
588 }
589 Some(ArraySuffix::Bounded(_)) | Some(ArraySuffix::Fixed(_)) | None => {}
590 }
591 }
592 Ok(())
593}
594
595fn emit_c_imports(file: &SynFile, out: &mut String) {
596 let mut emitted = false;
597 for item in &file.items {
598 if let Item::Import(import) = item {
599 out.push_str(&format!("#include \"{}\"\n", import_c_header(&import.path)));
600 emitted = true;
601 }
602 }
603 if emitted {
604 out.push('\n');
605 }
606}
607
608fn emit_rust_imports(file: &SynFile, out: &mut String) {
609 let mut emitted = false;
610 for item in &file.items {
611 if let Item::Import(import) = item {
612 out.push_str(&format!(
613 "use crate::{};\n",
614 import_rust_module(&import.path)
615 ));
616 emitted = true;
617 }
618 }
619 if emitted {
620 out.push('\n');
621 }
622}
623
624fn emit_items(file: &SynFile, out: &mut String, constants: &ConstContext<'_>) {
625 let mut has_mids = false;
627 for item in &file.items {
628 if let Some(m) = packet_item(item) {
629 if let Some(mid) = find_mid_attr(&m.attrs) {
630 if !has_mids {
631 out.push_str("/* Message IDs */\n");
632 has_mids = true;
633 }
634 let define_name = to_screaming_snake(&m.name);
635 let mid_str = literal_mid_str(mid, constants);
636 out.push_str(&format!("#define {}_MID {}\n", define_name, mid_str));
637 }
638 }
639 }
640 if has_mids {
641 out.push('\n');
642 }
643
644 let mut has_ccs = false;
645 for item in &file.items {
646 if let Item::Command(m) = item {
647 if let Some(cc) = find_cc_attr(&m.attrs) {
648 if !has_ccs {
649 out.push_str("/* Command Codes */\n");
650 has_ccs = true;
651 }
652 let define_name = to_screaming_snake(&m.name);
653 let cc_str = literal_cc_str(cc, constants);
654 out.push_str(&format!("#define {}_CC {}\n", define_name, cc_str));
655 }
656 }
657 }
658 if has_ccs {
659 out.push('\n');
660 }
661
662 let mut namespace = Vec::new();
664 for item in &file.items {
665 match item {
666 Item::Namespace(ns) => namespace = ns.name.clone(),
667 Item::Enum(e) => emit_enum(out, e, &namespace),
668 Item::Import(_)
669 | Item::Const(_)
670 | Item::Struct(_)
671 | Item::Table(_)
672 | Item::Command(_)
673 | Item::Telemetry(_)
674 | Item::Message(_) => {}
675 }
676 }
677
678 let mut namespace = Vec::new();
680 for item in &file.items {
681 match item {
682 Item::Namespace(ns) => namespace = ns.name.clone(),
683 Item::Import(_) | Item::Enum(_) => {}
684 Item::Const(c) => emit_const(out, c),
685 Item::Struct(s) | Item::Table(s) => emit_struct(out, s, &namespace),
686 Item::Command(m) | Item::Telemetry(m) | Item::Message(m) => {
687 emit_message(out, m, &namespace)
688 }
689 }
690 }
691}
692
693fn emit_const(out: &mut String, c: &ConstDecl) {
696 emit_doc_lines(out, &c.doc);
697 let val = literal_str(&c.value);
698 out.push_str(&format!("#define {} {}\n\n", c.name, val));
699}
700
701fn emit_enum(out: &mut String, e: &EnumDef, namespace: &[String]) {
704 let Some(repr) = e.repr else {
705 return;
706 };
707
708 let type_name = c_decl_type_name(&e.name, namespace);
709 emit_doc_lines(out, &e.doc);
710 out.push_str(&format!("typedef {} {};\n", primitive_str(repr), type_name));
711
712 let enum_prefix = to_screaming_snake(&e.name);
713 for variant in &e.variants {
714 emit_doc_lines(out, &variant.doc);
715 let value = variant
716 .value
717 .expect("represented enum variants validated before emission");
718 out.push_str(&format!(
719 "#define {}_{} (({}){})\n",
720 enum_prefix,
721 to_screaming_snake(&variant.name),
722 type_name,
723 value
724 ));
725 }
726 out.push('\n');
727}
728
729fn emit_struct(out: &mut String, s: &StructDef, namespace: &[String]) {
732 emit_doc_lines(out, &s.doc);
733 out.push_str("typedef struct {\n");
734 for f in &s.fields {
735 emit_c_field(out, f, namespace);
736 }
737 out.push_str(&format!("}} {};\n\n", c_decl_type_name(&s.name, namespace)));
738}
739
740fn emit_message(out: &mut String, m: &MessageDef, namespace: &[String]) {
743 let header_type = if packet_is_command(m) {
744 "CFE_MSG_CommandHeader_t"
745 } else {
746 "CFE_MSG_TelemetryHeader_t"
747 };
748
749 emit_doc_lines(out, &m.doc);
750
751 out.push_str(&format!("typedef struct {{\n"));
752 out.push_str(&format!(" {} Header;\n", header_type));
753 for f in &m.fields {
754 emit_c_field(out, f, namespace);
755 }
756 out.push_str(&format!("}} {};\n\n", c_decl_type_name(&m.name, namespace)));
757}
758
759fn emit_rust_items(
762 file: &SynFile,
763 opts: &RustOptions,
764 out: &mut String,
765 constants: &ConstContext<'_>,
766) {
767 let mut has_mids = false;
769 for item in &file.items {
770 if let Some(m) = packet_item(item) {
771 if let Some(mid) = find_mid_attr(&m.attrs) {
772 if !has_mids {
773 out.push_str("// Message IDs\n");
774 has_mids = true;
775 }
776 let const_name = format!("{}_MID", to_screaming_snake(&m.name));
777 let val = rust_mid_str(mid, constants);
778 out.push_str(&format!("pub const {}: u16 = {};\n", const_name, val));
779 }
780 }
781 }
782 if has_mids {
783 out.push('\n');
784 }
785
786 let mut has_ccs = false;
787 for item in &file.items {
788 if let Item::Command(m) = item {
789 if let Some(cc) = find_cc_attr(&m.attrs) {
790 if !has_ccs {
791 out.push_str("// Command Codes\n");
792 has_ccs = true;
793 }
794 let const_name = format!("{}_CC", to_screaming_snake(&m.name));
795 let val = rust_cc_str(cc, constants);
796 out.push_str(&format!("pub const {}: u16 = {};\n", const_name, val));
797 }
798 }
799 }
800 if has_ccs {
801 out.push('\n');
802 }
803
804 for item in &file.items {
806 match item {
807 Item::Namespace(_) | Item::Import(_) => {}
808 Item::Const(c) => emit_rust_const(out, c),
809 Item::Enum(e) => emit_rust_enum(out, e),
810 Item::Struct(s) | Item::Table(s) => emit_rust_struct(out, s),
811 Item::Command(m) | Item::Telemetry(m) | Item::Message(m) => {
812 emit_rust_message(out, m, opts)
813 }
814 }
815 }
816}
817
818fn emit_rust_const(out: &mut String, c: &ConstDecl) {
819 emit_doc_lines(out, &c.doc);
820 let val = rust_literal_str(&c.value);
821 let ty = rust_field_type_str(&c.ty);
822 out.push_str(&format!("pub const {}: {} = {};\n\n", c.name, ty, val));
823}
824
825fn emit_rust_enum(out: &mut String, e: &EnumDef) {
826 let Some(repr) = e.repr else {
827 return;
828 };
829
830 emit_doc_lines(out, &e.doc);
831 out.push_str(&format!(
832 "pub type {} = {};\n",
833 e.name,
834 rust_primitive_str(repr)
835 ));
836
837 let enum_prefix = to_screaming_snake(&e.name);
838 for variant in &e.variants {
839 emit_doc_lines(out, &variant.doc);
840 let value = variant
841 .value
842 .expect("represented enum variants validated before emission");
843 out.push_str(&format!(
844 "pub const {}_{}: {} = {};\n",
845 enum_prefix,
846 to_screaming_snake(&variant.name),
847 e.name,
848 value
849 ));
850 }
851 out.push('\n');
852}
853
854fn emit_rust_struct(out: &mut String, s: &StructDef) {
855 emit_doc_lines(out, &s.doc);
856 out.push_str("#[repr(C)]\n");
857 out.push_str(&format!("pub struct {} {{\n", s.name));
858 for f in &s.fields {
859 emit_indented_doc_lines(out, &f.doc);
860 out.push_str(&format!(
861 " pub {}: {},\n",
862 f.name,
863 rust_field_type_str(&f.ty)
864 ));
865 }
866 out.push_str("}\n\n");
867}
868
869fn emit_rust_message(out: &mut String, m: &MessageDef, opts: &RustOptions) {
870 let header_type = if packet_is_command(m) {
871 opts.cmd_header
872 } else {
873 opts.tlm_header
874 };
875 let qualified = if opts.cfs_module.is_empty() {
876 header_type.to_string()
877 } else {
878 format!("{}::{}", opts.cfs_module, header_type)
879 };
880
881 emit_doc_lines(out, &m.doc);
882
883 out.push_str("#[repr(C)]\n");
884 out.push_str(&format!("pub struct {} {{\n", m.name));
885 out.push_str(&format!(" pub cfs_header: {},\n", qualified));
886 for f in &m.fields {
887 emit_indented_doc_lines(out, &f.doc);
888 let ty = rust_field_type_str(&f.ty);
889 out.push_str(&format!(" pub {}: {},\n", f.name, ty));
890 }
891 out.push_str("}\n\n");
892}
893
894fn rust_field_type_str(ty: &TypeExpr) -> String {
895 if ty.base == BaseType::String {
896 return match &ty.array {
897 None | Some(ArraySuffix::Dynamic) => "*const u8".to_string(),
898 Some(ArraySuffix::Fixed(n)) | Some(ArraySuffix::Bounded(n)) => {
899 format!("[u8; {}]", n)
900 }
901 };
902 }
903
904 let base = rust_base_type_str(&ty.base);
905 match &ty.array {
906 None => base,
907 Some(ArraySuffix::Fixed(n)) => format!("[{}; {}]", base, n),
908 Some(ArraySuffix::Dynamic) => format!("*const {}", base),
910 Some(ArraySuffix::Bounded(n)) => format!("*const {} /* max {} */", base, n),
911 }
912}
913
914fn rust_base_type_str(base: &BaseType) -> String {
915 match base {
916 BaseType::String => "*const u8".to_string(),
917 BaseType::Primitive(p) => rust_primitive_str(*p).to_string(),
918 BaseType::Ref(segments) => segments.join("::"),
919 }
920}
921
922fn rust_primitive_str(p: PrimitiveType) -> &'static str {
923 match p {
924 PrimitiveType::F32 => "f32",
925 PrimitiveType::F64 => "f64",
926 PrimitiveType::I8 => "i8",
927 PrimitiveType::I16 => "i16",
928 PrimitiveType::I32 => "i32",
929 PrimitiveType::I64 => "i64",
930 PrimitiveType::U8 => "u8",
931 PrimitiveType::U16 => "u16",
932 PrimitiveType::U32 => "u32",
933 PrimitiveType::U64 => "u64",
934 PrimitiveType::Bool => "bool",
935 PrimitiveType::Bytes => "*const u8",
936 }
937}
938
939fn rust_mid_str(lit: &Literal, constants: &ConstContext<'_>) -> String {
940 match lit {
941 Literal::Hex(n) => format!("0x{:04X}", n),
942 Literal::Int(n) => n.to_string(),
943 Literal::Ident(segs) if constants.is_local_bare_ident(segs) => segs.join("::"),
944 Literal::Ident(segs) => resolve_ident_to_u64(segs, constants)
945 .map(|value| format!("0x{:04X}", value))
946 .unwrap_or_else(|| segs.join("::")),
947 other => rust_literal_str(other),
948 }
949}
950
951fn rust_cc_str(lit: &Literal, constants: &ConstContext<'_>) -> String {
952 match lit {
953 Literal::Hex(n) => format!("0x{:X}", n),
954 Literal::Int(n) => n.to_string(),
955 Literal::Ident(segs) if constants.is_local_bare_ident(segs) => segs.join("::"),
956 Literal::Ident(segs) => resolve_ident_to_u64(segs, constants)
957 .map(|value| value.to_string())
958 .unwrap_or_else(|| segs.join("::")),
959 other => rust_literal_str(other),
960 }
961}
962
963fn rust_literal_str(lit: &Literal) -> String {
964 match lit {
965 Literal::Hex(n) => format!("0x{:X}", n),
966 Literal::Int(n) => n.to_string(),
967 Literal::Bool(b) => b.to_string(),
968 Literal::Float(f) => {
969 let s = format!("{}", f);
970 if s.contains('.') || s.contains('e') {
971 s
972 } else {
973 format!("{}.0", s)
974 }
975 }
976 Literal::Str(s) => format!("{:?}", s),
977 Literal::Ident(segments) => segments.join("::"),
978 }
979}
980
981fn find_mid_attr(attrs: &[Attribute]) -> Option<&Literal> {
985 attrs.iter().find(|a| a.name == "mid").map(|a| &a.value)
986}
987
988fn find_cc_attr(attrs: &[Attribute]) -> Option<&Literal> {
990 attrs.iter().find(|a| a.name == "cc").map(|a| &a.value)
991}
992
993fn packet_item(item: &Item) -> Option<&MessageDef> {
994 match item {
995 Item::Command(m) | Item::Telemetry(m) => Some(m),
996 _ => None,
997 }
998}
999
1000fn packet_is_command(m: &MessageDef) -> bool {
1001 match m.kind {
1002 PacketKind::Command => true,
1003 PacketKind::Telemetry | PacketKind::Message => false,
1004 }
1005}
1006
1007fn literal_to_u64(lit: &Literal) -> Option<u64> {
1008 match lit {
1009 Literal::Hex(n) => Some(*n),
1010 Literal::Int(n) if *n >= 0 => Some(*n as u64),
1011 _ => None,
1012 }
1013}
1014
1015fn resolve_literal_to_u64(lit: &Literal, constants: &ConstContext<'_>) -> Option<u64> {
1016 resolve_literal_to_u64_inner(lit, constants, &mut Vec::new())
1017}
1018
1019fn resolve_literal_to_u64_inner(
1020 lit: &Literal,
1021 constants: &ConstContext<'_>,
1022 seen: &mut Vec<Vec<String>>,
1023) -> Option<u64> {
1024 match lit {
1025 Literal::Ident(segments) => resolve_ident_to_u64_inner(segments, constants, seen),
1026 other => literal_to_u64(other),
1027 }
1028}
1029
1030fn resolve_ident_to_u64(segments: &[String], constants: &ConstContext<'_>) -> Option<u64> {
1031 resolve_ident_to_u64_inner(segments, constants, &mut Vec::new())
1032}
1033
1034fn resolve_ident_to_u64_inner(
1035 segments: &[String],
1036 constants: &ConstContext<'_>,
1037 seen: &mut Vec<Vec<String>>,
1038) -> Option<u64> {
1039 if seen.iter().any(|s| s == segments) {
1040 return None;
1041 }
1042 if let Some(c) = constants.local_defs.get(segments) {
1043 seen.push(segments.to_vec());
1044 let resolved = resolve_literal_to_u64_inner(&c.value, constants, seen);
1045 seen.pop();
1046 return resolved;
1047 }
1048 constants.imported_values.get(segments).copied()
1049}
1050
1051fn type_expr_display(ty: &TypeExpr) -> String {
1052 let mut out = base_type_display(&ty.base);
1053 match &ty.array {
1054 None => {}
1055 Some(ArraySuffix::Dynamic) => out.push_str("[]"),
1056 Some(ArraySuffix::Fixed(n)) => out.push_str(&format!("[{n}]")),
1057 Some(ArraySuffix::Bounded(n)) => out.push_str(&format!("[<={n}]")),
1058 }
1059 out
1060}
1061
1062fn base_type_display(base: &BaseType) -> String {
1063 match base {
1064 BaseType::String => "string".to_string(),
1065 BaseType::Primitive(p) => primitive_name(*p).to_string(),
1066 BaseType::Ref(segments) => segments.join("::"),
1067 }
1068}
1069
1070fn primitive_name(p: PrimitiveType) -> &'static str {
1071 match p {
1072 PrimitiveType::F32 => "f32",
1073 PrimitiveType::F64 => "f64",
1074 PrimitiveType::I8 => "i8",
1075 PrimitiveType::I16 => "i16",
1076 PrimitiveType::I32 => "i32",
1077 PrimitiveType::I64 => "i64",
1078 PrimitiveType::U8 => "u8",
1079 PrimitiveType::U16 => "u16",
1080 PrimitiveType::U32 => "u32",
1081 PrimitiveType::U64 => "u64",
1082 PrimitiveType::Bool => "bool",
1083 PrimitiveType::Bytes => "bytes",
1084 }
1085}
1086
1087fn emit_doc_lines(out: &mut String, doc: &[String]) {
1088 for line in doc {
1089 if line.is_empty() {
1090 out.push_str("///\n");
1091 } else {
1092 out.push_str(&format!("/// {line}\n"));
1093 }
1094 }
1095}
1096
1097fn emit_indented_doc_lines(out: &mut String, doc: &[String]) {
1098 for line in doc {
1099 if line.is_empty() {
1100 out.push_str(" ///\n");
1101 } else {
1102 out.push_str(&format!(" /// {line}\n"));
1103 }
1104 }
1105}
1106
1107fn literal_mid_str(lit: &Literal, constants: &ConstContext<'_>) -> String {
1109 match lit {
1110 Literal::Hex(n) => format!("0x{:04X}U", n),
1111 Literal::Int(n) => format!("{}U", n),
1112 Literal::Ident(segs) if constants.is_local_bare_ident(segs) => segs.join("::"),
1113 Literal::Ident(segs) => resolve_ident_to_u64(segs, constants)
1114 .map(|value| format!("0x{:04X}U", value))
1115 .unwrap_or_else(|| segs.join("::")),
1116 other => literal_str(other),
1117 }
1118}
1119
1120fn literal_cc_str(lit: &Literal, constants: &ConstContext<'_>) -> String {
1122 match lit {
1123 Literal::Hex(n) => format!("0x{:X}U", n),
1124 Literal::Int(n) => format!("{}U", n),
1125 Literal::Ident(segs) if constants.is_local_bare_ident(segs) => segs.join("::"),
1126 Literal::Ident(segs) => resolve_ident_to_u64(segs, constants)
1127 .map(|value| format!("{}U", value))
1128 .unwrap_or_else(|| segs.join("::")),
1129 other => literal_str(other),
1130 }
1131}
1132
1133fn literal_str(lit: &Literal) -> String {
1134 match lit {
1135 Literal::Float(f) => {
1136 let s = format!("{}", f);
1137 if s.contains('.') || s.contains('e') {
1138 s
1139 } else {
1140 format!("{}.0", s)
1141 }
1142 }
1143 Literal::Int(n) => n.to_string(),
1144 Literal::Hex(n) => format!("0x{:X}U", n),
1145 Literal::Bool(b) => {
1146 if *b {
1147 "1".to_string()
1148 } else {
1149 "0".to_string()
1150 }
1151 }
1152 Literal::Str(s) => format!("{:?}", s),
1153 Literal::Ident(segments) => segments.join("::"),
1154 }
1155}
1156
1157fn non_fixed_type_str(ty: &TypeExpr, namespace: &[String]) -> String {
1158 if ty.base == BaseType::String {
1159 return match &ty.array {
1160 None | Some(ArraySuffix::Dynamic) => "const char*".to_string(),
1161 Some(ArraySuffix::Fixed(_)) => unreachable!("handled by emit_c_field"),
1162 Some(ArraySuffix::Bounded(n)) => format!("char[{}]", n),
1163 };
1164 }
1165
1166 let base = base_type_str(&ty.base, namespace);
1167 match &ty.array {
1168 None => base,
1169 Some(ArraySuffix::Fixed(_)) => unreachable!("handled by caller"),
1170 Some(ArraySuffix::Dynamic) => format!("CFE_Span_t /* {} */", base),
1171 Some(ArraySuffix::Bounded(n)) => format!("CFE_Span_t /* {} max {} */", base, n),
1172 }
1173}
1174
1175fn base_type_str(base: &BaseType, namespace: &[String]) -> String {
1176 match base {
1177 BaseType::String => "const char*".to_string(),
1178 BaseType::Primitive(p) => primitive_str(*p).to_string(),
1179 BaseType::Ref(segments) => c_ref_type_name(segments, namespace),
1180 }
1181}
1182
1183fn emit_c_field(out: &mut String, f: &synapse_parser::ast::FieldDef, namespace: &[String]) {
1184 emit_indented_doc_lines(out, &f.doc);
1185 match (&f.ty.base, &f.ty.array) {
1186 (BaseType::String, Some(ArraySuffix::Fixed(n) | ArraySuffix::Bounded(n))) => {
1187 out.push_str(&format!(" char {}[{}];\n", f.name, n));
1188 }
1189 (_, Some(ArraySuffix::Fixed(n))) => {
1190 out.push_str(&format!(
1191 " {} {}[{}];\n",
1192 base_type_str(&f.ty.base, namespace),
1193 f.name,
1194 n
1195 ));
1196 }
1197 _ => {
1198 out.push_str(&format!(
1199 " {} {};\n",
1200 non_fixed_type_str(&f.ty, namespace),
1201 f.name
1202 ));
1203 }
1204 }
1205}
1206
1207fn c_decl_type_name(name: &str, namespace: &[String]) -> String {
1208 let mut segments = namespace.to_vec();
1209 segments.push(name.to_string());
1210 format!("{}_t", segments.join("_"))
1211}
1212
1213fn c_ref_type_name(segments: &[String], namespace: &[String]) -> String {
1214 let resolved = if segments.len() == 1 && !namespace.is_empty() {
1215 let mut resolved = namespace.to_vec();
1216 resolved.push(segments[0].clone());
1217 resolved
1218 } else {
1219 segments.to_vec()
1220 };
1221 if resolved.is_empty() {
1222 return "_t".to_string();
1223 }
1224 format!("{}_t", resolved.join("_"))
1225}
1226
1227fn import_c_header(path: &str) -> String {
1228 replace_extension(path, "h")
1229}
1230
1231fn import_rust_module(path: &str) -> String {
1232 let header = path.rsplit('/').next().unwrap_or(path);
1233 replace_extension(header, "")
1234}
1235
1236fn replace_extension(path: &str, ext: &str) -> String {
1237 match path.rsplit_once('.') {
1238 Some((stem, _)) if ext.is_empty() => stem.to_string(),
1239 Some((stem, _)) => format!("{stem}.{ext}"),
1240 None if ext.is_empty() => path.to_string(),
1241 None => format!("{path}.{ext}"),
1242 }
1243}
1244
1245fn primitive_str(p: PrimitiveType) -> &'static str {
1246 match p {
1247 PrimitiveType::F32 => "float",
1248 PrimitiveType::F64 => "double",
1249 PrimitiveType::I8 => "int8_t",
1250 PrimitiveType::I16 => "int16_t",
1251 PrimitiveType::I32 => "int32_t",
1252 PrimitiveType::I64 => "int64_t",
1253 PrimitiveType::U8 => "uint8_t",
1254 PrimitiveType::U16 => "uint16_t",
1255 PrimitiveType::U32 => "uint32_t",
1256 PrimitiveType::U64 => "uint64_t",
1257 PrimitiveType::Bool => "bool",
1258 PrimitiveType::Bytes => "uint8_t*",
1259 }
1260}
1261
1262fn to_screaming_snake(name: &str) -> String {
1264 let mut out = String::new();
1265 for (i, ch) in name.chars().enumerate() {
1266 if ch.is_uppercase() && i > 0 {
1267 out.push('_');
1268 }
1269 out.push(ch.to_ascii_uppercase());
1270 }
1271 out
1272}
1273
1274#[cfg(test)]
1277mod tests {
1278 use super::*;
1279 use synapse_parser::ast::parse;
1280
1281 fn codegen(src: &str) -> String {
1282 generate_c(&parse(src).unwrap())
1283 }
1284
1285 #[test]
1286 fn telemetry_with_hex_mid() {
1287 let out = codegen("@mid(0x0801)\ntelemetry NavTlm { x: f64 y: f64 }");
1288 assert!(out.starts_with("/* Generated by Synapse. Do not edit directly. */\n"));
1289 assert!(out.contains("#define NAV_TLM_MID 0x0801U"));
1290 assert!(out.contains("CFE_MSG_TelemetryHeader_t Header;"));
1291 assert!(out.contains("typedef struct {"));
1292 assert!(out.contains("} NavTlm_t;"));
1293 assert!(out.contains(" double x;"));
1294 assert!(out.contains(" double y;"));
1295 }
1296
1297 #[test]
1298 fn command_uses_declared_packet_kind() {
1299 let out = codegen("@mid(0x1881)\n@cc(1)\ncommand NavCmd { seq: u16 }");
1300 assert!(out.contains("#define NAV_CMD_MID 0x1881U"));
1301 assert!(out.contains("#define NAV_CMD_CC 1U"));
1302 assert!(out.contains("CFE_MSG_CommandHeader_t Header;"));
1303 assert!(out.contains("} NavCmd_t;"));
1304 }
1305
1306 #[test]
1307 fn command_uses_command_header() {
1308 let out = codegen("@mid(0x1880)\n@cc(2)\ncommand SetMode { mode: u8 }");
1309 assert!(out.contains("#define SET_MODE_MID 0x1880U"));
1310 assert!(out.contains("#define SET_MODE_CC 2U"));
1311 assert!(out.contains("CFE_MSG_CommandHeader_t Header;"));
1312 assert!(!out.contains("CFE_MSG_TelemetryHeader_t Header;"));
1313 }
1314
1315 #[test]
1316 fn telemetry_uses_telemetry_header() {
1317 let out = codegen("@mid(0x0801)\ntelemetry NavState { x: f64 }");
1318 assert!(out.contains("#define NAV_STATE_MID 0x0801U"));
1319 assert!(out.contains("CFE_MSG_TelemetryHeader_t Header;"));
1320 assert!(!out.contains("CFE_MSG_CommandHeader_t Header;"));
1321 }
1322
1323 #[test]
1324 fn table_is_plain_data_without_bus_header() {
1325 let out = codegen("table NavConfig { max_speed: f64 enabled: bool }");
1326 assert!(out.contains("} NavConfig_t;"));
1327 assert!(out.contains(" double max_speed;"));
1328 assert!(!out.contains("CFE_MSG_CommandHeader_t Header;"));
1329 assert!(!out.contains("CFE_MSG_TelemetryHeader_t Header;"));
1330 }
1331
1332 #[test]
1333 fn c_rejects_legacy_message() {
1334 let file = parse("message Bare { x: f32 }").unwrap();
1335 let err = try_generate_c(&file).unwrap_err();
1336 assert_eq!(
1337 err,
1338 CodegenError::LegacyMessageUnsupported {
1339 packet: "Bare".to_string(),
1340 }
1341 );
1342 assert_eq!(
1343 err.to_string(),
1344 "legacy message `Bare` is not supported by cFS codegen; use `command` or `telemetry`"
1345 );
1346 }
1347
1348 #[test]
1349 fn c_rejects_command_without_mid() {
1350 let file = parse("command SetMode { mode: u8 }").unwrap();
1351 let err = try_generate_c(&file).unwrap_err();
1352 assert_eq!(
1353 err,
1354 CodegenError::MissingMid {
1355 packet: "SetMode".to_string(),
1356 }
1357 );
1358 assert_eq!(
1359 err.to_string(),
1360 "packet `SetMode` is missing required `@mid(...)`"
1361 );
1362 }
1363
1364 #[test]
1365 fn c_rejects_command_without_cc() {
1366 let file = parse("@mid(0x1880)\ncommand SetMode { mode: u8 }").unwrap();
1367 let err = try_generate_c(&file).unwrap_err();
1368 assert_eq!(
1369 err,
1370 CodegenError::MissingCommandCode {
1371 packet: "SetMode".to_string(),
1372 }
1373 );
1374 assert_eq!(
1375 err.to_string(),
1376 "command `SetMode` is missing required `@cc(...)`"
1377 );
1378 }
1379
1380 #[test]
1381 fn c_rejects_cc_on_telemetry() {
1382 let file = parse("@mid(0x0801)\n@cc(1)\ntelemetry Status { x: f32 }").unwrap();
1383 let err = try_generate_c(&file).unwrap_err();
1384 assert_eq!(
1385 err,
1386 CodegenError::CommandCodeUnsupported {
1387 item: "Status".to_string(),
1388 }
1389 );
1390 }
1391
1392 #[test]
1393 fn c_rejects_cc_on_table() {
1394 let file = parse("@cc(1)\ntable Config { enabled: bool }").unwrap();
1395 let err = try_generate_c(&file).unwrap_err();
1396 assert_eq!(
1397 err,
1398 CodegenError::CommandCodeUnsupported {
1399 item: "Config".to_string(),
1400 }
1401 );
1402 }
1403
1404 #[test]
1405 fn c_rejects_mid_on_table() {
1406 let file = parse("@mid(0x0801)\ntable Config { enabled: bool }").unwrap();
1407 let err = try_generate_c(&file).unwrap_err();
1408 assert_eq!(
1409 err,
1410 CodegenError::MessageIdUnsupported {
1411 item: "Config".to_string(),
1412 }
1413 );
1414 assert_eq!(
1415 err.to_string(),
1416 "`@mid(...)` is only supported on command and telemetry packets, found on `Config`"
1417 );
1418 }
1419
1420 #[test]
1421 fn c_rejects_unresolved_symbolic_command_code() {
1422 let file = parse("@mid(0x1880)\n@cc(SET_MODE_CC)\ncommand SetMode { mode: u8 }").unwrap();
1423 let err = try_generate_c(&file).unwrap_err();
1424 assert_eq!(
1425 err,
1426 CodegenError::CommandCodeValueUnsupported {
1427 packet: "SetMode".to_string(),
1428 }
1429 );
1430 }
1431
1432 #[test]
1433 fn c_rejects_unresolved_symbolic_mid() {
1434 let file = parse("@mid(NAV_TLM_MID)\ntelemetry Status { x: f32 }").unwrap();
1435 let err = try_generate_c(&file).unwrap_err();
1436 assert_eq!(
1437 err,
1438 CodegenError::MessageIdValueUnsupported {
1439 packet: "Status".to_string(),
1440 }
1441 );
1442 }
1443
1444 #[test]
1445 fn c_resolves_local_symbolic_mid_and_command_code() {
1446 let out = codegen(
1447 "const SET_MODE_MID_VALUE: u16 = 0x1880\nconst SET_MODE_CODE: u16 = 1\n@mid(SET_MODE_MID_VALUE)\n@cc(SET_MODE_CODE)\ncommand SetMode { mode: u8 }",
1448 );
1449 assert!(out.contains("#define SET_MODE_MID SET_MODE_MID_VALUE"));
1450 assert!(out.contains("#define SET_MODE_CC SET_MODE_CODE"));
1451 }
1452
1453 #[test]
1454 fn c_resolves_imported_symbolic_mid_and_command_code() {
1455 let file = parse(
1456 "@mid(nav_app::SET_MODE_MID_VALUE)\n@cc(nav_app::SET_MODE_CODE)\ncommand SetMode { mode: u8 }",
1457 )
1458 .unwrap();
1459 let mut constants = ResolvedConstants::new();
1460 constants.insert(
1461 vec!["nav_app".to_string(), "SET_MODE_MID_VALUE".to_string()],
1462 0x1880,
1463 );
1464 constants.insert(vec!["nav_app".to_string(), "SET_MODE_CODE".to_string()], 2);
1465
1466 let out = try_generate_c_with_constants(&file, &constants).unwrap();
1467 assert!(out.contains("#define SET_MODE_MID 0x1880U"));
1468 assert!(out.contains("#define SET_MODE_CC 2U"));
1469 }
1470
1471 #[test]
1472 fn c_validates_local_symbolic_mid_range() {
1473 let file = parse(
1474 "const SET_MODE_MID_VALUE: u16 = 0x0801\n@mid(SET_MODE_MID_VALUE)\n@cc(1)\ncommand SetMode { mode: u8 }",
1475 )
1476 .unwrap();
1477 let err = try_generate_c(&file).unwrap_err();
1478 assert_eq!(
1479 err,
1480 CodegenError::MidRangeMismatch {
1481 packet: "SetMode".to_string(),
1482 mid: "SET_MODE_MID_VALUE".to_string(),
1483 expected: "command MID with bit 0x1000 set",
1484 }
1485 );
1486 }
1487
1488 #[test]
1489 fn c_detects_duplicate_local_symbolic_command_codes() {
1490 let file = parse(
1491 "const CMD_MID: u16 = 0x1880\nconst SET_CC: u16 = 1\n@mid(CMD_MID)\n@cc(SET_CC)\ncommand A { x: u8 }\n@mid(CMD_MID)\n@cc(SET_CC)\ncommand B { x: u8 }",
1492 )
1493 .unwrap();
1494 let err = try_generate_c(&file).unwrap_err();
1495 assert_eq!(
1496 err,
1497 CodegenError::DuplicateCommandCode {
1498 mid: "CMD_MID".to_string(),
1499 cc: "SET_CC".to_string(),
1500 first_packet: "A".to_string(),
1501 second_packet: "B".to_string(),
1502 }
1503 );
1504 }
1505
1506 #[test]
1507 fn c_rejects_duplicate_telemetry_mids() {
1508 let file =
1509 parse("@mid(0x0801)\ntelemetry A { x: u8 }\n@mid(0x0801)\ntelemetry B { x: u8 }")
1510 .unwrap();
1511 let err = try_generate_c(&file).unwrap_err();
1512 assert_eq!(
1513 err,
1514 CodegenError::DuplicateMid {
1515 mid: "0x0801U".to_string(),
1516 first_packet: "A".to_string(),
1517 second_packet: "B".to_string(),
1518 }
1519 );
1520 assert_eq!(
1521 err.to_string(),
1522 "duplicate MID `0x0801U` used by packets `A` and `B`"
1523 );
1524 }
1525
1526 #[test]
1527 fn c_allows_shared_command_mid_with_distinct_ccs() {
1528 let out = codegen(
1529 "@mid(0x1880)\n@cc(1)\ncommand A { x: u8 }\n@mid(0x1880)\n@cc(2)\ncommand B { x: u8 }",
1530 );
1531 assert!(out.contains("#define A_MID 0x1880U"));
1532 assert!(out.contains("#define B_MID 0x1880U"));
1533 assert!(out.contains("#define A_CC 1U"));
1534 assert!(out.contains("#define B_CC 2U"));
1535 }
1536
1537 #[test]
1538 fn c_rejects_duplicate_command_mid_cc_pairs() {
1539 let file = parse(
1540 "@mid(0x1880)\n@cc(1)\ncommand A { x: u8 }\n@mid(0x1880)\n@cc(1)\ncommand B { x: u8 }",
1541 )
1542 .unwrap();
1543 let err = try_generate_c(&file).unwrap_err();
1544 assert_eq!(
1545 err,
1546 CodegenError::DuplicateCommandCode {
1547 mid: "0x1880U".to_string(),
1548 cc: "1U".to_string(),
1549 first_packet: "A".to_string(),
1550 second_packet: "B".to_string(),
1551 }
1552 );
1553 assert_eq!(
1554 err.to_string(),
1555 "duplicate command MID/CC pair `0x1880U`/`1U` used by packets `A` and `B`"
1556 );
1557 }
1558
1559 #[test]
1560 fn c_rejects_command_mid_without_command_bit() {
1561 let file = parse("@mid(0x0801)\n@cc(1)\ncommand SetMode { mode: u8 }").unwrap();
1562 let err = try_generate_c(&file).unwrap_err();
1563 assert_eq!(
1564 err,
1565 CodegenError::MidRangeMismatch {
1566 packet: "SetMode".to_string(),
1567 mid: "0x0801U".to_string(),
1568 expected: "command MID with bit 0x1000 set",
1569 }
1570 );
1571 assert_eq!(
1572 err.to_string(),
1573 "packet `SetMode` has MID `0x0801U`, expected command MID with bit 0x1000 set"
1574 );
1575 }
1576
1577 #[test]
1578 fn c_rejects_telemetry_mid_with_command_bit() {
1579 let file = parse("@mid(0x1880)\ntelemetry Status { x: f32 }").unwrap();
1580 let err = try_generate_c(&file).unwrap_err();
1581 assert_eq!(
1582 err,
1583 CodegenError::MidRangeMismatch {
1584 packet: "Status".to_string(),
1585 mid: "0x1880U".to_string(),
1586 expected: "telemetry MID with bit 0x1000 clear",
1587 }
1588 );
1589 }
1590
1591 #[test]
1592 fn c_rejects_optional_fields() {
1593 let file = parse("@mid(0x0801)\ntelemetry Status { error_code?: u32 }").unwrap();
1594 let err = try_generate_c(&file).unwrap_err();
1595 assert_eq!(
1596 err,
1597 CodegenError::OptionalFieldUnsupported {
1598 container: "Status".to_string(),
1599 field: "error_code".to_string(),
1600 }
1601 );
1602 assert_eq!(
1603 err.to_string(),
1604 "optional field `Status.error_code` is not supported by cFS codegen yet"
1605 );
1606 }
1607
1608 #[test]
1609 fn c_rejects_default_values() {
1610 let file = parse("table Config { exposure_us: u32 = 10000 }").unwrap();
1611 let err = try_generate_c(&file).unwrap_err();
1612 assert_eq!(
1613 err,
1614 CodegenError::DefaultValueUnsupported {
1615 container: "Config".to_string(),
1616 field: "exposure_us".to_string(),
1617 }
1618 );
1619 assert_eq!(
1620 err.to_string(),
1621 "default value for field `Config.exposure_us` is not supported by cFS codegen yet"
1622 );
1623 }
1624
1625 #[test]
1626 fn c_rejects_enum_fields() {
1627 let file = parse(
1628 "enum CameraMode { Idle = 0 Streaming = 1 }\n@mid(0x0801)\ntelemetry Status { mode: CameraMode }",
1629 )
1630 .unwrap();
1631 let err = try_generate_c(&file).unwrap_err();
1632 assert_eq!(
1633 err,
1634 CodegenError::EnumFieldUnsupported {
1635 container: "Status".to_string(),
1636 field: "mode".to_string(),
1637 ty: "CameraMode".to_string(),
1638 }
1639 );
1640 assert_eq!(
1641 err.to_string(),
1642 "enum field `Status.mode` with type `CameraMode` needs an explicit integer representation for cFS codegen"
1643 );
1644 }
1645
1646 #[test]
1647 fn c_emits_represented_enum_fields() {
1648 let file = parse(
1649 "enum u8 CameraMode { Idle = 0 Streaming = 1 }\n@mid(0x0801)\ntelemetry Status { mode: CameraMode }",
1650 )
1651 .unwrap();
1652 let out = try_generate_c(&file).unwrap();
1653 assert!(out.contains("typedef uint8_t CameraMode_t;"));
1654 assert!(out.contains("#define CAMERA_MODE_IDLE ((CameraMode_t)0)"));
1655 assert!(out.contains("#define CAMERA_MODE_STREAMING ((CameraMode_t)1)"));
1656 assert!(out.contains(" CameraMode_t mode;"));
1657 }
1658
1659 #[test]
1660 fn c_rejects_represented_enum_missing_value() {
1661 let file = parse("enum u8 CameraMode { Idle Streaming = 1 }").unwrap();
1662 let err = try_generate_c(&file).unwrap_err();
1663 assert_eq!(
1664 err,
1665 CodegenError::EnumVariantValueRequired {
1666 enum_name: "CameraMode".to_string(),
1667 variant: "Idle".to_string(),
1668 }
1669 );
1670 }
1671
1672 #[test]
1673 fn c_rejects_non_integer_enum_repr() {
1674 let file = parse("enum bool CameraMode { Idle = 0 Streaming = 1 }").unwrap();
1675 let err = try_generate_c(&file).unwrap_err();
1676 assert_eq!(
1677 err,
1678 CodegenError::EnumRepresentationUnsupported {
1679 enum_name: "CameraMode".to_string(),
1680 repr: "bool".to_string(),
1681 }
1682 );
1683 }
1684
1685 #[test]
1686 fn c_rejects_represented_enum_out_of_range() {
1687 let file = parse("enum u8 CameraMode { TooLarge = 256 }").unwrap();
1688 let err = try_generate_c(&file).unwrap_err();
1689 assert_eq!(
1690 err,
1691 CodegenError::EnumVariantValueOutOfRange {
1692 enum_name: "CameraMode".to_string(),
1693 variant: "TooLarge".to_string(),
1694 value: 256,
1695 repr: "u8".to_string(),
1696 }
1697 );
1698 }
1699
1700 #[test]
1701 fn c_rejects_dynamic_arrays() {
1702 let file = parse("@mid(0x0801)\ntelemetry Samples { values: f32[] }").unwrap();
1703 let err = try_generate_c(&file).unwrap_err();
1704 assert_eq!(
1705 err,
1706 CodegenError::DynamicArrayUnsupported {
1707 container: "Samples".to_string(),
1708 field: "values".to_string(),
1709 ty: "f32[]".to_string(),
1710 }
1711 );
1712 assert_eq!(
1713 err.to_string(),
1714 "dynamic array field `Samples.values` with type `f32[]` is not supported by cFS codegen yet"
1715 );
1716 }
1717
1718 #[test]
1719 fn c_rejects_non_string_bounded_arrays() {
1720 let file = parse("table Buffer { bytes: u8[<=256] }").unwrap();
1721 let err = try_generate_c(&file).unwrap_err();
1722 assert_eq!(
1723 err,
1724 CodegenError::BoundedArrayUnsupported {
1725 container: "Buffer".to_string(),
1726 field: "bytes".to_string(),
1727 ty: "u8[<=256]".to_string(),
1728 }
1729 );
1730 assert_eq!(
1731 err.to_string(),
1732 "bounded array field `Buffer.bytes` with type `u8[<=256]` is not supported by cFS codegen yet"
1733 );
1734 }
1735
1736 #[test]
1737 fn const_emits_define() {
1738 let out = codegen("const NAV_TLM_MID: u16 = 0x0801");
1739 assert!(out.contains("#define NAV_TLM_MID 0x801U"));
1740 }
1741
1742 #[test]
1743 fn fixed_array_field() {
1744 let out = codegen("@mid(0x0802)\ntelemetry Imu { covariance: f64[9] }");
1745 assert!(out.contains(" double covariance[9];"));
1746 }
1747
1748 #[test]
1749 fn c_refs_use_declared_typedef_names() {
1750 let out = codegen("struct Point { x: f64 }\n@mid(0x0801)\ntelemetry Pose { point: Point }");
1751 assert!(out.contains("} Point_t;"));
1752 assert!(out.contains(" Point_t point;"));
1753 }
1754
1755 #[test]
1756 fn c_qualified_refs_use_declared_typedef_names() {
1757 let out = codegen("@mid(0x0801)\ntelemetry Stamped { header: std_msgs::Header }");
1758 assert!(out.contains(" std_msgs_Header_t header;"));
1759 }
1760
1761 #[test]
1762 fn c_bounded_string_uses_inline_storage() {
1763 let out = codegen("struct Label { name: string[<=64] }");
1764 assert!(out.contains(" char name[64];"));
1765 }
1766
1767 #[test]
1768 fn c_rejects_unbounded_strings() {
1769 let file = parse("struct Label { name: string }").unwrap();
1770 let err = try_generate_c(&file).unwrap_err();
1771 assert_eq!(
1772 err,
1773 CodegenError::UnboundedStringUnsupported {
1774 container: "Label".to_string(),
1775 field: "name".to_string(),
1776 }
1777 );
1778 assert_eq!(
1779 err.to_string(),
1780 "unbounded string field `Label.name` is not supported by cFS codegen; use `string[<=N]` or `string[N]`"
1781 );
1782 }
1783
1784 #[test]
1785 fn c_imports_emit_header_includes() {
1786 let out = codegen(r#"import "std_msgs.syn""#);
1787 assert!(out.contains("#include \"std_msgs.h\""));
1788 }
1789
1790 #[test]
1791 fn c_doc_comments_emit_for_declarations_and_fields() {
1792 let out = codegen("/// A point\nstruct Point {\n/// X axis\nx: f64\n}");
1793 assert!(out.contains("/// A point\ntypedef struct {"));
1794 assert!(out.contains(" /// X axis\n double x;"));
1795 }
1796
1797 fn rust_codegen(src: &str) -> String {
1800 generate_rust(&parse(src).unwrap(), &RustOptions::default())
1801 }
1802
1803 #[test]
1804 fn rust_tlm_struct() {
1805 let out = rust_codegen("@mid(0x0801)\ntelemetry NavTlm { x: f64 y: f64 }");
1806 assert!(out.starts_with("// Generated by Synapse. Do not edit directly.\n"));
1807 assert!(out.contains("pub const NAV_TLM_MID: u16 = 0x0801;"));
1808 assert!(out.contains("#[repr(C)]"));
1809 assert!(out.contains("pub struct NavTlm {"));
1810 assert!(out.contains(" pub cfs_header: cfs_sys::CFE_MSG_TelemetryHeader_t,"));
1811 assert!(out.contains(" pub x: f64,"));
1812 assert!(out.contains(" pub y: f64,"));
1813 }
1814
1815 #[test]
1816 fn rust_cmd_struct() {
1817 let out = rust_codegen("@mid(0x1880)\n@cc(1)\ncommand NavCmd { seq: u16 }");
1818 assert!(out.contains("pub const NAV_CMD_MID: u16 = 0x1880;"));
1819 assert!(out.contains("pub const NAV_CMD_CC: u16 = 1;"));
1820 assert!(out.contains(" pub cfs_header: cfs_sys::CFE_MSG_CommandHeader_t,"));
1821 }
1822
1823 #[test]
1824 fn rust_command_uses_command_header() {
1825 let out = rust_codegen("@mid(0x1881)\n@cc(2)\ncommand SetMode { mode: u8 }");
1826 assert!(out.contains("pub const SET_MODE_MID: u16 = 0x1881;"));
1827 assert!(out.contains("pub const SET_MODE_CC: u16 = 2;"));
1828 assert!(out.contains(" pub cfs_header: cfs_sys::CFE_MSG_CommandHeader_t,"));
1829 assert!(!out.contains("CFE_MSG_TelemetryHeader_t"));
1830 }
1831
1832 #[test]
1833 fn rust_resolves_imported_symbolic_mid_and_command_code() {
1834 let file = parse(
1835 "@mid(nav_app::SET_MODE_MID_VALUE)\n@cc(nav_app::SET_MODE_CODE)\ncommand SetMode { mode: u8 }",
1836 )
1837 .unwrap();
1838 let mut constants = ResolvedConstants::new();
1839 constants.insert(
1840 vec!["nav_app".to_string(), "SET_MODE_MID_VALUE".to_string()],
1841 0x1880,
1842 );
1843 constants.insert(vec!["nav_app".to_string(), "SET_MODE_CODE".to_string()], 2);
1844
1845 let out =
1846 try_generate_rust_with_constants(&file, &RustOptions::default(), &constants).unwrap();
1847 assert!(out.contains("pub const SET_MODE_MID: u16 = 0x1880;"));
1848 assert!(out.contains("pub const SET_MODE_CC: u16 = 2;"));
1849 }
1850
1851 #[test]
1852 fn rust_telemetry_uses_telemetry_header() {
1853 let out = rust_codegen("@mid(0x0801)\ntelemetry NavState { x: f64 }");
1854 assert!(out.contains("pub const NAV_STATE_MID: u16 = 0x0801;"));
1855 assert!(out.contains(" pub cfs_header: cfs_sys::CFE_MSG_TelemetryHeader_t,"));
1856 assert!(!out.contains("CFE_MSG_CommandHeader_t"));
1857 }
1858
1859 #[test]
1860 fn rust_table_is_plain_data_without_bus_header() {
1861 let out = rust_codegen("table NavConfig { max_speed: f64 enabled: bool }");
1862 assert!(out.contains("pub struct NavConfig {"));
1863 assert!(out.contains(" pub max_speed: f64,"));
1864 assert!(!out.contains("cfs_header"));
1865 }
1866
1867 #[test]
1868 fn rust_fixed_array() {
1869 let out = rust_codegen("@mid(0x0802)\ntelemetry Imu { covariance: f64[9] }");
1870 assert!(out.contains(" pub covariance: [f64; 9],"));
1871 }
1872
1873 #[test]
1874 fn rust_custom_module() {
1875 let opts = RustOptions {
1876 cfs_module: "my_cfs",
1877 ..Default::default()
1878 };
1879 let out = generate_rust(
1880 &parse("@mid(0x0801)\ntelemetry T { x: f32 }").unwrap(),
1881 &opts,
1882 );
1883 assert!(out.contains("my_cfs::CFE_MSG_TelemetryHeader_t"));
1884 }
1885
1886 #[test]
1887 fn rust_bare_module() {
1888 let opts = RustOptions {
1889 cfs_module: "",
1890 ..Default::default()
1891 };
1892 let out = generate_rust(
1893 &parse("@mid(0x0801)\ntelemetry T { x: f32 }").unwrap(),
1894 &opts,
1895 );
1896 assert!(out.contains(" pub cfs_header: CFE_MSG_TelemetryHeader_t,"));
1897 assert!(!out.contains("::CFE_MSG_TelemetryHeader_t"));
1898 }
1899
1900 #[test]
1901 fn rust_message_can_have_payload_header_field() {
1902 let out = rust_codegen("@mid(0x0801)\ntelemetry Stamped { header: std_msgs::Header }");
1903 assert!(out.contains(" pub cfs_header: cfs_sys::CFE_MSG_TelemetryHeader_t,"));
1904 assert!(out.contains(" pub header: std_msgs::Header,"));
1905 }
1906
1907 #[test]
1908 fn rust_rejects_legacy_message() {
1909 let file = parse("@mid(0x0801)\nmessage Bare { x: f32 }").unwrap();
1910 let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
1911 assert_eq!(
1912 err,
1913 CodegenError::LegacyMessageUnsupported {
1914 packet: "Bare".to_string(),
1915 }
1916 );
1917 }
1918
1919 #[test]
1920 fn rust_rejects_telemetry_without_mid() {
1921 let file = parse("telemetry Status { x: f32 }").unwrap();
1922 let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
1923 assert_eq!(
1924 err,
1925 CodegenError::MissingMid {
1926 packet: "Status".to_string(),
1927 }
1928 );
1929 }
1930
1931 #[test]
1932 fn rust_rejects_mid_range_mismatch() {
1933 let file = parse("@mid(0x1880)\ntelemetry Status { x: f32 }").unwrap();
1934 let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
1935 assert_eq!(
1936 err,
1937 CodegenError::MidRangeMismatch {
1938 packet: "Status".to_string(),
1939 mid: "0x1880U".to_string(),
1940 expected: "telemetry MID with bit 0x1000 clear",
1941 }
1942 );
1943 }
1944
1945 #[test]
1946 fn rust_rejects_optional_fields() {
1947 let file = parse("struct Status { error_code?: u32 }").unwrap();
1948 let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
1949 assert_eq!(
1950 err,
1951 CodegenError::OptionalFieldUnsupported {
1952 container: "Status".to_string(),
1953 field: "error_code".to_string(),
1954 }
1955 );
1956 }
1957
1958 #[test]
1959 fn rust_rejects_default_values() {
1960 let file = parse("struct Config { gain: f32 = 1.0 }").unwrap();
1961 let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
1962 assert_eq!(
1963 err,
1964 CodegenError::DefaultValueUnsupported {
1965 container: "Config".to_string(),
1966 field: "gain".to_string(),
1967 }
1968 );
1969 }
1970
1971 #[test]
1972 fn rust_rejects_enum_fields() {
1973 let file = parse("enum CameraMode { Idle Streaming }\nstruct Status { mode: CameraMode }")
1974 .unwrap();
1975 let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
1976 assert_eq!(
1977 err,
1978 CodegenError::EnumFieldUnsupported {
1979 container: "Status".to_string(),
1980 field: "mode".to_string(),
1981 ty: "CameraMode".to_string(),
1982 }
1983 );
1984 }
1985
1986 #[test]
1987 fn rust_emits_represented_enum_fields() {
1988 let file = parse(
1989 "enum u8 CameraMode { Idle = 0 Streaming = 1 }\nstruct Status { mode: CameraMode }",
1990 )
1991 .unwrap();
1992 let out = try_generate_rust(&file, &RustOptions::default()).unwrap();
1993 assert!(out.contains("pub type CameraMode = u8;"));
1994 assert!(out.contains("pub const CAMERA_MODE_IDLE: CameraMode = 0;"));
1995 assert!(out.contains("pub const CAMERA_MODE_STREAMING: CameraMode = 1;"));
1996 assert!(out.contains(" pub mode: CameraMode,"));
1997 }
1998
1999 #[test]
2000 fn rust_rejects_dynamic_arrays() {
2001 let file = parse("struct Samples { values: Point[] }").unwrap();
2002 let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
2003 assert_eq!(
2004 err,
2005 CodegenError::DynamicArrayUnsupported {
2006 container: "Samples".to_string(),
2007 field: "values".to_string(),
2008 ty: "Point[]".to_string(),
2009 }
2010 );
2011 }
2012
2013 #[test]
2014 fn rust_rejects_non_string_bounded_arrays() {
2015 let file = parse("struct Buffer { bytes: bytes[<=256] }").unwrap();
2016 let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
2017 assert_eq!(
2018 err,
2019 CodegenError::BoundedArrayUnsupported {
2020 container: "Buffer".to_string(),
2021 field: "bytes".to_string(),
2022 ty: "bytes[<=256]".to_string(),
2023 }
2024 );
2025 }
2026
2027 #[test]
2028 fn rust_const_uses_declared_type() {
2029 let out = rust_codegen("const PI: f64 = 3.14\nconst ENABLED: bool = true");
2030 assert!(out.contains("pub const PI: f64 = 3.14;"));
2031 assert!(out.contains("pub const ENABLED: bool = true;"));
2032 }
2033
2034 #[test]
2035 fn rust_bounded_string_uses_inline_storage() {
2036 let out = rust_codegen("struct Label { name: string[<=64] }");
2037 assert!(out.contains(" pub name: [u8; 64],"));
2038 }
2039
2040 #[test]
2041 fn rust_rejects_unbounded_strings() {
2042 let file = parse("struct Label { name: string }").unwrap();
2043 let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
2044 assert_eq!(
2045 err,
2046 CodegenError::UnboundedStringUnsupported {
2047 container: "Label".to_string(),
2048 field: "name".to_string(),
2049 }
2050 );
2051 }
2052
2053 #[test]
2054 fn rust_imports_emit_crate_uses() {
2055 let out = rust_codegen(r#"import "std_msgs.syn""#);
2056 assert!(out.contains("use crate::std_msgs;"));
2057 }
2058
2059 #[test]
2060 fn rust_doc_comments_emit_for_declarations_and_fields() {
2061 let out = rust_codegen("/// A point\nstruct Point {\n/// X axis\nx: f64\n}");
2062 assert!(out.contains("/// A point\n#[repr(C)]\npub struct Point {"));
2063 assert!(out.contains(" /// X axis\n pub x: f64,"));
2064 }
2065
2066 #[test]
2067 fn screaming_snake_conversion() {
2068 assert_eq!(to_screaming_snake("NavTelemetry"), "NAV_TELEMETRY");
2069 assert_eq!(to_screaming_snake("PoseStamped"), "POSE_STAMPED");
2070 assert_eq!(to_screaming_snake("Foo"), "FOO");
2071 }
2072}