use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
pub fn run() {
let src = match fs::read_to_string("src/config.rs") {
Ok(s) => s,
Err(e) => {
println!(
"cargo:warning=config-help extract: cannot read src/config.rs: {e}"
);
emit_empty();
return;
}
};
println!("cargo:rerun-if-changed=src/config.rs");
let file = match syn::parse_file(&src) {
Ok(f) => f,
Err(e) => {
println!(
"cargo:warning=config-help extract: parse failed: {e}"
);
emit_empty();
return;
}
};
let mut structs: BTreeMap<String, Vec<FieldInfo>> = BTreeMap::new();
for item in &file.items {
if let syn::Item::Struct(s) = item {
let name = s.ident.to_string();
structs.insert(name, extract_fields(s));
}
}
let mut out: BTreeMap<String, String> = BTreeMap::new();
let mut visiting: std::collections::HashSet<String> =
std::collections::HashSet::new();
walk(&structs, "Config", "", &mut out, &mut visiting);
write_generated(&out);
}
fn emit_empty() {
let mut body = String::new();
body.push_str("// AUTO-GENERATED placeholder — extract failed.\n");
body.push_str("pub const FIELD_DOCS: &[(&str, &str)] = &[];\n");
let out_path = generated_path();
if let Err(e) = fs::write(&out_path, body) {
println!(
"cargo:warning=config-help extract: write {} failed: {e}",
out_path.display()
);
}
}
fn write_generated(out: &BTreeMap<String, String>) {
let mut body = String::new();
body.push_str("// AUTO-GENERATED by build.rs from src/config.rs — do not edit.\n");
body.push_str(
"// Maps dotted config paths to the matching field's `///` doc-comments,\n",
);
body.push_str("// extracted at compile time via `syn`.\n");
body.push_str("pub const FIELD_DOCS: &[(&str, &str)] = &[\n");
for (path, docs) in out {
body.push_str(&format!(
" ({}, {}),\n",
rust_literal(path),
rust_literal(docs)
));
}
body.push_str("];\n");
let out_path = generated_path();
if let Err(e) = fs::write(&out_path, &body) {
println!(
"cargo:warning=config-help extract: write {} failed: {e}",
out_path.display()
);
}
}
fn rust_literal(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {
out.push_str(&format!("\\u{{{:x}}}", c as u32));
}
c => out.push(c),
}
}
out.push('"');
out
}
fn generated_path() -> PathBuf {
let dir = env::var_os("OUT_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(env::temp_dir()).join("inkhaven-cfg-help"));
fs::create_dir_all(&dir).ok();
dir.join("config_help.rs")
}
struct FieldInfo {
name: String,
rust_type: String,
is_map: bool,
doc: String,
}
fn extract_fields(s: &syn::ItemStruct) -> Vec<FieldInfo> {
let mut out = Vec::new();
let syn::Fields::Named(named) = &s.fields else {
return out;
};
for field in &named.named {
let Some(ident) = &field.ident else {
continue;
};
if !matches!(field.vis, syn::Visibility::Public(_)) {
continue;
}
let name = extract_serde_rename(&field.attrs)
.unwrap_or_else(|| ident.to_string());
let doc = extract_doc(&field.attrs);
let (rust_type, is_map) = type_descent(&field.ty);
out.push(FieldInfo {
name,
rust_type,
is_map,
doc,
});
}
out
}
fn extract_doc(attrs: &[syn::Attribute]) -> String {
let mut lines: Vec<String> = Vec::new();
for attr in attrs {
if !attr.path().is_ident("doc") {
continue;
}
let syn::Meta::NameValue(mnv) = &attr.meta else {
continue;
};
let syn::Expr::Lit(el) = &mnv.value else {
continue;
};
let syn::Lit::Str(s) = &el.lit else {
continue;
};
let raw = s.value();
let trimmed = raw.strip_prefix(' ').unwrap_or(&raw);
lines.push(trimmed.to_string());
}
lines.join("\n")
}
fn extract_serde_rename(attrs: &[syn::Attribute]) -> Option<String> {
for attr in attrs {
if !attr.path().is_ident("serde") {
continue;
}
let mut found: Option<String> = None;
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("rename") {
let v = meta.value()?;
let lit: syn::LitStr = v.parse()?;
found = Some(lit.value());
}
Ok(())
});
if found.is_some() {
return found;
}
}
None
}
fn type_descent(ty: &syn::Type) -> (String, bool) {
let syn::Type::Path(tp) = ty else {
return (String::new(), false);
};
let Some(seg) = tp.path.segments.last() else {
return (String::new(), false);
};
let head = seg.ident.to_string();
match (head.as_str(), &seg.arguments) {
("Option", syn::PathArguments::AngleBracketed(args))
| ("Vec", syn::PathArguments::AngleBracketed(args))
| ("Box", syn::PathArguments::AngleBracketed(args))
| ("Arc", syn::PathArguments::AngleBracketed(args))
| ("Rc", syn::PathArguments::AngleBracketed(args)) => {
if let Some(syn::GenericArgument::Type(inner)) = args.args.last() {
let (t, m) = type_descent(inner);
return (t, m);
}
(String::new(), false)
}
("HashMap", syn::PathArguments::AngleBracketed(args))
| ("BTreeMap", syn::PathArguments::AngleBracketed(args)) => {
if let Some(syn::GenericArgument::Type(inner)) = args.args.last() {
let (t, _m_inner) = type_descent(inner);
return (t, true);
}
(String::new(), true)
}
_ => (head, false),
}
}
fn walk(
structs: &BTreeMap<String, Vec<FieldInfo>>,
struct_name: &str,
prefix: &str,
out: &mut BTreeMap<String, String>,
visiting: &mut std::collections::HashSet<String>,
) {
if !visiting.insert(struct_name.to_string()) {
return;
}
let Some(fields) = structs.get(struct_name) else {
visiting.remove(struct_name);
return;
};
for f in fields {
let path = if prefix.is_empty() {
f.name.clone()
} else {
format!("{prefix}.{}", f.name)
};
if !f.doc.is_empty() {
out.insert(path.clone(), f.doc.clone());
}
if !f.rust_type.is_empty() && structs.contains_key(&f.rust_type) {
let child_prefix = if f.is_map {
format!("{path}.<entry>")
} else {
path
};
walk(structs, &f.rust_type, &child_prefix, out, visiting);
}
}
visiting.remove(struct_name);
}
#[allow(dead_code)]
const _: fn(&Path) = |_| {};