cli_settings_derive/
lib.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
//! Manage CLI settings via config files and command line parsing.

use std::str::FromStr;

use quote::{quote, ToTokens};
use syn::{parse_macro_input, spanned::Spanned};

/// Map of attributes by category
/// 'cli_settings_default': default field value
/// 'cli_settings_file': config file related attributes
/// 'cli_settings_clap': command line related attributes
/// 'cli_settings_mandatory': indicate a mandatory CLI argument (presence/absence only, no associated value)
/// 'doc': doc related attributes
/// '_': other attributes
type AttrMap = std::collections::HashMap<String, proc_macro2::TokenStream>;

/// Field element, quite similar to syn::Field, keeping only the relevant fields
struct Field<'a> {
    attrs: AttrMap,           // classified attributes of the field
    vis: &'a syn::Visibility, // field visibility
    ident: &'a syn::Ident,    // field name
    ty: &'a syn::Type,        // field type
    opt: bool,                // whether the type shall be converted to Option<ty>
}

/// Container for the whole settings struct
struct SettingStruct<'a> {
    s: &'a syn::ItemStruct, // associated syn::ItemStruct object
    attrs: AttrMap,         // classified attributes of the struct
    fields: Vec<Field<'a>>, // list of fields
}

impl<'a> SettingStruct<'a> {
    /// Build SettingStruct from a syn::ItemStruct
    fn build(s: &'a syn::ItemStruct) -> Result<Self, syn::Error> {
        let mut ss = Self {
            s,
            attrs: Default::default(),
            fields: vec![],
        };

        let syn::Fields::Named(fields) = &s.fields else {
            return Err(syn::Error::new(
                s.span(),
                "only named structs are supported",
            ));
        };

        // struct attributes
        ss.attrs = Self::classify_attributes(&s.attrs)?;

        // fields
        ss.fields.reserve_exact(fields.named.len());
        for field in &fields.named {
            let mut f = Field {
                attrs: Self::classify_attributes(&field.attrs)?,
                vis: &field.vis,
                ident: field.ident.as_ref().ok_or_else(|| {
                    syn::Error::new(field.span(), "only named fields are supported")
                })?,
                ty: &field.ty,
                opt: false,
            };
            f.opt = !f.attrs.contains_key("cli_settings_mandatory");
            ss.fields.push(f);
        }

        Ok(ss)
    }

    /// Classify a list of attributes, related to file , clap, or other
    fn classify_attributes(attrs: &'a Vec<syn::Attribute>) -> Result<AttrMap, syn::Error> {
        let mut res: AttrMap = Default::default();
        for attr in attrs {
            let (path, value) = match &attr.meta {
                syn::Meta::Path(p) => (Some(p), None),
                syn::Meta::NameValue(v) => (Some(&v.path), Some(&v.value)),
                _ => (None, None),
            };
            let mut handled_attr = false;
            if let Some(p) = path {
                if let Some(path_ident) = p.get_ident() {
                    let path_ident_str = path_ident.to_string();
                    if path_ident_str == "doc" {
                        handled_attr = true;
                        res.entry("doc".to_string())
                            .or_default()
                            .extend(attr.to_token_stream());
                    } else if path_ident_str.starts_with("cli_settings_") {
                        handled_attr = true;
                        if value.is_none() {
                            res.entry(path_ident_str).or_default();
                        } else if let Some(syn::Expr::Lit(syn::ExprLit {
                            attrs: _,
                            lit: syn::Lit::Str(l),
                        })) = value
                        {
                            res.entry(path_ident_str)
                                .or_default()
                                .extend(proc_macro2::TokenStream::from_str(&l.value())?);
                        } else {
                            return Err(syn::Error::new(attr.span(), "invalid attribute format"));
                        }
                    }
                }
            }
            if !handled_attr {
                // other attribute (including doc), keep as is
                res.entry("_".to_string())
                    .or_default()
                    .extend(attr.to_token_stream());
            }
        }
        Ok(res)
    }

    /// Output struct with given selection
    fn output_struct(
        &self,
        prefix: &str,
        field_filter: Option<&str>,
        attr_keys: &[&str],
    ) -> proc_macro2::TokenStream {
        let empty = proc_macro2::TokenStream::new();
        let attrs = attr_keys
            .iter()
            .map(|k| self.attrs.get(*k).unwrap_or(&empty))
            .collect::<Vec<_>>();
        let vis = if prefix.is_empty() {
            self.s.vis.to_token_stream()
        } else {
            empty.clone()
        };
        let struct_token = &self.s.struct_token;
        let name = format!("{}{}", prefix, self.s.ident);
        let ident = syn::Ident::new(&name, self.s.ident.span());
        // all fields tokens
        let fields = self
            .fields
            .iter()
            .filter(|f| {
                if let Some(k) = field_filter {
                    f.attrs.contains_key(k)
                } else {
                    true
                }
            })
            .map(|f| {
                // field tokens
                let field_attrs = attr_keys
                    .iter()
                    .map(|k| f.attrs.get(*k).unwrap_or(&empty))
                    .collect::<Vec<_>>();
                let field_vis = f.vis;
                let field_ident = f.ident;
                let field_ty = f.ty;
                let (field_ty_start, field_ty_end) = if prefix.is_empty() || !f.opt {
                    // no prefix, field with configured type
                    (empty.clone(), empty.clone())
                } else {
                    // prefix, field with Option<configured type>
                    (
                        proc_macro2::TokenStream::from_str("Option<").unwrap(),
                        proc_macro2::TokenStream::from_str(">").unwrap(),
                    )
                };
                // struct tokens
                        // output one field (without separator)
                quote! {
                    #(#field_attrs)* #field_vis #field_ident: #field_ty_start #field_ty #field_ty_end
                }
            })
            .collect::<Vec<_>>();
        // output the whole struct
        quote! {
            #(#attrs)* #vis #struct_token #ident
            {
                #(#fields),*
            }
        }
    }

    /// Output the main structure
    fn output_main_struct(&self) -> proc_macro2::TokenStream {
        self.output_struct("", None, &["_", "doc"])
    }
    /// Output the file structure
    fn output_file_struct(&self) -> proc_macro2::TokenStream {
        self.output_struct("File", Some("cli_settings_file"), &["cli_settings_file"])
    }
    /// Output the clap structure
    fn output_clap_struct(&self) -> proc_macro2::TokenStream {
        self.output_struct(
            "Clap",
            Some("cli_settings_clap"),
            &["doc", "cli_settings_clap"],
        )
    }

    /// Output Default implementation for the main struct
    fn output_main_struct_default(&self) -> proc_macro2::TokenStream {
        let default = proc_macro2::TokenStream::from_str("Default::default()").unwrap();
        let ident = &self.s.ident;
        let fields = self
            .fields
            .iter()
            .map(|f| {
                let field_ident = f.ident;
                let field_default = f.attrs.get("cli_settings_default").unwrap_or(&default);
                // output one field (without separator)
                quote! {
                    #field_ident: #field_default
                }
            })
            .collect::<Vec<_>>();
        quote! {
            impl Default for #ident {
                fn default() -> Self {
                    Self{
                        #(#fields),*
                    }
                }
            }
        }
    }

    /// Output build() implementation for the main struct
    fn output_main_struct_build(&self) -> proc_macro2::TokenStream {
        let ident = &self.s.ident;
        quote! {
            impl #ident {
                pub fn build<F, I, T>(cfg_files: F, args: I) -> anyhow::Result<Self>
                where
                    F: IntoIterator<Item = std::path::PathBuf>,
                    I: IntoIterator<Item = T>,
                    T: Into<std::ffi::OsString> + Clone,
                {
                    let mut cfg = Self::default();
                    for file in cfg_files {
                        _cli_settings_derive::load_file(&file, &mut cfg)?;
                    }
                    _cli_settings_derive::parse_cli_args(args, &mut cfg)?;
                    Ok(cfg)
                }
            }
        }
    }

    /// Output update() implementation for the file struct
    fn output_struct_update(&self, prefix: &str, field_filter: &str) -> proc_macro2::TokenStream {
        let main_ident = &self.s.ident;
        let name = format!("{}{}", prefix, self.s.ident);
        let ident = syn::Ident::new(&name, self.s.ident.span());
        let fields = self
            .fields
            .iter()
            .filter(|f| f.attrs.contains_key(field_filter))
            .map(|f| {
                let field_ident = f.ident;
                // output one field (without separator)
                if f.opt {
                    quote! {
                        if let Some(param) = self.#field_ident {
                            cfg.#field_ident = param;
                        }
                    }
                } else {
                    quote! {
                        cfg.#field_ident = self.#field_ident;
                    }
                }
            })
            .collect::<Vec<_>>();
        quote! {
            impl #ident {
                fn update(self, cfg: &mut super::#main_ident) {
                    #(#fields)*
                }
            }
        }
    }
    /// Output the file struct update()
    fn output_file_struct_update(&self) -> proc_macro2::TokenStream {
        self.output_struct_update("File", "cli_settings_file")
    }
    /// Output the clap struct update()
    fn output_clap_struct_update(&self) -> proc_macro2::TokenStream {
        self.output_struct_update("Clap", "cli_settings_clap")
    }

    /// Output load_file() function
    fn output_load_file(&self) -> proc_macro2::TokenStream {
        let main_ident = &self.s.ident;
        let name = format!("File{}", self.s.ident);
        let ident = syn::Ident::new(&name, self.s.ident.span());
        quote! {
            pub fn load_file(path: &std::path::Path, cfg: &mut super::#main_ident) -> anyhow::Result<()> {
                // access file
                let file = std::fs::File::open(path);
                if let Err(err) = file {
                    if err.kind() == std::io::ErrorKind::NotFound {
                        // file not found is not a problem...
                        return Ok(());
                    }
                    // ... but everything else is -> propagate error
                    return Err(err).context(format!(
                        "Failed to open the configuration file '{}'",
                        path.display()
                    ));
                }
                let file = file.unwrap();

                // get parsed content
                let file_config: #ident = serde_yaml::from_reader(file).with_context(|| {
                    format!(
                        "Failed to parse the configuration file '{}'",
                        path.display()
                    )
                })?;

                // update config with content from the file
                file_config.update(cfg);

                Ok(())
            }
        }
    }

    /// Output parse_cli_args() function
    fn output_parse_cli_args(&self) -> proc_macro2::TokenStream {
        let main_ident = &self.s.ident;
        let name = format!("Clap{}", self.s.ident);
        let ident = syn::Ident::new(&name, self.s.ident.span());
        quote! {
            pub fn parse_cli_args<I, T>(args: I, cfg: &mut super::#main_ident) -> anyhow::Result<()>
            where
                I: IntoIterator<Item = T>,
                T: Into<std::ffi::OsString> + Clone,
            {
                let cli_args = #ident ::parse_from(args);
                cli_args.update(cfg);
                Ok(())
            }
        }
    }

    /// Output parse_cli_args() function
    fn output_clap_test(&self) -> proc_macro2::TokenStream {
        let name = format!("Clap{}", self.s.ident);
        let ident = syn::Ident::new(&name, self.s.ident.span());
        quote! {
            #[cfg(test)]
            mod tests {
                use super::*;

                #[test]
                fn verify_cli() {
                    use clap::CommandFactory;
                    #ident ::command().debug_assert()
                }
            }
        }
    }
}

/// Macro to use on the Command Line Interface settings struct
///
/// # Description
///
/// Use a derive macro with annotations on your Command Line Interface settings struct
/// to manage the settings of your application:
/// - create an instance with default values (provided by annotations)
/// - read each possible configuration file, if it exists:
///   - update the fields that are defined in the configuration file
/// - parse the command line arguments, and update the relevant fields with the provided argument
///
/// By using annotations, each field can be configurable via the configuration file(s) and/or the command line.
///
/// cli-settings-derive can be seen as a top layer above
/// - [serde](https://docs.rs/serde) for the file configuration parsing
/// - [clap](https://docs.rs/clap) for the command line parsing
///
/// ## Usage
///
/// - Define your own configuration structure.
/// - Add `#[cli_settings]` annotation to the struct
/// - Add `#[cli_settings_file = "xxx"]` annotation to provide the annotation(s) for file parsing (serde)
/// - Add `#[cli_settings_clap = "xxx"]` annotation to provide the annotation(s) for argument parsing (clap)
/// - For each field, also provide annotations (all are optional)
///   - `#[cli_settings_default = "xxx"]` to set the default value.
///   - `#[cli_settings_file = "xxx"]` to indicate that the field shall be read from the config
///     file(s). The passed string if any will be extra annotation(s) to the file parsing struct.
///   - `#[cli_settings_clap = "xxx"]` to indicate that the field shall be a command line argument.
///     The passed string (if any) will be extra annotation(s) to the command line parsing struct.
/// - For each field, provide documentation (with ///) to generate the help message via clap.
/// - In your application code, call the Settings::build() method with the list of config files to read
///   and the command line arguments to get your application configuration.
///
/// ### User-defined struct
///
/// A user-defined struct can be used as a field in the configuration struct.
/// It shall:
/// - be annotated with `#[derive(Debug, Clone)]` for command line argument parsing
/// - be annotated with `#[derive(Debug, serde_with::DeserializeFromStr)]` for config file parsing
/// - implement `std::str::FromStr`, method `from_str()` to generate an object instance from the argument string
///
/// ### Enumerations
///
/// #### User-defined enumeration
///
/// A user-defined enum can be used as a field in the configuration struct. Add the following annotations to the enum declaration:
/// - `#[derive(clap::ValueEnum, Clone, Debug)]` for command line argument parsing
/// - `#[derive(serde::Deserialize)]#[serde(rename_all = "lowercase")]` for config file parsing
///
/// #### External enumeration
///
/// An external enum can be used as a field in the configuration struct. As annotations are not possible on this
/// external enum, the solution is to use a custom parsing function:
/// - command line argument parsing
///   - define the parsing function, with signature `fn parse_field(input: &str) -> Result<FieldType, &'static str>`
///   - annotate the field to use the parsing function as value_parser: `#[cli_settings_clap = "#[arg(short, long, value_parser = parse_field)]"]`
/// - config file parsing: use `serde_with` with the following annotation `#[cli_settings_file = "#[serde_as(as = \"Option<serde_with::DisplayFromStr>\")]"]`
///
/// An alternate solution is to wrap the external enumeration in a user-defined struct, as described above.
///
/// ### Clap mandatory arguments
///
/// Clap mandatory arguments shall get the extra annotation `#[cli_settings_mandatory]`.
/// The field type shall implement Default or a default value shall be provided with `#[cli_settings_default = "xxx"]`.
/// This default value will never been used by the application as clap will terminate with error
/// if the argument is not provided, but is needed for the struct instantiation.
///
/// ### Clap subcommands
///
/// Clap subcommands are supported as mandatory arguments, as shown in the example from the repository.
///
/// Note: set `global = true` for fields of the first level parameters that apply to all subcommands,
/// so that parameters can be passed before and after the subcommand.
///
/// ## Basic example
///
/// ```
/// use cli_settings_derive::cli_settings;
///
/// #[cli_settings]
/// #[cli_settings_file = "#[serde_with::serde_as]#[derive(serde::Deserialize)]"]
/// #[cli_settings_clap = "#[derive(clap::Parser)]#[command(version)]"]
/// pub struct Settings {
///     /// alpha setting explanation
///     #[cli_settings_file]
///     #[cli_settings_clap = "#[arg(long)]"]
///     pub alpha: u32,
///
///     /// beta setting explanation, settable only from command line
///     #[cli_settings_default = "\"beta default value\".to_string()"]
///     #[cli_settings_clap = "#[arg(short, long)]"]
///     pub beta: String,
///
///     /// gamma setting explanation, settable only from config file
///     #[cli_settings_default = "1 << 63"]
///     #[cli_settings_file]
///     pub gamma: u64,
/// }
///
/// fn main_func() {
///     // Get the application configuration
///     let cfg = Settings::build(
///         vec![
///             std::path::PathBuf::from("/path/to/system-config.yml"),
///             std::path::PathBuf::from("/path/to/user-config.yml"),
///         ],
///         std::env::args_os(),
///     ).unwrap();
/// }
/// ```
///
/// ## Complete example
///
/// A more complex example is available in the [crate repository](https://github.com/mic006/cli-settings-derive/blob/main/examples/example.rs), with:
/// - clap settings to tune the generated help message (-h)
/// - field with custom type and user provided function to parse the value from string
/// - local enumeration field
/// - external enumeration field
/// - clap subcommands
///
#[proc_macro_attribute]
pub fn cli_settings(
    _attr: proc_macro::TokenStream,
    item: proc_macro::TokenStream,
) -> proc_macro::TokenStream {
    let syn_struct = parse_macro_input!(item as syn::ItemStruct);
    let ss = match SettingStruct::build(&syn_struct) {
        Ok(ss) => ss,
        Err(e) => return e.to_compile_error().into(),
    };

    let main_struct = ss.output_main_struct();
    let main_struct_default = ss.output_main_struct_default();
    let main_struct_build = ss.output_main_struct_build();
    let file_struct = ss.output_file_struct();
    let file_struct_update = ss.output_file_struct_update();
    let load_file = ss.output_load_file();
    let clap_struct = ss.output_clap_struct();
    let clap_struct_update = ss.output_clap_struct_update();
    let parse_cli_args = ss.output_parse_cli_args();
    let clap_test = ss.output_clap_test();

    quote! {
        #main_struct
        #main_struct_default
        #main_struct_build

        mod _cli_settings_derive {
            use anyhow::Context;
            use clap::Parser;
            use super::*;

            #file_struct
            #file_struct_update

            #load_file

            #clap_struct
            #clap_struct_update

            #parse_cli_args

            #clap_test
        }
    }
    .into()
}