inkhaven 1.2.13

Inkhaven — TUI literary work editor for Typst books
//! 1.2.10+ — build-time `(config_path, doc_comments)`
//! extractor.
//!
//! Parses `src/config.rs` with `syn`, walks the
//! type-graph rooted at `Config`, and emits a static
//! lookup table the config-TUI's help pane reads at
//! runtime.
//!
//! Special handling:
//!
//!   * Transparent single-arg wrappers: `Option<T>`,
//!     `Vec<T>`, `Box<T>`, `Arc<T>`, `Rc<T>` — the
//!     field itself emits an entry (using the
//!     wrapper's doc-comment), and the walk
//!     descends into `T`.  Nested wrappers chain:
//!     `Option<Vec<Foo>>` → descends to `Foo`.
//!
//!   * Map wrappers: `HashMap<K, V>`, `BTreeMap<K, V>`
//!     — descend on `V`; the descended path uses
//!     `<entry>` as a placeholder segment; the
//!     runtime lookup substitutes it when querying
//!     paths under known map paths.  Nested maps
//!     (`HashMap<K, HashMap<K2, V>>`) preserve the
//!     `is_map = true` marker so the walker drops
//!     the right `<entry>` placeholder.
//!
//!   * Composed wrappers: `Option<HashMap<K, V>>` /
//!     `Option<Vec<HashMap<K, V>>>` ride the
//!     recursive descent — the inner `is_map = true`
//!     bubbles up through the Option/Vec layers.
//!
//!   * `#[serde(rename = "x")]` — the JSON key
//!     used at runtime is `x`, not the Rust field
//!     name.  Honoured here so emitted paths
//!     match the schema tree.
//!
//! Output: a generated file at
//! `$OUT_DIR/config_help.rs` containing
//! `pub const FIELD_DOCS: &[(&str, &str)] = &[...];`.

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;
        }
    };

    // Index every `pub struct` in the file by its
    // ident.  Anonymous / private structs aren't in
    // the public Config tree, so we ignore them.
    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));
        }
    }

    // Walk from Config root.  Cycle guard via a
    // "currently descending" set so a struct
    // referencing itself (uncommon but possible
    // with `Box<Self>`) doesn't loop forever.
    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 {
    /// Final JSON key for this field, honouring
    /// `#[serde(rename = "x")]`.
    name: String,
    /// Bare type name for the descent decision.  For
    /// `Option<T>` / `Vec<T>` / the LAST type-arg of
    /// `HashMap<K, V>`, this is the unwrapped `T` / `V`.
    rust_type: String,
    /// `true` when the wrapping shape is a
    /// `HashMap<String, V>` — drives the
    /// `<entry>` placeholder in the emitted path.
    is_map: bool,
    /// Concatenated `///` lines, leading single
    /// space stripped per Rust doc-comment
    /// convention.  Empty when the field has no
    /// doc-comment.
    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;
        };
        // Skip private fields — they're not in the
        // serialised JSON tree.
        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();
        // `///` strips a single leading space.
        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
}

/// Pull the structural descent type out of a field
/// `Type`.  Returns `(inner_type_ident, is_map)`.
///
///   * `Foo`                          → ("Foo", false)
///   * `Option<Foo>`                  → ("Foo", false)
///   * `Vec<Foo>`                     → ("Foo", false)
///   * `Box<Foo>`                     → ("Foo", false)
///   * `Arc<Foo>` / `Rc<Foo>`         → ("Foo", false)
///   * `HashMap<String, Foo>`         → ("Foo", true)
///   * `BTreeMap<String, Foo>`        → ("Foo", true)
///   * `Option<HashMap<K, Foo>>`      → ("Foo", true)
///   * `Option<Vec<HashMap<K, Foo>>>` → ("Foo", true)
///   * `HashMap<K, HashMap<K2, Foo>>` → ("Foo", true)
///   * Anything else                  → ("", false)
///
/// The `is_map` flag stays `true` whenever a map
/// appears at *any* level of the descent — that
/// way nested-map wrappers ride the same map-aware
/// `<entry>` path-segment treatment a single-level
/// `HashMap<K, V>` already gets.
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) {
        // Transparent single-arg wrappers — recurse on
        // the inner type and bubble its `(t, m)` up.
        ("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)
        }
        // Map wrappers — force `is_map = true` AND
        // preserve the inner map flag if the value
        // type is itself a map (so deeply-nested
        // maps still descend correctly through the
        // map-aware walker).  Previously this arm
        // discarded the inner `is_map`, so
        // `HashMap<K, HashMap<K2, V>>` lost the
        // marker for the inner level.
        ("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());
        }
        // Descend if the field's (unwrapped) type
        // is another struct we know about.
        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);
}

// Silence unused-import warnings from the file
// being compiled both standalone (when other
// crates `cargo build` against it) and as part of
// build.rs.
#[allow(dead_code)]
const _: fn(&Path) = |_| {};