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;
#[derive(Debug, Error)]
pub enum NinjaGenError {
#[error("{message}")]
MissingAction {
id: String,
message: LocalizedMessage,
},
#[error("{message}")]
Format {
#[source]
source: fmt::Error,
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)?;
}
};
}
pub fn generate(graph: &BuildGraph) -> Result<String, NinjaGenError> {
let mut out = String::new();
generate_into(graph, &mut out)?;
Ok(out)
}
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(())
}
fn join(paths: &[Utf8PathBuf]) -> String {
paths.iter().map(|p| p.as_str()).join(" ")
}
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)
}
fn escape_script(script: &str) -> String {
script
.replace('\\', "\\\\")
.replace('$', "\\$")
.replace('"', "\\\"")
.replace('`', "\\`")
.replace('\'', "'\"'\"'")
.replace('\n', "\\n")
}
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 {
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) {
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)
}
}
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 {
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);
}
}