settings_macros/
lib.rs

1use std::{env::current_dir, fs::read_to_string, path::PathBuf};
2
3use derive_syn_parse::Parse;
4use proc_macro::TokenStream;
5use proc_macro2::{Span, TokenStream as TokenStream2};
6use quote::{quote, ToTokens};
7use syn::{parse2, Error, Expr, LitStr, Result, Token};
8use toml::{Table, Value};
9use walkdir::WalkDir;
10
11#[proc_macro]
12pub fn settings(tokens: TokenStream) -> TokenStream {
13    match settings_internal(tokens) {
14        Ok(tokens) => tokens.into(),
15        Err(err) => err.to_compile_error().into(),
16    }
17}
18
19#[derive(Parse)]
20struct SettingsProcArgs {
21    crate_name: LitStr,
22    #[prefix(Token![,])]
23    key: LitStr,
24    _comma2: Option<Token![,]>,
25    #[parse_if(_comma2.is_some())]
26    default: Option<Expr>,
27}
28
29#[derive(PartialEq, Copy, Clone)]
30enum ValueType {
31    String,
32    Integer,
33    Float,
34    Boolean,
35    Datetime,
36    Array,
37    Table,
38}
39
40trait GetValueType {
41    fn value_type(&self) -> ValueType;
42}
43
44impl GetValueType for Value {
45    fn value_type(&self) -> ValueType {
46        use ValueType::*;
47        match self {
48            Value::String(_) => String,
49            Value::Integer(_) => Integer,
50            Value::Float(_) => Float,
51            Value::Boolean(_) => Boolean,
52            Value::Datetime(_) => Datetime,
53            Value::Array(_) => Array,
54            Value::Table(_) => Table,
55        }
56    }
57}
58
59fn emit_toml_value(value: Value) -> Result<TokenStream2> {
60    match value {
61        Value::String(string) => Ok(quote!(#string)),
62        Value::Integer(integer) => Ok(quote!(#integer)),
63        Value::Float(float) => Ok(quote!(#float)),
64        Value::Boolean(bool) => Ok(quote!(#bool)),
65        Value::Datetime(date_time) => {
66            let date_time = date_time.to_string();
67            Ok(quote!(#date_time))
68        }
69        Value::Array(arr) => {
70            let mut new_arr: Vec<TokenStream2> = Vec::new();
71            let mut current_type: Option<ValueType> = None;
72            for value in arr.iter() {
73                if let Some(typ) = current_type {
74                    if typ != value.value_type() {
75                        let arr = arr.iter().map(|item| match item.as_str() {
76                            Some(st) => String::from(st),
77                            None => item.to_string(),
78                        });
79                        return Ok(quote!([#(#arr),*]));
80                    }
81                } else {
82                    current_type = Some(value.value_type());
83                }
84                new_arr.push(emit_toml_value(value.clone())?)
85            }
86            Ok(quote!([#(#new_arr),*]))
87        }
88        Value::Table(table) => {
89            let st = format!("{{ {} }}", table.to_string().trim().replace("\n", ", "));
90            Ok(quote!(#st))
91        }
92    }
93}
94
95/// Finds the root of the current workspace, falling back to the outer-most directory with a
96/// Cargo.toml, and then falling back to the current directory.
97fn workspace_root() -> PathBuf {
98    let mut current_dir = current_dir().expect("Failed to read current directory.");
99    let mut best_match = current_dir.clone();
100    loop {
101        let cargo_toml = current_dir.join("Cargo.toml");
102        if let Ok(cargo_toml) = read_to_string(&cargo_toml) {
103            best_match = current_dir.clone();
104            if let Ok(cargo_toml) = cargo_toml.parse::<Table>() {
105                if cargo_toml.contains_key("workspace") {
106                    return best_match;
107                }
108            } else if cargo_toml.contains("[workspace]") || {
109                let mut cargo_toml = cargo_toml.clone();
110                cargo_toml.retain(|c| !c.is_whitespace());
111                cargo_toml.contains("workspace=")
112            } {
113                // only used if `Cargo.toml` is invalid TOML
114                return best_match;
115            }
116        }
117        match current_dir.parent() {
118            Some(dir) => current_dir = dir.to_path_buf(),
119            None => break,
120        }
121    }
122    best_match
123}
124
125fn crate_root<S: AsRef<str>>(crate_name: S, current_dir: &PathBuf) -> PathBuf {
126    let root = workspace_root();
127    for entry in WalkDir::new(root).into_iter().filter_map(|e| e.ok()) {
128        let path = entry.path();
129        let Some(file_name) = path.file_name() else { continue };
130        if file_name != "Cargo.toml" {
131            continue;
132        }
133        let Ok(cargo_toml) = read_to_string(path) else { continue };
134        let Ok(cargo_toml) = cargo_toml.parse::<Table>() else { continue };
135        let Some(package) = cargo_toml.get("package") else { continue };
136        let Some(name) = package.get("name") else { continue };
137        let Value::String(name) = name else { continue };
138        if name == crate_name.as_ref() {
139            return path.parent().unwrap().to_path_buf();
140        }
141    }
142    current_dir.clone()
143}
144
145fn settings_internal_helper(
146    crate_name: String,
147    key: String,
148    current_dir: PathBuf,
149) -> Result<TokenStream2> {
150    println!("checking {}", current_dir.display());
151    println!(
152        "CARGO_MANIFEST_DIR: {}",
153        std::env::var("CARGO_MANIFEST_DIR").unwrap()
154    );
155    println!("OUT_DIR: {:?}", std::env::var("OUT_DIR"));
156    let parent_dir = match current_dir.parent() {
157        Some(parent_dir) => {
158            let parent_toml = parent_dir.join("Cargo.toml");
159            match parent_toml.exists() {
160                true => Some(parent_dir.to_path_buf()),
161                false => None,
162            }
163        }
164        None => None,
165    };
166    let cargo_toml_path = current_dir.join("Cargo.toml");
167    let Ok(cargo_toml) = read_to_string(&cargo_toml_path) else {
168		if let Some(parent_dir) = parent_dir {
169			return settings_internal_helper(crate_name, key, parent_dir);
170		}
171		return Err(Error::new(Span::call_site(), format!(
172			"Failed to read '{}'",
173			cargo_toml_path.display(),
174		)));
175	};
176    let Ok(cargo_toml) = cargo_toml.parse::<Table>() else {
177		if let Some(parent_dir) = parent_dir {
178			return settings_internal_helper(crate_name, key, parent_dir);
179		}
180		return Err(Error::new(Span::call_site(), format!(
181			"Failed to parse '{}' as valid TOML.",
182			cargo_toml_path.display(),
183		)));
184	};
185    let Some(package) = cargo_toml.get("package") else {
186		if let Some(parent_dir) = parent_dir {
187			return settings_internal_helper(crate_name, key, parent_dir);
188		}
189		return Err(Error::new(Span::call_site(), format!(
190			"Failed to find table 'package' in '{}'.",
191			cargo_toml_path.display(),
192		)));
193	};
194    let Some(metadata) = package.get("metadata") else {
195		if let Some(parent_dir) = parent_dir {
196			return settings_internal_helper(crate_name, key, parent_dir);
197		}
198		return Err(Error::new(Span::call_site(), format!(
199			"Failed to find table 'package.metadata' in '{}'.",
200			cargo_toml_path.display(),
201		)));
202	};
203    let Some(settings) = metadata.get("settings") else {
204		if let Some(parent_dir) = parent_dir {
205			return settings_internal_helper(crate_name, key, parent_dir);
206		}
207		return Err(Error::new(Span::call_site(), format!(
208			"Failed to find table 'package.metadata.settings' in '{}'.",
209			cargo_toml_path.display(),
210		)));
211	};
212    let Some(crate_name_table) = settings.get(&crate_name) else {
213		if let Some(parent_dir) = parent_dir {
214			return settings_internal_helper(crate_name, key, parent_dir);
215		}
216		return Err(Error::new(Span::call_site(), format!(
217			"Failed to find table 'package.metadata.settings.{}' in '{}'.",
218			crate_name,
219			cargo_toml_path.display(),
220		)));
221	};
222    let Some(value) = crate_name_table.get(&key) else {
223		if let Some(parent_dir) = parent_dir {
224			return settings_internal_helper(crate_name, key, parent_dir);
225		}
226		return Err(Error::new(Span::call_site(), format!(
227			"Failed to find table 'package.metadata.settings.{}.{}' in '{}'.",
228			crate_name,
229			key,
230			cargo_toml_path.display(),
231		)));
232	};
233    emit_toml_value(value.clone())
234}
235
236fn settings_internal(tokens: impl Into<TokenStream2>) -> Result<TokenStream2> {
237    let args = parse2::<SettingsProcArgs>(tokens.into())?;
238    let Ok(current_dir) = current_dir() else {
239		return Err(Error::new(Span::call_site(), "Failed to read current directory."));
240	};
241    let starting_dir = crate_root(args.crate_name.value(), &current_dir);
242    match settings_internal_helper(args.crate_name.value(), args.key.value(), starting_dir) {
243        Ok(tokens) => Ok(tokens),
244        Err(err) => {
245            if let Some(default) = args.default {
246                return Ok(default.to_token_stream());
247            }
248            Err(err)
249        }
250    }
251}