use std::collections::HashMap;
use regex::Regex;
use crate::archive::parse_archive_ref;
use crate::error::GraphError;
use crate::parse::{Rule, Stmt};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NodeIndex(pub usize);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ArcIndex(pub usize);
#[derive(Debug, Clone, Copy, Default)]
pub struct NodeFlags(u8);
impl NodeFlags {
pub const VIRTUAL: u8 = 1 << 0;
pub const MADE: u8 = 1 << 1;
pub const FAILED: u8 = 1 << 2;
pub const CYCLE: u8 = 1 << 3;
pub const VISITED: u8 = 1 << 4;
pub const PRETENDING: u8 = 1 << 5;
pub const NO_EXEC: u8 = 1 << 6;
pub fn is_virtual(&self) -> bool {
self.0 & Self::VIRTUAL != 0
}
pub fn is_made(&self) -> bool {
self.0 & Self::MADE != 0
}
pub fn is_failed(&self) -> bool {
self.0 & Self::FAILED != 0
}
pub fn set(&mut self, flag: u8) {
self.0 |= flag;
}
pub fn clear(&mut self, flag: u8) {
self.0 &= !flag;
}
}
#[derive(Debug, Clone)]
pub struct Node {
pub name: String,
pub mtime: Option<std::time::SystemTime>,
pub flags: NodeFlags,
pub arcs_in: Vec<ArcIndex>,
pub stem: String,
}
#[derive(Debug, Clone)]
pub struct Arc {
pub from: NodeIndex,
pub to: NodeIndex,
pub stem: String,
pub is_meta: bool,
pub prog: Option<String>,
pub line: usize,
}
#[derive(Debug, Clone)]
pub struct Graph {
pub nodes: Vec<Node>,
pub arcs: Vec<Arc>,
pub targets: Vec<NodeIndex>,
}
fn get_mtime(path: &str) -> Option<std::time::SystemTime> {
std::fs::metadata(path).ok()?.modified().ok()
}
fn expand_globs(prereqs: &[String]) -> Vec<String> {
let mut expanded = Vec::new();
for p in prereqs {
if p.contains('*') || p.contains('?') || p.contains('[') {
match glob::glob(p) {
Ok(paths) => {
for entry in paths.flatten() {
expanded.push(entry.to_string_lossy().into_owned());
}
}
Err(_) => {
expanded.push(p.clone());
}
}
} else {
expanded.push(p.clone());
}
}
expanded
}
pub fn match_metarule(target: &str, pattern: &str) -> Option<String> {
if pattern.contains('%') {
return match_percent(target, pattern);
}
if pattern.contains('&') {
return match_ampersand(target, pattern);
}
None
}
fn match_percent(target: &str, pattern: &str) -> Option<String> {
if let Some(pos) = pattern.find('%') {
let prefix = &pattern[..pos];
let suffix = &pattern[pos + 1..];
if target.starts_with(prefix) && target.ends_with(suffix) {
let stem_start = prefix.len();
let stem_end = target.len() - suffix.len();
if stem_start <= stem_end {
let stem = target[stem_start..stem_end].to_string();
return Some(stem);
}
}
}
None
}
fn match_ampersand(target: &str, pattern: &str) -> Option<String> {
if let Some(pos) = pattern.find('&') {
let prefix = &pattern[..pos];
let suffix = &pattern[pos + 1..];
if target.starts_with(prefix) && target.ends_with(suffix) {
let stem_start = prefix.len();
let stem_end = target.len() - suffix.len();
if stem_start <= stem_end {
let stem = &target[stem_start..stem_end];
if !stem.contains('.') && !stem.contains('/') {
return Some(stem.to_string());
}
}
}
}
None
}
pub fn build_graph(stmts: &[Stmt], target_names: &[String]) -> Result<Graph, GraphError> {
build_graph_with_nrep(stmts, target_names, 1)
}
pub fn build_graph_with_nrep(
stmts: &[Stmt],
target_names: &[String],
nrep: usize,
) -> Result<Graph, GraphError> {
let rules: Vec<&Rule> = stmts
.iter()
.filter_map(|stmt| match stmt {
Stmt::Rule(r) if !r.is_metarule && !r.is_regex => Some(r),
_ => None,
})
.collect();
let metarules: Vec<&Rule> = stmts
.iter()
.filter_map(|stmt| match stmt {
Stmt::Rule(r) if r.is_metarule && !r.is_regex => Some(r),
_ => None,
})
.collect();
let regex_rules: Vec<&Rule> = stmts
.iter()
.filter_map(|stmt| match stmt {
Stmt::Rule(r) if r.is_regex => Some(r),
_ => None,
})
.collect();
if rules.is_empty() && target_names.is_empty() {
return Err(GraphError::NoRule {
target: "(none)".into(),
});
}
let mut rules_by_target: HashMap<&str, Vec<&Rule>> = HashMap::new();
for rule in &rules {
for target in &rule.targets {
rules_by_target
.entry(target.as_str())
.or_default()
.push(rule);
}
}
let targets: Vec<String> = if target_names.is_empty() {
vec![rules[0].targets[0].clone()]
} else {
target_names.to_vec()
};
let mut graph = Graph {
nodes: Vec::new(),
arcs: Vec::new(),
targets: Vec::new(),
};
let mut name_to_index: HashMap<String, NodeIndex> = HashMap::new();
#[allow(clippy::too_many_arguments)]
fn build_node<'a>(
graph: &mut Graph,
rules_by_target: &HashMap<&str, Vec<&'a Rule>>,
metarules: &[&'a Rule],
regex_rules: &[&'a Rule],
name_to_index: &mut HashMap<String, NodeIndex>,
nrep: usize,
depth: usize,
name: &str,
) -> NodeIndex {
if let Some(&idx) = name_to_index.get(name) {
return idx;
}
let mtime = get_mtime(name);
let idx = NodeIndex(graph.nodes.len());
graph.nodes.push(Node {
name: name.to_string(),
mtime,
flags: NodeFlags::default(),
arcs_in: Vec::new(),
stem: String::new(),
});
name_to_index.insert(name.to_string(), idx);
if let Some(rules) = rules_by_target.get(name) {
for rule in rules {
if rule.attributes.is_virtual() {
graph.nodes[idx.0].flags.set(NodeFlags::VIRTUAL);
break;
}
}
let rule = rules[0];
let expanded_prereqs = expand_globs(&rule.prereqs);
for prereq in &expanded_prereqs {
let prereq_idx = build_node(
graph,
rules_by_target,
metarules,
regex_rules,
name_to_index,
nrep,
depth,
prereq,
);
let arc_idx = ArcIndex(graph.arcs.len());
graph.nodes[idx.0].arcs_in.push(arc_idx);
graph.arcs.push(Arc {
from: prereq_idx,
to: idx,
stem: String::new(),
is_meta: false,
prog: rule.prog.clone(),
line: rule.line,
});
}
} else if let Some(ar) = parse_archive_ref(name) {
graph.nodes[idx.0].flags.set(NodeFlags::NO_EXEC);
let member_idx = build_node(
graph,
rules_by_target,
metarules,
regex_rules,
name_to_index,
nrep,
depth,
&ar.member,
);
let arc_idx = ArcIndex(graph.arcs.len());
graph.nodes[idx.0].arcs_in.push(arc_idx);
graph.arcs.push(Arc {
from: member_idx,
to: idx,
stem: String::new(),
is_meta: false,
prog: None,
line: 0, });
} else if depth < nrep {
let mut matched = false;
let mut first_match_prereqs: Option<Vec<String>> = None;
for metarule in metarules {
if metarule.attributes.is_no_virtual() && !std::path::Path::new(name).exists() {
continue;
}
if let Some(stem) = match_metarule(name, &metarule.targets[0]) {
graph.nodes[idx.0].stem = stem.clone();
let prereqs: Vec<String> = metarule
.prereqs
.iter()
.map(|p| p.replace(['%', '&'], &stem))
.collect();
if !matched {
if metarule.attributes.is_virtual() {
graph.nodes[idx.0].flags.set(NodeFlags::VIRTUAL);
}
for prereq in &prereqs {
let prereq_idx = build_node(
graph,
rules_by_target,
metarules,
regex_rules,
name_to_index,
nrep,
depth + 1,
prereq,
);
let arc_idx = ArcIndex(graph.arcs.len());
graph.nodes[idx.0].arcs_in.push(arc_idx);
graph.arcs.push(Arc {
from: prereq_idx,
to: idx,
stem: stem.clone(),
is_meta: true,
prog: metarule.prog.clone(),
line: metarule.line,
});
}
matched = true;
first_match_prereqs = Some(prereqs);
} else {
if prereqs != *first_match_prereqs.as_ref().unwrap() {
eprintln!("mk: warning: ambiguous rules for target '{}'", name);
}
}
}
}
if !matched {
for regex_rule in regex_rules {
if regex_rule.attributes.is_no_virtual() && !std::path::Path::new(name).exists()
{
continue;
}
let pattern = ®ex_rule.targets[0];
if let Ok(re) = Regex::new(pattern) {
if let Some(caps) = re.captures(name) {
let full_match = caps
.get(0)
.map(|m| m.as_str().to_string())
.unwrap_or_default();
if regex_rule.attributes.is_virtual() {
graph.nodes[idx.0].flags.set(NodeFlags::VIRTUAL);
}
let prereqs: Vec<String> = regex_rule
.prereqs
.iter()
.map(|p| {
let mut result = p.clone();
for (i, cap) in caps.iter().enumerate() {
if let Some(m) = cap {
let placeholder = format!("\\{}", i);
result = result.replace(&placeholder, m.as_str());
}
}
result
})
.collect();
for prereq in &prereqs {
let prereq_idx = build_node(
graph,
rules_by_target,
metarules,
regex_rules,
name_to_index,
nrep,
depth + 1,
prereq,
);
let arc_idx = ArcIndex(graph.arcs.len());
graph.nodes[idx.0].arcs_in.push(arc_idx);
graph.arcs.push(Arc {
from: prereq_idx,
to: idx,
stem: full_match.clone(),
is_meta: true,
prog: regex_rule.prog.clone(),
line: regex_rule.line,
});
}
break;
}
}
}
}
}
idx
}
for target in &targets {
let idx = build_node(
&mut graph,
&rules_by_target,
&metarules,
®ex_rules,
&mut name_to_index,
nrep,
0,
target,
);
graph.targets.push(idx);
}
prune_vacuous(&mut graph);
for &target_idx in &graph.targets {
let node = &graph.nodes[target_idx.0];
let has_rule = rules_by_target.contains_key(node.name.as_str())
|| metarules
.iter()
.any(|mr| match_metarule(&node.name, &mr.targets[0]).is_some())
|| regex_rules.iter().any(|rr| {
if let Ok(re) = Regex::new(&rr.targets[0]) {
re.is_match(&node.name)
} else {
false
}
})
|| parse_archive_ref(&node.name).is_some();
if !has_rule && node.mtime.is_none() {
return Err(GraphError::NoRule {
target: node.name.clone(),
});
}
}
detect_cycles(&mut graph)?;
Ok(graph)
}
fn prune_vacuous(graph: &mut Graph) {
for node in &mut graph.nodes {
let has_concrete = node.arcs_in.iter().any(|&ai| !graph.arcs[ai.0].is_meta);
if has_concrete {
node.arcs_in.retain(|&ai| !graph.arcs[ai.0].is_meta);
}
}
}
fn detect_cycles(graph: &mut Graph) -> Result<(), GraphError> {
for i in 0..graph.nodes.len() {
if graph.nodes[i].flags.0 & NodeFlags::VISITED == 0 {
let mut path = Vec::new();
dfs_cycle_check(graph, NodeIndex(i), &mut path)?;
}
}
Ok(())
}
fn dfs_cycle_check(
graph: &mut Graph,
current: NodeIndex,
path: &mut Vec<NodeIndex>,
) -> Result<(), GraphError> {
if graph.nodes[current.0].flags.0 & NodeFlags::CYCLE != 0 {
let cycle_start = path
.iter()
.position(|&idx| idx == current)
.unwrap_or(path.len());
let mut chain = String::new();
for (i, &idx) in path[cycle_start..].iter().enumerate() {
if i > 0 {
chain.push_str(" -> ");
}
chain.push_str(&graph.nodes[idx.0].name);
}
chain.push_str(" -> ");
chain.push_str(&graph.nodes[current.0].name);
return Err(GraphError::Cycle { chain });
}
if graph.nodes[current.0].flags.0 & NodeFlags::VISITED != 0 {
return Ok(());
}
graph.nodes[current.0].flags.set(NodeFlags::CYCLE);
path.push(current);
let prereq_indices: Vec<NodeIndex> = graph.nodes[current.0]
.arcs_in
.iter()
.map(|&arc_idx| graph.arcs[arc_idx.0].from)
.collect();
for prereq_idx in prereq_indices {
dfs_cycle_check(graph, prereq_idx, path)?;
}
path.pop();
graph.nodes[current.0].flags.clear(NodeFlags::CYCLE);
graph.nodes[current.0].flags.set(NodeFlags::VISITED);
Ok(())
}
pub fn stale_nodes(graph: &Graph, force_intermediates: bool) -> Vec<NodeIndex> {
let n = graph.nodes.len();
let mut memo: Vec<Option<bool>> = vec![None; n];
let mut result: Vec<NodeIndex> = Vec::new();
let mut in_result: Vec<bool> = vec![false; n];
for &target_idx in &graph.targets {
check_stale(
graph,
target_idx,
&mut memo,
&mut result,
&mut in_result,
force_intermediates,
);
}
result
}
fn effective_mtime(
graph: &Graph,
idx: NodeIndex,
force_intermediates: bool,
) -> Option<std::time::SystemTime> {
let node = &graph.nodes[idx.0];
let is_intermediate = graph.arcs.iter().any(|arc| arc.from == idx);
if !force_intermediates
&& is_intermediate
&& !node.flags.is_virtual()
&& node.mtime.is_none()
&& !node.arcs_in.is_empty()
{
node.arcs_in
.iter()
.filter_map(|&arc_idx| graph.nodes[graph.arcs[arc_idx.0].from.0].mtime)
.max()
} else {
node.mtime
}
}
fn check_stale(
graph: &Graph,
idx: NodeIndex,
memo: &mut [Option<bool>],
result: &mut Vec<NodeIndex>,
in_result: &mut [bool],
force_intermediates: bool,
) -> bool {
if let Some(stale) = memo[idx.0] {
return stale;
}
let node = &graph.nodes[idx.0];
let eff_mtime = effective_mtime(graph, idx, force_intermediates);
let mut prereq_stale = false;
for &arc_idx in &node.arcs_in {
if check_stale(
graph,
graph.arcs[arc_idx.0].from,
memo,
result,
in_result,
force_intermediates,
) {
prereq_stale = true;
}
}
let stale = if node.flags.is_virtual() {
true
} else if node.mtime.is_none() {
true
} else {
let mtime = eff_mtime.unwrap();
prereq_stale
|| node.arcs_in.iter().any(|&arc_idx| {
let arc = &graph.arcs[arc_idx.0];
let prereq_idx = arc.from;
if let Some(ref prog) = arc.prog {
let target = &graph.nodes[idx.0].name;
let prereq = &graph.nodes[prereq_idx.0].name;
let status = std::process::Command::new("sh")
.arg("-c")
.arg(format!("{} '{}' '{}'", prog, target, prereq))
.status();
return !matches!(status, Ok(s) if s.success());
}
let prereq_eff = effective_mtime(graph, prereq_idx, force_intermediates);
match prereq_eff {
Some(pmtime) => pmtime > mtime,
None => true,
}
})
};
memo[idx.0] = Some(stale);
if stale && !in_result[idx.0] {
in_result[idx.0] = true;
result.push(idx);
}
stale
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum GraphScope {
All,
Subgraph,
}
impl Graph {
pub fn to_dot(&self, scope: GraphScope, root: Option<&str>) -> String {
let mut out = String::from("digraph mk {\n");
out.push_str(" rankdir=LR;\n");
out.push_str(" node [fontname=\"monospace\"];\n");
out.push_str(" edge [fontname=\"monospace\"];\n\n");
let included: std::collections::HashSet<NodeIndex> = match scope {
GraphScope::All => (0..self.nodes.len()).map(NodeIndex).collect(),
GraphScope::Subgraph => {
let root_idx = root
.and_then(|n| self.nodes.iter().position(|node| node.name == n))
.map(NodeIndex);
match root_idx {
Some(idx) => self.reachable_from(idx),
None => {
eprintln!("target '{}' not found", root.unwrap_or(""));
return String::new();
}
}
}
};
for idx in &included {
let node = &self.nodes[idx.0];
let shape = if node.flags.is_virtual() {
"ellipse"
} else {
"box"
};
let label = node.name.replace('\\', "\\\\").replace('"', "\\\"");
out.push_str(&format!(
" n{} [label=\"{}\" shape={}];\n",
idx.0, label, shape
));
}
out.push('\n');
for arc in self.arcs.iter() {
if !included.contains(&arc.from) || !included.contains(&arc.to) {
continue;
}
let mut attrs = Vec::new();
if arc.line > 0 {
attrs.push(format!("line {}", arc.line));
}
if arc.is_meta {
attrs.push("meta".into());
}
if let Some(prog) = &arc.prog {
attrs.push(format!("P:{}", prog));
}
if !arc.stem.is_empty() {
attrs.push(format!("stem={}", arc.stem));
}
let label = if attrs.is_empty() {
String::new()
} else {
format!(" [label=\"{}\"]", attrs.join("\\n"))
};
out.push_str(&format!(" n{} -> n{}{};\n", arc.from.0, arc.to.0, label));
}
out.push_str("}\n");
out
}
fn reachable_from(&self, start: NodeIndex) -> std::collections::HashSet<NodeIndex> {
let mut visited = std::collections::HashSet::new();
let mut stack = vec![start];
while let Some(idx) = stack.pop() {
if visited.insert(idx) {
for &arc_idx in &self.nodes[idx.0].arcs_in {
let prereq = self.arcs[arc_idx.0].from;
stack.push(prereq);
}
}
}
visited
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lex::{tokenize, ShellMode};
use crate::parse;
fn graph_from_str(input: &str, targets: &[&str]) -> Result<Graph, GraphError> {
let tokens = tokenize(input, ShellMode::Sh).unwrap();
let stmts = parse::parse(&tokens).unwrap();
let target_names: Vec<String> = targets.iter().map(|s| s.to_string()).collect();
build_graph(&stmts, &target_names)
}
#[test]
fn single_node_no_prereqs() {
let g = graph_from_str("a:\n", &["a"]).unwrap();
assert_eq!(g.nodes.len(), 1);
assert_eq!(g.nodes[0].name, "a");
}
#[test]
fn two_node_chain() {
let g = graph_from_str("a: b\nb:\n", &["a"]).unwrap();
assert_eq!(g.nodes.len(), 2);
let a_idx = g.nodes.iter().position(|n| n.name == "a").unwrap();
let b_idx = g.nodes.iter().position(|n| n.name == "b").unwrap();
assert_eq!(g.nodes[a_idx].arcs_in.len(), 1);
assert_eq!(g.arcs[g.nodes[a_idx].arcs_in[0].0].from, NodeIndex(b_idx));
}
#[test]
fn diamond_dependency() {
let input = "a: b c\nb: d\nc: d\nd:\n";
let g = graph_from_str(input, &["a"]).unwrap();
assert_eq!(g.nodes.len(), 4);
assert_eq!(g.arcs.len(), 4);
}
#[test]
fn self_loop_cycle() {
let result = graph_from_str("a: a\n", &["a"]);
assert!(result.is_err());
}
#[test]
fn indirect_cycle() {
let result = graph_from_str("a: b\nb: c\nc: a\n", &["a"]);
assert!(result.is_err());
}
#[test]
fn no_rule_for_target() {
let result = graph_from_str("a: b\n", &["nonexistent"]);
assert!(result.is_err());
}
#[test]
fn external_file_prereq() {
let g = graph_from_str("a: b\n", &["a"]).unwrap();
assert_eq!(g.nodes.len(), 2);
let b_idx = g.nodes.iter().position(|n| n.name == "b").unwrap();
assert!(g.nodes[b_idx].arcs_in.is_empty());
}
#[test]
fn virtual_target() {
let input = "all:V: prog\nprog:\n";
let g = graph_from_str(input, &["all"]).unwrap();
let all = g.nodes.iter().find(|n| n.name == "all").unwrap();
assert!(all.flags.is_virtual());
assert!(all.mtime.is_none());
}
#[test]
fn has_target_index() {
let g = graph_from_str("a: b\n", &["a"]).unwrap();
assert_eq!(g.targets.len(), 1);
assert_eq!(g.nodes[g.targets[0].0].name, "a");
}
#[test]
fn stale_nonexistent_target() {
let dir = std::env::temp_dir().join("mk_test_graph");
let _ = std::fs::create_dir_all(&dir);
let prereq_path = dir.join("source.txt");
std::fs::write(&prereq_path, "hello").unwrap();
let input = format!("target: {}\n", prereq_path.display());
let g = graph_from_str(&input, &["target"]).unwrap();
let stale = stale_nodes(&g, false);
assert!(!stale.is_empty());
assert!(stale.iter().any(|idx| g.nodes[idx.0].name == "target"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn stale_prereq_newer() {
let dir = std::env::temp_dir().join("mk_test_stale");
let _ = std::fs::create_dir_all(&dir);
let target_path = dir.join("target.txt");
let prereq_path = dir.join("source.txt");
std::fs::write(&target_path, "old").unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
std::fs::write(&prereq_path, "new").unwrap();
let input = format!("{}: {}\n", target_path.display(), prereq_path.display());
let g = graph_from_str(&input, &[&target_path.to_string_lossy()]).unwrap();
let stale = stale_nodes(&g, false);
assert!(!stale.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn stale_two_independent_prereqs_both_stale() {
let dir = std::env::temp_dir().join("mk_test_two_stale");
let _ = std::fs::create_dir_all(&dir);
let target_path = dir.join("target.txt");
let a_path = dir.join("a");
let b_path = dir.join("b");
let c_path = dir.join("c");
let d_path = dir.join("d");
std::fs::write(&a_path, "a-old").unwrap();
std::fs::write(&b_path, "b-old").unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
std::fs::write(&c_path, "c-new").unwrap();
std::fs::write(&d_path, "d-new").unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
std::fs::write(&target_path, "target").unwrap();
let input = format!(
"{}: {} {}\n{}: {}\n{}: {}\n",
target_path.display(),
a_path.display(),
b_path.display(),
a_path.display(),
c_path.display(),
b_path.display(),
d_path.display(),
);
let g = graph_from_str(&input, &[&target_path.to_string_lossy()]).unwrap();
let stale = stale_nodes(&g, false);
let names: Vec<&str> = stale
.iter()
.map(|idx| g.nodes[idx.0].name.as_str())
.collect();
assert!(
names.contains(&a_path.to_str().unwrap()),
"a should be stale (c is newer); stale set: {:?}",
names
);
assert!(
names.contains(&b_path.to_str().unwrap()),
"b should be stale (d is newer); stale set: {:?}",
names
);
assert!(
names.contains(&target_path.to_str().unwrap()),
"target should be stale (a and b are stale); stale set: {:?}",
names
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn up_to_date() {
let dir = std::env::temp_dir().join("mk_test_uptodate");
let _ = std::fs::create_dir_all(&dir);
let target_path = dir.join("target.txt");
let prereq_path = dir.join("source.txt");
std::fs::write(&prereq_path, "old").unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
std::fs::write(&target_path, "newer").unwrap();
let input = format!("{}: {}\n", target_path.display(), prereq_path.display());
let g = graph_from_str(&input, &[&target_path.to_string_lossy()]).unwrap();
let stale = stale_nodes(&g, false);
assert!(stale.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn missing_intermediate_skipped() {
let dir = std::env::temp_dir().join("mk_test_intermed");
let _ = std::fs::create_dir_all(&dir);
let source = dir.join("source.txt");
std::fs::write(&source, "data").unwrap();
let intermediate = dir.join("intermediate.o");
let _ = std::fs::remove_file(&intermediate);
let target = dir.join("target");
std::fs::write(&target, "built").unwrap();
let input = format!(
"{}: {}\n{}: {}\n",
target.display(),
intermediate.display(),
intermediate.display(),
source.display(),
);
let g = graph_from_str(&input, &[&target.to_string_lossy()]).unwrap();
let stale = stale_nodes(&g, false);
assert!(
stale
.iter()
.any(|idx| g.nodes[idx.0].name == intermediate.to_string_lossy()),
"intermediate should be stale (missing file)"
);
let stale_i = stale_nodes(&g, true);
assert!(
stale_i
.iter()
.any(|idx| g.nodes[idx.0].name == intermediate.to_string_lossy()),
"intermediate should be stale with -i (missing file)"
);
let stale_i = stale_nodes(&g, true);
assert!(
stale_i
.iter()
.any(|idx| g.nodes[idx.0].name == intermediate.to_string_lossy()),
"intermediate should be stale when force_intermediates=true"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn metarule_match_percent_o() {
let input = "%.o: %.c\n\tcc -c $stem.c\nprog: hello.o\n";
let tokens = tokenize(input, ShellMode::Sh).unwrap();
let stmts = parse::parse(&tokens).unwrap();
let g = build_graph(&stmts, &["prog".into()]).unwrap();
assert_eq!(g.nodes.len(), 3);
let hello_o = g.nodes.iter().position(|n| n.name == "hello.o").unwrap();
assert_eq!(g.nodes[hello_o].arcs_in.len(), 1);
let arc = &g.arcs[g.nodes[hello_o].arcs_in[0].0];
assert!(arc.is_meta);
assert_eq!(arc.stem, "hello");
let prereq_idx = arc.from;
assert_eq!(g.nodes[prereq_idx.0].name, "hello.c");
}
#[test]
fn bug_a_metarule_without_prereq_keeps_stem_on_node() {
let input = "data/%.toon:\n\techo $stem > $target\n";
let g = graph_from_str(input, &["data/FMMM.toon"]).unwrap();
let node = g.nodes.iter().find(|n| n.name == "data/FMMM.toon").unwrap();
assert_eq!(
node.stem, "FMMM",
"metarule without prereq must still capture stem on the node (Bug A)"
);
assert_eq!(
node.arcs_in.len(),
0,
"metarule without prereq creates no arcs"
);
}
#[test]
fn metarule_match_lib_percent_a() {
let input = "lib%.a: lib%.o\n";
let g = graph_from_str(input, &["libfoo.a"]).unwrap();
assert_eq!(g.nodes.len(), 2);
let libfoo_a = g.nodes.iter().position(|n| n.name == "libfoo.a").unwrap();
let arc = &g.arcs[g.nodes[libfoo_a].arcs_in[0].0];
assert!(arc.is_meta);
assert_eq!(arc.stem, "foo");
let prereq_idx = arc.from;
assert_eq!(g.nodes[prereq_idx.0].name, "libfoo.o");
}
#[test]
fn metarule_concrete_takes_priority() {
let input = "%.o: %.c\n\tcc -c $stem.c\nhello.o: hello.s\n";
let g = graph_from_str(input, &["hello.o"]).unwrap();
let hello_o = g.nodes.iter().position(|n| n.name == "hello.o").unwrap();
assert_eq!(g.nodes[hello_o].arcs_in.len(), 1);
let arc = &g.arcs[g.nodes[hello_o].arcs_in[0].0];
assert!(!arc.is_meta);
assert_eq!(g.nodes[arc.from.0].name, "hello.s");
}
#[test]
fn metarule_no_match() {
let input = "%.o: %.c\n";
let result = graph_from_str(input, &["foo.txt"]);
assert!(result.is_err());
}
#[test]
fn metarule_first_match_wins() {
let input = "%.o: %.c\n%.o: %.s\n";
let g = graph_from_str(input, &["hello.o"]).unwrap();
let hello_o = g.nodes.iter().position(|n| n.name == "hello.o").unwrap();
let arc = &g.arcs[g.nodes[hello_o].arcs_in[0].0];
assert_eq!(g.nodes[arc.from.0].name, "hello.c");
}
#[test]
fn metarule_with_virtual_attr() {
let input = "%.o:V: %.c\n\tcc -c $stem.c\n";
let g = graph_from_str(input, &["hello.o"]).unwrap();
let hello_o = g.nodes.iter().position(|n| n.name == "hello.o").unwrap();
assert!(g.nodes[hello_o].flags.is_virtual());
}
#[test]
fn ampersand_metarule_match() {
let input = "&.o: &.c\n\tcc -c $stem.c\nprog: hello.o\n";
let tokens = tokenize(input, ShellMode::Sh).unwrap();
let stmts = parse::parse(&tokens).unwrap();
let g = build_graph(&stmts, &["prog".into()]).unwrap();
assert!(g.nodes.iter().any(|n| n.name == "hello.o"));
assert!(g.nodes.iter().any(|n| n.name == "hello.c"));
}
#[test]
fn ampersand_rejects_dot_in_stem() {
let input = "&.o: &.c\n";
let result = graph_from_str(input, &["foo.bar.o"]);
assert!(result.is_err());
}
#[test]
fn ampersand_rejects_slash_in_stem() {
let input = "&.o: &.c\n";
let result = graph_from_str(input, &["dir/name.o"]);
assert!(result.is_err());
}
#[test]
fn ampersand_simple_match() {
let input = "&.o: &.c\n";
let g = graph_from_str(input, &["hello.o"]).unwrap();
assert_eq!(g.nodes.len(), 2);
let hello_o = g.nodes.iter().position(|n| n.name == "hello.o").unwrap();
let arc = &g.arcs[g.nodes[hello_o].arcs_in[0].0];
assert!(arc.is_meta);
assert_eq!(arc.stem, "hello");
assert_eq!(g.nodes[arc.from.0].name, "hello.c");
}
#[test]
fn bug_b_amp_compound_pattern_matches() {
assert_eq!(
match_metarule("out2/foo-metrics.toon", "out2/&-metrics.toon"),
Some("foo".to_string()),
"compound & pattern must match and extract stem (Bug B)"
);
let input = "out2/&-metrics.toon: src2/&.toon\n\tcp $prereq $target\n";
let g = graph_from_str(input, &["out2/foo-metrics.toon"]).unwrap();
let node = g
.nodes
.iter()
.find(|n| n.name == "out2/foo-metrics.toon")
.expect("target node must exist");
assert_eq!(node.arcs_in.len(), 1);
let arc = &g.arcs[node.arcs_in[0].0];
assert!(arc.is_meta, "arc should come from the & metarule");
assert_eq!(arc.stem, "foo");
assert_eq!(g.nodes[arc.from.0].name, "src2/foo.toon");
assert_eq!(node.stem, "foo");
}
#[test]
fn regex_metarule_simple() {
let input = "foo:R: bar\n";
let g = graph_from_str(input, &["foo"]).unwrap();
assert_eq!(g.nodes.len(), 2);
let foo_node = g.nodes.iter().position(|n| n.name == "foo").unwrap();
assert_eq!(g.nodes[foo_node].arcs_in.len(), 1);
}
#[test]
fn regex_metarule_with_capture() {
let input = "(.+)\\.o:R: \\1.c\n";
let g = graph_from_str(input, &["hello.o"]).unwrap();
assert_eq!(g.nodes.len(), 2);
let hello_o = g.nodes.iter().position(|n| n.name == "hello.o").unwrap();
let arc = &g.arcs[g.nodes[hello_o].arcs_in[0].0];
assert!(arc.is_meta);
assert_eq!(g.nodes[arc.from.0].name, "hello.c");
}
#[test]
fn regex_metarule_no_match() {
let input = "foo\\.txt:R: foo.src\n";
let result = graph_from_str(input, &["bar.txt"]);
assert!(result.is_err());
}
#[test]
fn regex_metarule_virtual_attr() {
let input = "target:VR: dep\n";
let g = graph_from_str(input, &["target"]).unwrap();
let target = g.nodes.iter().find(|n| n.name == "target").unwrap();
assert!(target.flags.is_virtual());
}
fn graph_with_nrep_from_str(
input: &str,
targets: &[&str],
nrep: usize,
) -> Result<Graph, GraphError> {
let tokens = tokenize(input, ShellMode::Sh).unwrap();
let stmts = parse::parse(&tokens).unwrap();
let target_names: Vec<String> = targets.iter().map(|s| s.to_string()).collect();
build_graph_with_nrep(&stmts, &target_names, nrep)
}
#[test]
fn n_attribute_target_exists() {
let dir = std::env::temp_dir().join("mk_test_n_attr");
let _ = std::fs::create_dir_all(&dir);
let c_file = dir.join("hello.c");
std::fs::write(&c_file, "int main(){}").unwrap();
let o_path = dir.join("hello.o");
let input = format!("%.o:n: %.c\nprog: {}\n", o_path.display());
let g = graph_from_str(&input, &["prog"]).unwrap();
let o_node = g
.nodes
.iter()
.find(|n| n.name == o_path.to_string_lossy())
.unwrap();
assert!(o_node.arcs_in.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn n_attribute_skips_nonexistent() {
let input = "%.o:n: %.c\nprog: ghost.o\n";
let g = graph_from_str(input, &["prog"]).unwrap();
let ghost = g.nodes.iter().find(|n| n.name == "ghost.o").unwrap();
assert!(
ghost.arcs_in.is_empty(),
"n: metarule should be skipped for non-existent ghost.o"
);
}
#[test]
fn n_attribute_allows_existing() {
let dir = std::env::temp_dir().join("mk_test_n_exists");
let _ = std::fs::create_dir_all(&dir);
let c_file = dir.join("real.c");
std::fs::write(&c_file, "int main(){}").unwrap();
let o_file = dir.join("real.o");
std::fs::write(&o_file, "object").unwrap();
let input = "%.o:n: %.c\n".to_string();
let g = graph_from_str(&input, &[&o_file.to_string_lossy()]).unwrap();
assert!(g.nodes.len() >= 2);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn n_attribute_regex_skips_nonexistent() {
let input = "(.+)\\.o:Rn: \\1.c\nprog: ghost.o\n";
let g = graph_from_str(input, &["prog"]).unwrap();
let ghost = g.nodes.iter().find(|n| n.name == "ghost.o").unwrap();
assert!(
ghost.arcs_in.is_empty(),
"n: regex metarule should be skipped for non-existent ghost.o"
);
}
#[test]
fn n_attribute_regex_allows_existing() {
let dir = std::env::temp_dir().join("mk_test_n_regex");
let _ = std::fs::create_dir_all(&dir);
let c_file = dir.join("real.c");
std::fs::write(&c_file, "int main(){}").unwrap();
let o_file = dir.join("real.o");
std::fs::write(&o_file, "object").unwrap();
let input = "(.+)\\.o:Rn: \\1.c\n".to_string();
let g = graph_from_str(&input, &[&o_file.to_string_lossy()]).unwrap();
assert!(g.nodes.len() >= 2);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn pruning_removes_meta_edges_when_concrete_exists() {
let mut graph = Graph {
nodes: vec![
Node {
name: "foo.o".into(),
mtime: None,
flags: NodeFlags::default(),
arcs_in: Vec::new(),
stem: String::new(),
},
Node {
name: "foo.c".into(),
mtime: None,
flags: NodeFlags::default(),
arcs_in: Vec::new(),
stem: String::new(),
},
Node {
name: "foo.s".into(),
mtime: None,
flags: NodeFlags::default(),
arcs_in: Vec::new(),
stem: String::new(),
},
],
arcs: vec![
Arc {
from: NodeIndex(1),
to: NodeIndex(0),
stem: "foo".into(),
is_meta: true,
prog: None,
line: 1,
},
Arc {
from: NodeIndex(2),
to: NodeIndex(0),
stem: String::new(),
is_meta: false,
prog: None,
line: 1,
},
],
targets: vec![NodeIndex(0)],
};
graph.nodes[0].arcs_in = vec![ArcIndex(0), ArcIndex(1)];
prune_vacuous(&mut graph);
assert_eq!(graph.nodes[0].arcs_in.len(), 1);
let remaining_arc = &graph.arcs[graph.nodes[0].arcs_in[0].0];
assert!(!remaining_arc.is_meta);
assert_eq!(graph.nodes[remaining_arc.from.0].name, "foo.s");
}
#[test]
fn ambiguous_metarules_different_prereqs_uses_first() {
let input = "%.o: %.c\n%.o: %.s\n";
let g = graph_from_str(input, &["hello.o"]).unwrap();
let hello_o = g.nodes.iter().position(|n| n.name == "hello.o").unwrap();
assert_eq!(g.nodes[hello_o].arcs_in.len(), 1);
let arc = &g.arcs[g.nodes[hello_o].arcs_in[0].0];
assert_eq!(g.nodes[arc.from.0].name, "hello.c");
}
#[test]
fn same_prereqs_no_ambiguity() {
let input = "%.o: %.c\n%.o: %.c\n";
let g = graph_from_str(input, &["hello.o"]).unwrap();
let hello_o = g.nodes.iter().position(|n| n.name == "hello.o").unwrap();
assert_eq!(g.nodes[hello_o].arcs_in.len(), 1);
let arc = &g.arcs[g.nodes[hello_o].arcs_in[0].0];
assert_eq!(g.nodes[arc.from.0].name, "hello.c");
}
#[test]
fn nrep_limits_recursion_depth_1() {
let input = "%: %.z\n\tcp $prereq $target\ntarget: source\n";
let g = graph_with_nrep_from_str(input, &["target"], 1).unwrap();
assert_eq!(g.nodes.len(), 3);
let source_node = g.nodes.iter().find(|n| n.name == "source").unwrap();
assert!(!source_node.arcs_in.is_empty());
let prereq_idx = g.arcs[source_node.arcs_in[0].0].from;
assert_eq!(g.nodes[prereq_idx.0].name, "source.z");
let z_node = &g.nodes[prereq_idx.0];
assert!(z_node.arcs_in.is_empty());
}
#[test]
fn nrep_limits_recursion_depth_2() {
let input = "%: %.z\ntarget: source\n";
let g = graph_with_nrep_from_str(input, &["target"], 2).unwrap();
assert_eq!(g.nodes.len(), 4);
let z_node = g.nodes.iter().find(|n| n.name == "source.z").unwrap();
assert!(!z_node.arcs_in.is_empty());
let z_prereq_idx = g.arcs[z_node.arcs_in[0].0].from;
assert_eq!(g.nodes[z_prereq_idx.0].name, "source.z.z");
let zz_node = &g.nodes[z_prereq_idx.0];
assert!(zz_node.arcs_in.is_empty());
}
#[test]
fn nrep_default_is_1() {
let input = "%: %.z\ntarget: source\n";
let g = graph_from_str(input, &["target"]).unwrap();
assert_eq!(g.nodes.len(), 3);
}
#[test]
fn archive_member_creates_dep_on_member() {
let input = "out: lib.a(foo.o)\n";
let g = graph_from_str(input, &["out"]).unwrap();
assert_eq!(g.nodes.len(), 3);
let archive_node = g.nodes.iter().find(|n| n.name == "lib.a(foo.o)").unwrap();
assert_eq!(archive_node.arcs_in.len(), 1);
let arc = &g.arcs[archive_node.arcs_in[0].0];
assert!(!arc.is_meta);
assert_eq!(g.nodes[arc.from.0].name, "foo.o");
}
#[test]
fn archive_member_has_n_flag() {
let input = "out: lib.a(foo.o)\n";
let g = graph_from_str(input, &["out"]).unwrap();
let archive_node = g.nodes.iter().find(|n| n.name == "lib.a(foo.o)").unwrap();
assert!(archive_node.flags.0 & NodeFlags::NO_EXEC != 0);
}
#[test]
fn archive_member_standalone() {
let g = graph_from_str("", &["lib.a(foo.o)"]).unwrap();
assert_eq!(g.nodes.len(), 2);
let archive_idx = g
.nodes
.iter()
.position(|n| n.name == "lib.a(foo.o)")
.unwrap();
assert_eq!(g.nodes[archive_idx].arcs_in.len(), 1);
let member_idx = g.arcs[g.nodes[archive_idx].arcs_in[0].0].from;
assert_eq!(g.nodes[member_idx.0].name, "foo.o");
}
#[test]
fn concrete_rule_overrides_archive_auto() {
let input = "lib.a(foo.o): explicit.c\n";
let g = graph_from_str(input, &["lib.a(foo.o)"]).unwrap();
assert_eq!(g.nodes.len(), 2);
let archive_node = g.nodes.iter().find(|n| n.name == "lib.a(foo.o)").unwrap();
let arc = &g.arcs[archive_node.arcs_in[0].0];
assert_eq!(g.nodes[arc.from.0].name, "explicit.c");
}
#[test]
fn archive_member_in_prereq_list() {
let input = "out: lib.a(foo.o) lib.a(bar.o)\n";
let g = graph_from_str(input, &["out"]).unwrap();
assert_eq!(g.nodes.len(), 5);
let foo_arch = g.nodes.iter().find(|n| n.name == "lib.a(foo.o)").unwrap();
let bar_arch = g.nodes.iter().find(|n| n.name == "lib.a(bar.o)").unwrap();
assert!(foo_arch.flags.0 & NodeFlags::NO_EXEC != 0);
assert!(bar_arch.flags.0 & NodeFlags::NO_EXEC != 0);
}
#[test]
fn p_attribute_prog_stored_in_arc() {
let input = "target:Pcmp: prereq\n";
let g = graph_from_str(input, &["target"]).unwrap();
let target = g.nodes.iter().find(|n| n.name == "target").unwrap();
assert_eq!(target.arcs_in.len(), 1);
let arc = &g.arcs[target.arcs_in[0].0];
assert_eq!(arc.prog, Some("cmp".into()));
}
#[test]
fn p_attribute_no_prog_stored_in_arc() {
let input = "target:P: prereq\n";
let g = graph_from_str(input, &["target"]).unwrap();
let target = g.nodes.iter().find(|n| n.name == "target").unwrap();
assert_eq!(target.arcs_in.len(), 1);
let arc = &g.arcs[target.arcs_in[0].0];
assert_eq!(arc.prog, None);
}
#[test]
fn p_attribute_no_attr_no_prog_in_arc() {
let input = "target: prereq\n";
let g = graph_from_str(input, &["target"]).unwrap();
let target = g.nodes.iter().find(|n| n.name == "target").unwrap();
assert_eq!(target.arcs_in.len(), 1);
let arc = &g.arcs[target.arcs_in[0].0];
assert_eq!(arc.prog, None);
}
#[test]
fn p_attribute_metarule_prog_stored_in_arc() {
let input = "%.o:Pcmp: %.c\nprog: hello.o\n";
let g = graph_from_str(input, &["prog"]).unwrap();
let hello_o = g.nodes.iter().find(|n| n.name == "hello.o").unwrap();
assert_eq!(hello_o.arcs_in.len(), 1);
let arc = &g.arcs[hello_o.arcs_in[0].0];
assert!(arc.is_meta);
assert_eq!(arc.prog, Some("cmp".into()));
}
#[test]
fn p_attribute_up_to_date_via_program() {
let dir = std::env::temp_dir().join("mk_test_p_uptodate");
let _ = std::fs::create_dir_all(&dir);
let target_path = dir.join("target.txt");
let prereq_path = dir.join("source.txt");
std::fs::write(&target_path, "old").unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
std::fs::write(&prereq_path, "new").unwrap();
let input = format!(
"{}:Ptrue: {}\n",
target_path.display(),
prereq_path.display(),
);
let g = graph_from_str(&input, &[&target_path.to_string_lossy()]).unwrap();
let stale = stale_nodes(&g, false);
assert!(
stale.is_empty(),
"target should be up-to-date via P program"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn p_attribute_stale_via_program() {
let dir = std::env::temp_dir().join("mk_test_p_stale");
let _ = std::fs::create_dir_all(&dir);
let target_path = dir.join("target.txt");
let prereq_path = dir.join("source.txt");
std::fs::write(&prereq_path, "old").unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
std::fs::write(&target_path, "newer").unwrap();
let input = format!(
"{}:Pfalse: {}\n",
target_path.display(),
prereq_path.display(),
);
let g = graph_from_str(&input, &[&target_path.to_string_lossy()]).unwrap();
let stale = stale_nodes(&g, false);
assert!(
stale
.iter()
.any(|idx| g.nodes[idx.0].name == target_path.to_string_lossy()),
"target should be stale via P program returning non-zero"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn archive_non_matching_parens_not_treated_as_archive() {
let result = graph_from_str("", &["just.a.file.o"]);
assert!(result.is_err());
}
#[test]
fn virtual_no_prereqs_always_stale() {
let g = graph_from_str("clean:V:\n\trm -f *.o\n", &["clean"]).unwrap();
let stale = stale_nodes(&g, false);
let clean_idx = g.nodes.iter().position(|n| n.name == "clean").unwrap();
assert!(
stale.contains(&NodeIndex(clean_idx)),
"virtual target with no prereqs must always be stale"
);
}
#[test]
fn virtual_with_prereqs_always_stale() {
let dir = std::env::temp_dir().join("mk_test_virtual_with_prereqs");
let _ = std::fs::create_dir_all(&dir);
let prereq = dir.join("in.txt");
std::fs::write(&prereq, "input").unwrap();
let prev = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir).unwrap();
let g = graph_from_str("vwith:V: in.txt\n\techo hi\n", &["vwith"]).unwrap();
let stale = stale_nodes(&g, false);
std::env::set_current_dir(prev).unwrap();
let vidx = g.nodes.iter().position(|n| n.name == "vwith").unwrap();
assert!(
stale.contains(&NodeIndex(vidx)),
"virtual target with up-to-date prereqs must STILL be stale (Bug 4)"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn missing_intermediate_cascades_to_dependents() {
let dir = std::env::temp_dir().join("mk_test_cascade");
let _ = std::fs::create_dir_all(&dir);
let source = dir.join("source.txt");
let intermediate = dir.join("intermediate.txt");
let report = dir.join("report.txt");
std::fs::write(&source, "data").unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
std::fs::write(&intermediate, "processed").unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
std::fs::write(&report, "report").unwrap();
let input = format!(
"{}: {}\n\tprocess\n{}: {}\n\tanalyze\n",
intermediate.display(),
source.display(),
report.display(),
intermediate.display(),
);
std::fs::remove_file(&intermediate).unwrap();
let g = graph_from_str(&input, &[&report.to_string_lossy()]).unwrap();
let stale = stale_nodes(&g, false);
let names: Vec<&str> = stale
.iter()
.map(|idx| g.nodes[idx.0].name.as_str())
.collect();
assert!(
names.contains(&intermediate.to_str().unwrap()),
"intermediate should be stale (was deleted)"
);
assert!(
names.contains(&report.to_str().unwrap()),
"report should be stale (depends on deleted intermediate)"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn glob_expands_prereqs() {
let dir = std::env::temp_dir().join("mk_test_glob");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(dir.join("a.json"), "").unwrap();
std::fs::write(dir.join("b.json"), "").unwrap();
std::fs::write(dir.join("c.txt"), "").unwrap();
let input = format!("target: {}\n", dir.join("*.json").display());
let g = graph_from_str(&input, &["target"]).unwrap();
let prereqs: Vec<&str> = g
.nodes
.iter()
.filter(|n| n.name == "target")
.flat_map(|n| n.arcs_in.iter())
.map(|&ai| g.arcs[ai.0].from)
.map(|idx| g.nodes[idx.0].name.as_str())
.collect();
assert!(
prereqs.contains(&dir.join("a.json").to_str().unwrap()),
"glob should expand to a.json"
);
let _ = std::fs::remove_dir_all(&dir);
}
}