use std::{collections::HashMap, error::Error as StdError, fmt};
use synapse_parser::ast::{
ArraySuffix, Attribute, BaseType, ConstDecl, EnumDef, FieldDef, Item, Literal, MessageDef,
PacketKind, PrimitiveType, StructDef, SynFile, TypeExpr,
};
pub const GENERATED_BANNER: &str = "Generated by Synapse. Do not edit directly.";
pub const PREAMBLE: &str = "\
/* Generated by Synapse. Do not edit directly. */
#pragma once
#include \"cfe.h\"
";
pub type ResolvedConstants = HashMap<Vec<String>, u64>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CfsPacketKind {
Command,
Telemetry,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CfsPacket {
pub namespace: Vec<String>,
pub name: String,
pub kind: CfsPacketKind,
pub mid: u64,
pub cc: Option<u64>,
}
pub struct RustOptions<'a> {
pub cfs_module: &'a str,
pub tlm_header: &'a str,
pub cmd_header: &'a str,
}
impl Default for RustOptions<'_> {
fn default() -> Self {
RustOptions {
cfs_module: "cfs_sys",
tlm_header: "CFE_MSG_TelemetryHeader_t",
cmd_header: "CFE_MSG_CommandHeader_t",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CodegenError {
OptionalFieldUnsupported { container: String, field: String },
DefaultValueUnsupported { container: String, field: String },
EnumFieldUnsupported {
container: String,
field: String,
ty: String,
},
EnumRepresentationUnsupported { enum_name: String, repr: String },
EnumVariantValueRequired { enum_name: String, variant: String },
EnumVariantValueOutOfRange {
enum_name: String,
variant: String,
value: i64,
repr: String,
},
UnboundedStringUnsupported { container: String, field: String },
LegacyMessageUnsupported { packet: String },
MissingMid { packet: String },
MessageIdUnsupported { item: String },
MessageIdValueUnsupported { packet: String },
MissingCommandCode { packet: String },
CommandCodeUnsupported { item: String },
CommandCodeValueUnsupported { packet: String },
DuplicateMid {
mid: String,
first_packet: String,
second_packet: String,
},
DuplicateCommandCode {
mid: String,
cc: String,
first_packet: String,
second_packet: String,
},
MidRangeMismatch {
packet: String,
mid: String,
expected: &'static str,
},
DynamicArrayUnsupported {
container: String,
field: String,
ty: String,
},
BoundedArrayUnsupported {
container: String,
field: String,
ty: String,
},
}
impl fmt::Display for CodegenError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CodegenError::OptionalFieldUnsupported { container, field } => write!(
f,
"optional field `{container}.{field}` is not supported by cFS codegen yet"
),
CodegenError::DefaultValueUnsupported { container, field } => write!(
f,
"default value for field `{container}.{field}` is not supported by cFS codegen yet"
),
CodegenError::EnumFieldUnsupported {
container,
field,
ty,
} => write!(
f,
"enum field `{container}.{field}` with type `{ty}` needs an explicit integer representation for cFS codegen"
),
CodegenError::EnumRepresentationUnsupported { enum_name, repr } => write!(
f,
"enum `{enum_name}` uses unsupported representation `{repr}`; cFS codegen supports integer enum representations"
),
CodegenError::EnumVariantValueRequired { enum_name, variant } => write!(
f,
"enum `{enum_name}` variant `{variant}` needs an explicit value for cFS codegen"
),
CodegenError::EnumVariantValueOutOfRange {
enum_name,
variant,
value,
repr,
} => write!(
f,
"enum `{enum_name}` variant `{variant}` value `{value}` does not fit `{repr}`"
),
CodegenError::UnboundedStringUnsupported { container, field } => write!(
f,
"unbounded string field `{container}.{field}` is not supported by cFS codegen; use `string[<=N]` or `string[N]`"
),
CodegenError::LegacyMessageUnsupported { packet } => write!(
f,
"legacy message `{packet}` is not supported by cFS codegen; use `command` or `telemetry`"
),
CodegenError::MissingMid { packet } => {
write!(f, "packet `{packet}` is missing required `@mid(...)`")
}
CodegenError::MessageIdUnsupported { item } => write!(
f,
"`@mid(...)` is only supported on command and telemetry packets, found on `{item}`"
),
CodegenError::MessageIdValueUnsupported { packet } => write!(
f,
"packet `{packet}` has unresolved or non-integer `@mid(...)`; cFS codegen requires an integer, hex, local integer constant, or imported integer constant message ID"
),
CodegenError::MissingCommandCode { packet } => {
write!(f, "command `{packet}` is missing required `@cc(...)`")
}
CodegenError::CommandCodeUnsupported { item } => write!(
f,
"`@cc(...)` is only supported on command packets, found on `{item}`"
),
CodegenError::CommandCodeValueUnsupported { packet } => write!(
f,
"command `{packet}` has unresolved or non-integer `@cc(...)`; cFS codegen requires an integer, hex, or local integer constant command code"
),
CodegenError::DuplicateMid {
mid,
first_packet,
second_packet,
} => write!(
f,
"duplicate MID `{mid}` used by packets `{first_packet}` and `{second_packet}`"
),
CodegenError::DuplicateCommandCode {
mid,
cc,
first_packet,
second_packet,
} => write!(
f,
"duplicate command MID/CC pair `{mid}`/`{cc}` used by packets `{first_packet}` and `{second_packet}`"
),
CodegenError::MidRangeMismatch {
packet,
mid,
expected,
} => write!(f, "packet `{packet}` has MID `{mid}`, expected {expected}"),
CodegenError::DynamicArrayUnsupported {
container,
field,
ty,
} => write!(
f,
"dynamic array field `{container}.{field}` with type `{ty}` is not supported by cFS codegen yet"
),
CodegenError::BoundedArrayUnsupported {
container,
field,
ty,
} => write!(
f,
"bounded array field `{container}.{field}` with type `{ty}` is not supported by cFS codegen yet"
),
}
}
}
impl StdError for CodegenError {}
pub fn generate_c(file: &SynFile) -> String {
try_generate_c(file).expect("parsed Synapse file is not supported by cFS C codegen")
}
pub fn try_generate_c(file: &SynFile) -> Result<String, CodegenError> {
try_generate_c_with_constants(file, &ResolvedConstants::new())
}
pub fn validate_cfs(file: &SynFile) -> Result<(), CodegenError> {
validate_cfs_with_constants(file, &ResolvedConstants::new())
}
pub fn validate_cfs_with_constants(
file: &SynFile,
imported_constants: &ResolvedConstants,
) -> Result<(), CodegenError> {
let constants = const_context(file, imported_constants);
validate_supported(file, &constants)
}
pub fn collect_cfs_packets_with_constants(
file: &SynFile,
imported_constants: &ResolvedConstants,
) -> Result<Vec<CfsPacket>, CodegenError> {
let constants = const_context(file, imported_constants);
collect_cfs_packets(file, &constants)
}
pub fn try_generate_c_with_constants(
file: &SynFile,
imported_constants: &ResolvedConstants,
) -> Result<String, CodegenError> {
let constants = const_context(file, imported_constants);
validate_supported(file, &constants)?;
let mut out = String::from(PREAMBLE);
emit_c_imports(file, &mut out);
emit_items(file, &mut out, &constants);
Ok(out)
}
pub fn generate_rust(file: &SynFile, opts: &RustOptions) -> String {
try_generate_rust(file, opts).expect("parsed Synapse file is not supported by cFS Rust codegen")
}
pub fn try_generate_rust(file: &SynFile, opts: &RustOptions) -> Result<String, CodegenError> {
try_generate_rust_with_constants(file, opts, &ResolvedConstants::new())
}
pub fn try_generate_rust_with_constants(
file: &SynFile,
opts: &RustOptions,
imported_constants: &ResolvedConstants,
) -> Result<String, CodegenError> {
let constants = const_context(file, imported_constants);
validate_supported(file, &constants)?;
let mut out = format!("// {GENERATED_BANNER}\n\n");
emit_rust_imports(file, &mut out);
emit_rust_items(file, opts, &mut out, &constants);
Ok(out)
}
pub fn resolve_integer_constants(
file: &SynFile,
imported_constants: &ResolvedConstants,
) -> ResolvedConstants {
let constants = const_context(file, imported_constants);
constants.resolved_local_constants()
}
fn validate_supported(file: &SynFile, constants: &ConstContext<'_>) -> Result<(), CodegenError> {
let enum_defs = enum_defs(file);
let mut telemetry_mids = HashMap::new();
let mut command_codes = HashMap::new();
for item in &file.items {
match item {
Item::Struct(s) | Item::Table(s) => {
validate_plain_item_attrs(&s.name, &s.attrs)?;
validate_fields(&s.name, &s.fields, &enum_defs)?
}
Item::Command(m) | Item::Telemetry(m) => {
validate_packet(m, constants, &mut telemetry_mids, &mut command_codes)?;
validate_fields(&m.name, &m.fields, &enum_defs)?
}
Item::Message(m) => {
return Err(CodegenError::LegacyMessageUnsupported {
packet: m.name.clone(),
});
}
Item::Enum(e) => validate_enum(e)?,
Item::Namespace(_) | Item::Import(_) | Item::Const(_) => {}
}
}
Ok(())
}
fn collect_cfs_packets(
file: &SynFile,
constants: &ConstContext<'_>,
) -> Result<Vec<CfsPacket>, CodegenError> {
let namespace = file_namespace(file);
let mut packets = Vec::new();
for item in &file.items {
let packet = match item {
Item::Command(m) | Item::Telemetry(m) => m,
Item::Namespace(_)
| Item::Import(_)
| Item::Const(_)
| Item::Enum(_)
| Item::Struct(_)
| Item::Table(_)
| Item::Message(_) => continue,
};
let Some(mid) = find_mid_attr(&packet.attrs) else {
return Err(CodegenError::MissingMid {
packet: packet.name.clone(),
});
};
let mid_value = resolve_literal_to_u64(mid, constants).ok_or_else(|| {
CodegenError::MessageIdValueUnsupported {
packet: packet.name.clone(),
}
})?;
validate_mid_range(packet, mid_value, mid, constants)?;
let cc = find_cc_attr(&packet.attrs);
let (kind, cc_value) = match packet.kind {
PacketKind::Command => {
let cc = cc.ok_or_else(|| CodegenError::MissingCommandCode {
packet: packet.name.clone(),
})?;
let cc_value = resolve_literal_to_u64(cc, constants).ok_or_else(|| {
CodegenError::CommandCodeValueUnsupported {
packet: packet.name.clone(),
}
})?;
(CfsPacketKind::Command, Some(cc_value))
}
PacketKind::Telemetry => {
if cc.is_some() {
return Err(CodegenError::CommandCodeUnsupported {
item: packet.name.clone(),
});
}
(CfsPacketKind::Telemetry, None)
}
PacketKind::Message => continue,
};
packets.push(CfsPacket {
namespace: namespace.clone(),
name: packet.name.clone(),
kind,
mid: mid_value,
cc: cc_value,
});
}
Ok(packets)
}
struct ConstContext<'a> {
local_defs: HashMap<Vec<String>, &'a ConstDecl>,
imported_values: &'a ResolvedConstants,
}
fn const_context<'a>(
file: &'a SynFile,
imported_values: &'a ResolvedConstants,
) -> ConstContext<'a> {
let namespace = file_namespace(file);
let mut local_defs = HashMap::new();
for item in &file.items {
if let Item::Const(c) = item {
local_defs.insert(vec![c.name.clone()], c);
if !namespace.is_empty() {
let mut qualified = namespace.clone();
qualified.push(c.name.clone());
local_defs.insert(qualified, c);
}
}
}
ConstContext {
local_defs,
imported_values,
}
}
impl ConstContext<'_> {
fn resolved_local_constants(&self) -> ResolvedConstants {
self.local_defs
.keys()
.filter_map(|segments| {
resolve_ident_to_u64(segments, self).map(|value| (segments.clone(), value))
})
.collect()
}
fn is_local_bare_ident(&self, segments: &[String]) -> bool {
segments.len() == 1 && self.local_defs.contains_key(segments)
}
}
fn file_namespace(file: &SynFile) -> Vec<String> {
file.items
.iter()
.find_map(|item| match item {
Item::Namespace(ns) => Some(ns.name.clone()),
_ => None,
})
.unwrap_or_default()
}
fn enum_defs(file: &SynFile) -> HashMap<String, &EnumDef> {
file.items
.iter()
.filter_map(|item| match item {
Item::Enum(e) => Some((e.name.clone(), e)),
_ => None,
})
.collect()
}
fn validate_enum(e: &EnumDef) -> Result<(), CodegenError> {
let Some(repr) = e.repr else {
return Ok(());
};
let Some((min, max)) = enum_repr_range(repr) else {
return Err(CodegenError::EnumRepresentationUnsupported {
enum_name: e.name.clone(),
repr: primitive_name(repr).to_string(),
});
};
for variant in &e.variants {
let value = variant
.value
.ok_or_else(|| CodegenError::EnumVariantValueRequired {
enum_name: e.name.clone(),
variant: variant.name.clone(),
})?;
if value < min || value > max {
return Err(CodegenError::EnumVariantValueOutOfRange {
enum_name: e.name.clone(),
variant: variant.name.clone(),
value,
repr: primitive_name(repr).to_string(),
});
}
}
Ok(())
}
fn enum_repr_range(repr: PrimitiveType) -> Option<(i64, i64)> {
match repr {
PrimitiveType::I8 => Some((i8::MIN as i64, i8::MAX as i64)),
PrimitiveType::I16 => Some((i16::MIN as i64, i16::MAX as i64)),
PrimitiveType::I32 => Some((i32::MIN as i64, i32::MAX as i64)),
PrimitiveType::I64 => Some((i64::MIN, i64::MAX)),
PrimitiveType::U8 => Some((0, u8::MAX as i64)),
PrimitiveType::U16 => Some((0, u16::MAX as i64)),
PrimitiveType::U32 => Some((0, u32::MAX as i64)),
PrimitiveType::U64 => Some((0, i64::MAX)),
PrimitiveType::F32 | PrimitiveType::F64 | PrimitiveType::Bool | PrimitiveType::Bytes => {
None
}
}
}
fn validate_packet(
packet: &MessageDef,
constants: &ConstContext<'_>,
telemetry_mids: &mut HashMap<u64, String>,
command_codes: &mut HashMap<(u64, u64), String>,
) -> Result<(), CodegenError> {
let Some(mid) = find_mid_attr(&packet.attrs) else {
return Err(CodegenError::MissingMid {
packet: packet.name.clone(),
});
};
let cc = find_cc_attr(&packet.attrs);
match packet.kind {
PacketKind::Command if cc.is_none() => {
return Err(CodegenError::MissingCommandCode {
packet: packet.name.clone(),
});
}
PacketKind::Telemetry if cc.is_some() => {
return Err(CodegenError::CommandCodeUnsupported {
item: packet.name.clone(),
});
}
PacketKind::Command | PacketKind::Telemetry | PacketKind::Message => {}
}
let cc_value = if packet.kind == PacketKind::Command {
let cc = cc.expect("command code was checked above");
Some(resolve_literal_to_u64(cc, constants).ok_or_else(|| {
CodegenError::CommandCodeValueUnsupported {
packet: packet.name.clone(),
}
})?)
} else {
None
};
let value = resolve_literal_to_u64(mid, constants).ok_or_else(|| {
CodegenError::MessageIdValueUnsupported {
packet: packet.name.clone(),
}
})?;
validate_mid_range(packet, value, mid, constants)?;
match packet.kind {
PacketKind::Command => {
let cc = cc.expect("command code was checked above");
let cc_value = cc_value.expect("command code value was checked above");
if let Some(first_packet) = command_codes.insert((value, cc_value), packet.name.clone())
{
return Err(CodegenError::DuplicateCommandCode {
mid: literal_mid_str(mid, constants),
cc: literal_cc_str(cc, constants),
first_packet,
second_packet: packet.name.clone(),
});
}
}
PacketKind::Telemetry => {
if let Some(first_packet) = telemetry_mids.insert(value, packet.name.clone()) {
return Err(CodegenError::DuplicateMid {
mid: literal_mid_str(mid, constants),
first_packet,
second_packet: packet.name.clone(),
});
}
}
PacketKind::Message => {}
}
Ok(())
}
fn validate_plain_item_attrs(item_name: &str, attrs: &[Attribute]) -> Result<(), CodegenError> {
if find_mid_attr(attrs).is_some() {
return Err(CodegenError::MessageIdUnsupported {
item: item_name.to_string(),
});
}
if find_cc_attr(attrs).is_some() {
return Err(CodegenError::CommandCodeUnsupported {
item: item_name.to_string(),
});
}
Ok(())
}
fn validate_mid_range(
packet: &MessageDef,
value: u64,
mid: &Literal,
constants: &ConstContext<'_>,
) -> Result<(), CodegenError> {
let command_bit_set = (value & 0x1000) != 0;
let expected = match packet.kind {
PacketKind::Command if !command_bit_set => Some("command MID with bit 0x1000 set"),
PacketKind::Telemetry if command_bit_set => Some("telemetry MID with bit 0x1000 clear"),
PacketKind::Command | PacketKind::Telemetry | PacketKind::Message => None,
};
if let Some(expected) = expected {
return Err(CodegenError::MidRangeMismatch {
packet: packet.name.clone(),
mid: literal_mid_str(mid, constants),
expected,
});
}
Ok(())
}
fn validate_fields(
container: &str,
fields: &[FieldDef],
enum_defs: &HashMap<String, &EnumDef>,
) -> Result<(), CodegenError> {
for field in fields {
if field.optional {
return Err(CodegenError::OptionalFieldUnsupported {
container: container.to_string(),
field: field.name.clone(),
});
}
if field.default.is_some() {
return Err(CodegenError::DefaultValueUnsupported {
container: container.to_string(),
field: field.name.clone(),
});
}
if field.ty.base == BaseType::String && field.ty.array.is_none() {
return Err(CodegenError::UnboundedStringUnsupported {
container: container.to_string(),
field: field.name.clone(),
});
}
if let BaseType::Ref(segments) = &field.ty.base {
if let Some(e) = segments
.last()
.and_then(|name| enum_defs.get(name.as_str()))
{
if e.repr.is_none() {
return Err(CodegenError::EnumFieldUnsupported {
container: container.to_string(),
field: field.name.clone(),
ty: segments.join("::"),
});
}
}
}
match &field.ty.array {
Some(ArraySuffix::Dynamic) => {
return Err(CodegenError::DynamicArrayUnsupported {
container: container.to_string(),
field: field.name.clone(),
ty: type_expr_display(&field.ty),
});
}
Some(ArraySuffix::Bounded(_)) if field.ty.base != BaseType::String => {
return Err(CodegenError::BoundedArrayUnsupported {
container: container.to_string(),
field: field.name.clone(),
ty: type_expr_display(&field.ty),
});
}
Some(ArraySuffix::Bounded(_)) | Some(ArraySuffix::Fixed(_)) | None => {}
}
}
Ok(())
}
fn emit_c_imports(file: &SynFile, out: &mut String) {
let mut emitted = false;
for item in &file.items {
if let Item::Import(import) = item {
out.push_str(&format!("#include \"{}\"\n", import_c_header(&import.path)));
emitted = true;
}
}
if emitted {
out.push('\n');
}
}
fn emit_rust_imports(file: &SynFile, out: &mut String) {
let mut emitted = false;
for item in &file.items {
if let Item::Import(import) = item {
out.push_str(&format!(
"use crate::{};\n",
import_rust_module(&import.path)
));
emitted = true;
}
}
if emitted {
out.push('\n');
}
}
fn emit_items(file: &SynFile, out: &mut String, constants: &ConstContext<'_>) {
let mut has_mids = false;
for item in &file.items {
if let Some(m) = packet_item(item) {
if let Some(mid) = find_mid_attr(&m.attrs) {
if !has_mids {
out.push_str("/* Message IDs */\n");
has_mids = true;
}
let define_name = to_screaming_snake(&m.name);
let mid_str = literal_mid_str(mid, constants);
out.push_str(&format!("#define {}_MID {}\n", define_name, mid_str));
}
}
}
if has_mids {
out.push('\n');
}
let mut has_ccs = false;
for item in &file.items {
if let Item::Command(m) = item {
if let Some(cc) = find_cc_attr(&m.attrs) {
if !has_ccs {
out.push_str("/* Command Codes */\n");
has_ccs = true;
}
let define_name = to_screaming_snake(&m.name);
let cc_str = literal_cc_str(cc, constants);
out.push_str(&format!("#define {}_CC {}\n", define_name, cc_str));
}
}
}
if has_ccs {
out.push('\n');
}
let mut namespace = Vec::new();
for item in &file.items {
match item {
Item::Namespace(ns) => namespace = ns.name.clone(),
Item::Enum(e) => emit_enum(out, e, &namespace),
Item::Import(_)
| Item::Const(_)
| Item::Struct(_)
| Item::Table(_)
| Item::Command(_)
| Item::Telemetry(_)
| Item::Message(_) => {}
}
}
let mut namespace = Vec::new();
for item in &file.items {
match item {
Item::Namespace(ns) => namespace = ns.name.clone(),
Item::Import(_) | Item::Enum(_) => {}
Item::Const(c) => emit_const(out, c),
Item::Struct(s) | Item::Table(s) => emit_struct(out, s, &namespace),
Item::Command(m) | Item::Telemetry(m) | Item::Message(m) => {
emit_message(out, m, &namespace)
}
}
}
}
fn emit_const(out: &mut String, c: &ConstDecl) {
emit_doc_lines(out, &c.doc);
let val = typed_literal_str(&c.value, &c.ty);
out.push_str(&format!("#define {} {}\n\n", c.name, val));
}
fn emit_enum(out: &mut String, e: &EnumDef, namespace: &[String]) {
let Some(repr) = e.repr else {
return;
};
let type_name = c_decl_type_name(&e.name, namespace);
emit_doc_lines(out, &e.doc);
out.push_str(&format!("typedef {} {};\n", primitive_str(repr), type_name));
let enum_prefix = c_enum_variant_prefix(&e.name, namespace);
for variant in &e.variants {
emit_doc_lines(out, &variant.doc);
let value = variant
.value
.expect("represented enum variants validated before emission");
out.push_str(&format!(
"#define {}_{} (({}){})\n",
enum_prefix,
to_screaming_snake(&variant.name),
type_name,
value
));
}
out.push('\n');
}
fn emit_struct(out: &mut String, s: &StructDef, namespace: &[String]) {
emit_doc_lines(out, &s.doc);
out.push_str("typedef struct {\n");
for f in &s.fields {
emit_c_field(out, f, namespace);
}
out.push_str(&format!("}} {};\n\n", c_decl_type_name(&s.name, namespace)));
}
fn emit_message(out: &mut String, m: &MessageDef, namespace: &[String]) {
let header_type = if packet_is_command(m) {
"CFE_MSG_CommandHeader_t"
} else {
"CFE_MSG_TelemetryHeader_t"
};
emit_doc_lines(out, &m.doc);
out.push_str(&format!("typedef struct {{\n"));
out.push_str(&format!(" {} Header;\n", header_type));
for f in &m.fields {
emit_c_field(out, f, namespace);
}
out.push_str(&format!("}} {};\n\n", c_decl_type_name(&m.name, namespace)));
}
fn emit_rust_items(
file: &SynFile,
opts: &RustOptions,
out: &mut String,
constants: &ConstContext<'_>,
) {
let mut has_mids = false;
for item in &file.items {
if let Some(m) = packet_item(item) {
if let Some(mid) = find_mid_attr(&m.attrs) {
if !has_mids {
out.push_str("// Message IDs\n");
has_mids = true;
}
let const_name = format!("{}_MID", to_screaming_snake(&m.name));
let val = rust_mid_str(mid, constants);
out.push_str(&format!("pub const {}: u16 = {};\n", const_name, val));
}
}
}
if has_mids {
out.push('\n');
}
let mut has_ccs = false;
for item in &file.items {
if let Item::Command(m) = item {
if let Some(cc) = find_cc_attr(&m.attrs) {
if !has_ccs {
out.push_str("// Command Codes\n");
has_ccs = true;
}
let const_name = format!("{}_CC", to_screaming_snake(&m.name));
let val = rust_cc_str(cc, constants);
out.push_str(&format!("pub const {}: u16 = {};\n", const_name, val));
}
}
}
if has_ccs {
out.push('\n');
}
for item in &file.items {
match item {
Item::Namespace(_) | Item::Import(_) => {}
Item::Const(c) => emit_rust_const(out, c),
Item::Enum(e) => emit_rust_enum(out, e),
Item::Struct(s) | Item::Table(s) => emit_rust_struct(out, s),
Item::Command(m) | Item::Telemetry(m) | Item::Message(m) => {
emit_rust_message(out, m, opts)
}
}
}
}
fn emit_rust_const(out: &mut String, c: &ConstDecl) {
emit_doc_lines(out, &c.doc);
let val = rust_typed_literal_str(&c.value, &c.ty);
let ty = rust_field_type_str(&c.ty);
out.push_str(&format!("pub const {}: {} = {};\n\n", c.name, ty, val));
}
fn emit_rust_enum(out: &mut String, e: &EnumDef) {
let Some(repr) = e.repr else {
return;
};
emit_doc_lines(out, &e.doc);
out.push_str(&format!(
"pub type {} = {};\n",
e.name,
rust_primitive_str(repr)
));
let enum_prefix = to_screaming_snake(&e.name);
for variant in &e.variants {
emit_doc_lines(out, &variant.doc);
let value = variant
.value
.expect("represented enum variants validated before emission");
out.push_str(&format!(
"pub const {}_{}: {} = {};\n",
enum_prefix,
to_screaming_snake(&variant.name),
e.name,
value
));
}
out.push('\n');
}
fn emit_rust_struct(out: &mut String, s: &StructDef) {
emit_doc_lines(out, &s.doc);
out.push_str("#[repr(C)]\n");
out.push_str(&format!("pub struct {} {{\n", s.name));
for f in &s.fields {
emit_indented_doc_lines(out, &f.doc);
out.push_str(&format!(
" pub {}: {},\n",
f.name,
rust_field_type_str(&f.ty)
));
}
out.push_str("}\n\n");
}
fn emit_rust_message(out: &mut String, m: &MessageDef, opts: &RustOptions) {
let header_type = if packet_is_command(m) {
opts.cmd_header
} else {
opts.tlm_header
};
let qualified = if opts.cfs_module.is_empty() {
header_type.to_string()
} else {
format!("{}::{}", opts.cfs_module, header_type)
};
emit_doc_lines(out, &m.doc);
out.push_str("#[repr(C)]\n");
out.push_str(&format!("pub struct {} {{\n", m.name));
out.push_str(&format!(" pub cfs_header: {},\n", qualified));
for f in &m.fields {
emit_indented_doc_lines(out, &f.doc);
let ty = rust_field_type_str(&f.ty);
out.push_str(&format!(" pub {}: {},\n", f.name, ty));
}
out.push_str("}\n\n");
}
fn rust_field_type_str(ty: &TypeExpr) -> String {
if ty.base == BaseType::String {
return match &ty.array {
None | Some(ArraySuffix::Dynamic) => "*const u8".to_string(),
Some(ArraySuffix::Fixed(n)) | Some(ArraySuffix::Bounded(n)) => {
format!("[u8; {}]", n)
}
};
}
let base = rust_base_type_str(&ty.base);
match &ty.array {
None => base,
Some(ArraySuffix::Fixed(n)) => format!("[{}; {}]", base, n),
Some(ArraySuffix::Dynamic) => format!("*const {}", base),
Some(ArraySuffix::Bounded(n)) => format!("*const {} /* max {} */", base, n),
}
}
fn rust_base_type_str(base: &BaseType) -> String {
match base {
BaseType::String => "*const u8".to_string(),
BaseType::Primitive(p) => rust_primitive_str(*p).to_string(),
BaseType::Ref(segments) => segments.join("::"),
}
}
fn rust_primitive_str(p: PrimitiveType) -> &'static str {
match p {
PrimitiveType::F32 => "f32",
PrimitiveType::F64 => "f64",
PrimitiveType::I8 => "i8",
PrimitiveType::I16 => "i16",
PrimitiveType::I32 => "i32",
PrimitiveType::I64 => "i64",
PrimitiveType::U8 => "u8",
PrimitiveType::U16 => "u16",
PrimitiveType::U32 => "u32",
PrimitiveType::U64 => "u64",
PrimitiveType::Bool => "bool",
PrimitiveType::Bytes => "*const u8",
}
}
fn rust_mid_str(lit: &Literal, constants: &ConstContext<'_>) -> String {
match lit {
Literal::Hex(n) => format!("0x{:04X}", n),
Literal::Int(n) => n.to_string(),
Literal::Ident(segs) if constants.is_local_bare_ident(segs) => segs.join("::"),
Literal::Ident(segs) => resolve_ident_to_u64(segs, constants)
.map(|value| format!("0x{:04X}", value))
.unwrap_or_else(|| segs.join("::")),
other => rust_literal_str(other),
}
}
fn rust_cc_str(lit: &Literal, constants: &ConstContext<'_>) -> String {
match lit {
Literal::Hex(n) => format!("0x{:X}", n),
Literal::Int(n) => n.to_string(),
Literal::Ident(segs) if constants.is_local_bare_ident(segs) => segs.join("::"),
Literal::Ident(segs) => resolve_ident_to_u64(segs, constants)
.map(|value| value.to_string())
.unwrap_or_else(|| segs.join("::")),
other => rust_literal_str(other),
}
}
fn rust_literal_str(lit: &Literal) -> String {
match lit {
Literal::Hex(n) => format!("0x{:X}", n),
Literal::Int(n) => n.to_string(),
Literal::Bool(b) => b.to_string(),
Literal::Float(f) => {
let s = format!("{}", f);
if s.contains('.') || s.contains('e') {
s
} else {
format!("{}.0", s)
}
}
Literal::Str(s) => format!("{:?}", s),
Literal::Ident(segments) => segments.join("::"),
}
}
fn rust_typed_literal_str(lit: &Literal, ty: &TypeExpr) -> String {
match (lit, &ty.base) {
(Literal::Hex(n), BaseType::Primitive(p)) => rust_hex_str(*n, *p),
_ => rust_literal_str(lit),
}
}
fn rust_hex_str(value: u64, ty: PrimitiveType) -> String {
match ty {
PrimitiveType::U8 | PrimitiveType::I8 => format!("0x{:02X}", value),
PrimitiveType::U16 | PrimitiveType::I16 => format!("0x{:04X}", value),
PrimitiveType::U32 | PrimitiveType::I32 => format!("0x{:08X}", value),
PrimitiveType::U64 | PrimitiveType::I64 => format!("0x{:016X}", value),
PrimitiveType::F32 | PrimitiveType::F64 | PrimitiveType::Bool | PrimitiveType::Bytes => {
format!("0x{:X}", value)
}
}
}
fn find_mid_attr(attrs: &[Attribute]) -> Option<&Literal> {
attrs.iter().find(|a| a.name == "mid").map(|a| &a.value)
}
fn find_cc_attr(attrs: &[Attribute]) -> Option<&Literal> {
attrs.iter().find(|a| a.name == "cc").map(|a| &a.value)
}
fn packet_item(item: &Item) -> Option<&MessageDef> {
match item {
Item::Command(m) | Item::Telemetry(m) => Some(m),
_ => None,
}
}
fn packet_is_command(m: &MessageDef) -> bool {
match m.kind {
PacketKind::Command => true,
PacketKind::Telemetry | PacketKind::Message => false,
}
}
fn literal_to_u64(lit: &Literal) -> Option<u64> {
match lit {
Literal::Hex(n) => Some(*n),
Literal::Int(n) if *n >= 0 => Some(*n as u64),
_ => None,
}
}
fn resolve_literal_to_u64(lit: &Literal, constants: &ConstContext<'_>) -> Option<u64> {
resolve_literal_to_u64_inner(lit, constants, &mut Vec::new())
}
fn resolve_literal_to_u64_inner(
lit: &Literal,
constants: &ConstContext<'_>,
seen: &mut Vec<Vec<String>>,
) -> Option<u64> {
match lit {
Literal::Ident(segments) => resolve_ident_to_u64_inner(segments, constants, seen),
other => literal_to_u64(other),
}
}
fn resolve_ident_to_u64(segments: &[String], constants: &ConstContext<'_>) -> Option<u64> {
resolve_ident_to_u64_inner(segments, constants, &mut Vec::new())
}
fn resolve_ident_to_u64_inner(
segments: &[String],
constants: &ConstContext<'_>,
seen: &mut Vec<Vec<String>>,
) -> Option<u64> {
if seen.iter().any(|s| s == segments) {
return None;
}
if let Some(c) = constants.local_defs.get(segments) {
seen.push(segments.to_vec());
let resolved = resolve_literal_to_u64_inner(&c.value, constants, seen);
seen.pop();
return resolved;
}
constants.imported_values.get(segments).copied()
}
fn type_expr_display(ty: &TypeExpr) -> String {
let mut out = base_type_display(&ty.base);
match &ty.array {
None => {}
Some(ArraySuffix::Dynamic) => out.push_str("[]"),
Some(ArraySuffix::Fixed(n)) => out.push_str(&format!("[{n}]")),
Some(ArraySuffix::Bounded(n)) => out.push_str(&format!("[<={n}]")),
}
out
}
fn base_type_display(base: &BaseType) -> String {
match base {
BaseType::String => "string".to_string(),
BaseType::Primitive(p) => primitive_name(*p).to_string(),
BaseType::Ref(segments) => segments.join("::"),
}
}
fn primitive_name(p: PrimitiveType) -> &'static str {
match p {
PrimitiveType::F32 => "f32",
PrimitiveType::F64 => "f64",
PrimitiveType::I8 => "i8",
PrimitiveType::I16 => "i16",
PrimitiveType::I32 => "i32",
PrimitiveType::I64 => "i64",
PrimitiveType::U8 => "u8",
PrimitiveType::U16 => "u16",
PrimitiveType::U32 => "u32",
PrimitiveType::U64 => "u64",
PrimitiveType::Bool => "bool",
PrimitiveType::Bytes => "bytes",
}
}
fn emit_doc_lines(out: &mut String, doc: &[String]) {
for line in doc {
if line.is_empty() {
out.push_str("///\n");
} else {
out.push_str(&format!("/// {line}\n"));
}
}
}
fn emit_indented_doc_lines(out: &mut String, doc: &[String]) {
for line in doc {
if line.is_empty() {
out.push_str(" ///\n");
} else {
out.push_str(&format!(" /// {line}\n"));
}
}
}
fn literal_mid_str(lit: &Literal, constants: &ConstContext<'_>) -> String {
match lit {
Literal::Hex(n) => format!("0x{:04X}U", n),
Literal::Int(n) => format!("{}U", n),
Literal::Ident(segs) if constants.is_local_bare_ident(segs) => segs.join("::"),
Literal::Ident(segs) => resolve_ident_to_u64(segs, constants)
.map(|value| format!("0x{:04X}U", value))
.unwrap_or_else(|| segs.join("::")),
other => literal_str(other),
}
}
fn literal_cc_str(lit: &Literal, constants: &ConstContext<'_>) -> String {
match lit {
Literal::Hex(n) => format!("0x{:X}U", n),
Literal::Int(n) => format!("{}U", n),
Literal::Ident(segs) if constants.is_local_bare_ident(segs) => segs.join("::"),
Literal::Ident(segs) => resolve_ident_to_u64(segs, constants)
.map(|value| format!("{}U", value))
.unwrap_or_else(|| segs.join("::")),
other => literal_str(other),
}
}
fn literal_str(lit: &Literal) -> String {
match lit {
Literal::Float(f) => {
let s = format!("{}", f);
if s.contains('.') || s.contains('e') {
s
} else {
format!("{}.0", s)
}
}
Literal::Int(n) => n.to_string(),
Literal::Hex(n) => format!("0x{:X}U", n),
Literal::Bool(b) => {
if *b {
"1".to_string()
} else {
"0".to_string()
}
}
Literal::Str(s) => format!("{:?}", s),
Literal::Ident(segments) => segments.join("::"),
}
}
fn typed_literal_str(lit: &Literal, ty: &TypeExpr) -> String {
match (lit, &ty.base) {
(Literal::Hex(n), BaseType::Primitive(p)) => c_hex_str(*n, *p),
_ => literal_str(lit),
}
}
fn c_hex_str(value: u64, ty: PrimitiveType) -> String {
match ty {
PrimitiveType::U8 | PrimitiveType::I8 => format!("0x{:02X}U", value),
PrimitiveType::U16 | PrimitiveType::I16 => format!("0x{:04X}U", value),
PrimitiveType::U32 | PrimitiveType::I32 => format!("0x{:08X}U", value),
PrimitiveType::U64 | PrimitiveType::I64 => format!("0x{:016X}U", value),
PrimitiveType::F32 | PrimitiveType::F64 | PrimitiveType::Bool | PrimitiveType::Bytes => {
format!("0x{:X}U", value)
}
}
}
fn non_fixed_type_str(ty: &TypeExpr, namespace: &[String]) -> String {
if ty.base == BaseType::String {
return match &ty.array {
None | Some(ArraySuffix::Dynamic) => "const char*".to_string(),
Some(ArraySuffix::Fixed(_)) => unreachable!("handled by emit_c_field"),
Some(ArraySuffix::Bounded(n)) => format!("char[{}]", n),
};
}
let base = base_type_str(&ty.base, namespace);
match &ty.array {
None => base,
Some(ArraySuffix::Fixed(_)) => unreachable!("handled by caller"),
Some(ArraySuffix::Dynamic) => format!("CFE_Span_t /* {} */", base),
Some(ArraySuffix::Bounded(n)) => format!("CFE_Span_t /* {} max {} */", base, n),
}
}
fn base_type_str(base: &BaseType, namespace: &[String]) -> String {
match base {
BaseType::String => "const char*".to_string(),
BaseType::Primitive(p) => primitive_str(*p).to_string(),
BaseType::Ref(segments) => c_ref_type_name(segments, namespace),
}
}
fn emit_c_field(out: &mut String, f: &synapse_parser::ast::FieldDef, namespace: &[String]) {
emit_indented_doc_lines(out, &f.doc);
match (&f.ty.base, &f.ty.array) {
(BaseType::String, Some(ArraySuffix::Fixed(n) | ArraySuffix::Bounded(n))) => {
out.push_str(&format!(" char {}[{}];\n", f.name, n));
}
(_, Some(ArraySuffix::Fixed(n))) => {
out.push_str(&format!(
" {} {}[{}];\n",
base_type_str(&f.ty.base, namespace),
f.name,
n
));
}
_ => {
out.push_str(&format!(
" {} {};\n",
non_fixed_type_str(&f.ty, namespace),
f.name
));
}
}
}
fn c_decl_type_name(name: &str, namespace: &[String]) -> String {
let mut segments = namespace.to_vec();
segments.push(name.to_string());
format!("{}_t", segments.join("_"))
}
fn c_enum_variant_prefix(name: &str, namespace: &[String]) -> String {
let mut segments = namespace.to_vec();
segments.push(name.to_string());
segments
.iter()
.map(|segment| to_screaming_snake(segment))
.collect::<Vec<_>>()
.join("_")
}
fn c_ref_type_name(segments: &[String], namespace: &[String]) -> String {
let resolved = if segments.len() == 1 && !namespace.is_empty() {
let mut resolved = namespace.to_vec();
resolved.push(segments[0].clone());
resolved
} else {
segments.to_vec()
};
if resolved.is_empty() {
return "_t".to_string();
}
format!("{}_t", resolved.join("_"))
}
fn import_c_header(path: &str) -> String {
replace_extension(path, "h")
}
fn import_rust_module(path: &str) -> String {
let header = path.rsplit('/').next().unwrap_or(path);
replace_extension(header, "")
}
fn replace_extension(path: &str, ext: &str) -> String {
match path.rsplit_once('.') {
Some((stem, _)) if ext.is_empty() => stem.to_string(),
Some((stem, _)) => format!("{stem}.{ext}"),
None if ext.is_empty() => path.to_string(),
None => format!("{path}.{ext}"),
}
}
fn primitive_str(p: PrimitiveType) -> &'static str {
match p {
PrimitiveType::F32 => "float",
PrimitiveType::F64 => "double",
PrimitiveType::I8 => "int8_t",
PrimitiveType::I16 => "int16_t",
PrimitiveType::I32 => "int32_t",
PrimitiveType::I64 => "int64_t",
PrimitiveType::U8 => "uint8_t",
PrimitiveType::U16 => "uint16_t",
PrimitiveType::U32 => "uint32_t",
PrimitiveType::U64 => "uint64_t",
PrimitiveType::Bool => "bool",
PrimitiveType::Bytes => "uint8_t*",
}
}
fn to_screaming_snake(name: &str) -> String {
let mut out = String::new();
for (i, ch) in name.chars().enumerate() {
if ch.is_uppercase() && i > 0 {
out.push('_');
}
out.push(ch.to_ascii_uppercase());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use synapse_parser::ast::parse;
fn codegen(src: &str) -> String {
generate_c(&parse(src).unwrap())
}
#[test]
fn telemetry_with_hex_mid() {
let out = codegen("@mid(0x0801)\ntelemetry NavTlm { x: f64 y: f64 }");
assert!(out.starts_with("/* Generated by Synapse. Do not edit directly. */\n"));
assert!(out.contains("#define NAV_TLM_MID 0x0801U"));
assert!(out.contains("CFE_MSG_TelemetryHeader_t Header;"));
assert!(out.contains("typedef struct {"));
assert!(out.contains("} NavTlm_t;"));
assert!(out.contains(" double x;"));
assert!(out.contains(" double y;"));
}
#[test]
fn command_uses_declared_packet_kind() {
let out = codegen("@mid(0x1881)\n@cc(1)\ncommand NavCmd { seq: u16 }");
assert!(out.contains("#define NAV_CMD_MID 0x1881U"));
assert!(out.contains("#define NAV_CMD_CC 1U"));
assert!(out.contains("CFE_MSG_CommandHeader_t Header;"));
assert!(out.contains("} NavCmd_t;"));
}
#[test]
fn command_uses_command_header() {
let out = codegen("@mid(0x1880)\n@cc(2)\ncommand SetMode { mode: u8 }");
assert!(out.contains("#define SET_MODE_MID 0x1880U"));
assert!(out.contains("#define SET_MODE_CC 2U"));
assert!(out.contains("CFE_MSG_CommandHeader_t Header;"));
assert!(!out.contains("CFE_MSG_TelemetryHeader_t Header;"));
}
#[test]
fn telemetry_uses_telemetry_header() {
let out = codegen("@mid(0x0801)\ntelemetry NavState { x: f64 }");
assert!(out.contains("#define NAV_STATE_MID 0x0801U"));
assert!(out.contains("CFE_MSG_TelemetryHeader_t Header;"));
assert!(!out.contains("CFE_MSG_CommandHeader_t Header;"));
}
#[test]
fn table_is_plain_data_without_bus_header() {
let out = codegen("table NavConfig { max_speed: f64 enabled: bool }");
assert!(out.contains("} NavConfig_t;"));
assert!(out.contains(" double max_speed;"));
assert!(!out.contains("CFE_MSG_CommandHeader_t Header;"));
assert!(!out.contains("CFE_MSG_TelemetryHeader_t Header;"));
}
#[test]
fn c_rejects_legacy_message() {
let file = parse("message Bare { x: f32 }").unwrap();
let err = try_generate_c(&file).unwrap_err();
assert_eq!(
err,
CodegenError::LegacyMessageUnsupported {
packet: "Bare".to_string(),
}
);
assert_eq!(
err.to_string(),
"legacy message `Bare` is not supported by cFS codegen; use `command` or `telemetry`"
);
}
#[test]
fn c_rejects_command_without_mid() {
let file = parse("command SetMode { mode: u8 }").unwrap();
let err = try_generate_c(&file).unwrap_err();
assert_eq!(
err,
CodegenError::MissingMid {
packet: "SetMode".to_string(),
}
);
assert_eq!(
err.to_string(),
"packet `SetMode` is missing required `@mid(...)`"
);
}
#[test]
fn c_rejects_command_without_cc() {
let file = parse("@mid(0x1880)\ncommand SetMode { mode: u8 }").unwrap();
let err = try_generate_c(&file).unwrap_err();
assert_eq!(
err,
CodegenError::MissingCommandCode {
packet: "SetMode".to_string(),
}
);
assert_eq!(
err.to_string(),
"command `SetMode` is missing required `@cc(...)`"
);
}
#[test]
fn c_rejects_cc_on_telemetry() {
let file = parse("@mid(0x0801)\n@cc(1)\ntelemetry Status { x: f32 }").unwrap();
let err = try_generate_c(&file).unwrap_err();
assert_eq!(
err,
CodegenError::CommandCodeUnsupported {
item: "Status".to_string(),
}
);
}
#[test]
fn c_rejects_cc_on_table() {
let file = parse("@cc(1)\ntable Config { enabled: bool }").unwrap();
let err = try_generate_c(&file).unwrap_err();
assert_eq!(
err,
CodegenError::CommandCodeUnsupported {
item: "Config".to_string(),
}
);
}
#[test]
fn c_rejects_mid_on_table() {
let file = parse("@mid(0x0801)\ntable Config { enabled: bool }").unwrap();
let err = try_generate_c(&file).unwrap_err();
assert_eq!(
err,
CodegenError::MessageIdUnsupported {
item: "Config".to_string(),
}
);
assert_eq!(
err.to_string(),
"`@mid(...)` is only supported on command and telemetry packets, found on `Config`"
);
}
#[test]
fn c_rejects_unresolved_symbolic_command_code() {
let file = parse("@mid(0x1880)\n@cc(SET_MODE_CC)\ncommand SetMode { mode: u8 }").unwrap();
let err = try_generate_c(&file).unwrap_err();
assert_eq!(
err,
CodegenError::CommandCodeValueUnsupported {
packet: "SetMode".to_string(),
}
);
}
#[test]
fn c_rejects_unresolved_symbolic_mid() {
let file = parse("@mid(NAV_TLM_MID)\ntelemetry Status { x: f32 }").unwrap();
let err = try_generate_c(&file).unwrap_err();
assert_eq!(
err,
CodegenError::MessageIdValueUnsupported {
packet: "Status".to_string(),
}
);
}
#[test]
fn c_resolves_local_symbolic_mid_and_command_code() {
let out = codegen(
"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 }",
);
assert!(out.contains("#define SET_MODE_MID SET_MODE_MID_VALUE"));
assert!(out.contains("#define SET_MODE_CC SET_MODE_CODE"));
}
#[test]
fn c_resolves_imported_symbolic_mid_and_command_code() {
let file = parse(
"@mid(nav_app::SET_MODE_MID_VALUE)\n@cc(nav_app::SET_MODE_CODE)\ncommand SetMode { mode: u8 }",
)
.unwrap();
let mut constants = ResolvedConstants::new();
constants.insert(
vec!["nav_app".to_string(), "SET_MODE_MID_VALUE".to_string()],
0x1880,
);
constants.insert(vec!["nav_app".to_string(), "SET_MODE_CODE".to_string()], 2);
let out = try_generate_c_with_constants(&file, &constants).unwrap();
assert!(out.contains("#define SET_MODE_MID 0x1880U"));
assert!(out.contains("#define SET_MODE_CC 2U"));
}
#[test]
fn c_validates_local_symbolic_mid_range() {
let file = parse(
"const SET_MODE_MID_VALUE: u16 = 0x0801\n@mid(SET_MODE_MID_VALUE)\n@cc(1)\ncommand SetMode { mode: u8 }",
)
.unwrap();
let err = try_generate_c(&file).unwrap_err();
assert_eq!(
err,
CodegenError::MidRangeMismatch {
packet: "SetMode".to_string(),
mid: "SET_MODE_MID_VALUE".to_string(),
expected: "command MID with bit 0x1000 set",
}
);
}
#[test]
fn c_detects_duplicate_local_symbolic_command_codes() {
let file = parse(
"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 }",
)
.unwrap();
let err = try_generate_c(&file).unwrap_err();
assert_eq!(
err,
CodegenError::DuplicateCommandCode {
mid: "CMD_MID".to_string(),
cc: "SET_CC".to_string(),
first_packet: "A".to_string(),
second_packet: "B".to_string(),
}
);
}
#[test]
fn c_rejects_duplicate_telemetry_mids() {
let file =
parse("@mid(0x0801)\ntelemetry A { x: u8 }\n@mid(0x0801)\ntelemetry B { x: u8 }")
.unwrap();
let err = try_generate_c(&file).unwrap_err();
assert_eq!(
err,
CodegenError::DuplicateMid {
mid: "0x0801U".to_string(),
first_packet: "A".to_string(),
second_packet: "B".to_string(),
}
);
assert_eq!(
err.to_string(),
"duplicate MID `0x0801U` used by packets `A` and `B`"
);
}
#[test]
fn c_allows_shared_command_mid_with_distinct_ccs() {
let out = codegen(
"@mid(0x1880)\n@cc(1)\ncommand A { x: u8 }\n@mid(0x1880)\n@cc(2)\ncommand B { x: u8 }",
);
assert!(out.contains("#define A_MID 0x1880U"));
assert!(out.contains("#define B_MID 0x1880U"));
assert!(out.contains("#define A_CC 1U"));
assert!(out.contains("#define B_CC 2U"));
}
#[test]
fn c_rejects_duplicate_command_mid_cc_pairs() {
let file = parse(
"@mid(0x1880)\n@cc(1)\ncommand A { x: u8 }\n@mid(0x1880)\n@cc(1)\ncommand B { x: u8 }",
)
.unwrap();
let err = try_generate_c(&file).unwrap_err();
assert_eq!(
err,
CodegenError::DuplicateCommandCode {
mid: "0x1880U".to_string(),
cc: "1U".to_string(),
first_packet: "A".to_string(),
second_packet: "B".to_string(),
}
);
assert_eq!(
err.to_string(),
"duplicate command MID/CC pair `0x1880U`/`1U` used by packets `A` and `B`"
);
}
#[test]
fn c_rejects_command_mid_without_command_bit() {
let file = parse("@mid(0x0801)\n@cc(1)\ncommand SetMode { mode: u8 }").unwrap();
let err = try_generate_c(&file).unwrap_err();
assert_eq!(
err,
CodegenError::MidRangeMismatch {
packet: "SetMode".to_string(),
mid: "0x0801U".to_string(),
expected: "command MID with bit 0x1000 set",
}
);
assert_eq!(
err.to_string(),
"packet `SetMode` has MID `0x0801U`, expected command MID with bit 0x1000 set"
);
}
#[test]
fn c_rejects_telemetry_mid_with_command_bit() {
let file = parse("@mid(0x1880)\ntelemetry Status { x: f32 }").unwrap();
let err = try_generate_c(&file).unwrap_err();
assert_eq!(
err,
CodegenError::MidRangeMismatch {
packet: "Status".to_string(),
mid: "0x1880U".to_string(),
expected: "telemetry MID with bit 0x1000 clear",
}
);
}
#[test]
fn c_rejects_optional_fields() {
let file = parse("@mid(0x0801)\ntelemetry Status { error_code?: u32 }").unwrap();
let err = try_generate_c(&file).unwrap_err();
assert_eq!(
err,
CodegenError::OptionalFieldUnsupported {
container: "Status".to_string(),
field: "error_code".to_string(),
}
);
assert_eq!(
err.to_string(),
"optional field `Status.error_code` is not supported by cFS codegen yet"
);
}
#[test]
fn c_rejects_default_values() {
let file = parse("table Config { exposure_us: u32 = 10000 }").unwrap();
let err = try_generate_c(&file).unwrap_err();
assert_eq!(
err,
CodegenError::DefaultValueUnsupported {
container: "Config".to_string(),
field: "exposure_us".to_string(),
}
);
assert_eq!(
err.to_string(),
"default value for field `Config.exposure_us` is not supported by cFS codegen yet"
);
}
#[test]
fn c_rejects_enum_fields() {
let file = parse(
"enum CameraMode { Idle = 0 Streaming = 1 }\n@mid(0x0801)\ntelemetry Status { mode: CameraMode }",
)
.unwrap();
let err = try_generate_c(&file).unwrap_err();
assert_eq!(
err,
CodegenError::EnumFieldUnsupported {
container: "Status".to_string(),
field: "mode".to_string(),
ty: "CameraMode".to_string(),
}
);
assert_eq!(
err.to_string(),
"enum field `Status.mode` with type `CameraMode` needs an explicit integer representation for cFS codegen"
);
}
#[test]
fn c_emits_represented_enum_fields() {
let file = parse(
"enum u8 CameraMode { Idle = 0 Streaming = 1 }\n@mid(0x0801)\ntelemetry Status { mode: CameraMode }",
)
.unwrap();
let out = try_generate_c(&file).unwrap();
assert!(out.contains("typedef uint8_t CameraMode_t;"));
assert!(out.contains("#define CAMERA_MODE_IDLE ((CameraMode_t)0)"));
assert!(out.contains("#define CAMERA_MODE_STREAMING ((CameraMode_t)1)"));
assert!(out.contains(" CameraMode_t mode;"));
}
#[test]
fn c_namespaces_represented_enum_variant_constants() {
let file = parse(
"namespace camera_app\nenum u8 CameraMode { Idle = 0 Streaming = 1 }\n@mid(0x0801)\ntelemetry Status { mode: CameraMode }",
)
.unwrap();
let out = try_generate_c(&file).unwrap();
assert!(out.contains("typedef uint8_t camera_app_CameraMode_t;"));
assert!(out.contains("#define CAMERA_APP_CAMERA_MODE_IDLE ((camera_app_CameraMode_t)0)"));
assert!(
out.contains("#define CAMERA_APP_CAMERA_MODE_STREAMING ((camera_app_CameraMode_t)1)")
);
assert!(out.contains(" camera_app_CameraMode_t mode;"));
}
#[test]
fn c_rejects_represented_enum_missing_value() {
let file = parse("enum u8 CameraMode { Idle Streaming = 1 }").unwrap();
let err = try_generate_c(&file).unwrap_err();
assert_eq!(
err,
CodegenError::EnumVariantValueRequired {
enum_name: "CameraMode".to_string(),
variant: "Idle".to_string(),
}
);
}
#[test]
fn c_rejects_non_integer_enum_repr() {
let file = parse("enum bool CameraMode { Idle = 0 Streaming = 1 }").unwrap();
let err = try_generate_c(&file).unwrap_err();
assert_eq!(
err,
CodegenError::EnumRepresentationUnsupported {
enum_name: "CameraMode".to_string(),
repr: "bool".to_string(),
}
);
}
#[test]
fn c_rejects_represented_enum_out_of_range() {
let file = parse("enum u8 CameraMode { TooLarge = 256 }").unwrap();
let err = try_generate_c(&file).unwrap_err();
assert_eq!(
err,
CodegenError::EnumVariantValueOutOfRange {
enum_name: "CameraMode".to_string(),
variant: "TooLarge".to_string(),
value: 256,
repr: "u8".to_string(),
}
);
}
#[test]
fn c_rejects_dynamic_arrays() {
let file = parse("@mid(0x0801)\ntelemetry Samples { values: f32[] }").unwrap();
let err = try_generate_c(&file).unwrap_err();
assert_eq!(
err,
CodegenError::DynamicArrayUnsupported {
container: "Samples".to_string(),
field: "values".to_string(),
ty: "f32[]".to_string(),
}
);
assert_eq!(
err.to_string(),
"dynamic array field `Samples.values` with type `f32[]` is not supported by cFS codegen yet"
);
}
#[test]
fn c_rejects_non_string_bounded_arrays() {
let file = parse("table Buffer { bytes: u8[<=256] }").unwrap();
let err = try_generate_c(&file).unwrap_err();
assert_eq!(
err,
CodegenError::BoundedArrayUnsupported {
container: "Buffer".to_string(),
field: "bytes".to_string(),
ty: "u8[<=256]".to_string(),
}
);
assert_eq!(
err.to_string(),
"bounded array field `Buffer.bytes` with type `u8[<=256]` is not supported by cFS codegen yet"
);
}
#[test]
fn const_emits_define() {
let out = codegen("const NAV_TLM_MID: u16 = 0x0801");
assert!(out.contains("#define NAV_TLM_MID 0x0801U"));
}
#[test]
fn fixed_array_field() {
let out = codegen("@mid(0x0802)\ntelemetry Imu { covariance: f64[9] }");
assert!(out.contains(" double covariance[9];"));
}
#[test]
fn c_refs_use_declared_typedef_names() {
let out = codegen("struct Point { x: f64 }\n@mid(0x0801)\ntelemetry Pose { point: Point }");
assert!(out.contains("} Point_t;"));
assert!(out.contains(" Point_t point;"));
}
#[test]
fn c_qualified_refs_use_declared_typedef_names() {
let out = codegen("@mid(0x0801)\ntelemetry Stamped { header: std_msgs::Header }");
assert!(out.contains(" std_msgs_Header_t header;"));
}
#[test]
fn c_bounded_string_uses_inline_storage() {
let out = codegen("struct Label { name: string[<=64] }");
assert!(out.contains(" char name[64];"));
}
#[test]
fn c_rejects_unbounded_strings() {
let file = parse("struct Label { name: string }").unwrap();
let err = try_generate_c(&file).unwrap_err();
assert_eq!(
err,
CodegenError::UnboundedStringUnsupported {
container: "Label".to_string(),
field: "name".to_string(),
}
);
assert_eq!(
err.to_string(),
"unbounded string field `Label.name` is not supported by cFS codegen; use `string[<=N]` or `string[N]`"
);
}
#[test]
fn c_imports_emit_header_includes() {
let out = codegen(r#"import "std_msgs.syn""#);
assert!(out.contains("#include \"std_msgs.h\""));
}
#[test]
fn c_doc_comments_emit_for_declarations_and_fields() {
let out = codegen("/// A point\nstruct Point {\n/// X axis\nx: f64\n}");
assert!(out.contains("/// A point\ntypedef struct {"));
assert!(out.contains(" /// X axis\n double x;"));
}
fn rust_codegen(src: &str) -> String {
generate_rust(&parse(src).unwrap(), &RustOptions::default())
}
#[test]
fn rust_tlm_struct() {
let out = rust_codegen("@mid(0x0801)\ntelemetry NavTlm { x: f64 y: f64 }");
assert!(out.starts_with("// Generated by Synapse. Do not edit directly.\n"));
assert!(out.contains("pub const NAV_TLM_MID: u16 = 0x0801;"));
assert!(out.contains("#[repr(C)]"));
assert!(out.contains("pub struct NavTlm {"));
assert!(out.contains(" pub cfs_header: cfs_sys::CFE_MSG_TelemetryHeader_t,"));
assert!(out.contains(" pub x: f64,"));
assert!(out.contains(" pub y: f64,"));
}
#[test]
fn rust_cmd_struct() {
let out = rust_codegen("@mid(0x1880)\n@cc(1)\ncommand NavCmd { seq: u16 }");
assert!(out.contains("pub const NAV_CMD_MID: u16 = 0x1880;"));
assert!(out.contains("pub const NAV_CMD_CC: u16 = 1;"));
assert!(out.contains(" pub cfs_header: cfs_sys::CFE_MSG_CommandHeader_t,"));
}
#[test]
fn rust_command_uses_command_header() {
let out = rust_codegen("@mid(0x1881)\n@cc(2)\ncommand SetMode { mode: u8 }");
assert!(out.contains("pub const SET_MODE_MID: u16 = 0x1881;"));
assert!(out.contains("pub const SET_MODE_CC: u16 = 2;"));
assert!(out.contains(" pub cfs_header: cfs_sys::CFE_MSG_CommandHeader_t,"));
assert!(!out.contains("CFE_MSG_TelemetryHeader_t"));
}
#[test]
fn rust_resolves_imported_symbolic_mid_and_command_code() {
let file = parse(
"@mid(nav_app::SET_MODE_MID_VALUE)\n@cc(nav_app::SET_MODE_CODE)\ncommand SetMode { mode: u8 }",
)
.unwrap();
let mut constants = ResolvedConstants::new();
constants.insert(
vec!["nav_app".to_string(), "SET_MODE_MID_VALUE".to_string()],
0x1880,
);
constants.insert(vec!["nav_app".to_string(), "SET_MODE_CODE".to_string()], 2);
let out =
try_generate_rust_with_constants(&file, &RustOptions::default(), &constants).unwrap();
assert!(out.contains("pub const SET_MODE_MID: u16 = 0x1880;"));
assert!(out.contains("pub const SET_MODE_CC: u16 = 2;"));
}
#[test]
fn rust_telemetry_uses_telemetry_header() {
let out = rust_codegen("@mid(0x0801)\ntelemetry NavState { x: f64 }");
assert!(out.contains("pub const NAV_STATE_MID: u16 = 0x0801;"));
assert!(out.contains(" pub cfs_header: cfs_sys::CFE_MSG_TelemetryHeader_t,"));
assert!(!out.contains("CFE_MSG_CommandHeader_t"));
}
#[test]
fn rust_table_is_plain_data_without_bus_header() {
let out = rust_codegen("table NavConfig { max_speed: f64 enabled: bool }");
assert!(out.contains("pub struct NavConfig {"));
assert!(out.contains(" pub max_speed: f64,"));
assert!(!out.contains("cfs_header"));
}
#[test]
fn rust_fixed_array() {
let out = rust_codegen("@mid(0x0802)\ntelemetry Imu { covariance: f64[9] }");
assert!(out.contains(" pub covariance: [f64; 9],"));
}
#[test]
fn rust_custom_module() {
let opts = RustOptions {
cfs_module: "my_cfs",
..Default::default()
};
let out = generate_rust(
&parse("@mid(0x0801)\ntelemetry T { x: f32 }").unwrap(),
&opts,
);
assert!(out.contains("my_cfs::CFE_MSG_TelemetryHeader_t"));
}
#[test]
fn rust_bare_module() {
let opts = RustOptions {
cfs_module: "",
..Default::default()
};
let out = generate_rust(
&parse("@mid(0x0801)\ntelemetry T { x: f32 }").unwrap(),
&opts,
);
assert!(out.contains(" pub cfs_header: CFE_MSG_TelemetryHeader_t,"));
assert!(!out.contains("::CFE_MSG_TelemetryHeader_t"));
}
#[test]
fn rust_message_can_have_payload_header_field() {
let out = rust_codegen("@mid(0x0801)\ntelemetry Stamped { header: std_msgs::Header }");
assert!(out.contains(" pub cfs_header: cfs_sys::CFE_MSG_TelemetryHeader_t,"));
assert!(out.contains(" pub header: std_msgs::Header,"));
}
#[test]
fn rust_rejects_legacy_message() {
let file = parse("@mid(0x0801)\nmessage Bare { x: f32 }").unwrap();
let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
assert_eq!(
err,
CodegenError::LegacyMessageUnsupported {
packet: "Bare".to_string(),
}
);
}
#[test]
fn rust_rejects_telemetry_without_mid() {
let file = parse("telemetry Status { x: f32 }").unwrap();
let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
assert_eq!(
err,
CodegenError::MissingMid {
packet: "Status".to_string(),
}
);
}
#[test]
fn rust_rejects_mid_range_mismatch() {
let file = parse("@mid(0x1880)\ntelemetry Status { x: f32 }").unwrap();
let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
assert_eq!(
err,
CodegenError::MidRangeMismatch {
packet: "Status".to_string(),
mid: "0x1880U".to_string(),
expected: "telemetry MID with bit 0x1000 clear",
}
);
}
#[test]
fn rust_rejects_optional_fields() {
let file = parse("struct Status { error_code?: u32 }").unwrap();
let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
assert_eq!(
err,
CodegenError::OptionalFieldUnsupported {
container: "Status".to_string(),
field: "error_code".to_string(),
}
);
}
#[test]
fn rust_rejects_default_values() {
let file = parse("struct Config { gain: f32 = 1.0 }").unwrap();
let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
assert_eq!(
err,
CodegenError::DefaultValueUnsupported {
container: "Config".to_string(),
field: "gain".to_string(),
}
);
}
#[test]
fn rust_rejects_enum_fields() {
let file = parse("enum CameraMode { Idle Streaming }\nstruct Status { mode: CameraMode }")
.unwrap();
let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
assert_eq!(
err,
CodegenError::EnumFieldUnsupported {
container: "Status".to_string(),
field: "mode".to_string(),
ty: "CameraMode".to_string(),
}
);
}
#[test]
fn rust_emits_represented_enum_fields() {
let file = parse(
"enum u8 CameraMode { Idle = 0 Streaming = 1 }\nstruct Status { mode: CameraMode }",
)
.unwrap();
let out = try_generate_rust(&file, &RustOptions::default()).unwrap();
assert!(out.contains("pub type CameraMode = u8;"));
assert!(out.contains("pub const CAMERA_MODE_IDLE: CameraMode = 0;"));
assert!(out.contains("pub const CAMERA_MODE_STREAMING: CameraMode = 1;"));
assert!(out.contains(" pub mode: CameraMode,"));
}
#[test]
fn rust_rejects_dynamic_arrays() {
let file = parse("struct Samples { values: Point[] }").unwrap();
let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
assert_eq!(
err,
CodegenError::DynamicArrayUnsupported {
container: "Samples".to_string(),
field: "values".to_string(),
ty: "Point[]".to_string(),
}
);
}
#[test]
fn rust_rejects_non_string_bounded_arrays() {
let file = parse("struct Buffer { bytes: bytes[<=256] }").unwrap();
let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
assert_eq!(
err,
CodegenError::BoundedArrayUnsupported {
container: "Buffer".to_string(),
field: "bytes".to_string(),
ty: "bytes[<=256]".to_string(),
}
);
}
#[test]
fn rust_const_uses_declared_type() {
let out = rust_codegen(
"const PI: f64 = 3.14\nconst ENABLED: bool = true\nconst NAV_TLM_MID: u16 = 0x0801",
);
assert!(out.contains("pub const PI: f64 = 3.14;"));
assert!(out.contains("pub const ENABLED: bool = true;"));
assert!(out.contains("pub const NAV_TLM_MID: u16 = 0x0801;"));
}
#[test]
fn rust_bounded_string_uses_inline_storage() {
let out = rust_codegen("struct Label { name: string[<=64] }");
assert!(out.contains(" pub name: [u8; 64],"));
}
#[test]
fn rust_rejects_unbounded_strings() {
let file = parse("struct Label { name: string }").unwrap();
let err = try_generate_rust(&file, &RustOptions::default()).unwrap_err();
assert_eq!(
err,
CodegenError::UnboundedStringUnsupported {
container: "Label".to_string(),
field: "name".to_string(),
}
);
}
#[test]
fn rust_imports_emit_crate_uses() {
let out = rust_codegen(r#"import "std_msgs.syn""#);
assert!(out.contains("use crate::std_msgs;"));
}
#[test]
fn rust_doc_comments_emit_for_declarations_and_fields() {
let out = rust_codegen("/// A point\nstruct Point {\n/// X axis\nx: f64\n}");
assert!(out.contains("/// A point\n#[repr(C)]\npub struct Point {"));
assert!(out.contains(" /// X axis\n pub x: f64,"));
}
#[test]
fn screaming_snake_conversion() {
assert_eq!(to_screaming_snake("NavTelemetry"), "NAV_TELEMETRY");
assert_eq!(to_screaming_snake("PoseStamped"), "POSE_STAMPED");
assert_eq!(to_screaming_snake("Foo"), "FOO");
assert_eq!(
c_enum_variant_prefix("SensorMode", &["demo_msgs".to_string()]),
"DEMO_MSGS_SENSOR_MODE"
);
}
}