rustyfix_codegen/
lib.rs

1use heck::{ToPascalCase, ToShoutySnakeCase};
2use indoc::formatdoc;
3use rustc_hash::FxHashSet;
4use rustyfix_dictionary::{self as dict, TagU32};
5use smartstring::alias::String as SmartString;
6
7const RUSTYFIX_VERSION: &str = env!("CARGO_PKG_VERSION");
8
9/// Creates a [`String`] that contains a multiline Rust "Doc" comment explaining
10/// that all subsequent code was automatically generated.
11///
12/// The following example is for illustrative purposes only and the actual
13/// contents might change. The string is guaranteed not to have any trailing or
14/// leading whitespace.
15///
16/// ```text
17/// //! Generated automatically by RustyFix. Do not modify manually.
18/// ```
19pub fn generated_code_notice() -> SmartString {
20    formatdoc!(
21        r#"
22            // Generated automatically by RustyFix {} on {}.
23            //
24            // DO NOT MODIFY MANUALLY.
25            // DO NOT COMMIT TO VERSION CONTROL.
26            // ALL CHANGES WILL BE OVERWRITTEN."#,
27        RUSTYFIX_VERSION,
28        chrono::Utc::now().to_rfc2822(),
29    )
30    .into()
31}
32
33/// Generates the Rust code for an `enum` that has variants that map 1:1 the
34/// available values for `field`.
35pub fn codegen_field_type_enum(field: dict::Field, settings: &Settings) -> String {
36    let derives = settings.derives_for_allowed_values.join(", ");
37    let attributes = settings.attributes_for_allowed_values.join("\n");
38    let mut variant_identifiers = FxHashSet::default();
39    let variants = field
40        .enums()
41        .unwrap()
42        .map(|v| codegen_field_type_enum_variant(v, settings, &mut variant_identifiers))
43        .collect::<Vec<_>>()
44        .join("\n");
45    formatdoc!(
46        r#"
47            /// Field type variants for [`{field_name}`].
48            #[derive({derives})]
49            {attributes}
50            pub enum {identifier} {{
51            {variants}
52            }}"#,
53        field_name = field.name().to_pascal_case(),
54        derives = derives,
55        attributes = attributes,
56        identifier = field.name().to_pascal_case(),
57        variants = variants,
58    )
59}
60
61fn codegen_field_type_enum_variant(
62    allowed_value: dict::FieldEnum,
63    settings: &Settings,
64    variant_identifiers: &mut FxHashSet<String>,
65) -> SmartString {
66    let mut identifier = allowed_value.description().to_pascal_case();
67    let original_identifier = identifier.clone();
68    let identifier_needs_prefix = !allowed_value
69        .description()
70        .chars()
71        .next()
72        .unwrap_or('_')
73        .is_ascii_alphabetic();
74    if identifier_needs_prefix {
75        identifier = format!("_{identifier}");
76    }
77    // E.g. `TickDirection::PlusTick` -> `TickDirection::Plus`.
78    if let Some(s) = identifier.strip_suffix("Tick") {
79        if !s.is_empty() {
80            identifier = s.to_string();
81        }
82    }
83    // E.g. `QuoteCancelType::CancelForSymbol` -> `QuoteCancelType::Symbol`
84    if let Some(s) = identifier.strip_prefix("CancelFor") {
85        if !s.is_empty() {
86            identifier = s.to_string();
87        }
88    }
89    // E.g. `SecurityRequestType::RequestSecurityIdentityAndSpecifications`
90    if let Some(s) = identifier.strip_prefix("Request") {
91        if !s.is_empty() {
92            identifier = s.to_string();
93        }
94    }
95    // E.g. `MultiLegReportingType::SingleSecurity`
96    if let Some(s) = identifier.strip_suffix("Security") {
97        if !s.is_empty() {
98            identifier = s.to_string();
99        }
100    }
101    if let Some(s) = identifier.strip_prefix("RelatedTo") {
102        if !s.is_empty() {
103            identifier = s.to_string();
104        }
105    }
106    if let Some(s) = identifier.strip_suffix("Price") {
107        if !s.is_empty() {
108            identifier = s.to_string();
109        }
110    }
111    if let Some(s) = identifier.strip_prefix("No") {
112        if !s.is_empty() {
113            identifier = s.to_string();
114        }
115    }
116    if let Some(s) = identifier.strip_suffix("Trade") {
117        if !s.is_empty() {
118            identifier = s.to_string();
119        }
120    }
121    if let Some(s) = identifier.strip_prefix("At") {
122        if !s.is_empty() {
123            identifier = s.to_string();
124        }
125    }
126    if let Some(s) = identifier.strip_suffix("Deal") {
127        if !s.is_empty() {
128            identifier = s.to_string();
129        }
130    }
131    if let Some(s) = identifier.strip_suffix("Sale") {
132        if !s.is_empty() {
133            identifier = s.to_string();
134        }
135    }
136    if let Some(s) = identifier.strip_prefix("As") {
137        if !s.is_empty() {
138            identifier = s.to_string();
139        }
140    }
141    if let Some(s) = identifier.strip_prefix("Of") {
142        if !s.is_empty() {
143            identifier = s.to_string();
144        }
145    }
146    // Ensure identifier is valid Rust identifier
147    let mut final_identifier = identifier.to_pascal_case();
148    if final_identifier
149        .chars()
150        .next()
151        .is_none_or(|c| !c.is_ascii_alphabetic())
152    {
153        final_identifier = format!("_{final_identifier}");
154    }
155
156    // Handle duplicates on the final identifier
157    if variant_identifiers.contains(&final_identifier) {
158        // Fall back to original identifier if there's a collision
159        final_identifier = original_identifier.to_pascal_case();
160        if final_identifier
161            .chars()
162            .next()
163            .is_none_or(|c| !c.is_ascii_alphabetic())
164        {
165            final_identifier = format!("_{final_identifier}");
166        }
167
168        // If still duplicated, add a suffix
169        let mut counter = 2;
170        let base_identifier = final_identifier.clone();
171        while variant_identifiers.contains(&final_identifier) {
172            final_identifier = format!("{base_identifier}{counter}");
173            counter += 1;
174        }
175    }
176    variant_identifiers.insert(final_identifier.clone());
177    let value_literal = allowed_value.value();
178    indent_string(
179        formatdoc!(
180            r#"
181                /// {doc}
182                #[rustyfix(variant = "{value_literal}")]
183                {identifier},"#,
184            doc = format!("Field variant '{}'.", value_literal),
185            value_literal = value_literal,
186            identifier = final_identifier,
187        )
188        .as_str(),
189        settings.indentation.as_str(),
190    )
191}
192
193/// Code generation settings. Instantiate with [`Default::default`] and then
194/// change field values if necessary.
195#[derive(Debug, Clone)]
196#[non_exhaustive]
197pub struct Settings {
198    /// The indentation prefix of all generated Rust code. Four
199    /// spaces by default.
200    pub indentation: String,
201    /// The base indentation level of all generated Rust code. Zero by default.
202    pub indentation_depth: u32,
203    /// The name of the `rustyfix` crate for imports. `rustyfix` by default.
204    pub rustyfix_crate_name: String,
205    /// A list of derive macros on top of all generated FIX datatype `enum`s. E.g.:
206    ///
207    /// ```
208    /// // #[derive(Foobar, Spam)]
209    /// enum FoodOrDrink {
210    ///     Food,
211    ///     Drink,
212    /// }
213    /// ```
214    ///
215    /// Contains [`Debug`], [`Copy`], [`Clone`], [`PartialEq`], [`Eq`],
216    /// [`Hash`], [`FieldType`](crate::FieldType) by default.
217    pub derives_for_allowed_values: Vec<String>,
218    /// A list of attribute macros for generated `enum`s variants. E.g.:
219    ///
220    /// ```
221    /// enum FoodOrDrink {
222    ///     // #[foobar]
223    ///     Food,
224    ///     // #[spam]
225    ///     Drink,
226    /// }
227    /// ```
228    ///
229    /// Empty by default.
230    pub attributes_for_allowed_values: Vec<String>,
231}
232
233impl Default for Settings {
234    fn default() -> Self {
235        Self {
236            indentation: "    ".to_string(),
237            indentation_depth: 0,
238            derives_for_allowed_values: vec![
239                "Debug".to_string(),
240                "Copy".to_string(),
241                "Clone".to_string(),
242                "PartialEq".to_string(),
243                "Eq".to_string(),
244                "Hash".to_string(),
245                "FieldType".to_string(),
246            ],
247            attributes_for_allowed_values: vec![],
248            rustyfix_crate_name: "rustyfix".to_string(),
249        }
250    }
251}
252
253/// Generates the Rust code for a FIX field definition.
254pub fn codegen_field_definition_struct(
255    fix_dictionary: &dict::Dictionary,
256    field: dict::Field,
257) -> String {
258    let mut header = FxHashSet::default();
259    let mut trailer = FxHashSet::default();
260    for item in fix_dictionary
261        .component_by_name("StandardHeader")
262        .unwrap()
263        .items()
264    {
265        if let dict::LayoutItemKind::Field(f) = item.kind() {
266            header.insert(f.tag());
267        }
268    }
269    for item in fix_dictionary
270        .component_by_name("StandardTrailer")
271        .unwrap()
272        .items()
273    {
274        if let dict::LayoutItemKind::Field(f) = item.kind() {
275            trailer.insert(f.tag());
276        }
277    }
278    gen_field_definition_with_hashsets(fix_dictionary, &header, &trailer, field)
279}
280
281/// Generates `const` implementors of
282/// [`IsFieldDefinition`](super::dict::IsFieldDefinition).
283///
284/// The generated module will contain:
285///
286/// - A generated code notice ([generated_code_notice]).
287/// - `enum` definitions for FIX field types.
288/// - A constant implementor of
289///   [`IsFieldDefinition`](super::dict::IsFieldDefinition)
290///   for each FIX field.
291///
292/// The Rust code will be free of any leading and trailing whitespace.
293/// An effort is made to provide good formatting, but users shouldn't rely on it
294/// and assume that formatting might be bad.
295pub fn gen_definitions(fix_dictionary: &dict::Dictionary, settings: &Settings) -> String {
296    let enums = fix_dictionary
297        .fields()
298        .iter()
299        .filter(|f| f.enums().is_some())
300        .map(|f| codegen_field_type_enum(*f, settings))
301        .collect::<Vec<String>>()
302        .join("\n\n");
303    let field_defs = fix_dictionary
304        .fields()
305        .iter()
306        .map(|field| codegen_field_definition_struct(fix_dictionary, *field))
307        .collect::<Vec<String>>()
308        .join("\n\n");
309    let top_comment = onixs_link_to_dictionary(fix_dictionary.version()).unwrap_or_default();
310    let code = formatdoc!(
311        r#"
312            {notice}
313
314            // {top_comment}
315
316            use {rustyfix_path}::dict::{{FieldLocation, FixDatatype}};
317            use {rustyfix_path}::definitions::HardCodedFixFieldDefinition;
318            use {rustyfix_path}::FieldType;
319
320            {enum_definitions}
321
322            {field_defs}"#,
323        notice = generated_code_notice(),
324        top_comment = top_comment,
325        enum_definitions = enums,
326        field_defs = field_defs,
327        rustyfix_path = settings.rustyfix_crate_name,
328    );
329    code
330}
331
332fn indent_string(s: &str, prefix: &str) -> SmartString {
333    s.lines()
334        .map(|line| format!("{prefix}{line}"))
335        .collect::<Vec<String>>()
336        .join("\n")
337        .into()
338}
339
340fn onixs_link_to_field(fix_version: &str, field: dict::Field) -> Option<SmartString> {
341    Some(
342        format!(
343            "https://www.onixs.biz/fix-dictionary/{}/tagnum_{}.html",
344            onixs_dictionary_id(fix_version)?,
345            field.tag().get()
346        )
347        .into(),
348    )
349}
350
351fn onixs_link_to_dictionary(fix_version: &str) -> Option<SmartString> {
352    Some(
353        format!(
354            "https://www.onixs.biz/fix-dictionary/{}/index.html",
355            onixs_dictionary_id(fix_version)?
356        )
357        .into(),
358    )
359}
360
361fn onixs_dictionary_id(fix_version: &str) -> Option<&str> {
362    Some(match fix_version {
363        "FIX.4.0" => "4.0",
364        "FIX.4.1" => "4.1",
365        "FIX.4.2" => "4.2",
366        "FIX.4.3" => "4.3",
367        "FIX.4.4" => "4.4",
368        "FIX.5.0" => "5.0",
369        "FIX.5.0-SP1" => "5.0.sp1",
370        "FIX.5.0-SP2" => "5.0.sp2",
371        "FIXT.1.1" => "fixt1.1",
372        _ => return None,
373    })
374}
375
376fn gen_field_definition_with_hashsets(
377    fix_dictionary: &dict::Dictionary,
378    header_tags: &FxHashSet<TagU32>,
379    trailer_tags: &FxHashSet<TagU32>,
380    field: dict::Field,
381) -> String {
382    let name = field.name().to_shouty_snake_case();
383    let tag = field.tag().to_string();
384    let field_location = if header_tags.contains(&field.tag()) {
385        "Header"
386    } else if trailer_tags.contains(&field.tag()) {
387        "Trailer"
388    } else {
389        "Body"
390    };
391    let doc_link = onixs_link_to_field(fix_dictionary.version(), field);
392    let doc = if let Some(doc_link) = doc_link {
393        format!("/// Field attributes for [`{name} <{tag}>`]({doc_link}).")
394    } else {
395        format!("/// Field attributes for `{name} <{tag}>`.")
396    };
397
398    formatdoc!(
399        r#"
400            {doc}
401            pub const {identifier}: &HardCodedFixFieldDefinition = &HardCodedFixFieldDefinition {{
402                name: "{name}",
403                tag: {tag},
404                data_type: FixDatatype::{data_type},
405                location: FieldLocation::{field_location},
406            }};"#,
407        doc = doc,
408        identifier = name,
409        name = field.name(),
410        tag = tag,
411        field_location = field_location,
412        data_type = <&'static str as From<dict::FixDatatype>>::from(field.data_type().basetype()),
413    )
414}
415
416#[cfg(test)]
417mod test {
418    use super::*;
419
420    #[test]
421    fn syntax_of_field_definitions_is_ok() {
422        let codegen_settings = Settings::default();
423        for dict in dict::Dictionary::common_dictionaries().into_iter() {
424            let code = gen_definitions(&dict, &codegen_settings);
425            syn::parse_file(code.as_str()).unwrap();
426        }
427    }
428
429    #[test]
430    fn generated_code_notice_is_trimmed() {
431        let notice = generated_code_notice();
432        assert_eq!(notice, notice.trim());
433    }
434}