hotfix_codegen/
lib.rs

1//! Code generation for [HotFIX](https://crates.io/crates/hotfix).
2
3use fnv::FnvHashSet;
4use heck::{ToPascalCase, ToShoutySnakeCase};
5use hotfix_dictionary::{self as dict, TagU32};
6use indoc::indoc;
7use std::marker::PhantomData;
8
9const HOTFIX_VERSION: &str = env!("CARGO_PKG_VERSION");
10
11/// Creates a [`String`] that contains a multiline Rust "Doc" comment explaining
12/// that all subsequent code was automatically generated.
13///
14/// The following example is for illustrative purposes only and the actual
15/// contents might change. The string is guaranteed not to have any trailing or
16/// leading whitespace.
17///
18/// ```text
19/// // Generated automatically by HotFIX. Do not modify manually.
20/// ```
21pub fn generated_code_notice() -> String {
22    use chrono::prelude::*;
23
24    format!(
25        indoc!(
26            r#"
27            // Generated automatically by HotFIX {} on {}.
28            //
29            // DO NOT MODIFY MANUALLY.
30            // DO NOT COMMIT TO VERSION CONTROL.
31            // ALL CHANGES WILL BE OVERWRITTEN."#
32        ),
33        HOTFIX_VERSION,
34        Utc::now().to_rfc2822(),
35    )
36}
37
38/// Generates the Rust code for an `enum` that has variants that map 1:1 the
39/// available values for `field`.
40pub fn codegen_field_type_enum(field: dict::Field, settings: &Settings) -> String {
41    let derives = settings.derives_for_allowed_values.join(", ");
42    let attributes = settings.attributes_for_allowed_values.join("\n");
43    let variants = field
44        .enums()
45        .unwrap()
46        .map(|v| codegen_field_type_enum_variant(v, settings))
47        .collect::<Vec<String>>()
48        .join("\n");
49    format!(
50        indoc!(
51            r#"
52            /// Field type variants for [`{field_name}`].
53            #[derive({derives})]
54            {attributes}
55            pub enum {identifier} {{
56            {variants}
57            }}"#
58        ),
59        field_name = field.name().to_pascal_case(),
60        derives = derives,
61        attributes = attributes,
62        identifier = field.name().to_pascal_case(),
63        variants = variants,
64    )
65}
66
67fn codegen_field_type_enum_variant(allowed_value: dict::FieldEnum, settings: &Settings) -> String {
68    let mut identifier = allowed_value.description().to_pascal_case();
69    let identifier_needs_prefix = !allowed_value
70        .description()
71        .chars()
72        .next()
73        .unwrap_or('_')
74        .is_ascii_alphabetic();
75    if identifier_needs_prefix {
76        identifier = format!("_{identifier}");
77    }
78    let value_literal = allowed_value.value();
79    indent_string(
80        format!(
81            indoc!(
82                r#"
83                /// {doc}
84                #[hotfix(variant = "{value_literal}")]
85                {identifier},"#
86            ),
87            doc = format!("Field variant '{}'.", value_literal),
88            value_literal = value_literal,
89            identifier = identifier,
90        )
91        .as_str(),
92        settings.indentation.as_str(),
93    )
94}
95
96/// Code generation settings. Instantiate with [`Default::default`] and then
97/// change field values if necessary.
98#[derive(Debug, Clone)]
99pub struct Settings {
100    phantom: PhantomData<()>,
101
102    /// The indentation prefix of all generated Rust code. Four
103    /// spaces by default.
104    pub indentation: String,
105    /// The indentation level of all generated Rust code. Zero by default.
106    pub indentation_depth: u32,
107    /// The name of the `hotfix` crate for imports. `hotfix` by default.
108    pub hotfix_crate_name: String,
109    /// A list of derive macros on top of all generated FIX datatype `enum`s. E.g.:
110    ///
111    /// ```
112    /// // #[derive(Foobar, Spam)]
113    /// enum FoodOrDrink {
114    ///     Food,
115    ///     Drink,
116    /// }
117    /// ```
118    ///
119    /// Contains [`Debug`], [`Copy`], [`PartialEq`], [`Eq`], [`Hash`],
120    /// `FieldType` by default.
121    pub derives_for_allowed_values: Vec<String>,
122    /// A list of attribute macros for generated `enum`s variants. E.g.:
123    ///
124    /// ```
125    /// enum FoodOrDrink {
126    ///     // #[foobar]
127    ///     Food,
128    ///     // #[spam]
129    ///     Drink,
130    /// }
131    /// ```
132    ///
133    /// Empty by default.
134    pub attributes_for_allowed_values: Vec<String>,
135}
136
137impl Default for Settings {
138    fn default() -> Self {
139        Self {
140            indentation: "    ".to_string(),
141            indentation_depth: 0,
142            derives_for_allowed_values: vec![
143                "Debug".to_string(),
144                "Copy".to_string(),
145                "Clone".to_string(),
146                "PartialEq".to_string(),
147                "Eq".to_string(),
148                "Hash".to_string(),
149                "FieldType".to_string(),
150            ],
151            attributes_for_allowed_values: vec![],
152            hotfix_crate_name: "hotfix".to_string(),
153            phantom: PhantomData,
154        }
155    }
156}
157
158/// Generates the Rust code for a FIX field definition.
159pub fn codegen_field_definition_struct(
160    fix_dictionary: &dict::Dictionary,
161    field: dict::Field,
162) -> String {
163    let mut header = FnvHashSet::default();
164    let mut trailer = FnvHashSet::default();
165    for item in fix_dictionary
166        .component_by_name("StandardHeader")
167        .unwrap()
168        .items()
169    {
170        if let dict::LayoutItemKind::Field(f) = item.kind() {
171            header.insert(f.tag());
172        }
173    }
174    for item in fix_dictionary
175        .component_by_name("StandardTrailer")
176        .unwrap()
177        .items()
178    {
179        if let dict::LayoutItemKind::Field(f) = item.kind() {
180            trailer.insert(f.tag());
181        }
182    }
183    gen_field_definition_with_hashsets(fix_dictionary, &header, &trailer, field)
184}
185
186/// Generates `const` implementors of `IsFieldDefinition`.
187///
188/// The generated module will contain:
189///
190/// - A generated code notice ([generated_code_notice]).
191/// - `enum` definitions for FIX field types.
192/// - A constant implementor of `IsFieldDefinition` for each FIX field.
193///
194/// The Rust code will be free of any leading and trailing whitespace.
195/// An effort is made to provide good formatting, but users shouldn't rely on it
196/// and assume that formatting might be bad.
197pub fn gen_definitions(fix_dictionary: &dict::Dictionary, settings: &Settings) -> String {
198    let enums = fix_dictionary
199        .fields()
200        .iter()
201        .filter(|f| f.enums().is_some())
202        .map(|f| codegen_field_type_enum(*f, settings))
203        .collect::<Vec<String>>()
204        .join("\n\n");
205    let field_defs = fix_dictionary
206        .fields()
207        .iter()
208        .map(|field| codegen_field_definition_struct(fix_dictionary, *field))
209        .collect::<Vec<String>>()
210        .join("\n");
211    let top_comment = onixs_link_to_dictionary(fix_dictionary.version()).unwrap_or_default();
212    let code = format!(
213        indoc!(
214            r#"
215            {notice}
216
217            // {top_comment}
218
219            use {hotfix_path}::dict::FieldLocation;
220            use {hotfix_path}::dict::FixDatatype;
221            use {hotfix_path}::{{FieldType, HardCodedFixFieldDefinition}};
222
223            {enum_definitions}
224
225            {field_defs}"#
226        ),
227        notice = generated_code_notice(),
228        top_comment = top_comment,
229        enum_definitions = enums,
230        field_defs = field_defs,
231        hotfix_path = settings.hotfix_crate_name,
232    );
233    code
234}
235
236fn indent_string(s: &str, prefix: &str) -> String {
237    s.lines().fold(String::new(), |mut s, line| {
238        if line.contains(char::is_whitespace) {
239            s.push_str(prefix);
240        }
241        s.push_str(line);
242        s.push('\n');
243        s
244    })
245}
246
247fn onixs_link_to_field(fix_version: &str, field: dict::Field) -> Option<String> {
248    Some(format!(
249        "https://www.onixs.biz/fix-dictionary/{}/tagnum_{}.html",
250        onixs_dictionary_id(fix_version)?,
251        field.tag().get()
252    ))
253}
254
255fn onixs_link_to_dictionary(fix_version: &str) -> Option<String> {
256    Some(format!(
257        "https://www.onixs.biz/fix-dictionary/{}/index.html",
258        onixs_dictionary_id(fix_version)?
259    ))
260}
261
262fn onixs_dictionary_id(fix_version: &str) -> Option<&str> {
263    Some(match fix_version {
264        "FIX.4.0" => "4.0",
265        "FIX.4.1" => "4.1",
266        "FIX.4.2" => "4.2",
267        "FIX.4.3" => "4.3",
268        "FIX.4.4" => "4.4",
269        "FIX.5.0" => "5.0",
270        "FIX.5.0-SP1" => "5.0.sp1",
271        "FIX.5.0-SP2" => "5.0.sp2",
272        "FIXT.1.1" => "fixt1.1",
273        _ => return None,
274    })
275}
276
277fn gen_field_definition_with_hashsets(
278    fix_dictionary: &dict::Dictionary,
279    header_tags: &FnvHashSet<TagU32>,
280    trailer_tags: &FnvHashSet<TagU32>,
281    field: dict::Field,
282) -> String {
283    let name = field.name().to_shouty_snake_case();
284    let tag = field.tag().to_string();
285    let field_location = if header_tags.contains(&field.tag()) {
286        "Header"
287    } else if trailer_tags.contains(&field.tag()) {
288        "Trailer"
289    } else {
290        "Body"
291    };
292    let doc_link = onixs_link_to_field(fix_dictionary.version(), field);
293    let doc = if let Some(doc_link) = doc_link {
294        format!("/// Field attributes for [`{name} <{tag}>`]({doc_link}).")
295    } else {
296        format!("/// Field attributes for `{name} <{tag}>`.")
297    };
298
299    format!(
300        indoc!(
301            r#"
302                {doc}
303                pub const {identifier}: &HardCodedFixFieldDefinition = &HardCodedFixFieldDefinition {{
304                    name: "{name}",
305                    tag: {tag},
306                    data_type: FixDatatype::{data_type},
307                    location: FieldLocation::{field_location},
308                }};"#
309        ),
310        doc = doc,
311        identifier = name,
312        name = field.name(),
313        tag = tag,
314        field_location = field_location,
315        data_type = <&'static str as From<dict::FixDatatype>>::from(field.data_type().basetype()),
316    )
317}
318
319#[cfg(test)]
320mod test {
321    use super::*;
322
323    #[test]
324    fn syntax_of_field_definitions_is_ok() {
325        let codegen_settings = Settings::default();
326        for dict in dict::Dictionary::common_dictionaries().into_iter() {
327            let code = gen_definitions(&dict, &codegen_settings);
328            syn::parse_file(code.as_str()).unwrap();
329        }
330    }
331
332    #[test]
333    fn generated_code_notice_is_trimmed() {
334        let notice = generated_code_notice();
335        assert_eq!(notice, notice.trim());
336    }
337}