use proptest::prelude::*;
use test_support::ninja_gen::paths_strategy;
use super::{
DisplayEdge, GeneratedDyndep, GeneratedNinja, NinjaGenError, generate, generate_bundle,
test_support::command_action,
};
use crate::{
ast::{Recipe, StringOrList},
ir::{Action, BuildEdge, BuildGraph, DependencyOrder},
};
use camino::Utf8PathBuf;
use std::collections::{HashMap, HashSet};
fn edge_strategy_with_ranges(
input_range: std::ops::Range<usize>,
implicit_range: std::ops::Range<usize>,
order_only_range: std::ops::Range<usize>,
) -> impl Strategy<Value = BuildEdge> {
(
"[a-z][a-z0-9_]{0,8}",
paths_strategy("in", input_range),
paths_strategy("out", 1..5),
paths_strategy("iout", 0..5),
paths_strategy("imp", implicit_range),
paths_strategy("order", order_only_range),
)
.prop_map(
|(
action_id,
inputs,
explicit_outputs,
implicit_outputs,
implicit_deps,
order_only_deps,
)| {
BuildEdge {
action_id,
inputs,
implicit_deps,
dependency_order: crate::ir::DependencyOrder::Parallel,
explicit_outputs,
implicit_outputs,
order_only_deps,
phony: false,
always: false,
}
},
)
}
fn format_edge(edge: &BuildEdge) -> String {
DisplayEdge {
edge,
action_restat: false,
implicit_deps: &edge.implicit_deps,
}
.to_string()
}
fn build_line(formatted: &str) -> Option<&str> {
formatted.lines().next()
}
fn dependency_side(line: &str) -> Option<&str> {
line.split_once(": ").map(|(_, deps)| deps)
}
fn bare_pipe_position(line: &str) -> Option<usize> {
line.match_indices(" | ").map(|(index, _)| index).next()
}
fn serial_graph(dependencies: Vec<Utf8PathBuf>) -> BuildGraph {
let mut graph = BuildGraph::default();
graph.actions.insert(
"a".into(),
Action {
recipe: Recipe::Command {
command: "touch aggregate".into(),
},
description: None,
depfile: None,
deps_format: None,
pool: None,
restat: false,
},
);
graph.targets.insert(
Utf8PathBuf::from("aggregate"),
BuildEdge {
action_id: "a".into(),
inputs: Vec::new(),
implicit_deps: dependencies,
dependency_order: DependencyOrder::Serial,
explicit_outputs: vec![Utf8PathBuf::from("aggregate")],
implicit_outputs: Vec::new(),
order_only_deps: Vec::new(),
phony: false,
always: false,
},
);
graph
}
fn generate_serial_bundle(dependencies: Vec<Utf8PathBuf>) -> Result<GeneratedNinja, TestCaseError> {
generate_bundle(&serial_graph(dependencies))
.map_err(|error| TestCaseError::fail(format!("serial bundle generation failed: {error}")))
}
fn aggregate_gates(build_file: &str) -> Option<Vec<&str>> {
build_file.lines().find_map(|line| {
line.strip_prefix("build aggregate: ")
.and_then(|rest| rest.split_once(" | "))
.map(|(_, gates)| gates.split_whitespace().collect())
})
}
fn sidecar_dependency(content: &str) -> Option<&str> {
content
.lines()
.find_map(|line| line.split_once(" | ").map(|(_, dependency)| dependency))
}
fn repeated_dependencies_strategy() -> impl Strategy<Value = Vec<Utf8PathBuf>> {
("[a-z][a-z0-9_]{0,7}", paths_strategy("dep", 0..4)).prop_map(|(repeated, mut dependencies)| {
dependencies.push(Utf8PathBuf::from(&repeated));
dependencies.push(Utf8PathBuf::from(repeated));
dependencies
})
}
fn command_graph(recipe: StringOrList) -> BuildGraph {
let mut graph = BuildGraph::default();
graph
.actions
.insert("action".into(), command_action(recipe));
graph
}
fn command_list_graph(entries: &[String]) -> BuildGraph {
command_graph(StringOrList::List(
entries
.iter()
.map(|entry| format!("echo {entry}"))
.collect(),
))
}
fn scalar_graph(command: String) -> BuildGraph {
command_graph(StringOrList::String(command))
}
fn command_list_entry_strategy() -> impl Strategy<Value = String> {
prop_oneof![
Just("plain"),
Just("two words"),
Just("apostrophe's"),
Just("dollar$value"),
Just("hash # comment"),
Just("semi;colon"),
Just("double\"quote"),
Just("parentheses()"),
]
.prop_map(str::to_owned)
}
fn canonical_shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', r"'\''"))
}
proptest! {
#![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })]
#[test]
fn implicit_deps_separator_precedes_order_only_separator(edge in edge_strategy_with_ranges(1..5, 1..5, 1..5)) {
let formatted = format_edge(&edge);
let line = build_line(&formatted).expect("build line should be emitted");
let deps = dependency_side(line).expect("build line should contain rule separator");
let implicit_pos = bare_pipe_position(deps).expect("implicit separator should be emitted");
let order_pos = deps.find(" || ").expect("order-only separator should be emitted");
let (_, dependency_groups) = deps
.split_once(' ')
.expect("action identifier should precede dependencies");
let (before_order_only, order_only) = dependency_groups
.split_once(" || ")
.expect("order-only separator should be emitted");
let (inputs, implicit) = before_order_only
.split_once(" | ")
.expect("implicit separator should be emitted");
prop_assert!(implicit_pos < order_pos);
prop_assert_eq!(inputs.split_whitespace().collect::<Vec<_>>(), edge.inputs.iter().map(|path| path.as_str()).collect::<Vec<_>>());
prop_assert_eq!(implicit.split_whitespace().collect::<Vec<_>>(), edge.implicit_deps.iter().map(|path| path.as_str()).collect::<Vec<_>>());
prop_assert_eq!(order_only.split_whitespace().collect::<Vec<_>>(), edge.order_only_deps.iter().map(|path| path.as_str()).collect::<Vec<_>>());
}
#[test]
fn implicit_deps_separator_is_absent_when_empty(edge in edge_strategy_with_ranges(0..5, 0..1, 1..5)) {
let formatted = format_edge(&edge);
let line = build_line(&formatted).expect("build line should be emitted");
let deps = dependency_side(line).expect("build line should contain rule separator");
prop_assert!(bare_pipe_position(deps).is_none());
prop_assert!(deps.contains(" || "));
}
#[test]
fn command_lists_preserve_order_boundaries_and_fail_fast_joins(entries in prop::collection::vec(command_list_entry_strategy(), 1..9)) {
let ninja = generate(&command_list_graph(&entries)).expect("non-empty command list should generate");
let command_line = ninja.lines().find(|line| line.starts_with(" command = "))
.expect("generated action should include a command line");
let mut previous = 0;
for entry in &entries {
let expected_entry = format!("eval {}", canonical_shell_single_quote(&format!("echo {entry}")));
let expected_count = entries.iter().filter(|candidate| *candidate == entry).count();
prop_assert_eq!(
command_line.matches(&expected_entry).count(),
expected_count,
"every entry should retain one independently quoted evaluator"
);
let position = command_line
.get(previous..)
.and_then(|remaining| remaining.find(&expected_entry))
.expect("entries should retain their declaration order");
previous += position + expected_entry.len();
}
prop_assert_eq!(
command_line
.matches("{ _netsuke_background_before=$${!:-};")
.count(),
entries.len()
);
prop_assert_eq!(command_line.matches("} && {").count(), entries.len() - 1);
}
#[test]
fn scalar_command_output_retains_the_preexisting_form(command in "echo [a-z]{1,12}") {
let ninja = generate(&scalar_graph(command.clone())).expect("scalar command should generate");
let expected_command_line = format!(" command = {command}\n");
let retains_scalar_form = ninja.contains(&expected_command_line);
let uses_list_boundary = ninja.contains("_netsuke_background_before=$${!:-}");
prop_assert!(retains_scalar_form);
prop_assert!(!uses_list_boundary);
}
#[test]
fn programmatic_empty_command_recipes_are_rejected(
use_empty_list in any::<bool>(),
) {
let graph = command_graph(if use_empty_list {
StringOrList::List(Vec::new())
} else {
StringOrList::Empty
});
let error = generate(&graph).expect_err("empty command recipe should be rejected");
let is_stable_empty_recipe_error = matches!(error, NinjaGenError::EmptyCommandRecipe { action_index: 1 });
prop_assert!(is_stable_empty_recipe_error);
}
#[test]
fn short_serial_lists_need_no_staging(dependencies in paths_strategy("dep", 0..2)) {
let bundle = generate_serial_bundle(dependencies.clone())?;
prop_assert!(bundle.dyndep_files().is_empty());
prop_assert!(!bundle.build_file().contains("ninja_required_version = 1.10"));
for dependency in dependencies {
prop_assert!(bundle.build_file().contains(dependency.as_str()));
}
}
#[test]
fn staged_serial_lists_preserve_declaration_order(dependencies in paths_strategy("dep", 2..6)) {
let bundle = generate_serial_bundle(dependencies.clone())?;
prop_assert!(bundle.build_file().contains("ninja_required_version = 1.10"));
let gates = aggregate_gates(bundle.build_file())
.ok_or_else(|| TestCaseError::fail("aggregate gate list missing"))?;
prop_assert_eq!(gates.len(), dependencies.len());
prop_assert_eq!(bundle.dyndep_files().len(), dependencies.len());
for (index, ((_gate, dependency), sidecar)) in gates
.iter()
.zip(&dependencies)
.zip(bundle.dyndep_files())
.enumerate()
{
prop_assert_eq!(sidecar_dependency(sidecar.content()), Some(dependency.as_str()));
let sidecar_line = format!("build {}: phony", sidecar.relative_path());
let rendered_sidecar = bundle
.build_file()
.lines()
.find(|line| line.starts_with(&sidecar_line))
.ok_or_else(|| TestCaseError::fail(format!("sidecar stage {index} missing")))?;
if let Some(previous) = index.checked_sub(1) {
let previous_gate = gates
.get(previous)
.ok_or_else(|| TestCaseError::fail("preceding gate missing"))?;
prop_assert!(rendered_sidecar.ends_with(previous_gate));
}
let binding = format!(" dyndep = {}", sidecar.relative_path());
prop_assert!(bundle.build_file().contains(&binding));
}
}
#[test]
fn repeated_serial_dependencies_preserve_occurrences(
dependencies in repeated_dependencies_strategy()
) {
let bundle = generate_serial_bundle(dependencies.clone())?;
let gates = aggregate_gates(bundle.build_file())
.ok_or_else(|| TestCaseError::fail("aggregate gate list missing"))?;
prop_assert_eq!(gates.len(), dependencies.len());
let exposed = bundle
.dyndep_files()
.iter()
.map(|sidecar| sidecar_dependency(sidecar.content()))
.collect::<Vec<_>>();
let expected = dependencies
.iter()
.map(|dependency| Some(dependency.as_str()))
.collect::<Vec<_>>();
prop_assert_eq!(exposed, expected);
let unique_contents = bundle
.dyndep_files()
.iter()
.map(GeneratedDyndep::content)
.collect::<HashSet<_>>();
prop_assert_eq!(bundle.dyndep_files().len(), unique_contents.len());
let mut content_by_path = HashMap::new();
for sidecar in bundle.dyndep_files() {
if let Some(existing) = content_by_path.insert(sidecar.relative_path(), sidecar.content()) {
prop_assert_eq!(existing, sidecar.content());
}
}
}
#[test]
fn serial_bundle_generation_is_deterministic(dependencies in paths_strategy("dep", 0..6)) {
let first = generate_serial_bundle(dependencies.clone())?;
let second = generate_serial_bundle(dependencies)?;
prop_assert_eq!(first.build_file(), second.build_file());
let first_sidecars = first
.dyndep_files()
.iter()
.map(|sidecar| (sidecar.relative_path(), sidecar.content()))
.collect::<Vec<_>>();
let second_sidecars = second
.dyndep_files()
.iter()
.map(|sidecar| (sidecar.relative_path(), sidecar.content()))
.collect::<Vec<_>>();
prop_assert_eq!(first_sidecars, second_sidecars);
}
}