From 944d68fac995d548dfaed824c036d5be7edf0ef7 Mon Sep 17 00:00:00 2001 From: Felix van Oost Date: Thu, 9 Jul 2026 17:08:07 +0000 Subject: [PATCH 01/17] Emit user-defined structs from DBC attributes --- src/lib.rs | 243 ++++++++++++++++++++++++++++++++++++- tests/attribute_structs.rs | 122 +++++++++++++++++++ 2 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 tests/attribute_structs.rs diff --git a/src/lib.rs b/src/lib.rs index fbaa3144..e73bfa12 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,12 +20,12 @@ use std::path::Path; use anyhow::{anyhow, ensure, Context, Error, Result}; use can_dbc::ByteOrder::{BigEndian, LittleEndian}; -use can_dbc::MultiplexIndicator::{ +use can_dbc::MultsiplexIndicator::{ MultiplexedSignal, Multiplexor, MultiplexorAndMultiplexedSignal, Plain, }; use can_dbc::ValueType::Signed; use can_dbc::{Dbc, Message, MessageId, Signal, ValDescription, ValueDescription}; -use heck::ToSnakeCase; +use heck::ToSnaskeCase; use quote::ToTokens; use typed_builder::TypedBuilder; @@ -95,6 +95,108 @@ pub struct Config<'a> { /// Optional: Allow dead code in the generated module. Default: `false`. #[builder(default)] pub allow_dead_code: bool, + + /// Optional: User-defined structs populated from DBC attributes and message layout. + /// These are emitted as associated constants in the generated message types. + /// Default: empty. + #[builder(default)] + pub attribute_structs: &'a [AttributeStruct<'a>], +} + +/// A user-defined struct that [`Config`] fills from DBC data and emits as an +/// associated constant in the generated message type. +/// +/// # Example +/// +/// Given a consumer type `data_protection::E2EDataIdInfo { data_id, start_byte, +/// width_bit }`, this declaration: +/// +/// ``` +/// use dbc_codegen::{AttributeField, AttributeScope, AttributeStruct, FieldSource}; +/// +/// const E2E: AttributeStruct = AttributeStruct { +/// type_path: "data_protection::E2EDataIdInfo", +/// const_name: "E2E", +/// scope: AttributeScope::Signal, +/// require: "E2EDataId", +/// fields: &[ +/// AttributeField { name: "data_id", source: FieldSource::Attr("E2EDataId") }, +/// AttributeField { name: "start_byte", source: FieldSource::StartByte }, +/// AttributeField { name: "width_bit", source: FieldSource::Attr("E2EDataLength") }, +/// ], +/// }; +/// ``` +/// +/// emits, for every signal that carries an `E2EDataId` attribute: +/// +/// ```ignore +/// impl SomeMessage { +/// pub const SOME_SIGNAL_E2E: data_protection::E2EDataIdInfo = +/// data_protection::E2EDataIdInfo { data_id: 373, start_byte: 0, width_bit: 48 }; +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct AttributeStruct<'a> { + /// Fully-qualified path of the target Rust type, e.g. + /// 'data_protection::E2EDataIdInfo'. + pub type_path: &'a str, + + /// Base name of the emitted associated constant. + pub const_name: &'a str, + + /// Whether one constant is emitted per message or per matching signal. + pub scope: AttributeScope, + + /// Only emit the constant when this attribute has an assigned value on the + /// scoped object (the message for [`AttributeScope::Message`] or the signal + /// for [`AttributeScope::Signal`]). + pub require: &'a str, + + /// The struct fields, written in this order. + pub fields: &'a [AttributeField<'a>], +} + +/// Whether an [`AttributeStruct`] is emitted once per message or once per signal. +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +pub enum AttributeScope { + /// One const per message (BO_ attribute). + Message, + /// One const per signal (SG_ attribute). + Signal, +} + +/// One field of an [`AttributeStruct`] and where its value comes from. +#[derive(Debug, Clone)] +pub struct AttributeField<'a> { + /// The target struct field name. + pub name: &'a str, + /// Where the field's value comes from. + pub source: FieldSource<'a>, +} + +/// Source of a generated [`AttributeField`] value. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum FieldSource<'a> { + /// Value of an attribute on the scoped object: a signal-level attribute in + /// [`AttributeScope::Signal`] or a message-level attribute in + /// [`AttributeScope::Message`]. + Attr(&'a str), + /// Value of a message-level attribute. + MessageAttr(&'a str), + /// The signal's start bit. [`AttributeScope::Signal`] only. + StartBit, + /// The signal's start byte. [`AttributeScope::Signal`] only. + StartByte, + /// The signal's width in bits. [`AttributeScope::Signal`] only. + BitWidth, + /// The message size in bytes. + MessageSize, + /// A literal integer. + Int(i64), + /// A literal string. + Str(&'a str), } impl Config<'_> { @@ -290,6 +392,8 @@ impl Config<'_> { } writeln!(w)?; + self.render_attribute_structs(&mut w, msg, dbc)?; + writeln!(w, "/// Construct new {} from values", msg.name)?; let args = msg .signals @@ -414,6 +518,71 @@ impl Config<'_> { Ok(()) } + /// Emit the configured [`AttributeStruct`]s as associated constants. + fn render_attribute_structs(&self, w: &mut impl Write, msg: &Message, dbc: &Dbc) -> Result<()> { + for spec in self.attribute_structs { + match spec.scope { + AttributeScope::Message => { + if message_attr(dbc, msg.id, spec.require).is_none() { + continue; + } + self.render_attribute_struct(w, spec, spec.const_name, msg, dbc, None)?; + } + AttributeScope::Signal => { + for signal in &msg.signals { + if signal_attr(dbc, msg.id, &signal.name, spec.require).is_none() { + continue; + } + let name = + format!("{}_{}", signal.field_name().to_uppercase(), spec.const_name); + self.render_attribute_struct(w, spec, &name, msg, dbc, Some(signal))?; + } + } + } + } + Ok(()) + } + + /// Emit a single struct constant. + fn render_attribute_struct( + &self, + w: &mut impl Write, + spec: &AttributeStruct<'_>, + const_name: &str, + msg: &Message, + dbc: &Dbc, + signal: Option<&Signal>, + ) -> Result<()> { + let mut fields = Vec::with_capacity(spec.fields.len()); + for field in spec.fields { + match resolve_field_source(&field.source, msg, dbc, signal) { + Some(lit) => fields.push((field.name, lit)), + None => { + if self.debug_prints { + eprintln!( + "Skipping const {const_name}: field `{}` has no value", + field.name + ); + } + return Ok(()); + } + } + } + + let ty = spec.type_path; + writeln!(w, "/// Generated from DBC attributes '{}'", spec.require)?; + writeln!(w, "pub const {const_name}: {ty} = {ty} {{")?; + { + let mut w = PadAdapter::wrap(w); + for (name, lit) in fields { + writeln!(w, "{name}: {lit},")?; + } + } + writeln!(w, "}};")?; + writeln!(w)?; + Ok(()) + } + fn render_signal( &self, w: &mut impl Write, @@ -1325,6 +1494,76 @@ fn message_ignored(message: &Message) -> bool { message.name == "VECTOR__INDEPENDENT_SIG_MSG" } +/// Look up an assigned message-level (BO_) attribute value. +fn message_attr<'a>(dbc: &'a Dbc, id: MessageId, name: &str) -> Option<&'a AttributeValue> { + dbc.attribute_values_message + .iter() + .find(|a| a.message_id == id && a.name == name) + .map(|a| &a.value) +} + +/// Look up an assigned signal-level (SG_) attribute value. +fn signal_attr<'a>( + dbc: &'a Dbc, + id: MessageId, + signal: &str, + name: &str, +) -> Option<&'a AttributeValue> { + dbc.attribute_values_signal + .iter() + .find(|a| a.message_id == id && a.signal_name == signal && a.name == name) + .map(|a| &a.value) +} + +/// Look up an attribute's default value (BA_DEF_DEF_). +fn attr_default<'a>(dbc: &'a Dbc, name: &str) -> Option<&'a AttributeValue> { + dbc.attribute_defaults + .iter() + .find(|d| d.name == name) + .map(|d| &d.value) +} + +/// Resolve a [`FieldSource`] to a Rust literal. +fn resolve_field_source( + source: &FieldSource<'_>, + msg: &Message, + dbc: &Dbc, + signal: Option<&Signal>, +) -> Option { + match source { + FieldSource::Attr(name) => { + let value = match signal { + Some(s) => signal_attr(dbc, msg.id, &s.name, name), + None => message_attr(dbc, msg.id, name), + } + .or_else(|| attr_default(dbc, name))?; + Some(attr_value_literal(value)) + } + FieldSource::MessageAttr(name) => { + let value = message_attr(dbc, msg.id, name).or_else(|| attr_default(dbc, name))?; + Some(attr_value_literal(value)) + } + FieldSource::StartBit => signal.map(|s| s.start_bit.to_string()), + FieldSource::StartByte => { + signal.map(|s| s.start_bit.checked_div(8).unwrap_or(0).to_string()) + } + FieldSource::BitWidth => signal.map(|s| s.size.to_string()), + FieldSource::MessageSize => Some(msg.size.to_string()), + FieldSource::Int(v) => Some(v.to_string()), + FieldSource::Str(v) => Some(format!("{v:?}")), + } +} + +/// Render an attribute value as an un-suffixed Rust literal. +fn attr_value_literal(value: &AttributeValue) -> String { + match value { + AttributeValue::Uint(v) => v.to_string(), + AttributeValue::Int(v) => v.to_string(), + AttributeValue::Double(v) => format!("{v:?}"), + AttributeValue::String(v) => format!("{v:?}"), + } +} + impl Config<'_> { /// Generate Rust structs matching DBC input description and return as String pub fn generate(self) -> Result { diff --git a/tests/attribute_structs.rs b/tests/attribute_structs.rs new file mode 100644 index 00000000..c7a81e8c --- /dev/null +++ b/tests/attribute_structs.rs @@ -0,0 +1,122 @@ +#![cfg(feature = "std")] + +//! Tests for emitting user-defined structs populated from DBC attributes and +//! message layout. + +use dbc_codegen::{AttributeField, AttributeScope, AttributeStruct, Config, FieldSource}; + +const DBC: &str = r#"VERSION "" + +NS_ : + +BS_: + +BU_: ECU1 + +BO_ 256 Protected: 8 ECU1 + SG_ TestSig : 16|8@1+ (1,0) [0|255] "" ECU1 + +BO_ 257 Plain: 8 ECU1 + SG_ OtherSig : 0|8@1+ (1,0) [0|255] "" ECU1 + +BA_DEF_ BO_ "SC_Message" ENUM "0","1","2"; +BA_DEF_ BO_ "SCP_FreshnessValueId" INT 0 65535; +BA_DEF_ SG_ "E2EDataId" INT 0 65535; +BA_DEF_ SG_ "E2EDataLength" INT 0 65535; +BA_DEF_ SG_ "E2EProfile" STRING ; +BA_DEF_DEF_ "SC_Message" "0"; +BA_DEF_DEF_ "SCP_FreshnessValueId" 0; +BA_DEF_DEF_ "E2EDataId" 0; +BA_DEF_DEF_ "E2EDataLength" 0; +BA_DEF_DEF_ "E2EProfile" "none"; +BA_ "SC_Message" BO_ 256 1; +BA_ "SCP_FreshnessValueId" BO_ 256 1002; +BA_ "E2EDataId" SG_ 256 TestSig 373; +BA_ "E2EDataLength" SG_ 256 TestSig 48; +"#; + +const E2E: AttributeStruct = AttributeStruct { + type_path: "data_protection::E2EDataIdInfo", + const_name: "E2E", + scope: AttributeScope::Signal, + require: "E2EDataId", + fields: &[ + AttributeField { + name: "data_id", + source: FieldSource::Attr("E2EDataId"), + }, + AttributeField { + name: "start_byte", + source: FieldSource::StartByte, + }, + AttributeField { + name: "width_bit", + source: FieldSource::Attr("E2EDataLength"), + }, + AttributeField { + name: "profile", + source: FieldSource::Attr("E2EProfile"), + }, + ], +}; + +const SEC_OC: AttributeStruct = AttributeStruct { + type_path: "data_protection::SecOcInfo", + const_name: "SEC_OC", + scope: AttributeScope::Message, + require: "SC_Message", + fields: &[AttributeField { + name: "freshness_id", + source: FieldSource::Attr("SCP_FreshnessValueId"), + }], +}; + +fn generate(specs: &[AttributeStruct<'_>]) -> String { + Config::builder() + .dbc_name("attribute_structs_test") + .dbc_content(DBC) + .attribute_structs(specs) + .build() + .generate() + .expect("generate") +} + +#[test] +fn default_emits_nothing() { + let out = generate(&[]); + assert!(!out.contains("E2EDataIdInfo"), "{out}"); +} + +#[test] +fn signal_scope_emits_typed_struct_with_derived_layout() { + let out = generate(&[E2E]); + assert!( + out.contains("pub const TEST_SIG_E2E: data_protection::E2EDataIdInfo"), + "{out}" + ); + assert!(out.contains("data_id: 373"), "{out}"); + assert!(out.contains("start_byte: 2"), "{out}"); + assert!(out.contains("width_bit: 48"), "{out}"); + assert!(out.contains(r#"profile: "none""#), "{out}"); +} + +#[test] +fn message_scope_emits_once_per_protected_message() { + let out = generate(&[SEC_OC]); + assert!( + out.contains("pub const SEC_OC: data_protection::SecOcInfo"), + "{out}" + ); + assert!(out.contains("freshness_id: 1002"), "{out}"); +} + +#[test] +fn unprotected_message_and_signal_get_no_const() { + let out = generate(&[E2E, SEC_OC]); + assert!(!out.contains("OTHER_SIG_E2E"), "{out}"); + assert_eq!( + out.matches("pub const SEC_OC").count(), + 1, + "SEC_OC should appear once\n{out}" + ); +} From fec3a1a7894f1afa5caf571b00fa7ff2a8068d0b Mon Sep 17 00:00:00 2001 From: Felix van Oost Date: Thu, 9 Jul 2026 17:16:56 +0000 Subject: [PATCH 02/17] Handle start byte ordering --- examples/attribute_structs.rs | 78 +++++++++++++++++++++++++++++++++++ src/lib.rs | 14 +++++-- 2 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 examples/attribute_structs.rs diff --git a/examples/attribute_structs.rs b/examples/attribute_structs.rs new file mode 100644 index 00000000..d3e11a10 --- /dev/null +++ b/examples/attribute_structs.rs @@ -0,0 +1,78 @@ +//! Demonstrates [Config::attribute_structs]. +//! Emits constants for AUTOSAR E2E / SecOC-style metdata from DBC 'BA_' attributes. + +use dbc_codegen::{AttributeField, AttributeScope, AttributeStruct, Config, FieldSource}; + +const DBC: &str = r#"VERSION "" + +NS_ : + +BS_: + +BU_: ECU1 + +BO_ 256 Protected: 8 ECU1 + SG_ Speed_CRC : 16|8@1+ (1,0) [0|255] "" ECU1 + +BA_DEF_ BO_ "SCP_FreshnessValueId" INT 0 65535; +BA_DEF_ SG_ "E2EDataId" INT 0 65535; +BA_DEF_ SG_ "E2EDataLength" INT 0 65535; +BA_DEF_ SG_ "E2EProfile" STRING ; +BA_DEF_DEF_ "SCP_FreshnessValueId" 0; +BA_DEF_DEF_ "E2EDataId" 0; +BA_DEF_DEF_ "E2EDataLength" 0; +BA_DEF_DEF_ "E2EProfile" "none"; +BA_ "SCP_FreshnessValueId" BO_ 256 1002; +BA_ "E2EDataId" SG_ 256 Speed_CRC 373; +BA_ "E2EDataLength" SG_ 256 Speed_CRC 48; +BA_ "E2EProfile" SG_ 256 Speed_CRC "P01"; +"#; + +fn field(name: &'static str, source: FieldSource<'static>) -> AttributeField<'static> { + AttributeField { name, source } +} + +fn main() { + let e2e = AttributeStruct { + type_path: "data_protection::E2EDataIdInfo", + const_name: "E2E", + scope: AttributeScope::Signal, + require: "E2EDataId", + fields: &[ + field("data_id", FieldSource::Attr("E2EDataId")), + field("start_byte", FieldSource::StartByte), + field("width_bit", FieldSource::Attr("E2EDataLength")), + field("profile", FieldSource::Attr("E2EProfile")), + ], + }; + let secoc = AttributeStruct { + type_path: "data_protection::SecOcInfo", + const_name: "SEC_OC", + scope: AttributeScope::Message, + require: "SCP_FreshnessValueId", + fields: &[field( + "freshness_id", + FieldSource::Attr("SCP_FreshnessValueId"), + )], + }; + + let code = Config::builder() + .dbc_name("example") + .dbc_content(DBC) + .attribute_structs(&[e2e, secoc]) + .build() + .generate() + .expect("generate"); + + // Print just the associated consts to keep the output readable. + for line in code.lines() { + let t = line.trim_start(); + if t.starts_with("pub const") || t.starts_with("data_id") + || t.starts_with("start_byte") || t.starts_with("width_bit") + || t.starts_with("profile") || t.starts_with("freshness_id") + || t.contains("E2EDataIdInfo {") || t.contains("SecOcInfo {") + { + println!("{line}"); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index e73bfa12..65df36d4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -185,7 +185,7 @@ pub enum FieldSource<'a> { Attr(&'a str), /// Value of a message-level attribute. MessageAttr(&'a str), - /// The signal's start bit. [`AttributeScope::Signal`] only. + /// The signal's raw DBC start bit. [`AttributeScope::Signal`] only. StartBit, /// The signal's start byte. [`AttributeScope::Signal`] only. StartByte, @@ -1544,9 +1544,7 @@ fn resolve_field_source( Some(attr_value_literal(value)) } FieldSource::StartBit => signal.map(|s| s.start_bit.to_string()), - FieldSource::StartByte => { - signal.map(|s| s.start_bit.checked_div(8).unwrap_or(0).to_string()) - } + FieldSource::StartByte => signal.map(|s| signal_start_byte(s).to_string()), FieldSource::BitWidth => signal.map(|s| s.size.to_string()), FieldSource::MessageSize => Some(msg.size.to_string()), FieldSource::Int(v) => Some(v.to_string()), @@ -1554,6 +1552,14 @@ fn resolve_field_source( } } +/// Frame-relative byte index of a signal's start bit. +fn signal_start_byte(signal: &Signal) -> u64 { + let start_bit = match signal.byte_order { + LittleEndian | BigEndian => signal.start_bit, + }; + start_bit.checked_div(8).unwrap_or(0) +} + /// Render an attribute value as an un-suffixed Rust literal. fn attr_value_literal(value: &AttributeValue) -> String { match value { From 87e8848a96c626a467a173e296932babfad82cc9 Mon Sep 17 00:00:00 2001 From: Felix van Oost Date: Thu, 9 Jul 2026 17:41:48 +0000 Subject: [PATCH 03/17] Validate attribute structs and update README --- README.md | 41 ++++++++++++++++ src/lib.rs | 99 ++++++++++++++++++++++++++++++++------ tests/attribute_structs.rs | 97 +++++++++++++++++++++++++++++++------ 3 files changed, 209 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 56de8b15..3c7e1c3c 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,47 @@ Config::builder() .check_ranges(FeatureConfig::Never); ``` +### Attribute-driven constants + +Many DBCs contain additional metadata in `BA_` attributes, e.g. parameters describing an AUTOSAR E2E protection scheme. `attribute_structs` lets you expose these as typed constants in the generated message types. + +You declare a struct that your own crate owns and map each field to a DBC attribute, derived signal, or a literal: + +```rust,no_run +use dbc_codegen::{AttributeField, AttributeScope, AttributeStruct, Config, FieldSource}; + +// `data_protection::E2EDataIdInfo { data_id, start_byte, width_bit }` is a type defined in your crate. +let e2e = AttributeStruct { + type_path: "data_protection::E2EDataIdInfo", + const_name: "E2E", + scope: AttributeScope::Signal, // One const per matching signal + require: "E2EDataId", // Only signals carrying this attribute + fields: &[ + AttributeField { name: "data_id", source: FieldSource::Attr("E2EDataId") }, + AttributeField { name: "start_byte", source: FieldSource::StartByte }, + AttributeField { name: "width_bit", source: FieldSource::Attr("E2EDataLength") }, + ], +}; + +let dbc_file = ""; +Config::builder() + .dbc_name("example.dbc") + .dbc_content(dbc_file) + .attribute_structs(&[e2e]) + .build() + .generate() + .unwrap(); +``` + +For every signal that carries an `E2EDataId` attribute, this generates: + +```rust,ignore +impl SomeMessage { + pub const SOME_SIGNAL_E2E: data_protection::E2EDataIdInfo = + data_protection::E2EDataIdInfo { data_id: 373, start_byte: 0, width_bit: 48 }; +} +``` + ### `no_std` The generated code is `no_std` compatible, unless you enable `impl_error`. diff --git a/src/lib.rs b/src/lib.rs index 65df36d4..1236107d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -202,6 +202,7 @@ pub enum FieldSource<'a> { impl Config<'_> { /// Write Rust structs matching DBC input description to `out` buffer fn codegen(&self, out: impl Write) -> Result<()> { + self.validate_attribute_structs()?; let dbc = Dbc::try_from(self.dbc_content).map_err(|e| { let msg = "Could not parse dbc file"; if self.debug_prints { @@ -518,15 +519,72 @@ impl Config<'_> { Ok(()) } + /// Validate the [`AttributeStruct`] specs. + fn validate_attribute_structs(&self) -> Result<()> { + for spec in self.attribute_structs { + ensure!( + !spec.const_name.is_empty(), + "attribute_structs: 'const_name' must not be empty" + ); + ensure!( + !spec.type_path.is_empty(), + "attribute_structs: 'type_path' must not be empty for '{}'", + spec.const_name + ); + ensure!( + !spec.require.is_empty(), + "attribute_structs: 'require' must not be empty for '{}'", + spec.const_name + ); + ensure!( + !spec.fields.is_empty(), + "attribute_structs: '{}' declares no fields", + spec.const_name + ); + if matches!(spec.scope, AttributeScope::Message) { + for field in spec.fields { + ensure!( + !matches!( + field.source, + FieldSource::StartBit | FieldSource::StartByte | FieldSource::BitWidth + ), + "attribute_structs: field '{}' of '{}' uses a signal-only source \ + (StartBit/StartByte/BitWidth) but the struct has message scope", + field.name, + spec.const_name + ); + } + } + } + Ok(()) + } + /// Emit the configured [`AttributeStruct`]s as associated constants. fn render_attribute_structs(&self, w: &mut impl Write, msg: &Message, dbc: &Dbc) -> Result<()> { + if self.attribute_structs.is_empty() { + return Ok(()); + } + + // Track every const name already emitted for this message type so that + // a duplicate is rejected here instead of producing code that fails to + // compile. + let mut used: BTreeSet = BTreeSet::new(); + used.insert("MESSAGE_ID".to_string()); + for signal in &msg.signals { + if ValType::from_signal(signal) != ValType::Bool { + let sig = signal.field_name().to_uppercase(); + used.insert(format!("{sig}_MIN")); + used.insert(format!("{sig}_MAX")); + } + } + for spec in self.attribute_structs { match spec.scope { AttributeScope::Message => { if message_attr(dbc, msg.id, spec.require).is_none() { continue; } - self.render_attribute_struct(w, spec, spec.const_name, msg, dbc, None)?; + self.render_attribute_struct(w, spec, spec.const_name, msg, dbc, None, &mut used)?; } AttributeScope::Signal => { for signal in &msg.signals { @@ -535,7 +593,15 @@ impl Config<'_> { } let name = format!("{}_{}", signal.field_name().to_uppercase(), spec.const_name); - self.render_attribute_struct(w, spec, &name, msg, dbc, Some(signal))?; + self.render_attribute_struct( + w, + spec, + &name, + msg, + dbc, + Some(signal), + &mut used, + )?; } } } @@ -552,21 +618,26 @@ impl Config<'_> { msg: &Message, dbc: &Dbc, signal: Option<&Signal>, + used: &mut BTreeSet, ) -> Result<()> { + ensure!( + used.insert(const_name.to_string()), + "attribute_structs: generated const '{const_name}' on message '{}' collides with \ + another const. Use a distinct 'const_name'.", + msg.name + ); + let mut fields = Vec::with_capacity(spec.fields.len()); for field in spec.fields { - match resolve_field_source(&field.source, msg, dbc, signal) { - Some(lit) => fields.push((field.name, lit)), - None => { - if self.debug_prints { - eprintln!( - "Skipping const {const_name}: field `{}` has no value", - field.name - ); - } - return Ok(()); - } - } + let lit = resolve_field_source(&field.source, msg, dbc, signal).ok_or_else(|| { + anyhow!( + "attribute_structs: const '{const_name}' field '{}' has no value in the DBC \ + and no default (source: {:?})", + field.name, + field.source + ) + })?; + fields.push((field.name, lit)); } let ty = spec.type_path; diff --git a/tests/attribute_structs.rs b/tests/attribute_structs.rs index c7a81e8c..dff80191 100644 --- a/tests/attribute_structs.rs +++ b/tests/attribute_structs.rs @@ -1,7 +1,6 @@ #![cfg(feature = "std")] -//! Tests for emitting user-defined structs populated from DBC attributes and -//! message layout. +//! Tests for emitting user-defined structs populated from DBC attributes. use dbc_codegen::{AttributeField, AttributeScope, AttributeStruct, Config, FieldSource}; @@ -19,6 +18,9 @@ BO_ 256 Protected: 8 ECU1 BO_ 257 Plain: 8 ECU1 SG_ OtherSig : 0|8@1+ (1,0) [0|255] "" ECU1 +BO_ 258 BeProtected: 8 ECU1 + SG_ BeSig : 23|8@0+ (1,0) [0|255] "" ECU1 + BA_DEF_ BO_ "SC_Message" ENUM "0","1","2"; BA_DEF_ BO_ "SCP_FreshnessValueId" INT 0 65535; BA_DEF_ SG_ "E2EDataId" INT 0 65535; @@ -33,8 +35,14 @@ BA_ "SC_Message" BO_ 256 1; BA_ "SCP_FreshnessValueId" BO_ 256 1002; BA_ "E2EDataId" SG_ 256 TestSig 373; BA_ "E2EDataLength" SG_ 256 TestSig 48; +BA_ "E2EDataId" SG_ 258 BeSig 500; +BA_ "E2EDataLength" SG_ 258 BeSig 16; "#; +fn field(name: &'static str, source: FieldSource<'static>) -> AttributeField<'static> { + AttributeField { name, source } +} + const E2E: AttributeStruct = AttributeStruct { type_path: "data_protection::E2EDataIdInfo", const_name: "E2E", @@ -64,21 +72,30 @@ const SEC_OC: AttributeStruct = AttributeStruct { type_path: "data_protection::SecOcInfo", const_name: "SEC_OC", scope: AttributeScope::Message, - require: "SC_Message", - fields: &[AttributeField { - name: "freshness_id", - source: FieldSource::Attr("SCP_FreshnessValueId"), - }], + require: "SCP_FreshnessValueId", + fields: &[ + AttributeField { + name: "freshness_id", + source: FieldSource::Attr("SCP_FreshnessValueId"), + }, + AttributeField { + name: "sc_message", + source: FieldSource::Attr("SC_Message"), + }, + ], }; -fn generate(specs: &[AttributeStruct<'_>]) -> String { +fn try_generate(specs: &[AttributeStruct<'_>]) -> anyhow::Result { Config::builder() .dbc_name("attribute_structs_test") .dbc_content(DBC) .attribute_structs(specs) .build() .generate() - .expect("generate") +} + +fn generate(specs: &[AttributeStruct<'_>]) -> String { + try_generate(specs).expect("generate") } #[test] @@ -100,6 +117,17 @@ fn signal_scope_emits_typed_struct_with_derived_layout() { assert!(out.contains(r#"profile: "none""#), "{out}"); } +#[test] +fn big_endian_start_byte_uses_start_bit_over_eight() { + let out = generate(&[E2E]); + assert!( + out.contains("pub const BE_SIG_E2E: data_protection::E2EDataIdInfo"), + "{out}" + ); + assert!(out.contains("data_id: 500"), "{out}"); + assert!(out.contains("start_byte: 2"), "{out}"); +} + #[test] fn message_scope_emits_once_per_protected_message() { let out = generate(&[SEC_OC]); @@ -110,13 +138,54 @@ fn message_scope_emits_once_per_protected_message() { assert!(out.contains("freshness_id: 1002"), "{out}"); } +#[test] +fn enum_attribute_emitted_as_integer_index() { + let out = generate(&[SEC_OC]); + assert!(out.contains("sc_message: 1"), "{out}"); +} + #[test] fn unprotected_message_and_signal_get_no_const() { let out = generate(&[E2E, SEC_OC]); assert!(!out.contains("OTHER_SIG_E2E"), "{out}"); - assert_eq!( - out.matches("pub const SEC_OC").count(), - 1, - "SEC_OC should appear once\n{out}" - ); + assert_eq!(out.matches("pub const SEC_OC").count(), 1, "{out}"); +} + +#[test] +fn message_scope_with_signal_source_is_rejected() { + let bad = AttributeStruct { + type_path: "foo::Bar", + const_name: "BAD", + scope: AttributeScope::Message, + require: "SCP_FreshnessValueId", + fields: &[field("x", FieldSource::StartByte)], + }; + let err = format!("{:#}", try_generate(&[bad]).unwrap_err()); + assert!(err.contains("signal-only source"), "{err}"); +} + +#[test] +fn missing_field_value_is_an_error() { + let bad = AttributeStruct { + type_path: "foo::Bar", + const_name: "BAD", + scope: AttributeScope::Signal, + require: "E2EDataId", + fields: &[field("x", FieldSource::Attr("NoSuchAttr"))], + }; + let err = format!("{:#}", try_generate(&[bad]).unwrap_err()); + assert!(err.contains("has no value"), "{err}"); +} + +#[test] +fn duplicate_const_name_is_an_error() { + let dup = AttributeStruct { + type_path: "foo::Bar", + const_name: "SEC_OC", + scope: AttributeScope::Message, + require: "SCP_FreshnessValueId", + fields: &[field("freshness_id", FieldSource::Attr("SCP_FreshnessValueId"))], + }; + let err = format!("{:#}", try_generate(&[SEC_OC, dup]).unwrap_err()); + assert!(err.contains("collides"), "{err}"); } From e479153ef781b4461fbc13361ddb0240d3bb9e39 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:55:12 +0000 Subject: [PATCH 04/17] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- examples/attribute_structs.rs | 12 ++++++++---- src/lib.rs | 10 +++++++++- tests/attribute_structs.rs | 5 ++++- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/examples/attribute_structs.rs b/examples/attribute_structs.rs index d3e11a10..19932049 100644 --- a/examples/attribute_structs.rs +++ b/examples/attribute_structs.rs @@ -67,10 +67,14 @@ fn main() { // Print just the associated consts to keep the output readable. for line in code.lines() { let t = line.trim_start(); - if t.starts_with("pub const") || t.starts_with("data_id") - || t.starts_with("start_byte") || t.starts_with("width_bit") - || t.starts_with("profile") || t.starts_with("freshness_id") - || t.contains("E2EDataIdInfo {") || t.contains("SecOcInfo {") + if t.starts_with("pub const") + || t.starts_with("data_id") + || t.starts_with("start_byte") + || t.starts_with("width_bit") + || t.starts_with("profile") + || t.starts_with("freshness_id") + || t.contains("E2EDataIdInfo {") + || t.contains("SecOcInfo {") { println!("{line}"); } diff --git a/src/lib.rs b/src/lib.rs index 1236107d..21fcaa18 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -584,7 +584,15 @@ impl Config<'_> { if message_attr(dbc, msg.id, spec.require).is_none() { continue; } - self.render_attribute_struct(w, spec, spec.const_name, msg, dbc, None, &mut used)?; + self.render_attribute_struct( + w, + spec, + spec.const_name, + msg, + dbc, + None, + &mut used, + )?; } AttributeScope::Signal => { for signal in &msg.signals { diff --git a/tests/attribute_structs.rs b/tests/attribute_structs.rs index dff80191..202dd020 100644 --- a/tests/attribute_structs.rs +++ b/tests/attribute_structs.rs @@ -184,7 +184,10 @@ fn duplicate_const_name_is_an_error() { const_name: "SEC_OC", scope: AttributeScope::Message, require: "SCP_FreshnessValueId", - fields: &[field("freshness_id", FieldSource::Attr("SCP_FreshnessValueId"))], + fields: &[field( + "freshness_id", + FieldSource::Attr("SCP_FreshnessValueId"), + )], }; let err = format!("{:#}", try_generate(&[SEC_OC, dup]).unwrap_err()); assert!(err.contains("collides"), "{err}"); From b2c4d143998d10c2cb9abce2790616c2bbf5c74c Mon Sep 17 00:00:00 2001 From: Felix van Oost Date: Fri, 10 Jul 2026 14:58:39 +0000 Subject: [PATCH 05/17] Resolve clippy warnings --- examples/attribute_structs.rs | 2 +- src/lib.rs | 14 ++++++-------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/examples/attribute_structs.rs b/examples/attribute_structs.rs index 19932049..a0881eba 100644 --- a/examples/attribute_structs.rs +++ b/examples/attribute_structs.rs @@ -1,4 +1,4 @@ -//! Demonstrates [Config::attribute_structs]. +//! Demonstrates [`Config::attribute_structs`]. //! Emits constants for AUTOSAR E2E / SecOC-style metdata from DBC 'BA_' attributes. use dbc_codegen::{AttributeField, AttributeScope, AttributeStruct, Config, FieldSource}; diff --git a/src/lib.rs b/src/lib.rs index 21fcaa18..7aa74a2e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -137,8 +137,7 @@ pub struct Config<'a> { /// ``` #[derive(Debug, Clone)] pub struct AttributeStruct<'a> { - /// Fully-qualified path of the target Rust type, e.g. - /// 'data_protection::E2EDataIdInfo'. + /// Fully-qualified path of the target Rust type. pub type_path: &'a str, /// Base name of the emitted associated constant. @@ -584,7 +583,7 @@ impl Config<'_> { if message_attr(dbc, msg.id, spec.require).is_none() { continue; } - self.render_attribute_struct( + Self::render_attribute_struct( w, spec, spec.const_name, @@ -601,7 +600,7 @@ impl Config<'_> { } let name = format!("{}_{}", signal.field_name().to_uppercase(), spec.const_name); - self.render_attribute_struct( + Self::render_attribute_struct( w, spec, &name, @@ -619,7 +618,6 @@ impl Config<'_> { /// Emit a single struct constant. fn render_attribute_struct( - &self, w: &mut impl Write, spec: &AttributeStruct<'_>, const_name: &str, @@ -1573,7 +1571,7 @@ fn message_ignored(message: &Message) -> bool { message.name == "VECTOR__INDEPENDENT_SIG_MSG" } -/// Look up an assigned message-level (BO_) attribute value. +/// Look up an assigned message-level (`BO_`) attribute value. fn message_attr<'a>(dbc: &'a Dbc, id: MessageId, name: &str) -> Option<&'a AttributeValue> { dbc.attribute_values_message .iter() @@ -1581,7 +1579,7 @@ fn message_attr<'a>(dbc: &'a Dbc, id: MessageId, name: &str) -> Option<&'a Attri .map(|a| &a.value) } -/// Look up an assigned signal-level (SG_) attribute value. +/// Look up an assigned signal-level (`SG_`) attribute value. fn signal_attr<'a>( dbc: &'a Dbc, id: MessageId, @@ -1594,7 +1592,7 @@ fn signal_attr<'a>( .map(|a| &a.value) } -/// Look up an attribute's default value (BA_DEF_DEF_). +/// Look up an attribute's default value (`BA_DEF_DEF_`). fn attr_default<'a>(dbc: &'a Dbc, name: &str) -> Option<&'a AttributeValue> { dbc.attribute_defaults .iter() From d8559825e7f12a8b28b376310bbbe95ef9c23581 Mon Sep 17 00:00:00 2001 From: Felix van Oost Date: Fri, 10 Jul 2026 17:03:21 +0000 Subject: [PATCH 06/17] Improve test coverage --- tests/attribute_structs.rs | 63 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/attribute_structs.rs b/tests/attribute_structs.rs index 202dd020..14050242 100644 --- a/tests/attribute_structs.rs +++ b/tests/attribute_structs.rs @@ -23,16 +23,22 @@ BO_ 258 BeProtected: 8 ECU1 BA_DEF_ BO_ "SC_Message" ENUM "0","1","2"; BA_DEF_ BO_ "SCP_FreshnessValueId" INT 0 65535; +BA_DEF_ BO_ "TxOffset" INT -128 127; +BA_DEF_ BO_ "Gain" FLOAT 0 10; BA_DEF_ SG_ "E2EDataId" INT 0 65535; BA_DEF_ SG_ "E2EDataLength" INT 0 65535; BA_DEF_ SG_ "E2EProfile" STRING ; BA_DEF_DEF_ "SC_Message" "0"; BA_DEF_DEF_ "SCP_FreshnessValueId" 0; +BA_DEF_DEF_ "TxOffset" 0; +BA_DEF_DEF_ "Gain" 0; BA_DEF_DEF_ "E2EDataId" 0; BA_DEF_DEF_ "E2EDataLength" 0; BA_DEF_DEF_ "E2EProfile" "none"; BA_ "SC_Message" BO_ 256 1; BA_ "SCP_FreshnessValueId" BO_ 256 1002; +BA_ "TxOffset" BO_ 256 -7; +BA_ "Gain" BO_ 256 2.5; BA_ "E2EDataId" SG_ 256 TestSig 373; BA_ "E2EDataLength" SG_ 256 TestSig 48; BA_ "E2EDataId" SG_ 258 BeSig 500; @@ -85,6 +91,43 @@ const SEC_OC: AttributeStruct = AttributeStruct { ], }; +const LAYOUT: AttributeStruct = AttributeStruct { + type_path: "layout::SignalLayout", + const_name: "LAYOUT", + scope: AttributeScope::Signal, + require: "E2EDataId", + fields: &[ + AttributeField { + name: "start_bit", + source: FieldSource::StartBit, + }, + AttributeField { + name: "bit_width", + source: FieldSource::BitWidth, + }, + AttributeField { + name: "message_size", + source: FieldSource::MessageSize, + }, + AttributeField { + name: "tx_offset", + source: FieldSource::MessageAttr("TxOffset"), + }, + AttributeField { + name: "gain", + source: FieldSource::MessageAttr("Gain"), + }, + AttributeField { + name: "version", + source: FieldSource::Int(42), + }, + AttributeField { + name: "label", + source: FieldSource::Str("hello"), + }, + ], +}; + fn try_generate(specs: &[AttributeStruct<'_>]) -> anyhow::Result { Config::builder() .dbc_name("attribute_structs_test") @@ -151,6 +194,26 @@ fn unprotected_message_and_signal_get_no_const() { assert_eq!(out.matches("pub const SEC_OC").count(), 1, "{out}"); } +#[test] +fn layout_and_literal_sources_resolve() { + let out = generate(&[LAYOUT]); + assert!( + out.contains("pub const TEST_SIG_LAYOUT: layout::SignalLayout"), + "{out}" + ); + // TestSig is `16|8@1+` on an 8-byte message. + assert!(out.contains("start_bit: 16"), "{out}"); + assert!(out.contains("bit_width: 8"), "{out}"); + assert!(out.contains("message_size: 8"), "{out}"); + // Message-level attributes pulled into a signal-scoped struct, covering the + // signed-integer and floating-point attribute-value shapes. + assert!(out.contains("tx_offset: -7"), "{out}"); + assert!(out.contains("gain: 2.5"), "{out}"); + // Literal sources. + assert!(out.contains("version: 42"), "{out}"); + assert!(out.contains(r#"label: "hello""#), "{out}"); +} + #[test] fn message_scope_with_signal_source_is_rejected() { let bad = AttributeStruct { From 1d78f7bc45cafdd2d7e883f848897694b3c5b2ce Mon Sep 17 00:00:00 2001 From: Felix van Oost Date: Mon, 13 Jul 2026 09:37:47 -0400 Subject: [PATCH 07/17] Improve comment clarity --- README.md | 4 ++-- examples/attribute_structs.rs | 2 +- src/lib.rs | 40 +++++------------------------------ 3 files changed, 8 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 3c7e1c3c..f6cb989a 100644 --- a/README.md +++ b/README.md @@ -114,9 +114,9 @@ Config::builder() ### Attribute-driven constants -Many DBCs contain additional metadata in `BA_` attributes, e.g. parameters describing an AUTOSAR E2E protection scheme. `attribute_structs` lets you expose these as typed constants in the generated message types. +Many DBCs contain additional metadata in `BA_` attributes - for example, parameters describing an AUTOSAR E2E protection scheme for specific CAN signals. `attribute_structs` lets you expose this metadata as typed constants in the generated message types. -You declare a struct that your own crate owns and map each field to a DBC attribute, derived signal, or a literal: +You can declare a struct in your own crate and map each field to a DBC attribute, derived signal, or a literal: ```rust,no_run use dbc_codegen::{AttributeField, AttributeScope, AttributeStruct, Config, FieldSource}; diff --git a/examples/attribute_structs.rs b/examples/attribute_structs.rs index a0881eba..b6eca421 100644 --- a/examples/attribute_structs.rs +++ b/examples/attribute_structs.rs @@ -64,7 +64,7 @@ fn main() { .generate() .expect("generate"); - // Print just the associated consts to keep the output readable. + // Print only the associated consts to keep the output readable. for line in code.lines() { let t = line.trim_start(); if t.starts_with("pub const") diff --git a/src/lib.rs b/src/lib.rs index 7aa74a2e..3e33221a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -96,45 +96,15 @@ pub struct Config<'a> { #[builder(default)] pub allow_dead_code: bool, - /// Optional: User-defined structs populated from DBC attributes and message layout. + /// Optional: User-defined structs populated from DBC attributes. /// These are emitted as associated constants in the generated message types. /// Default: empty. #[builder(default)] pub attribute_structs: &'a [AttributeStruct<'a>], } -/// A user-defined struct that [`Config`] fills from DBC data and emits as an +/// A user-defined struct that [`Config`] fills from DBC attributes and emits as an /// associated constant in the generated message type. -/// -/// # Example -/// -/// Given a consumer type `data_protection::E2EDataIdInfo { data_id, start_byte, -/// width_bit }`, this declaration: -/// -/// ``` -/// use dbc_codegen::{AttributeField, AttributeScope, AttributeStruct, FieldSource}; -/// -/// const E2E: AttributeStruct = AttributeStruct { -/// type_path: "data_protection::E2EDataIdInfo", -/// const_name: "E2E", -/// scope: AttributeScope::Signal, -/// require: "E2EDataId", -/// fields: &[ -/// AttributeField { name: "data_id", source: FieldSource::Attr("E2EDataId") }, -/// AttributeField { name: "start_byte", source: FieldSource::StartByte }, -/// AttributeField { name: "width_bit", source: FieldSource::Attr("E2EDataLength") }, -/// ], -/// }; -/// ``` -/// -/// emits, for every signal that carries an `E2EDataId` attribute: -/// -/// ```ignore -/// impl SomeMessage { -/// pub const SOME_SIGNAL_E2E: data_protection::E2EDataIdInfo = -/// data_protection::E2EDataIdInfo { data_id: 373, start_byte: 0, width_bit: 48 }; -/// } -/// ``` #[derive(Debug, Clone)] pub struct AttributeStruct<'a> { /// Fully-qualified path of the target Rust type. @@ -564,8 +534,8 @@ impl Config<'_> { return Ok(()); } - // Track every const name already emitted for this message type so that - // a duplicate is rejected here instead of producing code that fails to + // Track every constant name already emitted for this message type so that + // duplicates are rejected here instead of producing code that fails to // compile. let mut used: BTreeSet = BTreeSet::new(); used.insert("MESSAGE_ID".to_string()); @@ -1629,7 +1599,7 @@ fn resolve_field_source( } } -/// Frame-relative byte index of a signal's start bit. +/// Byte index of a signal's start bit. fn signal_start_byte(signal: &Signal) -> u64 { let start_bit = match signal.byte_order { LittleEndian | BigEndian => signal.start_bit, From 6e58c09b5b1df81ad5065ca1d96d8c5662659bb8 Mon Sep 17 00:00:00 2001 From: Felix van Oost Date: Mon, 13 Jul 2026 15:52:16 +0000 Subject: [PATCH 08/17] Create test cases based on attributes.dbc fixture --- tests/attribute_structs.rs | 297 +++++++++++++++++++++++++++++++------ 1 file changed, 255 insertions(+), 42 deletions(-) diff --git a/tests/attribute_structs.rs b/tests/attribute_structs.rs index 14050242..692425d2 100644 --- a/tests/attribute_structs.rs +++ b/tests/attribute_structs.rs @@ -1,6 +1,7 @@ #![cfg(feature = "std")] -//! Tests for emitting user-defined structs populated from DBC attributes. +//! Tests for emitting user-defined structs populated from DBC attributes +//! (`Config::attribute_structs`). use dbc_codegen::{AttributeField, AttributeScope, AttributeStruct, Config, FieldSource}; @@ -45,10 +46,9 @@ BA_ "E2EDataId" SG_ 258 BeSig 500; BA_ "E2EDataLength" SG_ 258 BeSig 16; "#; -fn field(name: &'static str, source: FieldSource<'static>) -> AttributeField<'static> { - AttributeField { name, source } -} +const FIXTURE: &str = "tests/fixtures/shared-test-files/dbc-cantools/attributes.dbc"; +/// Signal-scoped const E2E: AttributeStruct = AttributeStruct { type_path: "data_protection::E2EDataIdInfo", const_name: "E2E", @@ -74,6 +74,7 @@ const E2E: AttributeStruct = AttributeStruct { ], }; +/// Message-scoped const SEC_OC: AttributeStruct = AttributeStruct { type_path: "data_protection::SecOcInfo", const_name: "SEC_OC", @@ -91,6 +92,7 @@ const SEC_OC: AttributeStruct = AttributeStruct { ], }; +/// Non-attribute sources const LAYOUT: AttributeStruct = AttributeStruct { type_path: "layout::SignalLayout", const_name: "LAYOUT", @@ -128,41 +130,46 @@ const LAYOUT: AttributeStruct = AttributeStruct { ], }; -fn try_generate(specs: &[AttributeStruct<'_>]) -> anyhow::Result { - Config::builder() - .dbc_name("attribute_structs_test") +#[test] +fn default_emits_nothing() { + let out = Config::builder() + .dbc_name("test") .dbc_content(DBC) - .attribute_structs(specs) .build() .generate() -} - -fn generate(specs: &[AttributeStruct<'_>]) -> String { - try_generate(specs).expect("generate") -} - -#[test] -fn default_emits_nothing() { - let out = generate(&[]); + .unwrap(); assert!(!out.contains("E2EDataIdInfo"), "{out}"); } #[test] fn signal_scope_emits_typed_struct_with_derived_layout() { - let out = generate(&[E2E]); + let out = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[E2E]) + .build() + .generate() + .unwrap(); assert!( out.contains("pub const TEST_SIG_E2E: data_protection::E2EDataIdInfo"), "{out}" ); assert!(out.contains("data_id: 373"), "{out}"); - assert!(out.contains("start_byte: 2"), "{out}"); + assert!(out.contains("start_byte: 2"), "{out}"); // start_bit 16 / 8 assert!(out.contains("width_bit: 48"), "{out}"); - assert!(out.contains(r#"profile: "none""#), "{out}"); + assert!(out.contains(r#"profile: "none""#), "{out}"); // BA_DEF_DEF_ default } #[test] fn big_endian_start_byte_uses_start_bit_over_eight() { - let out = generate(&[E2E]); + // BeSig is `23|8@0+` (Motorola): start byte is still start_bit / 8 == 2. + let out = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[E2E]) + .build() + .generate() + .unwrap(); assert!( out.contains("pub const BE_SIG_E2E: data_protection::E2EDataIdInfo"), "{out}" @@ -173,7 +180,13 @@ fn big_endian_start_byte_uses_start_bit_over_eight() { #[test] fn message_scope_emits_once_per_protected_message() { - let out = generate(&[SEC_OC]); + let out = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[SEC_OC]) + .build() + .generate() + .unwrap(); assert!( out.contains("pub const SEC_OC: data_protection::SecOcInfo"), "{out}" @@ -183,33 +196,49 @@ fn message_scope_emits_once_per_protected_message() { #[test] fn enum_attribute_emitted_as_integer_index() { - let out = generate(&[SEC_OC]); + let out = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[SEC_OC]) + .build() + .generate() + .unwrap(); + // The enum value is emitted as its index (1), not the label. assert!(out.contains("sc_message: 1"), "{out}"); } #[test] fn unprotected_message_and_signal_get_no_const() { - let out = generate(&[E2E, SEC_OC]); + let out = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[E2E, SEC_OC]) + .build() + .generate() + .unwrap(); assert!(!out.contains("OTHER_SIG_E2E"), "{out}"); assert_eq!(out.matches("pub const SEC_OC").count(), 1, "{out}"); } #[test] fn layout_and_literal_sources_resolve() { - let out = generate(&[LAYOUT]); + // TestSig is `16|8@1+` on an 8-byte message. + let out = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[LAYOUT]) + .build() + .generate() + .unwrap(); assert!( out.contains("pub const TEST_SIG_LAYOUT: layout::SignalLayout"), "{out}" ); - // TestSig is `16|8@1+` on an 8-byte message. assert!(out.contains("start_bit: 16"), "{out}"); assert!(out.contains("bit_width: 8"), "{out}"); assert!(out.contains("message_size: 8"), "{out}"); - // Message-level attributes pulled into a signal-scoped struct, covering the - // signed-integer and floating-point attribute-value shapes. assert!(out.contains("tx_offset: -7"), "{out}"); assert!(out.contains("gain: 2.5"), "{out}"); - // Literal sources. assert!(out.contains("version: 42"), "{out}"); assert!(out.contains(r#"label: "hello""#), "{out}"); } @@ -221,10 +250,19 @@ fn message_scope_with_signal_source_is_rejected() { const_name: "BAD", scope: AttributeScope::Message, require: "SCP_FreshnessValueId", - fields: &[field("x", FieldSource::StartByte)], + fields: &[AttributeField { + name: "x", + source: FieldSource::StartByte, + }], }; - let err = format!("{:#}", try_generate(&[bad]).unwrap_err()); - assert!(err.contains("signal-only source"), "{err}"); + let err = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[bad]) + .build() + .generate() + .unwrap_err(); + assert!(format!("{err:#}").contains("signal-only source"), "{err:#}"); } #[test] @@ -234,24 +272,199 @@ fn missing_field_value_is_an_error() { const_name: "BAD", scope: AttributeScope::Signal, require: "E2EDataId", - fields: &[field("x", FieldSource::Attr("NoSuchAttr"))], + fields: &[AttributeField { + name: "x", + source: FieldSource::Attr("NoSuchAttr"), + }], }; - let err = format!("{:#}", try_generate(&[bad]).unwrap_err()); - assert!(err.contains("has no value"), "{err}"); + let err = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[bad]) + .build() + .generate() + .unwrap_err(); + assert!(format!("{err:#}").contains("has no value"), "{err:#}"); } #[test] fn duplicate_const_name_is_an_error() { let dup = AttributeStruct { type_path: "foo::Bar", - const_name: "SEC_OC", + const_name: "SEC_OC", // collides with SEC_OC scope: AttributeScope::Message, require: "SCP_FreshnessValueId", - fields: &[field( - "freshness_id", - FieldSource::Attr("SCP_FreshnessValueId"), - )], + fields: &[AttributeField { + name: "freshness_id", + source: FieldSource::Attr("SCP_FreshnessValueId"), + }], }; - let err = format!("{:#}", try_generate(&[SEC_OC, dup]).unwrap_err()); - assert!(err.contains("collides"), "{err}"); + let err = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[SEC_OC, dup]) + .build() + .generate() + .unwrap_err(); + assert!(format!("{err:#}").contains("collides"), "{err:#}"); +} + +#[test] +fn fixture_message_scope_resolves_hex_float_enum_and_size() { + let spec = AttributeStruct { + type_path: "MsgAttrs", + const_name: "ATTRS", + scope: AttributeScope::Message, + require: "TheHexAttribute", + fields: &[ + AttributeField { + name: "hex_attr", + source: FieldSource::Attr("TheHexAttribute"), + }, + AttributeField { + name: "float_attr", + source: FieldSource::Attr("TheFloatAttribute"), + }, + AttributeField { + name: "send_type", + source: FieldSource::Attr("GenMsgSendType"), + }, + AttributeField { + name: "size_bytes", + source: FieldSource::MessageSize, + }, + ], + }; + let bytes = std::fs::read(FIXTURE).unwrap(); + let dbc = can_dbc::decode_cp1252(&bytes).unwrap(); + let out = Config::builder() + .dbc_name("attributes.dbc") + .dbc_content(&dbc) + .attribute_structs(&[spec]) + .build() + .generate() + .unwrap(); + assert!(out.contains("hex_attr: 5"), "{out}"); + assert!(out.contains("float_attr: 58.7"), "{out}"); + assert!(out.contains("send_type: 0"), "{out}"); + assert!(out.contains("size_bytes: 8"), "{out}"); + assert_eq!(out.matches("pub const ATTRS").count(), 1, "{out}"); +} + +#[test] +fn fixture_signal_scope_resolves_string_enum_and_layout() { + let spec = AttributeStruct { + type_path: "SigAttrs", + const_name: "SIG", + scope: AttributeScope::Signal, + require: "TheSignalStringAttribute", + fields: &[ + AttributeField { + name: "label", + source: FieldSource::Attr("TheSignalStringAttribute"), + }, + AttributeField { + name: "send_type", + source: FieldSource::Attr("GenSigSendType"), + }, + AttributeField { + name: "start_byte", + source: FieldSource::StartByte, + }, + AttributeField { + name: "width", + source: FieldSource::BitWidth, + }, + ], + }; + let bytes = std::fs::read(FIXTURE).unwrap(); + let dbc = can_dbc::decode_cp1252(&bytes).unwrap(); + let out = Config::builder() + .dbc_name("attributes.dbc") + .dbc_content(&dbc) + .attribute_structs(&[spec]) + .build() + .generate() + .unwrap(); + assert!(out.contains(r#"label: "TestString""#), "{out}"); + assert!(out.contains("send_type: 1"), "{out}"); + assert!(out.contains("start_byte: 0"), "{out}"); + assert!(out.contains("width: 8"), "{out}"); + assert_eq!(out.matches("_SIG: SigAttrs").count(), 1, "{out}"); +} + +#[test] +fn fixture_absent_attribute_falls_back_to_default() { + // GenSigSendType is present on the signal in both messages. + let spec = AttributeStruct { + type_path: "SigAttrs", + const_name: "SIG2", + scope: AttributeScope::Signal, + require: "GenSigSendType", + fields: &[AttributeField { + name: "label", + source: FieldSource::Attr("TheSignalStringAttribute"), + }], + }; + let bytes = std::fs::read(FIXTURE).unwrap(); + let dbc = can_dbc::decode_cp1252(&bytes).unwrap(); + let out = Config::builder() + .dbc_name("attributes.dbc") + .dbc_content(&dbc) + .attribute_structs(&[spec]) + .build() + .generate() + .unwrap(); + assert_eq!(out.matches("pub const THE_SIGNAL_SIG2").count(), 2, "{out}"); + assert!(out.contains(r#"label: "TestString""#), "{out}"); + assert!(out.contains(r#"label: "100""#), "{out}"); // BA_DEF_DEF_ default +} + +#[test] +fn fixture_enum_attribute_is_index_not_label() { + let spec = AttributeStruct { + type_path: "SendTypeInfo", + const_name: "ST", + scope: AttributeScope::Message, + require: "GenMsgSendType", + fields: &[AttributeField { + name: "send_type", + source: FieldSource::Attr("GenMsgSendType"), + }], + }; + let bytes = std::fs::read(FIXTURE).unwrap(); + let dbc = can_dbc::decode_cp1252(&bytes).unwrap(); + let out = Config::builder() + .dbc_name("attributes.dbc") + .dbc_content(&dbc) + .attribute_structs(&[spec]) + .build() + .generate() + .unwrap(); + assert!(out.contains("send_type: 0"), "{out}"); + assert!(!out.contains("Cyclic"), "{out}"); +} + +#[test] +fn fixture_definition_without_default_is_an_error() { + let spec = AttributeStruct { + type_path: "Bad", + const_name: "BAD", + scope: AttributeScope::Message, + require: "TheHexAttribute", + fields: &[AttributeField { + name: "x", + source: FieldSource::Attr("TheUnusedAttributeDefinitionWithoutDefault"), + }], + }; + let bytes = std::fs::read(FIXTURE).unwrap(); + let dbc = can_dbc::decode_cp1252(&bytes).unwrap(); + let err = Config::builder() + .dbc_name("attributes.dbc") + .dbc_content(&dbc) + .attribute_structs(&[spec]) + .build() + .generate() + .unwrap_err(); + assert!(format!("{err:#}").contains("has no value"), "{err:#}"); } From a6af48817cc21e3c24195df054ff6a4f4092cc09 Mon Sep 17 00:00:00 2001 From: Felix van Oost Date: Wed, 15 Jul 2026 18:47:11 +0000 Subject: [PATCH 09/17] Use helper methods from can-dbc --- src/lib.rs | 42 ++++++------------------------------------ 1 file changed, 6 insertions(+), 36 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 3e33221a..ce9bb027 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -550,7 +550,7 @@ impl Config<'_> { for spec in self.attribute_structs { match spec.scope { AttributeScope::Message => { - if message_attr(dbc, msg.id, spec.require).is_none() { + if dbc.message_attribute(msg.id, spec.require).is_none() { continue; } Self::render_attribute_struct( @@ -565,7 +565,7 @@ impl Config<'_> { } AttributeScope::Signal => { for signal in &msg.signals { - if signal_attr(dbc, msg.id, &signal.name, spec.require).is_none() { + if dbc.signal_attribute(msg.id, &signal.name, spec.require).is_none() { continue; } let name = @@ -1541,35 +1541,6 @@ fn message_ignored(message: &Message) -> bool { message.name == "VECTOR__INDEPENDENT_SIG_MSG" } -/// Look up an assigned message-level (`BO_`) attribute value. -fn message_attr<'a>(dbc: &'a Dbc, id: MessageId, name: &str) -> Option<&'a AttributeValue> { - dbc.attribute_values_message - .iter() - .find(|a| a.message_id == id && a.name == name) - .map(|a| &a.value) -} - -/// Look up an assigned signal-level (`SG_`) attribute value. -fn signal_attr<'a>( - dbc: &'a Dbc, - id: MessageId, - signal: &str, - name: &str, -) -> Option<&'a AttributeValue> { - dbc.attribute_values_signal - .iter() - .find(|a| a.message_id == id && a.signal_name == signal && a.name == name) - .map(|a| &a.value) -} - -/// Look up an attribute's default value (`BA_DEF_DEF_`). -fn attr_default<'a>(dbc: &'a Dbc, name: &str) -> Option<&'a AttributeValue> { - dbc.attribute_defaults - .iter() - .find(|d| d.name == name) - .map(|d| &d.value) -} - /// Resolve a [`FieldSource`] to a Rust literal. fn resolve_field_source( source: &FieldSource<'_>, @@ -1580,14 +1551,13 @@ fn resolve_field_source( match source { FieldSource::Attr(name) => { let value = match signal { - Some(s) => signal_attr(dbc, msg.id, &s.name, name), - None => message_attr(dbc, msg.id, name), - } - .or_else(|| attr_default(dbc, name))?; + Some(s) => dbc.resolved_signal_attribute(msg.id, &s.name, name), + None => dbc.resolved_message_attribute(msg.id, name), + }?; Some(attr_value_literal(value)) } FieldSource::MessageAttr(name) => { - let value = message_attr(dbc, msg.id, name).or_else(|| attr_default(dbc, name))?; + let value = dbc.resolved_message_attribute(msg.id, name)?; Some(attr_value_literal(value)) } FieldSource::StartBit => signal.map(|s| s.start_bit.to_string()), From 4137f67bec5f0817c6b97db3a321c20bdbeeb2bf Mon Sep 17 00:00:00 2001 From: Felix van Oost Date: Wed, 15 Jul 2026 19:06:24 +0000 Subject: [PATCH 10/17] Check for invalid Rust identifiers --- examples/attribute_structs.rs | 2 +- src/lib.rs | 51 ++++++++++---- src/utils.rs | 25 +++++++ tests/attribute_structs.rs | 122 ++++++++++++++++++++++++++++++++++ 4 files changed, 186 insertions(+), 14 deletions(-) diff --git a/examples/attribute_structs.rs b/examples/attribute_structs.rs index b6eca421..3b8ad1c1 100644 --- a/examples/attribute_structs.rs +++ b/examples/attribute_structs.rs @@ -1,5 +1,5 @@ //! Demonstrates [`Config::attribute_structs`]. -//! Emits constants for AUTOSAR E2E / SecOC-style metdata from DBC 'BA_' attributes. +//! Emits constants for AUTOSAR E2E / SecOC-style metadata from DBC 'BA_' attributes. use dbc_codegen::{AttributeField, AttributeScope, AttributeStruct, Config, FieldSource}; diff --git a/src/lib.rs b/src/lib.rs index ce9bb027..179bf99b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,8 +33,9 @@ pub use crate::feature_config::FeatureConfig; use crate::pad::PadAdapter; use crate::signal_type::{IntSize, ValType}; use crate::utils::{ - enum_name, enum_variant_name, multiplex_enum_name, multiplexed_enum_variant_name, - multiplexed_enum_variant_wrapper_name, MessageExt as _, SignalExt as _, + enum_name, enum_variant_name, is_valid_ident, is_valid_type_path, multiplex_enum_name, + multiplexed_enum_variant_name, multiplexed_enum_variant_wrapper_name, MessageExt as _, + SignalExt as _, }; static ALLOW_DEADCODE: &str = "#[allow(dead_code)]"; @@ -492,12 +493,14 @@ impl Config<'_> { fn validate_attribute_structs(&self) -> Result<()> { for spec in self.attribute_structs { ensure!( - !spec.const_name.is_empty(), - "attribute_structs: 'const_name' must not be empty" + is_valid_ident(spec.const_name), + "attribute_structs: 'const_name' {:?} is not a valid Rust identifier", + spec.const_name ); ensure!( - !spec.type_path.is_empty(), - "attribute_structs: 'type_path' must not be empty for '{}'", + is_valid_type_path(spec.type_path), + "attribute_structs: 'type_path' {:?} for '{}' is not a valid Rust type path", + spec.type_path, spec.const_name ); ensure!( @@ -510,8 +513,22 @@ impl Config<'_> { "attribute_structs: '{}' declares no fields", spec.const_name ); - if matches!(spec.scope, AttributeScope::Message) { - for field in spec.fields { + + let mut seen = BTreeSet::new(); + for field in spec.fields { + ensure!( + is_valid_ident(field.name), + "attribute_structs: field name {:?} in '{}' is not a valid Rust identifier", + field.name, + spec.const_name + ); + ensure!( + seen.insert(field.name), + "attribute_structs: duplicate field '{}' in '{}'", + field.name, + spec.const_name + ); + if matches!(spec.scope, AttributeScope::Message) { ensure!( !matches!( field.source, @@ -534,14 +551,19 @@ impl Config<'_> { return Ok(()); } - // Track every constant name already emitted for this message type so that - // duplicates are rejected here instead of producing code that fails to - // compile. + // Track every associated item already emitted for this message type so + // that colliding const names are rejected here instead of producing code + // that fails to build. let mut used: BTreeSet = BTreeSet::new(); used.insert("MESSAGE_ID".to_string()); + used.insert("new".to_string()); + used.insert("raw".to_string()); for signal in &msg.signals { + let field = signal.field_name(); + used.insert(field.clone()); + used.insert(format!("set_{field}")); if ValType::from_signal(signal) != ValType::Bool { - let sig = signal.field_name().to_uppercase(); + let sig = field.to_uppercase(); used.insert(format!("{sig}_MIN")); used.insert(format!("{sig}_MAX")); } @@ -565,7 +587,10 @@ impl Config<'_> { } AttributeScope::Signal => { for signal in &msg.signals { - if dbc.signal_attribute(msg.id, &signal.name, spec.require).is_none() { + if dbc + .signal_attribute(msg.id, &signal.name, spec.require) + .is_none() + { continue; } let name = diff --git a/src/utils.rs b/src/utils.rs index c787c88d..c210160c 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -89,3 +89,28 @@ pub fn enum_variant_name(x: &str) -> String { pub fn is_integer(val: f64) -> bool { val.fract().abs() < f64::EPSILON } + +/// Check whether an identifier is usable as a Rust identifier (const or field name). +/// It must start with a letter or `_`, contain only `[A-Za-z0-9_]`, is not the bare +/// wildcard `_`, and is not a keyword. +pub fn is_valid_ident(s: &str) -> bool { + let mut chars = s.chars(); + let starts_ok = matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_'); + starts_ok + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') + && s != "_" + && !keywords::is_keyword(s) +} + +/// Check Whether an idenfitier is a plain `::`-separated Rust type path, e.g. +/// `crate::Foo`. Each segment must look like an identifier - path keywords +/// (`self`, `Self`) are allowed, but generics and other punctuation are not. +pub fn is_valid_type_path(s: &str) -> bool { + !s.is_empty() + && s.split("::").all(|seg| { + let mut chars = seg.chars(); + matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') + && seg != "_" + }) +} diff --git a/tests/attribute_structs.rs b/tests/attribute_structs.rs index 692425d2..cabf05ba 100644 --- a/tests/attribute_structs.rs +++ b/tests/attribute_structs.rs @@ -309,6 +309,128 @@ fn duplicate_const_name_is_an_error() { assert!(format!("{err:#}").contains("collides"), "{err:#}"); } +#[test] +fn invalid_const_name_is_rejected() { + let bad = AttributeStruct { + type_path: "foo::Bar", + const_name: "1bad", // not a valid identifier + scope: AttributeScope::Message, + require: "SCP_FreshnessValueId", + fields: &[AttributeField { + name: "x", + source: FieldSource::Attr("SCP_FreshnessValueId"), + }], + }; + let err = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[bad]) + .build() + .generate() + .unwrap_err(); + assert!( + format!("{err:#}").contains("not a valid Rust identifier"), + "{err:#}" + ); +} + +#[test] +fn const_name_colliding_with_generated_method_is_rejected() { + let bad = AttributeStruct { + type_path: "foo::Bar", + const_name: "new", // valid identifier, but collides with the `new()` constructor + scope: AttributeScope::Message, + require: "SCP_FreshnessValueId", + fields: &[AttributeField { + name: "x", + source: FieldSource::Attr("SCP_FreshnessValueId"), + }], + }; + let err = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[bad]) + .build() + .generate() + .unwrap_err(); + assert!(format!("{err:#}").contains("collides"), "{err:#}"); +} + +#[test] +fn invalid_type_path_is_rejected() { + let bad = AttributeStruct { + type_path: "1bad::Type", // not a valid type path + const_name: "OK", + scope: AttributeScope::Message, + require: "SCP_FreshnessValueId", + fields: &[AttributeField { + name: "x", + source: FieldSource::Attr("SCP_FreshnessValueId"), + }], + }; + let err = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[bad]) + .build() + .generate() + .unwrap_err(); + assert!( + format!("{err:#}").contains("not a valid Rust type path"), + "{err:#}" + ); +} + +#[test] +fn invalid_field_name_is_rejected() { + let bad = AttributeStruct { + type_path: "foo::Bar", + const_name: "OK", + scope: AttributeScope::Message, + require: "SCP_FreshnessValueId", + fields: &[AttributeField { + name: "1x", // not a valid identifier + source: FieldSource::Attr("SCP_FreshnessValueId"), + }], + }; + let err = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[bad]) + .build() + .generate() + .unwrap_err(); + assert!(format!("{err:#}").contains("field name"), "{err:#}"); +} + +#[test] +fn duplicate_field_names_are_rejected() { + let bad = AttributeStruct { + type_path: "foo::Bar", + const_name: "OK", + scope: AttributeScope::Message, + require: "SCP_FreshnessValueId", + fields: &[ + AttributeField { + name: "dup", + source: FieldSource::Attr("SCP_FreshnessValueId"), + }, + AttributeField { + name: "dup", + source: FieldSource::Int(1), + }, + ], + }; + let err = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[bad]) + .build() + .generate() + .unwrap_err(); + assert!(format!("{err:#}").contains("duplicate field"), "{err:#}"); +} + #[test] fn fixture_message_scope_resolves_hex_float_enum_and_size() { let spec = AttributeStruct { From af0822d3df0131eaa9fecd63f8a059c9424b5316 Mon Sep 17 00:00:00 2001 From: Felix van Oost Date: Wed, 15 Jul 2026 19:20:02 +0000 Subject: [PATCH 11/17] Require attribute consts to be in SNAKE_CASE --- src/lib.rs | 19 ++++++--------- src/utils.rs | 7 +++++- tests/attribute_structs.rs | 50 ++++++++++++++++++++------------------ 3 files changed, 40 insertions(+), 36 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 179bf99b..6fdad073 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,9 +33,9 @@ pub use crate::feature_config::FeatureConfig; use crate::pad::PadAdapter; use crate::signal_type::{IntSize, ValType}; use crate::utils::{ - enum_name, enum_variant_name, is_valid_ident, is_valid_type_path, multiplex_enum_name, - multiplexed_enum_variant_name, multiplexed_enum_variant_wrapper_name, MessageExt as _, - SignalExt as _, + enum_name, enum_variant_name, is_screaming_snake_case, is_valid_ident, is_valid_type_path, + multiplex_enum_name, multiplexed_enum_variant_name, multiplexed_enum_variant_wrapper_name, + MessageExt as _, SignalExt as _, }; static ALLOW_DEADCODE: &str = "#[allow(dead_code)]"; @@ -492,9 +492,11 @@ impl Config<'_> { /// Validate the [`AttributeStruct`] specs. fn validate_attribute_structs(&self) -> Result<()> { for spec in self.attribute_structs { + // Require SCREAMING_SNAKE_CASE to guarantee that the name never collides + // with a generated method (all `snake_case`). ensure!( - is_valid_ident(spec.const_name), - "attribute_structs: 'const_name' {:?} is not a valid Rust identifier", + is_screaming_snake_case(spec.const_name), + "attribute_structs: 'const_name' {:?} must be SCREAMING_SNAKE_CASE", spec.const_name ); ensure!( @@ -556,14 +558,9 @@ impl Config<'_> { // that fails to build. let mut used: BTreeSet = BTreeSet::new(); used.insert("MESSAGE_ID".to_string()); - used.insert("new".to_string()); - used.insert("raw".to_string()); for signal in &msg.signals { - let field = signal.field_name(); - used.insert(field.clone()); - used.insert(format!("set_{field}")); if ValType::from_signal(signal) != ValType::Bool { - let sig = field.to_uppercase(); + let sig = signal.field_name().to_uppercase(); used.insert(format!("{sig}_MIN")); used.insert(format!("{sig}_MAX")); } diff --git a/src/utils.rs b/src/utils.rs index c210160c..2f31bc10 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -102,7 +102,12 @@ pub fn is_valid_ident(s: &str) -> bool { && !keywords::is_keyword(s) } -/// Check Whether an idenfitier is a plain `::`-separated Rust type path, e.g. +/// Check whether an identifier is valid with no lowercase letters. +pub fn is_screaming_snake_case(s: &str) -> bool { + is_valid_ident(s) && !s.bytes().any(|b| b.is_ascii_lowercase()) +} + +/// Check whether an idenfitier is a plain `::`-separated Rust type path, e.g. /// `crate::Foo`. Each segment must look like an identifier - path keywords /// (`self`, `Self`) are allowed, but generics and other punctuation are not. pub fn is_valid_type_path(s: &str) -> bool { diff --git a/tests/attribute_structs.rs b/tests/attribute_structs.rs index cabf05ba..53be2b31 100644 --- a/tests/attribute_structs.rs +++ b/tests/attribute_structs.rs @@ -310,35 +310,37 @@ fn duplicate_const_name_is_an_error() { } #[test] -fn invalid_const_name_is_rejected() { - let bad = AttributeStruct { - type_path: "foo::Bar", - const_name: "1bad", // not a valid identifier - scope: AttributeScope::Message, - require: "SCP_FreshnessValueId", - fields: &[AttributeField { - name: "x", - source: FieldSource::Attr("SCP_FreshnessValueId"), - }], - }; - let err = Config::builder() - .dbc_name("test") - .dbc_content(DBC) - .attribute_structs(&[bad]) - .build() - .generate() - .unwrap_err(); - assert!( - format!("{err:#}").contains("not a valid Rust identifier"), - "{err:#}" - ); +fn non_screaming_snake_case_const_name_is_rejected() { + for name in ["1bad", "new", "raw", "with-dash"] { + let bad = AttributeStruct { + type_path: "foo::Bar", + const_name: name, + scope: AttributeScope::Message, + require: "SCP_FreshnessValueId", + fields: &[AttributeField { + name: "x", + source: FieldSource::Attr("SCP_FreshnessValueId"), + }], + }; + let err = Config::builder() + .dbc_name("test") + .dbc_content(DBC) + .attribute_structs(&[bad]) + .build() + .generate() + .unwrap_err(); + assert!( + format!("{err:#}").contains("SCREAMING_SNAKE_CASE"), + "{name}: {err:#}" + ); + } } #[test] -fn const_name_colliding_with_generated_method_is_rejected() { +fn const_name_colliding_with_reserved_const_is_rejected() { let bad = AttributeStruct { type_path: "foo::Bar", - const_name: "new", // valid identifier, but collides with the `new()` constructor + const_name: "MESSAGE_ID", // Collides with the generated const MESSAGE_ID scope: AttributeScope::Message, require: "SCP_FreshnessValueId", fields: &[AttributeField { From 64e8b7978633c5326a405c4c4528bcf68322590b Mon Sep 17 00:00:00 2001 From: Felix van Oost Date: Wed, 15 Jul 2026 19:25:13 +0000 Subject: [PATCH 12/17] Remove redundant start byte ordering logic --- src/lib.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 6fdad073..a9801bac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1593,10 +1593,7 @@ fn resolve_field_source( /// Byte index of a signal's start bit. fn signal_start_byte(signal: &Signal) -> u64 { - let start_bit = match signal.byte_order { - LittleEndian | BigEndian => signal.start_bit, - }; - start_bit.checked_div(8).unwrap_or(0) + signal.start_bit.checked_div(8).unwrap_or(0) } /// Render an attribute value as an un-suffixed Rust literal. From 85bdd700d9fe55ff461ea9f3000e4bf011076225 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:51:02 +0000 Subject: [PATCH 13/17] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index a9801bac..15a274aa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,12 +20,12 @@ use std::path::Path; use anyhow::{anyhow, ensure, Context, Error, Result}; use can_dbc::ByteOrder::{BigEndian, LittleEndian}; -use can_dbc::MultsiplexIndicator::{ +use can_dbc::MultiplexIndicator::{ MultiplexedSignal, Multiplexor, MultiplexorAndMultiplexedSignal, Plain, }; use can_dbc::ValueType::Signed; -use can_dbc::{Dbc, Message, MessageId, Signal, ValDescription, ValueDescription}; -use heck::ToSnaskeCase; +use can_dbc::{AttributeValue, Dbc, Message, MessageId, Signal, ValDescription, ValueDescription}; +use heck::ToSnakeCase; use quote::ToTokens; use typed_builder::TypedBuilder; From d07d6fd49aa933ab1e4463bcb1204d998a60b829 Mon Sep 17 00:00:00 2001 From: Felix van Oost Date: Thu, 16 Jul 2026 01:05:26 +0000 Subject: [PATCH 14/17] Simplify identifier helper functions --- src/lib.rs | 2 +- src/utils.rs | 66 ++++++++++++++++++++++++++++++++++++---------------- 2 files changed, 47 insertions(+), 21 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 15a274aa..f10efcdd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -555,7 +555,7 @@ impl Config<'_> { // Track every associated item already emitted for this message type so // that colliding const names are rejected here instead of producing code - // that fails to build. + // that fails to build. Using a BTree to ensure deterministic output. let mut used: BTreeSet = BTreeSet::new(); used.insert("MESSAGE_ID".to_string()); for signal in &msg.signals { diff --git a/src/utils.rs b/src/utils.rs index 2f31bc10..d7b1cd11 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -90,32 +90,58 @@ pub fn is_integer(val: f64) -> bool { val.fract().abs() < f64::EPSILON } -/// Check whether an identifier is usable as a Rust identifier (const or field name). -/// It must start with a letter or `_`, contain only `[A-Za-z0-9_]`, is not the bare -/// wildcard `_`, and is not a keyword. +/// Check whether `s` is usable as a Rust identifier (const or field name). +/// `syn` accepts exactly valid, non-keyword identifiers, so it also rejects +/// keywords, the bare wildcard `_`, and anything not identifier-shaped. pub fn is_valid_ident(s: &str) -> bool { - let mut chars = s.chars(); - let starts_ok = matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_'); - starts_ok - && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') - && s != "_" - && !keywords::is_keyword(s) + syn::parse_str::(s).is_ok() } -/// Check whether an identifier is valid with no lowercase letters. +/// Check whether `s` is valid with no lowercase letters. pub fn is_screaming_snake_case(s: &str) -> bool { is_valid_ident(s) && !s.bytes().any(|b| b.is_ascii_lowercase()) } -/// Check whether an idenfitier is a plain `::`-separated Rust type path, e.g. -/// `crate::Foo`. Each segment must look like an identifier - path keywords -/// (`self`, `Self`) are allowed, but generics and other punctuation are not. +/// Check whether `s` is a usable Rust type path, e.g. `crate::Foo`. +/// `syn` validates the whole path (including generics), while we +/// reject a qualified self-type (`::X`) and a leading `::`, +/// since neither makes sense as an emitted constant's type. pub fn is_valid_type_path(s: &str) -> bool { - !s.is_empty() - && s.split("::").all(|seg| { - let mut chars = seg.chars(); - matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_') - && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') - && seg != "_" - }) + matches!( + syn::parse_str::(s), + Ok(tp) if tp.qself.is_none() && tp.path.leading_colon.is_none() + ) +} + +#[cfg(test)] +mod tests { + use super::{is_screaming_snake_case, is_valid_ident, is_valid_type_path}; + + #[test] + fn idents() { + assert!(is_valid_ident("data_id")); + assert!(is_valid_ident("E2E")); + assert!(!is_valid_ident("1bad")); + assert!(!is_valid_ident("_")); + assert!(!is_valid_ident("type")); // keyword + assert!(!is_valid_ident("with-dash")); + } + + #[test] + fn screaming_snake_case() { + assert!(is_screaming_snake_case("E2E_DATA_ID")); + assert!(!is_screaming_snake_case("mixedCase")); + assert!(!is_screaming_snake_case("new")); + } + + #[test] + fn type_paths() { + assert!(is_valid_type_path("data_protection::E2EDataIdInfo")); + assert!(is_valid_type_path("crate::Foo")); + assert!(is_valid_type_path("my::Wrapper")); // generics now accepted + assert!(!is_valid_type_path("1bad::Type")); // digit-leading segment + assert!(!is_valid_type_path("")); // empty + assert!(!is_valid_type_path("::foo")); // leading `::` + assert!(!is_valid_type_path("::X")); // qualified self-type + } } From c3d51759bff2cb888c363668b89ddc60e82c7850 Mon Sep 17 00:00:00 2001 From: Felix van Oost Date: Thu, 16 Jul 2026 02:42:55 +0000 Subject: [PATCH 15/17] Use backticks around DBC attribute in generated comments --- src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index f10efcdd..7880fe6f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -639,7 +639,7 @@ impl Config<'_> { } let ty = spec.type_path; - writeln!(w, "/// Generated from DBC attributes '{}'", spec.require)?; + writeln!(w, "/// Generated from DBC attributes `{}`", spec.require)?; writeln!(w, "pub const {const_name}: {ty} = {ty} {{")?; { let mut w = PadAdapter::wrap(w); From d7cdb12c329956436b421cf85e2537b95d2bcfdb Mon Sep 17 00:00:00 2001 From: Felix van Oost Date: Thu, 16 Jul 2026 02:53:11 +0000 Subject: [PATCH 16/17] Display quotes in error messages consistently --- src/lib.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 7880fe6f..0c12a613 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -501,42 +501,43 @@ impl Config<'_> { ); ensure!( is_valid_type_path(spec.type_path), - "attribute_structs: 'type_path' {:?} for '{}' is not a valid Rust type path", + "attribute_structs: 'type_path' {:?} for {:?} is not a valid Rust type path", spec.type_path, spec.const_name ); ensure!( !spec.require.is_empty(), - "attribute_structs: 'require' must not be empty for '{}'", + "attribute_structs: 'require' must not be empty for {:?}", spec.const_name ); ensure!( !spec.fields.is_empty(), - "attribute_structs: '{}' declares no fields", + "attribute_structs: {:?} declares no fields", spec.const_name ); + let message_scope = matches!(spec.scope, AttributeScope::Message); let mut seen = BTreeSet::new(); for field in spec.fields { ensure!( is_valid_ident(field.name), - "attribute_structs: field name {:?} in '{}' is not a valid Rust identifier", + "attribute_structs: field name {:?} in {:?} is not a valid Rust identifier", field.name, spec.const_name ); ensure!( seen.insert(field.name), - "attribute_structs: duplicate field '{}' in '{}'", + "attribute_structs: duplicate field {:?} in {:?}", field.name, spec.const_name ); - if matches!(spec.scope, AttributeScope::Message) { + if message_scope { ensure!( !matches!( field.source, FieldSource::StartBit | FieldSource::StartByte | FieldSource::BitWidth ), - "attribute_structs: field '{}' of '{}' uses a signal-only source \ + "attribute_structs: field {:?} of {:?} uses a signal-only source \ (StartBit/StartByte/BitWidth) but the struct has message scope", field.name, spec.const_name @@ -620,7 +621,7 @@ impl Config<'_> { ) -> Result<()> { ensure!( used.insert(const_name.to_string()), - "attribute_structs: generated const '{const_name}' on message '{}' collides with \ + "attribute_structs: generated const '{const_name}' on message {:?} collides with \ another const. Use a distinct 'const_name'.", msg.name ); @@ -629,7 +630,7 @@ impl Config<'_> { for field in spec.fields { let lit = resolve_field_source(&field.source, msg, dbc, signal).ok_or_else(|| { anyhow!( - "attribute_structs: const '{const_name}' field '{}' has no value in the DBC \ + "attribute_structs: const '{const_name}' field {:?} has no value in the DBC \ and no default (source: {:?})", field.name, field.source From 4e221314450be40658a6e7a1e7e230b79d4f1067 Mon Sep 17 00:00:00 2001 From: Felix van Oost Date: Sat, 18 Jul 2026 02:19:53 +0000 Subject: [PATCH 17/17] Print complete output in example code --- examples/attribute_structs.rs | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/examples/attribute_structs.rs b/examples/attribute_structs.rs index 3b8ad1c1..7f7bbbd8 100644 --- a/examples/attribute_structs.rs +++ b/examples/attribute_structs.rs @@ -64,19 +64,5 @@ fn main() { .generate() .expect("generate"); - // Print only the associated consts to keep the output readable. - for line in code.lines() { - let t = line.trim_start(); - if t.starts_with("pub const") - || t.starts_with("data_id") - || t.starts_with("start_byte") - || t.starts_with("width_bit") - || t.starts_with("profile") - || t.starts_with("freshness_id") - || t.contains("E2EDataIdInfo {") - || t.contains("SecOcInfo {") - { - println!("{line}"); - } - } + println!("{code}"); }