float-pigment-forest 0.10.4

A node tree implementation for float-pigment-layout.
Documentation
// HTML -> imperative Rust translator: each tests/cases/<topic>/<case>.html
// is translated into a `#[test]` that exercises the TestCtx high-level API
// (create_node / create_text / set_style / append + layout_imperative +
// getters).
//
// Output:
//   - OUT_DIR/generated/<topic>/<case>.rs + OUT_DIR/generated/all.rs (all.rs
//     does `include!("topic/case.rs")` for each case — paths resolve relative
//     to all.rs). tests/mod.rs pulls all.rs in via
//     `include!(OUT_DIR/generated/all.rs)`. This is what compiles. rustfmt
//     does not parse include! macros, so `cargo fmt --check` is happy without
//     the generated tree being present.
//   - Local (non-CI, non-publish-verify) builds ALSO write the same tree
//     under tests/generated/ for human review. CI (CI=true) and cargo
//     package/publish verify (CARGO_MANIFEST_DIR under target/package) skip
//     the mirror.
//
// If tests/cases is absent (published crate excludes tests/ — see Cargo.toml
// `exclude`), build.rs writes nothing: it also runs when a dependent crate
// builds us (in the registry source dir), which must stay clean.
//
// The translator walks the parsed DOM and emits imperative calls in
// document order. Layout assertions (data-expect-*) are collected during
// the walk and emitted after `ctx.layout_imperative()` so they read back
// computed values.
use std::{
    collections::BTreeMap,
    fs,
    path::{Path, PathBuf},
};

use float_pigment_mlp::{
    context::{Context, Parse},
    node::{attribute::Attribute, NodeType},
};

fn main() {
    let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap();
    let cases_dir = PathBuf::from(&manifest).join("tests/cases");
    // Published crate excludes tests/ → no cases, and build.rs must not
    // write anything (it runs in dependent crates' builds, in the registry
    // source dir).
    if !cases_dir.exists() {
        return;
    }
    let out = std::env::var("OUT_DIR").unwrap();
    let out_gen = PathBuf::from(&out).join("generated");

    // topic -> Vec<(case_name, name_ident, body, ignore)>
    let mut by_topic: BTreeMap<String, Vec<(String, String, String, bool)>> = BTreeMap::new();

    let mut entries = walk(&cases_dir, &cases_dir);
    entries.sort();
    for (rel, abs) in entries {
        if abs.extension().and_then(|e| e.to_str()) != Some("html") {
            continue;
        }
        let rel_str = rel.with_extension("").to_string_lossy().replace('\\', "/");
        let mut parts = rel_str.split('/');
        let topic = parts.next().unwrap_or("misc").to_string();
        let name = parts.collect::<Vec<_>>().join("_");
        let name_ident = name.replace('-', "_");
        let html = fs::read_to_string(&abs).unwrap_or_default();
        let ignore = html.contains("data-ignore=\"true\"");
        let body = translate_html(&html);
        by_topic
            .entry(topic)
            .or_default()
            .push((name, name_ident, body, ignore));
    }

    // OUT_DIR/generated: per-case .rs + all.rs (compiled). all.rs is always
    // emitted here; mod.rs/ tree is also emitted (harmless, aids browsing).
    write_cases_tree(&out_gen, &by_topic, true);

    // Local mirror under tests/generated for human review.
    let is_ci = std::env::var("CI").is_ok();
    let is_publish_verify = manifest.contains("target/package");
    if !is_ci && !is_publish_verify {
        let gen_dir = PathBuf::from(&manifest).join("tests/generated");
        write_cases_tree(&gen_dir, &by_topic, false);
    }

    println!("cargo:rerun-if-changed=tests/cases");
    println!("cargo:rerun-if-changed=build.rs");
}

/// Write per-case .rs under `<gen_dir>/<topic>/<case>.rs` plus a nested
/// `mod.rs` tree (browsing aid) and, when `emit_all_rs`, an `all.rs` that
/// `include!`s every case — that all.rs is what tests/mod.rs compiles.
fn write_cases_tree(
    gen_dir: &Path,
    by_topic: &BTreeMap<String, Vec<(String, String, String, bool)>>,
    emit_all_rs: bool,
) {
    let _ = fs::remove_dir_all(gen_dir);
    fs::create_dir_all(gen_dir).unwrap();
    let mut all_rs = String::from("// AUTO-GENERATED by build.rs. Do not edit.\n\n");
    let mut top_mod = String::from("// AUTO-GENERATED by build.rs. Do not edit.\n\n");
    for (topic, cases) in by_topic {
        let topic_dir = gen_dir.join(topic);
        fs::create_dir_all(&topic_dir).unwrap();
        let topic_ident = topic.replace('-', "_");
        let mut topic_mod = String::from("// AUTO-GENERATED. Do not edit.\n\n");
        for (name, name_ident, body, ignore) in cases {
            let fn_name = format!("html_{}_{}", topic_ident, name_ident);
            let ignore_attr = if *ignore { "#[ignore]\n" } else { "" };
            // `use crate::TestCtx;` inside the fn so all.rs's flat include!
            // does not trip "duplicate use".
            let case_rs = format!(
                "// AUTO-GENERATED from tests/cases/{topic}/{name}.html. Do not edit.\n\n{ignore_attr}#[rustfmt::skip]\n#[test]\nfn {fn_name}() {{\n    use crate::TestCtx;\n{body}}}\n"
            );
            fs::write(topic_dir.join(format!("{name}.rs")), &case_rs).unwrap();
            // all.rs includes each case; path is relative to all.rs (gen_dir),
            // which sits next to <topic>/, so "<topic>/<case>.rs" resolves.
            all_rs.push_str(&format!("include!(\"{topic}/{name}.rs\");\n"));
            topic_mod.push_str(&format!("mod {name_ident};\n"));
        }
        fs::write(topic_dir.join("mod.rs"), topic_mod).unwrap();
        top_mod.push_str(&format!("mod {topic_ident};\n"));
    }
    fs::write(gen_dir.join("mod.rs"), top_mod).unwrap();
    if emit_all_rs {
        fs::write(gen_dir.join("all.rs"), all_rs).unwrap();
    }
}

/// Parse the HTML and emit imperative TestCtx calls.
///
/// Order: create_node/create_text + set_style + append (pre-order DOM walk),
/// then `ctx.layout_imperative()`, then the collected assert_eq! calls.
fn translate_html(html: &str) -> String {
    let mut parse_ctx = Context::create(None);
    parse_ctx.parse(html);
    let mut out = String::from("    let mut ctx = TestCtx::new();\n");
    let mut counter = Counter::default();
    // The parser always wraps content in a Fragment root. `gen_node` mirrors
    // `TestCtx::create_node_recursive` by materialising that Fragment as a
    // Block wrapper Node, so the generated tree structurally matches the
    // legacy `from_str` path (important for layout assertions on top-level
    // elements — they sit inside the wrapper, not as the layout root).
    if let Some(tree) = parse_ctx.tree() {
        if let Some(root) = tree.root() {
            let _ = gen_node(&mut out, root, None, &mut counter);
        }
    }
    out.push_str("    ctx.layout_imperative();\n");
    for a in &counter.asserts {
        out.push_str(a);
    }
    out
}

#[derive(Default)]
struct Counter {
    n: usize,
    t: usize,
    asserts: Vec<String>,
}

/// Walk a DOM node, emit create_node/create_text + set_style + append, and
/// collect data-expect-* assertions. Returns the variable name bound to this
/// node (for the parent's `append` call).
fn gen_node(out: &mut String, node: &NodeType, parent: Option<&str>, c: &mut Counter) -> String {
    match node {
        NodeType::Element(e) => {
            let var = format!("n{}", c.n);
            c.n += 1;
            out.push_str(&format!(
                "    let {var} = ctx.create_node({});\n",
                rust_str_literal(e.tag())
            ));
            let attrs = e.attributes();
            if let Some(style) = attrs.get("style") {
                if !style.is_empty() {
                    out.push_str(&format!(
                        "    ctx.set_style({var}, {});\n",
                        rust_str_literal(&style)
                    ));
                }
            }
            // Measure-text slots: tags recognised by `is_measure_text_slot`
            // (currently just `text-slot`) carry synthetic `len` / `fontSize`
            // attributes that drive an intrinsic-size measure func. Emit a
            // `set_measure_text` call so build_dfs wires up TextInfo exactly
            // like the legacy `create_node_recursive`. Defaults match the
            // legacy path: len=0, fontSize=16.
            if is_measure_text_slot(e.tag()) {
                let len = attrs
                    .get("len")
                    .and_then(|v| v.trim().parse::<usize>().ok())
                    .unwrap_or(0);
                let font_size = attrs
                    .get("fontSize")
                    .and_then(|v| v.trim().parse::<f32>().ok())
                    .unwrap_or(16.0);
                out.push_str(&format!(
                    "    ctx.set_measure_text({var}, {len}, {});\n",
                    parse_value(&font_size.to_string())
                ));
            }
            collect_asserts(&var, attrs, c);
            if let Some(p) = parent {
                out.push_str(&format!("    ctx.append({}, {var});\n", p));
            }
            // Collect children into a Vec first to drop the RefMut borrow
            // before recursing (gen_node may itself borrow the same element's
            // siblings via the parent's RefCell — defensive; recursion target
            // is a different Rc<NodeType> so this is not strictly required,
            // but it keeps the borrow story obviously correct).
            let children: Vec<_> = e.children_mut().iter().cloned().collect();
            for child in children.iter() {
                gen_node(out, child.as_ref(), Some(&var), c);
            }
            var
        }
        NodeType::Text(t) => {
            let var = format!("t{}", c.t);
            c.t += 1;
            out.push_str(&format!(
                "    let {var} = ctx.create_text({});\n",
                rust_str_literal(t.text())
            ));
            if let Some(p) = parent {
                out.push_str(&format!("    ctx.append({}, {var});\n", p));
            }
            var
        }
        NodeType::Fragment(f) => {
            // Fragment always materialises as a Block wrapper Node — this
            // mirrors `TestCtx::create_node_recursive`, where a Fragment
            // becomes `Node::new_ptr()` (default Display::Block) and its
            // children are appended underneath. Skipping the wrapper breaks
            // layout assertions on top-level elements (they would become the
            // layout root instead of sitting inside a Block container).
            let var = format!("n{}", c.n);
            c.n += 1;
            out.push_str(&format!(
                "    let {var} = ctx.create_node({});\n",
                rust_str_literal("div")
            ));
            if let Some(p) = parent {
                out.push_str(&format!("    ctx.append({}, {var});\n", p));
            }
            let children: Vec<_> = f.children_mut().iter().cloned().collect();
            for child in children.iter() {
                let _ = gen_node(out, child.as_ref(), Some(&var), c);
            }
            var
        }
    }
}

/// Collect data-expect-* / expect_* assertions for a node. Assertions are
/// appended after `ctx.layout_imperative()` in `translate_html`.
fn collect_asserts(var: &str, attrs: &Attribute, c: &mut Counter) {
    // (html_attr_alt, html_attr_primary, getter)
    const MAP: &[(&str, &str, &str)] = &[
        ("expect_width", "data-expect-width", "width"),
        ("expect_height", "data-expect-height", "height"),
        ("expect_left", "data-expect-left", "left"),
        ("expect_top", "data-expect-top", "top"),
        ("expect_margin_top", "data-expect-margin-top", "margin_top"),
        (
            "expect_margin_right",
            "data-expect-margin-right",
            "margin_right",
        ),
        (
            "expect_margin_bottom",
            "data-expect-margin-bottom",
            "margin_bottom",
        ),
        (
            "expect_margin_left",
            "data-expect-margin-left",
            "margin_left",
        ),
    ];
    for (alt, primary, getter) in MAP {
        let v = attrs.get(primary).or_else(|| attrs.get(alt));
        if let Some(v) = v {
            // `.round()` mirrors the legacy `PartialLayoutPosition` eq impl,
            // which compares `expect == layout.to_f32().round()`. Without
            // rounding, percentage/flex layout values with f32 precision
            // drift (e.g. 99.90234 vs 100.0) would spuriously fail.
            c.asserts.push(format!(
                "    assert_eq!(ctx.{getter}({var}).round(), {});\n",
                parse_value(&v)
            ));
        }
    }
}

/// Format an HTML scalar value as an f32 literal. Integers get a trailing
/// `.0` so the literal has type f32 and matches the getter return type.
fn parse_value(v: &str) -> String {
    let v = v.trim();
    if v.contains('.') {
        v.to_string()
    } else {
        format!("{}.0", v)
    }
}

/// Tag set that the legacy `create_node_recursive` treats as a measure-text
/// slot. Kept in sync with `is_measure_text_slot` in tests/mod.rs; build.rs
/// cannot import the test helper, so we duplicate the predicate here.
fn is_measure_text_slot(tag: &str) -> bool {
    tag == "text-slot"
}

/// Build a Rust string literal that round-trips the input text. Prefers raw
/// strings (`r#"..."#`, escalating the `#` count) so generated code reads
/// cleanly; falls back to a fully escaped `"..."` literal only when the text
/// contains every raw-string terminator we try.
fn rust_str_literal(s: &str) -> String {
    // Try escalating raw-string hash counts. `"#` rules out r#"..."#, etc.
    for hashes in 1..=5 {
        let pat = format!("\"{}", "#".repeat(hashes));
        if !s.contains(&pat) {
            let h = "#".repeat(hashes);
            return format!("r{h}\"{s}\"{h}");
        }
    }
    // Fallback: escaped string literal.
    let mut out = String::from('"');
    for ch in s.chars() {
        match ch {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            other => out.push(other),
        }
    }
    out.push('"');
    out
}

fn walk(root: &PathBuf, dir: &PathBuf) -> Vec<(PathBuf, PathBuf)> {
    let mut out = Vec::new();
    if let Ok(rd) = fs::read_dir(dir) {
        for e in rd.flatten() {
            let p = e.path();
            if p.is_dir() {
                out.extend(walk(root, &p));
            } else {
                let rel = p.strip_prefix(root).unwrap().to_path_buf();
                out.push((rel, p));
            }
        }
    }
    out
}