Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 122 additions & 18 deletions make/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ const DEFAULT_SHELL: &str = "/bin/sh";
/// The only way to create a Make is from a Makefile and a Config.
pub struct Make {
macros: Vec<VariableDefinition>,
/// Target rules (non-special, non-inference).
/// Invariant: inference rules are never stored here, so `first_target()`
/// always returns a valid default target per POSIX.
Comment on lines +43 to +44
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The struct-level invariant comment says first_target() "always returns a valid default target per POSIX", but first_target() explicitly falls back to self.inference_rules.first() when there are no regular targets, which returns an inference rule target (and will scan the CWD). The comment should be updated to match the actual behavior (or the fallback behavior adjusted) so future changes don’t rely on an incorrect invariant.

Suggested change
/// Invariant: inference rules are never stored here, so `first_target()`
/// always returns a valid default target per POSIX.
/// Invariant: inference rules are never stored here; they are kept
/// separately in `inference_rules`. When there are no regular targets,
/// `first_target()` may fall back to an inference rule instead.

Copilot uses AI. Check for mistakes.
rules: Vec<Rule>,
/// Inference rules (e.g. `.c.o:`, `.txt.out:`).
inference_rules: Vec<Rule>,
default_rule: Option<Rule>, // .DEFAULT
pub config: Config,
}
Expand All @@ -58,25 +63,84 @@ impl Make {
}

pub fn first_target(&self) -> Result<&Target, ErrorCode> {
let rule = self.rules.first().ok_or(NoTarget { target: None })?;
// Per POSIX: "the first target that make encounters that is not a special
// target or an inference rule shall be used."
// If there are no non-special, non-inference targets, fall back to the
// first inference rule (which will scan CWD for matching files).
let rule = self
.rules
.first()
.or_else(|| self.inference_rules.first())
.ok_or(NoTarget { target: None })?;
rule.targets().next().ok_or(NoTarget { target: None })
}

/// Finds a matching inference rule for the given target name.
///
/// Per POSIX: the suffix of the target (.s1) is compared to .SUFFIXES.
/// If found, inference rules are searched for the first .s2.s1 rule whose
/// prerequisite file ($*.s2) exists.
fn find_inference_rule(&self, name: &str) -> Option<&Rule> {
let suffixes = self.config.rules.get(".SUFFIXES")?;

// Find the target's suffix (.s1)
let target_suffix = suffixes
.iter()
.filter(|s| name.ends_with(s.as_str()))
.max_by_key(|s| s.len())?;

let stem = &name[..name.len() - target_suffix.len()];

// Search inference rules for .s2.s1 where $*.s2 exists
for rule in &self.inference_rules {
let Some(rule_target) = rule.targets().next() else {
continue;
};
if let Target::Inference { from, to, .. } = rule_target {
let expected_suffix = format!(".{}", to);
if expected_suffix == *target_suffix {
let prereq_path = format!("{}.{}", stem, from);
if std::path::Path::new(&prereq_path).exists() {
return Some(rule);
}
}
}
}
None
}

/// Builds the target with the given name.
///
/// # Returns
/// - Ok(true) if the target was built.
/// - Ok(false) if the target was already up to date.
/// - Err(_) if any errors occur.
pub fn build_target(&self, name: impl AsRef<str>) -> Result<bool, ErrorCode> {
// Search both regular rules and inference rules
let rule = match self.rule_by_target_name(&name) {
Some(rule) => rule,
None => match &self.default_rule {
None => match self
.inference_rules
.iter()
.find(|rule| rule.targets().any(|t| t.as_ref() == name.as_ref()))
{
Some(rule) => rule,
None => {
return Err(NoTarget {
target: Some(name.as_ref().to_string()),
})
// Per POSIX: "If a target exists and there is neither a target rule
// nor an inference rule for the target, the target shall be considered
// up-to-date."
if get_modified_time(&name).is_some() {
return Ok(false);
}
// No rule and file doesn't exist - try .DEFAULT or fail
match &self.default_rule {
Some(rule) => rule,
None => {
return Err(NoTarget {
target: Some(name.as_ref().to_string()),
})
}
}
}
},
};
Expand Down Expand Up @@ -111,6 +175,18 @@ impl Make {
for prerequisite in &newer_prerequisites {
self.build_target(prerequisite)?;
}

// Per POSIX: "When no target rule with commands is found to update a
// target, the inference rules shall be checked." If the matched target
// rule has no recipes, look for a matching inference rule and run it
// for this specific target instead.
if rule.recipes().count() == 0 {
if let Some(inference_rule) = self.find_inference_rule(target.as_ref()) {
inference_rule.run_for_target(&self.config, &self.macros, target, up_to_date)?;
return Ok(true);
}
}

rule.run(&self.config, &self.macros, target, up_to_date)?;

Ok(true)
Expand Down Expand Up @@ -184,32 +260,60 @@ impl TryFrom<(Makefile, Config)> for Make {
type Error = ErrorCode;

fn try_from((makefile, config): (Makefile, Config)) -> Result<Self, Self::Error> {
let mut rules = vec![];
let mut special_rules = vec![];
let mut inference_rules = vec![];

for rule in makefile.rules() {
let rule = Rule::from(rule);
// Two-pass classification: .SUFFIXES must be processed before inference
// rule classification so that user-defined suffixes (especially with -r)
// are available when determining whether a rule like `.txt.out:` is an
// inference rule.

let mut suffixes_rules = vec![];
let mut remaining_parsed_rules = vec![];

// Pass 1: Separate .SUFFIXES rules from everything else and process
// them immediately so config.rules[".SUFFIXES"] is populated.
for parsed_rule in makefile.rules() {
let rule = Rule::from(parsed_rule);
let Some(target) = rule.targets().next() else {
return Err(NoTarget { target: None });
};

if SpecialTarget::try_from(target.clone()).is_ok() {
special_rules.push(rule);
} else if InferenceTarget::try_from((target.clone(), config.clone())).is_ok() {
inference_rules.push(rule);
if let Ok(SpecialTarget::Suffixes) = SpecialTarget::try_from(target.clone()) {
suffixes_rules.push(rule);
} else {
rules.push(rule);
remaining_parsed_rules.push(rule);
}
}

// Build the Make struct early so we can process .SUFFIXES via the
// normal special_target::process path (which writes to make.config).
let mut make = Self {
rules,
rules: vec![],
inference_rules: vec![],
macros: makefile.variable_definitions().collect(),
default_rule: None,
config,
};

for rule in suffixes_rules {
special_target::process(rule, &mut make)?;
}

// Pass 2: Classify remaining rules. Now make.config.rules[".SUFFIXES"]
// contains both built-in (unless -r) and user-defined suffixes.
let mut special_rules = vec![];

for rule in remaining_parsed_rules {
let Some(target) = rule.targets().next() else {
return Err(NoTarget { target: None });
};

if SpecialTarget::try_from(target.clone()).is_ok() {
special_rules.push(rule);
} else if InferenceTarget::try_from((target.clone(), make.config.clone())).is_ok() {
make.inference_rules.push(rule);
} else {
make.rules.push(rule);
}
}

for rule in special_rules {
special_target::process(rule, &mut make)?;
}
Expand Down
71 changes: 57 additions & 14 deletions make/src/rule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,37 @@ impl Rule {
self.recipes.iter()
}

/// Runs an inference rule for a specific target (not a CWD scan).
///
/// This is used when POSIX requires applying an inference rule to a specific
/// target that has no commands of its own. The internal macros ($<, $*, etc.)
/// are substituted based on the target name and the inference rule's suffixes.
pub fn run_for_target(
&self,
global_config: &GlobalConfig,
macros: &[VariableDefinition],
target: &Target,
up_to_date: bool,
) -> Result<(), ErrorCode> {
// For an inference rule applied to a specific target, compute the
// input/output pair from the target name and the rule's suffixes.
let files = if let Some(Target::Inference { from, to, .. }) = self.targets().next() {
let target_name = target.as_ref();
let expected_suffix = format!(".{}", to);
if let Some(stem) = target_name.strip_suffix(&expected_suffix) {
let input = PathBuf::from(format!("{}.{}", stem, from));
let output = PathBuf::from(target_name);
vec![(input, output)]
} else {
vec![(PathBuf::from(""), PathBuf::from(""))]
}
} else {
vec![(PathBuf::from(""), PathBuf::from(""))]
};

self.run_with_files(global_config, macros, target, up_to_date, files)
Comment on lines +81 to +97
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

run_for_target() falls back to running the rule with empty input/output paths when the rule isn't an inference rule or when the target name doesn't match the rule's to suffix. That can cause recipes to execute with $</$* substituted as empty strings (and potentially run cp/rm against unintended paths) instead of failing fast. Consider returning an error (e.g., ErrorCode::NoTarget { target: Some(target_name.to_string()) }) or Ok(()) without running recipes when the suffix doesn't match, and treat a non-inference rule here as a programmer error.

Suggested change
// For an inference rule applied to a specific target, compute the
// input/output pair from the target name and the rule's suffixes.
let files = if let Some(Target::Inference { from, to, .. }) = self.targets().next() {
let target_name = target.as_ref();
let expected_suffix = format!(".{}", to);
if let Some(stem) = target_name.strip_suffix(&expected_suffix) {
let input = PathBuf::from(format!("{}.{}", stem, from));
let output = PathBuf::from(target_name);
vec![(input, output)]
} else {
vec![(PathBuf::from(""), PathBuf::from(""))]
}
} else {
vec![(PathBuf::from(""), PathBuf::from(""))]
};
self.run_with_files(global_config, macros, target, up_to_date, files)
let target_name = target.as_ref();
// For an inference rule applied to a specific target, compute the
// input/output pair from the target name and the rule's suffixes.
if let Some(Target::Inference { from, to, .. }) = self.targets().next() {
let expected_suffix = format!(".{}", to);
if let Some(stem) = target_name.strip_suffix(&expected_suffix) {
let input = PathBuf::from(format!("{}.{}", stem, from));
let output = PathBuf::from(target_name);
let files = vec![(input, output)];
return self.run_with_files(global_config, macros, target, up_to_date, files);
}
// Suffix does not match this inference rule; do not run any recipes.
return Ok(());
}
// `run_for_target` should only be used with inference rules; treat other
// usages as a programmer error and report a missing target.
debug_assert!(
false,
"run_for_target called on non-inference rule for target {target_name}"
);
Err(NoTarget {
target: Some(target_name.to_string()),
})

Copilot uses AI. Check for mistakes.
}

/// Runs the rule with the global config and macros passed in.
///
/// Returns `Ok` on success and `Err` on any errors while running the rule.
Expand All @@ -75,6 +106,32 @@ impl Rule {
macros: &[VariableDefinition],
target: &Target,
up_to_date: bool,
) -> Result<(), ErrorCode> {
let files = match target {
Target::Inference { from, to, .. } => find_files_with_extension(from)?
.into_iter()
.map(|input| {
let mut output = input.clone();
output.set_extension(to);
(input, output)
})
.collect::<Vec<_>>(),
_ => {
vec![(PathBuf::from(""), PathBuf::from(""))]
}
};

self.run_with_files(global_config, macros, target, up_to_date, files)
}

/// Internal helper: runs the rule's recipes for the given input/output file pairs.
fn run_with_files(
&self,
global_config: &GlobalConfig,
macros: &[VariableDefinition],
target: &Target,
up_to_date: bool,
files: Vec<(PathBuf, PathBuf)>,
) -> Result<(), ErrorCode> {
let GlobalConfig {
ignore: global_ignore,
Expand All @@ -97,20 +154,6 @@ impl Rule {
phony: _,
} = self.config;

let files = match target {
Target::Inference { from, to, .. } => find_files_with_extension(from)?
.into_iter()
.map(|input| {
let mut output = input.clone();
output.set_extension(to);
(input, output)
})
.collect::<Vec<_>>(),
_ => {
vec![(PathBuf::from(""), PathBuf::from(""))]
}
};

for inout in files {
for recipe in self.recipes() {
let RecipeConfig {
Expand Down
7 changes: 6 additions & 1 deletion make/src/special_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ impl Processor<'_> {
self.make
.rules
.iter_mut()
.chain(self.make.inference_rules.iter_mut())
.filter(|r| r.targets().any(|t| t.as_ref() == prerequisite.as_ref()))
.for_each(f.clone());
}
Expand All @@ -209,7 +210,11 @@ impl Processor<'_> {
/// specified.
fn global(&mut self, f: impl FnMut(&mut Rule) + Clone) {
if self.rule.prerequisites().count() == 0 {
self.make.rules.iter_mut().for_each(f);
self.make
.rules
.iter_mut()
.chain(self.make.inference_rules.iter_mut())
.for_each(f);
}
}
}
Expand Down
Loading