netsuke-build 0.1.0-beta1

A YAML-powered Ninja/Jinja hybrid build system.
//! Ninja file generator.
//!
//! This module converts a [`crate::ir::BuildGraph`] into the textual
//! representation expected by the Ninja build system. The generator sorts
//! actions and edges to ensure deterministic output for snapshot tests. The
//! generated Ninja file is written by the runner and `generate` command for
//! downstream execution by the Ninja build system.

use crate::ast::Recipe;
use crate::ir::{BuildEdge, BuildGraph};
use crate::localization::{self, LocalizedMessage, keys};
use camino::Utf8PathBuf;
use itertools::Itertools;
use std::collections::HashSet;
use std::fmt::{self, Display, Formatter, Write};
use thiserror::Error;

/// Errors produced while rendering Ninja manifests.
#[derive(Debug, Error)]
pub enum NinjaGenError {
    /// The build graph referenced an action that was not defined.
    #[error("{message}")]
    MissingAction {
        /// Identifier of the missing action referenced by a build edge.
        id: String,
        /// Localized error message.
        message: LocalizedMessage,
    },
    /// Formatting the Ninja output failed.
    #[error("{message}")]
    Format {
        /// Underlying formatting error.
        #[source]
        source: fmt::Error,
        /// Localized error message.
        message: LocalizedMessage,
    },
}

impl From<fmt::Error> for NinjaGenError {
    fn from(source: fmt::Error) -> Self {
        Self::Format {
            message: localization::message(keys::NINJA_GEN_FORMAT),
            source,
        }
    }
}

macro_rules! write_kv {
    ($f:expr, $key:expr, $opt:expr) => {
        if let Some(val) = $opt {
            writeln!($f, "  {} = {}", $key, val)?;
        }
    };
}

macro_rules! write_flag {
    ($f:expr, $key:expr, $cond:expr) => {
        if $cond {
            writeln!($f, "  {} = 1", $key)?;
        }
    };
}

/// Generate a Ninja build file as a string.
///
/// # Examples
/// ```
/// use netsuke::ast::Recipe;
/// use netsuke::ir::{Action, BuildEdge, BuildGraph};
/// use camino::Utf8PathBuf;
/// let mut graph = BuildGraph::default();
/// graph.actions.insert("a".into(), Action {
///     recipe: Recipe::Command { command: "true".into() },
///     description: None, depfile: None, deps_format: None,
///     pool: None, restat: false
/// });
/// graph.targets.insert(Utf8PathBuf::from("out"), BuildEdge {
///     action_id: "a".into(), inputs: Vec::new(),
///     implicit_deps: Vec::new(),
///     explicit_outputs: vec![Utf8PathBuf::from("out")],
///     implicit_outputs: Vec::new(), order_only_deps: Vec::new(),
///     phony: false, always: false
/// });
/// # let result: Result<(), netsuke::ninja_gen::NinjaGenError> = (|| {
/// let text = netsuke::ninja_gen::generate(&graph)?;
/// assert!(text.contains("rule a"));
/// # Ok(())
/// # })();
/// # assert!(result.is_ok());
/// ```
///
/// # Errors
///
/// Returns [`NinjaGenError`] if a build edge references an unknown action or
/// writing to the output fails.
pub fn generate(graph: &BuildGraph) -> Result<String, NinjaGenError> {
    let mut out = String::new();
    generate_into(graph, &mut out)?;
    Ok(out)
}

/// Write a Ninja build file to the provided writer.
///
/// # Examples
/// ```
/// use netsuke::ast::Recipe;
/// use netsuke::ir::{Action, BuildEdge, BuildGraph};
/// use camino::Utf8PathBuf;
/// let mut graph = BuildGraph::default();
/// graph.actions.insert("a".into(), Action {
///     recipe: Recipe::Command { command: "true".into() },
///     description: None, depfile: None, deps_format: None,
///     pool: None, restat: false
/// });
/// graph.targets.insert(Utf8PathBuf::from("out"), BuildEdge {
///     action_id: "a".into(), inputs: Vec::new(),
///     implicit_deps: Vec::new(),
///     explicit_outputs: vec![Utf8PathBuf::from("out")],
///     implicit_outputs: Vec::new(), order_only_deps: Vec::new(),
///     phony: false, always: false
/// });
/// let mut out = String::new();
/// # let result: Result<(), netsuke::ninja_gen::NinjaGenError> = (|| {
/// netsuke::ninja_gen::generate_into(&graph, &mut out)?;
/// assert!(out.contains("build out: a"));
/// # Ok(())
/// # })();
/// # assert!(result.is_ok());
/// ```
///
/// # Errors
///
/// Returns [`NinjaGenError`] if a build edge references an unknown action or writing to the output fails.
pub fn generate_into<W: Write>(graph: &BuildGraph, out: &mut W) -> Result<(), NinjaGenError> {
    let mut actions: Vec<_> = graph.actions.iter().collect();
    actions.sort_by_key(|(id, _)| *id);
    for (id, action) in actions {
        write!(out, "{}", NamedAction { id, action })?;
    }

    let mut edges: Vec<_> = graph.targets.values().collect();
    edges.sort_by_key(|a| path_key(&a.explicit_outputs));
    let mut seen = HashSet::new();
    for edge in edges {
        let key = path_key(&edge.explicit_outputs);
        if !seen.insert(key.clone()) {
            continue;
        }
        let action =
            graph
                .actions
                .get(&edge.action_id)
                .ok_or_else(|| NinjaGenError::MissingAction {
                    id: edge.action_id.clone(),
                    message: localization::message(keys::NINJA_GEN_MISSING_ACTION)
                        .with_arg("id", &edge.action_id),
                })?;
        write!(
            out,
            "{}",
            DisplayEdge {
                edge,
                action_restat: action.restat,
            }
        )?;
    }

    if !graph.default_targets.is_empty() {
        let mut defs = graph.default_targets.clone();
        defs.sort();
        writeln!(out, "default {}", join(&defs))?;
    }

    Ok(())
}

/// Convert a slice of paths into a space-separated string.
fn join(paths: &[Utf8PathBuf]) -> String {
    paths.iter().map(|p| p.as_str()).join(" ")
}

/// Generate a stable key for a list of paths.
fn path_key(paths: &[Utf8PathBuf]) -> String {
    let mut parts: Vec<String> = paths.iter().map(|p| p.as_str().to_owned()).collect();
    parts.sort_unstable();
    let separator = char::from(0).to_string();
    parts.join(&separator)
}

/// Escape a script for embedding within a single-quoted `printf %b` argument.
///
/// Backslashes, dollar signs, double quotes, backticks, and single quotes are
/// escaped so the outer shell preserves them, while newlines become `\n` to
/// keep the rule on one line. Percent signs are passed through unchanged because
/// the script is an argument rather than a format string, allowing the inner
/// shell to perform variable expansion.
fn escape_script(script: &str) -> String {
    script
        .replace('\\', "\\\\")
        .replace('$', "\\$")
        .replace('"', "\\\"")
        .replace('`', "\\`")
        .replace('\'', "'\"'\"'")
        .replace('\n', "\\n")
}

/// Wrapper struct to display a rule with its identifier.
struct NamedAction<'a> {
    id: &'a str,
    action: &'a crate::ir::Action,
}

impl NamedAction<'_> {
    fn write_recipe(&self, f: &mut Formatter<'_>) -> fmt::Result {
        match &self.action.recipe {
            Recipe::Command { command } => {
                Self::assert_shell_command(command);
                writeln!(f, "  command = {command}")
            }
            Recipe::Script { script } => Self::write_script_command(f, script),
            Recipe::Rule { .. } => Self::reject_rule_recipe(),
        }
    }

    fn write_script_command(f: &mut Formatter<'_>, script: &str) -> fmt::Result {
        // Ninja commands must be single-line. Encode newlines and reconstruct the
        // original script with `printf %b` piped into a fresh shell to preserve
        // expected expansions.
        let escaped = escape_script(script);
        let cmd = format!("/bin/sh -e -c \"printf %b '{escaped}' | /bin/sh -e\"");
        Self::assert_shell_command(&cmd);
        writeln!(f, "  command = {cmd}")
    }

    fn write_metadata(&self, f: &mut Formatter<'_>) -> fmt::Result {
        write_kv!(f, "description", &self.action.description);
        write_kv!(f, "depfile", &self.action.depfile);
        write_kv!(f, "deps", &self.action.deps_format);
        write_kv!(f, "pool", &self.action.pool);
        write_flag!(f, "restat", self.action.restat);
        writeln!(f)
    }

    fn assert_shell_command(command: &str) {
        // `shlex::split` approximates POSIX shell parsing; keep this debug-only
        // sanity guard to catch obviously malformed commands during development.
        debug_assert!(
            shlex::split(command).is_some(),
            "invalid command: {command}"
        );
    }

    #[cold]
    #[expect(
        clippy::panic_in_result_fn,
        reason = "debug builds intentionally panic to expose rule recursion"
    )]
    #[expect(
        clippy::manual_assert,
        reason = "debug-only guard escalates to panic for visibility"
    )]
    fn reject_rule_recipe() -> fmt::Result {
        if cfg!(debug_assertions) {
            panic!("rules do not reference other rules");
        }
        Err(fmt::Error)
    }
}

impl Display for NamedAction<'_> {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        writeln!(f, "rule {}", self.id)?;
        self.write_recipe(f)?;
        self.write_metadata(f)
    }
}

/// Wrapper struct to display a build edge.
struct DisplayEdge<'a> {
    edge: &'a BuildEdge,
    action_restat: bool,
}

impl Display for DisplayEdge<'_> {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        write!(f, "build {}", join(&self.edge.explicit_outputs))?;
        if !self.edge.implicit_outputs.is_empty() {
            write!(f, " | {}", join(&self.edge.implicit_outputs))?;
        }
        write!(f, ": {}", self.edge.action_id)?;
        if !self.edge.inputs.is_empty() {
            write!(f, " {}", join(&self.edge.inputs))?;
        }
        if !self.edge.implicit_deps.is_empty() {
            write!(f, " | {}", join(&self.edge.implicit_deps))?;
        }
        if !self.edge.order_only_deps.is_empty() {
            write!(f, " || {}", join(&self.edge.order_only_deps))?;
        }
        writeln!(f)?;
        write_flag!(f, "restat", self.edge.always && !self.action_restat);
        writeln!(f)
    }
}
#[cfg(test)]
#[path = "ninja_gen_property_tests.rs"]
mod property_tests;
#[cfg(test)]
mod tests {
    //! Unit tests for Ninja file generation and rule synthesis.
    use super::*;
    use crate::ir::{Action, BuildEdge, BuildGraph};
    use anyhow::{Result, ensure};
    use rstest::rstest;
    #[rstest]
    fn generate_simple_ninja() -> Result<()> {
        let action = Action {
            recipe: Recipe::Command {
                command: "echo hi".into(),
            },
            description: None,
            depfile: None,
            deps_format: None,
            pool: None,
            restat: false,
        };
        let edge = BuildEdge {
            action_id: "a".into(),
            inputs: vec![Utf8PathBuf::from("in")],
            implicit_deps: Vec::new(),
            explicit_outputs: vec![Utf8PathBuf::from("out")],
            implicit_outputs: Vec::new(),
            order_only_deps: Vec::new(),
            phony: false,
            always: false,
        };
        let mut graph = BuildGraph::default();
        graph.actions.insert("a".into(), action);
        graph.targets.insert(Utf8PathBuf::from("out"), edge);
        graph.default_targets.push(Utf8PathBuf::from("out"));

        let ninja = generate(&graph)?;
        let expected = concat!(
            "rule a\n",
            "  command = echo hi\n\n",
            "build out: a in\n\n",
            "default out\n"
        );
        ensure!(
            ninja == expected,
            "expected Ninja manifest:\n{expected}\nactual:\n{ninja}"
        );
        Ok(())
    }

    #[rstest]
    fn generate_script_ninja_round_trips() -> Result<()> {
        let script = "echo 'a b' && echo \"$HOME\" && printf %s \"`whoami`\"\n# line";
        let action = Action {
            recipe: Recipe::Script {
                script: script.into(),
            },
            description: None,
            depfile: None,
            deps_format: None,
            pool: None,
            restat: false,
        };
        let edge = BuildEdge {
            action_id: "a".into(),
            inputs: Vec::new(),
            implicit_deps: Vec::new(),
            explicit_outputs: vec![Utf8PathBuf::from("out")],
            implicit_outputs: Vec::new(),
            order_only_deps: Vec::new(),
            phony: false,
            always: false,
        };
        let mut graph = BuildGraph::default();
        graph.actions.insert("a".into(), action);
        graph.targets.insert(Utf8PathBuf::from("out"), edge);

        let ninja = generate(&graph)?;
        ensure!(ninja.contains("rule a"));
        ensure!(ninja.contains("command = /bin/sh -e -c"));
        ensure!(ninja.contains("echo '\"'\"'a b'\"'\"'"));
        ensure!(ninja.contains("\\\"\\$HOME\\\""));
        ensure!(ninja.contains("\\`whoami\\`"));
        ensure!(ninja.contains("printf %b"));
        ensure!(ninja.contains("\\n# line' | /bin/sh -e"));
        Ok(())
    }

    #[test]
    fn assert_shell_command_tolerates_complex_syntax() {
        let command = r#"/bin/sh -c "echo 'nested quotes' && echo \"double\" && (echo subshell)""#;
        NamedAction::assert_shell_command(command);
    }
}