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