netsuke-build 0.1.0-beta1

A YAML-powered Ninja/Jinja hybrid build system.
//! Renders manifest templates using `MiniJinja` before IR lowering.
//!
//! Provides [`render_manifest`], which evaluates Jinja2-style template
//! expressions in target and rule fields.  [`render_recipe_str_with`] ensures
//! `ins`/`outs` context keys are always present, inserting
//! `__NETSUKE_INS_PLACEHOLDER__`/`__NETSUKE_OUTS_PLACEHOLDER__` when absent
//! so that [`crate::ir::cmd_interpolate`] can substitute them later.
use super::ManifestValue;
use super::jinja_macros::render_template;
use crate::ast::{NetsukeManifest, Recipe, StringOrList, Target, Vars};
use crate::ir::{INS_TOKEN, OUTS_TOKEN};
use anyhow::{Context, Result};
use minijinja::Environment;

/// Render manifest targets and rules by evaluating template expressions.
///
/// # Errors
///
/// Returns an error when a template evaluation fails or when rendered
/// values cannot be serialized back into the manifest structure.
pub fn render_manifest(
    mut manifest: NetsukeManifest,
    env: &Environment,
) -> Result<NetsukeManifest> {
    for action in &mut manifest.actions {
        render_target(action, env)?;
    }
    for target in &mut manifest.targets {
        render_target(target, env)?;
    }
    let rule_vars = manifest.vars.clone();
    for rule in &mut manifest.rules {
        render_rule(rule, env, &rule_vars)?;
    }
    Ok(manifest)
}

fn render_rule(rule: &mut crate::ast::Rule, env: &Environment, vars: &Vars) -> Result<()> {
    if let Some(desc) = &mut rule.description {
        *desc = render_str_with(env, desc, vars, || "render rule description".into())?;
    }
    match &mut rule.recipe {
        Recipe::Command { command } => {
            *command = render_recipe_str_with(env, command, vars, || "render rule command".into())?;
        }
        Recipe::Script { script } => {
            *script = render_str_with(env, script, vars, || "render rule script".into())?;
        }
        Recipe::Rule { rule: r } => render_string_or_list(r, env, vars)?,
    }
    Ok(())
}

fn render_target(target: &mut Target, env: &Environment) -> Result<()> {
    render_vars(&mut target.vars, env)?;
    render_string_or_list(&mut target.name, env, &target.vars)?;
    render_string_or_list(&mut target.sources, env, &target.vars)?;
    render_string_or_list(&mut target.deps, env, &target.vars)?;
    render_string_or_list(&mut target.order_only_deps, env, &target.vars)?;
    match &mut target.recipe {
        Recipe::Command { command } => {
            *command = render_recipe_str_with(env, command, &target.vars, || {
                "render target command".into()
            })?;
        }
        Recipe::Script { script } => {
            *script = render_str_with(env, script, &target.vars, || "render target script".into())?;
        }
        Recipe::Rule { rule } => render_string_or_list(rule, env, &target.vars)?,
    }
    Ok(())
}

fn render_vars(vars: &mut Vars, env: &Environment) -> Result<()> {
    let snapshot = vars.clone();
    for (key, value) in vars.iter_mut() {
        if let ManifestValue::String(s) = value {
            *s = render_str_with(env, s, &snapshot, || format!("render var '{key}'"))?;
        }
    }
    Ok(())
}

fn render_string_or_list(value: &mut StringOrList, env: &Environment, ctx: &Vars) -> Result<()> {
    match value {
        StringOrList::String(s) => {
            *s = render_str_with(env, s, ctx, || "render string value".into())?;
        }
        StringOrList::List(list) => {
            for item in list {
                *item = render_str_with(env, item, ctx, || "render list value".into())?;
            }
        }
        StringOrList::Empty => {}
    }
    Ok(())
}

fn render_str_with(
    env: &Environment,
    tpl: &str,
    ctx: &impl serde::Serialize,
    what: impl FnOnce() -> String,
) -> Result<String> {
    render_template(env, tpl, ctx).with_context(what)
}

/// Clones the supplied template context (`Vars`) and guarantees `ins` and `outs`
/// entries exist before invoking `MiniJinja` rendering.
///
/// If `ins` or `outs` are absent, they are populated with the placeholders
/// `__NETSUKE_INS_PLACEHOLDER__` and `__NETSUKE_OUTS_PLACEHOLDER__` so
/// downstream logic can rely on those variables being present before later
/// `Ninja` substitution. Rendering is performed by
/// calling `render_str_with`.
fn render_recipe_str_with(
    env: &Environment,
    tpl: &str,
    ctx: &Vars,
    what: impl FnOnce() -> String,
) -> Result<String> {
    let mut recipe_ctx = ctx.clone();
    recipe_ctx
        .entry("ins".into())
        .or_insert_with(|| ManifestValue::String(INS_TOKEN.into()));
    recipe_ctx
        .entry("outs".into())
        .or_insert_with(|| ManifestValue::String(OUTS_TOKEN.into()));
    render_str_with(env, tpl, &recipe_ctx, what)
}

#[cfg(test)]
mod tests {
    //! Unit tests for manifest template rendering.
    use super::*;
    use crate::ast::Rule;
    use minijinja::Environment;
    use semver::Version;

    fn sample_manifest() -> Result<NetsukeManifest> {
        let mut target_vars = Vars::new();
        target_vars.insert("greet".into(), ManifestValue::String("hello".into()));
        target_vars.insert("subject".into(), ManifestValue::String("world".into()));
        target_vars.insert(
            "message".into(),
            ManifestValue::String("{{ greet }} {{ subject }}".into()),
        );

        let target = Target {
            name: StringOrList::String("{{ message }}!".into()),
            recipe: Recipe::Command {
                command: "{{ message }}".into(),
            },
            sources: StringOrList::List(vec!["{{ subject }}.txt".into()]),
            deps: StringOrList::Empty,
            order_only_deps: StringOrList::List(vec!["{{ subject }}.meta".into()]),
            vars: target_vars,
            phony: false,
            always: false,
        };

        let rule = Rule {
            name: "example".into(),
            recipe: Recipe::Command {
                command: "{{ 2 + 2 }}".into(),
            },
            description: Some("{{ 1 + 1 }}".into()),
        };

        let mut manifest_vars = Vars::new();
        manifest_vars.insert(
            "message".into(),
            ManifestValue::String("hello world".into()),
        );

        Ok(NetsukeManifest {
            netsuke_version: Version::parse("1.0.0")?,
            vars: manifest_vars,
            macros: Vec::new(),
            rules: vec![rule],
            actions: Vec::new(),
            targets: vec![target],
            defaults: Vec::new(),
        })
    }

    #[expect(clippy::panic, reason = "panic for clearer test failures")]
    fn expect_var(vars: &Vars, key: impl AsRef<str>) -> &str {
        let key_ref = key.as_ref();
        let Some(value) = vars.get(key_ref).and_then(|value| value.as_str()) else {
            panic!("expected rendered var '{key_ref}'");
        };
        value
    }

    #[expect(clippy::panic, reason = "panic for clearer test failures")]
    fn expect_string(value: &StringOrList, label: impl std::fmt::Display) -> &str {
        match value {
            StringOrList::String(item) => item,
            other => panic!("expected {label} as string, got {other:?}"),
        }
    }

    #[expect(clippy::panic, reason = "panic for clearer test failures")]
    fn expect_list(value: &StringOrList, label: impl std::fmt::Display) -> &[String] {
        match value {
            StringOrList::List(items) => items,
            other => panic!("expected {label} as list, got {other:?}"),
        }
    }

    #[expect(clippy::panic, reason = "panic for clearer test failures")]
    fn expect_command(recipe: &Recipe, label: impl std::fmt::Display) -> &str {
        match recipe {
            Recipe::Command { command } => command,
            other => panic!("expected {label} command recipe, got {other:?}"),
        }
    }

    fn assert_rendered_target(target: &Target) {
        assert_eq!(expect_var(&target.vars, "message"), "hello world");
        assert_eq!(expect_string(&target.name, "target name"), "hello world!");
        assert_eq!(
            expect_list(&target.sources, "target sources"),
            ["world.txt"]
        );
        assert_eq!(expect_command(&target.recipe, "target"), "hello world");
        assert_eq!(
            expect_list(&target.order_only_deps, "order-only deps"),
            ["world.meta"]
        );
    }

    fn assert_rendered_rule(rule: &Rule) {
        assert_eq!(rule.description.as_deref(), Some("2"));
        match &rule.recipe {
            Recipe::Command { command } => assert_eq!(command, "4"),
            other => panic!("expected command recipe, got {other:?}"),
        }
    }

    #[test]
    fn render_manifest_renders_targets_and_rules() -> Result<()> {
        let env = Environment::new();
        let manifest = sample_manifest()?;
        let rendered = render_manifest(manifest, &env)?;
        let rendered_target = rendered
            .targets
            .first()
            .context("rendered target missing")?;
        assert_rendered_target(rendered_target);
        let rendered_rule = rendered.rules.first().context("rendered rule missing")?;
        assert_rendered_rule(rendered_rule);
        Ok(())
    }
}