use crate::linter::{Diagnostic, Fix, LintResult, Severity, Span};
use std::collections::HashSet;
const PHONY_TARGETS: &[&str] = &[
"all",
"clean",
"test",
"install",
"uninstall",
"check",
"build",
"run",
"help",
"dist",
"distclean",
"lint",
"format",
"fmt",
"doc",
"docs",
"benchmark",
"bench",
"coverage",
"deploy",
"release",
"dev",
"prod",
];
fn is_phony_line(line: &str) -> bool {
line.trim_start().starts_with(".PHONY:")
}
fn parse_phony_line(line: &str) -> Vec<String> {
line.split(':')
.nth(1)
.map(|targets_str| {
targets_str
.split_whitespace()
.map(|s| s.to_string())
.collect()
})
.unwrap_or_default()
}
fn parse_phony_targets(source: &str) -> HashSet<String> {
let mut targets = HashSet::new();
let lines: Vec<&str> = source.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
if is_phony_line(line) {
let mut combined_line = line.to_string();
while combined_line.trim_end().ends_with('\\') && i + 1 < lines.len() {
combined_line = combined_line.trim_end().trim_end_matches('\\').to_string();
i += 1;
combined_line.push(' ');
combined_line.push_str(lines[i].trim());
}
for target in parse_phony_line(&combined_line) {
targets.insert(target);
}
}
i += 1;
}
targets
}
fn should_skip_line(line: &str) -> bool {
line.trim_start().starts_with(".PHONY") || line.trim_start().starts_with('#')
}
fn is_target_line(line: &str) -> bool {
line.contains(':') && !line.starts_with('\t') && !line.trim_start().is_empty()
}
fn is_variable_assignment(line: &str) -> bool {
line.contains('=')
}
fn extract_target_name(line: &str) -> Option<&str> {
line.split(':').next().map(|s| s.trim())
}
fn should_be_phony(target: &str) -> bool {
PHONY_TARGETS.contains(&target)
}
fn build_phony_diagnostic(line_num: usize, target: &str) -> Diagnostic {
let span = Span::new(line_num + 1, 1, line_num + 1, target.len() + 1);
let fix = format!(".PHONY: {}", target);
let message = format!("Target '{}' should be marked as .PHONY", target);
Diagnostic::new("MAKE004", Severity::Warning, message, span).with_fix(Fix::new(&fix))
}
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
let phony_targets = parse_phony_targets(source);
for (line_num, line) in source.lines().enumerate() {
if should_skip_line(line) || !is_target_line(line) || is_variable_assignment(line) {
continue;
}
if let Some(target) = extract_target_name(line) {
if should_be_phony(target) && !phony_targets.contains(target) {
let diag = build_phony_diagnostic(line_num, target);
result.add(diag);
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_MAKE004_detects_missing_phony_clean() {
let makefile = "clean:\n\trm -f *.o";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert_eq!(diag.code, "MAKE004");
assert_eq!(diag.severity, Severity::Warning);
assert!(diag.message.contains("clean"));
assert!(diag.message.contains(".PHONY"));
}
#[test]
fn test_MAKE004_no_warning_with_phony() {
let makefile = ".PHONY: clean\n\nclean:\n\trm -f *.o";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_MAKE004_detects_test_target() {
let makefile = "test:\n\tpytest tests/";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 1);
assert!(result.diagnostics[0].message.contains("test"));
}
#[test]
fn test_MAKE004_provides_fix() {
let makefile = "clean:\n\trm -f *.o";
let result = check(makefile);
assert!(result.diagnostics[0].fix.is_some());
let fix = result.diagnostics[0].fix.as_ref().unwrap();
assert_eq!(fix.replacement, ".PHONY: clean");
}
#[test]
fn test_MAKE004_multiple_missing_phony() {
let makefile = "clean:\n\trm -f *.o\n\ntest:\n\tpytest\n\ninstall:\n\tcp app /usr/bin";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 3);
}
#[test]
fn test_MAKE004_no_warning_for_file_targets() {
let makefile = "app.o: app.c\n\tgcc -c app.c";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_MAKE004_phony_with_multiple_targets() {
let makefile = ".PHONY: clean test\n\nclean:\n\trm -f *.o\n\ntest:\n\tpytest";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_MAKE004_no_false_positive_on_variables() {
let makefile = "CC = gcc\nCFLAGS = -Wall";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_F038_MAKE004_multiline_phony() {
let makefile = r#".PHONY: clean \
test \
install
clean:
rm -f *.o
test:
pytest
install:
cp app /usr/bin"#;
let result = check(makefile);
assert_eq!(
result.diagnostics.len(),
0,
"F038 FALSIFIED: MAKE004 must recognize multi-line .PHONY declarations. Got: {:?}",
result.diagnostics
);
}
#[test]
fn test_F038_MAKE004_mixed_phony_declarations() {
let makefile = r#".PHONY: clean
.PHONY: test \
install
clean:
rm -f *.o
test:
pytest
install:
cp app /usr/bin"#;
let result = check(makefile);
assert_eq!(
result.diagnostics.len(),
0,
"F038 FALSIFIED: Mixed .PHONY declarations should work. Got: {:?}",
result.diagnostics
);
}
}