cli_settings_derive/lib.rs
1//! Manage CLI settings via config files and command line parsing.
2
3use std::str::FromStr;
4
5use quote::{quote, ToTokens};
6use syn::{parse_macro_input, spanned::Spanned};
7
8/// Map of attributes by category
9/// `cli_settings_default`: default field value
10/// `cli_settings_file`: config file related attributes
11/// `cli_settings_clap`: command line related attributes
12/// `cli_settings_mandatory`: indicate a mandatory CLI argument (presence/absence only, no associated value)
13/// 'doc': doc related attributes
14/// '_': other attributes
15type AttrMap = std::collections::HashMap<String, proc_macro2::TokenStream>;
16
17/// Field element, quite similar to `syn::Field`, keeping only the relevant fields
18struct Field<'a> {
19 attrs: AttrMap, // classified attributes of the field
20 vis: &'a syn::Visibility, // field visibility
21 ident: &'a syn::Ident, // field name
22 ty: &'a syn::Type, // field type
23 opt: bool, // whether the type shall be converted to Option<ty>
24}
25
26/// Container for the whole settings struct
27struct SettingStruct<'a> {
28 s: &'a syn::ItemStruct, // associated syn::ItemStruct object
29 attrs: AttrMap, // classified attributes of the struct
30 fields: Vec<Field<'a>>, // list of fields
31}
32
33impl<'a> SettingStruct<'a> {
34 /// Build `SettingStruct` from a `syn::ItemStruct`
35 fn build(s: &'a syn::ItemStruct) -> Result<Self, syn::Error> {
36 let mut ss = Self {
37 s,
38 attrs: AttrMap::default(),
39 fields: vec![],
40 };
41
42 let syn::Fields::Named(fields) = &s.fields else {
43 return Err(syn::Error::new(
44 s.span(),
45 "only named structs are supported",
46 ));
47 };
48
49 // struct attributes
50 ss.attrs = Self::classify_attributes(&s.attrs)?;
51
52 // fields
53 ss.fields.reserve_exact(fields.named.len());
54 for field in &fields.named {
55 let mut f = Field {
56 attrs: Self::classify_attributes(&field.attrs)?,
57 vis: &field.vis,
58 ident: field.ident.as_ref().ok_or_else(|| {
59 syn::Error::new(field.span(), "only named fields are supported")
60 })?,
61 ty: &field.ty,
62 opt: false,
63 };
64 f.opt = !f.attrs.contains_key("cli_settings_mandatory");
65 ss.fields.push(f);
66 }
67
68 Ok(ss)
69 }
70
71 /// Classify a list of attributes, related to file , clap, or other
72 fn classify_attributes(attrs: &'a Vec<syn::Attribute>) -> Result<AttrMap, syn::Error> {
73 let mut res = AttrMap::default();
74 for attr in attrs {
75 #[allow(clippy::match_wildcard_for_single_variants)]
76 let (path, value) = match &attr.meta {
77 syn::Meta::Path(p) => (Some(p), None),
78 syn::Meta::NameValue(v) => (Some(&v.path), Some(&v.value)),
79 _ => (None, None),
80 };
81 let mut handled_attr = false;
82 if let Some(p) = path {
83 if let Some(path_ident) = p.get_ident() {
84 let path_ident_str = path_ident.to_string();
85 if path_ident_str == "doc" {
86 handled_attr = true;
87 res.entry("doc".to_string())
88 .or_default()
89 .extend(attr.to_token_stream());
90 } else if path_ident_str.starts_with("cli_settings_") {
91 handled_attr = true;
92 if value.is_none() {
93 res.entry(path_ident_str).or_default();
94 } else if let Some(syn::Expr::Lit(syn::ExprLit {
95 attrs: _,
96 lit: syn::Lit::Str(l),
97 })) = value
98 {
99 res.entry(path_ident_str)
100 .or_default()
101 .extend(proc_macro2::TokenStream::from_str(&l.value())?);
102 } else {
103 return Err(syn::Error::new(attr.span(), "invalid attribute format"));
104 }
105 }
106 }
107 }
108 if !handled_attr {
109 // other attribute (including doc), keep as is
110 res.entry("_".to_string())
111 .or_default()
112 .extend(attr.to_token_stream());
113 }
114 }
115 Ok(res)
116 }
117
118 /// Output struct with given selection
119 fn output_struct(
120 &self,
121 prefix: &str,
122 field_filter: Option<&str>,
123 attr_keys: &[&str],
124 ) -> proc_macro2::TokenStream {
125 let empty = proc_macro2::TokenStream::new();
126 let attrs = attr_keys
127 .iter()
128 .map(|k| self.attrs.get(*k).unwrap_or(&empty))
129 .collect::<Vec<_>>();
130 let vis = if prefix.is_empty() {
131 self.s.vis.to_token_stream()
132 } else {
133 empty.clone()
134 };
135 let struct_token = &self.s.struct_token;
136 let name = format!("{}{}", prefix, self.s.ident);
137 let ident = syn::Ident::new(&name, self.s.ident.span());
138 // all fields tokens
139 let fields = self
140 .fields
141 .iter()
142 .filter(|f| {
143 if let Some(k) = field_filter {
144 f.attrs.contains_key(k)
145 } else {
146 true
147 }
148 })
149 .map(|f| {
150 // field tokens
151 let field_attrs = attr_keys
152 .iter()
153 .map(|k| f.attrs.get(*k).unwrap_or(&empty))
154 .collect::<Vec<_>>();
155 let field_vis = f.vis;
156 let field_ident = f.ident;
157 let field_ty = f.ty;
158 let (field_ty_start, field_ty_end) = if prefix.is_empty() || !f.opt {
159 // no prefix, field with configured type
160 (empty.clone(), empty.clone())
161 } else {
162 // prefix, field with Option<configured type>
163 (
164 proc_macro2::TokenStream::from_str("Option<").unwrap(),
165 proc_macro2::TokenStream::from_str(">").unwrap(),
166 )
167 };
168 // struct tokens
169 // output one field (without separator)
170 quote! {
171 #(#field_attrs)* #field_vis #field_ident: #field_ty_start #field_ty #field_ty_end
172 }
173 })
174 .collect::<Vec<_>>();
175 // output the whole struct
176 quote! {
177 #(#attrs)* #vis #struct_token #ident
178 {
179 #(#fields),*
180 }
181 }
182 }
183
184 /// Output the main structure
185 fn output_main_struct(&self) -> proc_macro2::TokenStream {
186 self.output_struct("", None, &["_", "doc"])
187 }
188 /// Output the file structure
189 fn output_file_struct(&self) -> proc_macro2::TokenStream {
190 self.output_struct("File", Some("cli_settings_file"), &["cli_settings_file"])
191 }
192 /// Output the clap structure
193 fn output_clap_struct(&self) -> proc_macro2::TokenStream {
194 self.output_struct(
195 "Clap",
196 Some("cli_settings_clap"),
197 &["doc", "cli_settings_clap"],
198 )
199 }
200
201 /// Output Default implementation for the main struct
202 fn output_main_struct_default(&self) -> proc_macro2::TokenStream {
203 let default = proc_macro2::TokenStream::from_str("Default::default()").unwrap();
204 let ident = &self.s.ident;
205 let fields = self
206 .fields
207 .iter()
208 .map(|f| {
209 let field_ident = f.ident;
210 let field_default = f.attrs.get("cli_settings_default").unwrap_or(&default);
211 // output one field (without separator)
212 quote! {
213 #field_ident: #field_default
214 }
215 })
216 .collect::<Vec<_>>();
217 quote! {
218 impl Default for #ident {
219 fn default() -> Self {
220 Self{
221 #(#fields),*
222 }
223 }
224 }
225 }
226 }
227
228 /// Output `build()` implementation for the main struct
229 fn output_main_struct_build(&self) -> proc_macro2::TokenStream {
230 let ident = &self.s.ident;
231 quote! {
232 impl #ident {
233 pub fn build<F, I, T>(cfg_files: F, args: I) -> anyhow::Result<Self>
234 where
235 F: IntoIterator<Item = std::path::PathBuf>,
236 I: IntoIterator<Item = T>,
237 T: Into<std::ffi::OsString> + Clone,
238 {
239 let mut cfg = Self::default();
240 for file in cfg_files {
241 _cli_settings_derive::load_file(&file, &mut cfg)?;
242 }
243 _cli_settings_derive::parse_cli_args(args, &mut cfg)?;
244 Ok(cfg)
245 }
246 }
247 }
248 }
249
250 /// Output `update()` implementation for the file struct
251 fn output_struct_update(&self, prefix: &str, field_filter: &str) -> proc_macro2::TokenStream {
252 let main_ident = &self.s.ident;
253 let name = format!("{}{}", prefix, self.s.ident);
254 let ident = syn::Ident::new(&name, self.s.ident.span());
255 let fields = self
256 .fields
257 .iter()
258 .filter(|f| f.attrs.contains_key(field_filter))
259 .map(|f| {
260 let field_ident = f.ident;
261 // output one field (without separator)
262 if f.opt {
263 quote! {
264 if let Some(param) = self.#field_ident {
265 cfg.#field_ident = param;
266 }
267 }
268 } else {
269 quote! {
270 cfg.#field_ident = self.#field_ident;
271 }
272 }
273 })
274 .collect::<Vec<_>>();
275 quote! {
276 impl #ident {
277 fn update(self, cfg: &mut super::#main_ident) {
278 #(#fields)*
279 }
280 }
281 }
282 }
283 /// Output the file struct `update()`
284 fn output_file_struct_update(&self) -> proc_macro2::TokenStream {
285 self.output_struct_update("File", "cli_settings_file")
286 }
287 /// Output the clap struct `update()`
288 fn output_clap_struct_update(&self) -> proc_macro2::TokenStream {
289 self.output_struct_update("Clap", "cli_settings_clap")
290 }
291
292 /// Output `load_file()` function
293 fn output_load_file(&self) -> proc_macro2::TokenStream {
294 let main_ident = &self.s.ident;
295 let name = format!("File{}", self.s.ident);
296 let ident = syn::Ident::new(&name, self.s.ident.span());
297 quote! {
298 pub fn load_file(path: &std::path::Path, cfg: &mut super::#main_ident) -> anyhow::Result<()> {
299 // access file
300 let file = std::fs::File::open(path);
301 if let Err(err) = file {
302 if err.kind() == std::io::ErrorKind::NotFound {
303 // file not found is not a problem...
304 return Ok(());
305 }
306 // ... but everything else is -> propagate error
307 return Err(err).context(format!(
308 "Failed to open the configuration file '{}'",
309 path.display()
310 ));
311 }
312 let file = file.unwrap();
313
314 // get parsed content
315 let file_config: #ident = serde_yaml::from_reader(file).with_context(|| {
316 format!(
317 "Failed to parse the configuration file '{}'",
318 path.display()
319 )
320 })?;
321
322 // update config with content from the file
323 file_config.update(cfg);
324
325 Ok(())
326 }
327 }
328 }
329
330 /// Output `parse_cli_args()` function
331 fn output_parse_cli_args(&self) -> proc_macro2::TokenStream {
332 let main_ident = &self.s.ident;
333 let name = format!("Clap{}", self.s.ident);
334 let ident = syn::Ident::new(&name, self.s.ident.span());
335 quote! {
336 pub fn parse_cli_args<I, T>(args: I, cfg: &mut super::#main_ident) -> anyhow::Result<()>
337 where
338 I: IntoIterator<Item = T>,
339 T: Into<std::ffi::OsString> + Clone,
340 {
341 let cli_args = #ident ::parse_from(args);
342 cli_args.update(cfg);
343 Ok(())
344 }
345 }
346 }
347
348 /// Output `parse_cli_args()` function
349 fn output_clap_test(&self) -> proc_macro2::TokenStream {
350 let name = format!("Clap{}", self.s.ident);
351 let ident = syn::Ident::new(&name, self.s.ident.span());
352 quote! {
353 #[cfg(test)]
354 mod tests {
355 use super::*;
356
357 #[test]
358 fn verify_cli() {
359 use clap::CommandFactory;
360 #ident ::command().debug_assert()
361 }
362 }
363 }
364 }
365}
366
367/// Macro to use on the Command Line Interface settings struct
368///
369/// # Description
370///
371/// Use a derive macro with annotations on your Command Line Interface settings struct
372/// to manage the settings of your application:
373/// - create an instance with default values (provided by annotations)
374/// - read each possible configuration file, if it exists:
375/// - update the fields that are defined in the configuration file
376/// - parse the command line arguments, and update the relevant fields with the provided argument
377///
378/// By using annotations, each field can be configurable via the configuration file(s) and/or the command line.
379///
380/// cli-settings-derive can be seen as a top layer above
381/// - [serde](https://docs.rs/serde) for the file configuration parsing
382/// - [clap](https://docs.rs/clap) for the command line parsing
383///
384/// ## Usage
385///
386/// - Define your own configuration structure.
387/// - Add `#[cli_settings]` annotation to the struct
388/// - Add `#[cli_settings_file = "xxx"]` annotation to provide the annotation(s) for file parsing (serde)
389/// - Add `#[cli_settings_clap = "xxx"]` annotation to provide the annotation(s) for argument parsing (clap)
390/// - For each field, also provide annotations (all are optional)
391/// - `#[cli_settings_default = "xxx"]` to set the default value.
392/// - `#[cli_settings_file = "xxx"]` to indicate that the field shall be read from the config
393/// file(s). The passed string if any will be extra annotation(s) to the file parsing struct.
394/// - `#[cli_settings_clap = "xxx"]` to indicate that the field shall be a command line argument.
395/// The passed string (if any) will be extra annotation(s) to the command line parsing struct.
396/// - For each field, provide documentation (with ///) to generate the help message via clap.
397/// - In your application code, call the `Settings::build()` method with the list of config files to read
398/// and the command line arguments to get your application configuration.
399///
400/// ### User-defined struct
401///
402/// A user-defined struct can be used as a field in the configuration struct.
403/// It shall:
404/// - be annotated with `#[derive(Debug, Clone)]` for command line argument parsing
405/// - be annotated with `#[derive(Debug, serde_with::DeserializeFromStr)]` for config file parsing
406/// - implement `std::str::FromStr`, method `from_str()` to generate an object instance from the argument string
407///
408/// ### Enumerations
409///
410/// #### User-defined enumeration
411///
412/// A user-defined enum can be used as a field in the configuration struct. Add the following annotations to the enum declaration:
413/// - `#[derive(clap::ValueEnum, Clone, Debug)]` for command line argument parsing
414/// - `#[derive(serde::Deserialize)]#[serde(rename_all = "lowercase")]` for config file parsing
415///
416/// #### External enumeration
417///
418/// An external enum can be used as a field in the configuration struct. As annotations are not possible on this
419/// external enum, the solution is to use a custom parsing function:
420/// - command line argument parsing
421/// - define the parsing function, with signature `fn parse_field(input: &str) -> Result<FieldType, &'static str>`
422/// - annotate the field to use the parsing function as `value_parser`: `#[cli_settings_clap = "#[arg(short, long, value_parser = parse_field)]"]`
423/// - config file parsing: use `serde_with` with the following annotation `#[cli_settings_file = "#[serde_as(as = \"Option<serde_with::DisplayFromStr>\")]"]`
424///
425/// An alternate solution is to wrap the external enumeration in a user-defined struct, as described above.
426///
427/// ### Clap mandatory arguments
428///
429/// Clap mandatory arguments shall get the extra annotation `#[cli_settings_mandatory]`.
430/// The field type shall implement Default or a default value shall be provided with `#[cli_settings_default = "xxx"]`.
431/// This default value will never been used by the application as clap will terminate with error
432/// if the argument is not provided, but is needed for the struct instantiation.
433///
434/// ### Clap subcommands
435///
436/// Clap subcommands are supported as mandatory arguments, as shown in the example from the repository.
437///
438/// Note: set `global = true` for fields of the first level parameters that apply to all subcommands,
439/// so that parameters can be passed before and after the subcommand.
440///
441/// ## Basic example
442///
443/// ```
444/// use cli_settings_derive::cli_settings;
445///
446/// #[cli_settings]
447/// #[cli_settings_file = "#[serde_with::serde_as]#[derive(serde::Deserialize)]"]
448/// #[cli_settings_clap = "#[derive(clap::Parser)]#[command(version)]"]
449/// pub struct Settings {
450/// /// alpha setting explanation
451/// #[cli_settings_file]
452/// #[cli_settings_clap = "#[arg(long)]"]
453/// pub alpha: u32,
454///
455/// /// beta setting explanation, settable only from command line
456/// #[cli_settings_default = "\"beta default value\".to_string()"]
457/// #[cli_settings_clap = "#[arg(short, long)]"]
458/// pub beta: String,
459///
460/// /// gamma setting explanation, settable only from config file
461/// #[cli_settings_default = "1 << 63"]
462/// #[cli_settings_file]
463/// pub gamma: u64,
464/// }
465///
466/// fn main() {
467/// // Get the application configuration
468/// let cfg = Settings::build(
469/// vec![
470/// std::path::PathBuf::from("/path/to/system-config.yml"),
471/// std::path::PathBuf::from("/path/to/user-config.yml"),
472/// ],
473/// std::env::args_os(),
474/// ).unwrap();
475/// }
476/// ```
477///
478/// ## Complete example
479///
480/// A more complex example is available in the [crate repository](https://github.com/mic006/cli-settings-derive/blob/main/examples/example.rs), with:
481/// - clap settings to tune the generated help message (-h)
482/// - field with custom type and user provided function to parse the value from string
483/// - local enumeration field
484/// - external enumeration field
485/// - clap subcommands
486///
487#[proc_macro_attribute]
488pub fn cli_settings(
489 _attr: proc_macro::TokenStream,
490 item: proc_macro::TokenStream,
491) -> proc_macro::TokenStream {
492 let syn_struct = parse_macro_input!(item as syn::ItemStruct);
493 let ss = match SettingStruct::build(&syn_struct) {
494 Ok(ss) => ss,
495 Err(e) => return e.to_compile_error().into(),
496 };
497
498 let main_struct = ss.output_main_struct();
499 let main_struct_default = ss.output_main_struct_default();
500 let main_struct_build = ss.output_main_struct_build();
501 let file_struct = ss.output_file_struct();
502 let file_struct_update = ss.output_file_struct_update();
503 let load_file = ss.output_load_file();
504 let clap_struct = ss.output_clap_struct();
505 let clap_struct_update = ss.output_clap_struct_update();
506 let parse_cli_args = ss.output_parse_cli_args();
507 let clap_test = ss.output_clap_test();
508
509 quote! {
510 #main_struct
511 #main_struct_default
512 #main_struct_build
513
514 mod _cli_settings_derive {
515 use anyhow::Context;
516 use clap::Parser;
517 use super::*;
518
519 #file_struct
520 #file_struct_update
521
522 #load_file
523
524 #clap_struct
525 #clap_struct_update
526
527 #parse_cli_args
528
529 #clap_test
530 }
531 }
532 .into()
533}