use crate::linter::{Diagnostic, Fix, LintResult, Severity, Span};
fn should_skip_line(line: &str) -> bool {
line.trim_start().starts_with('#') || line.trim().is_empty() || line.starts_with('\t')
}
fn get_char_before_eq(line: &str, eq_pos: usize) -> Option<char> {
if eq_pos > 0 {
line.chars().nth(eq_pos - 1)
} else {
None
}
}
fn is_special_operator(before_eq: Option<char>) -> bool {
before_eq == Some(':')
|| before_eq == Some('!')
|| before_eq == Some('+')
|| before_eq == Some('?')
}
fn contains_shell_command(value: &str) -> bool {
value.contains("$(shell")
}
fn create_shell_diagnostic(
var_name: &str,
after_eq: &str,
line_num: usize,
eq_pos: usize,
) -> Diagnostic {
let span = Span::new(line_num + 1, eq_pos + 1, line_num + 1, eq_pos + 2);
let fix_replacement = ":=".to_string();
Diagnostic::new(
"MAKE005",
Severity::Warning,
format!(
"Variable '{}' uses recursive expansion (=) with $(shell ...) - use := for immediate expansion",
var_name
),
span,
)
.with_fix(Fix::new(&fix_replacement))
}
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
for (line_num, line) in source.lines().enumerate() {
if should_skip_line(line) {
continue;
}
if let Some(eq_pos) = line.find('=') {
let before_eq = get_char_before_eq(line, eq_pos);
if is_special_operator(before_eq) {
continue;
}
let after_eq = &line[eq_pos + 1..];
if contains_shell_command(after_eq) {
let var_name = line[..eq_pos].trim();
let diag = create_shell_diagnostic(var_name, after_eq, line_num, eq_pos);
result.add(diag);
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prop_make005_comments_never_diagnosed() {
let test_cases = vec![
"# VERSION = $(shell git describe)",
" # TIMESTAMP = $(shell date)",
"\t# VAR = $(shell cmd)",
];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}
#[test]
fn prop_make005_recipe_lines_never_diagnosed() {
let test_cases = vec![
"\techo $(shell date)",
"\tVERSION=$(shell git describe)",
"\t@echo $(shell pwd)",
];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}
#[test]
fn prop_make005_immediate_expansion_never_diagnosed() {
let test_cases = vec![
"VERSION := $(shell git describe)",
"TIMESTAMP := $(shell date +%s)",
"PWD := $(shell pwd)",
];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}
#[test]
fn prop_make005_other_operators_never_diagnosed() {
let test_cases = vec![
"FLAGS += $(shell pkg-config --cflags)",
"CC ?= $(shell which gcc)",
"VAR != $(shell echo test)",
];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}
#[test]
fn prop_make005_recursive_with_shell_always_diagnosed() {
let test_cases = vec![
("VERSION = $(shell git describe)", "VERSION"),
("TIMESTAMP = $(shell date +%s)", "TIMESTAMP"),
("PWD = $(shell pwd)", "PWD"),
];
for (code, var_name) in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 1, "Should diagnose: {}", code);
assert!(result.diagnostics[0].message.contains(var_name));
}
}
#[test]
fn prop_make005_simple_assignments_never_diagnosed() {
let test_cases = vec!["PREFIX = /usr/local", "VERSION = 1.0.0", "NAME = myproject"];
for code in test_cases {
let result = check(code);
assert_eq!(result.diagnostics.len(), 0);
}
}
#[test]
fn prop_make005_all_diagnostics_have_fix() {
let code = "VERSION = $(shell git describe)\nDATE = $(shell date)";
let result = check(code);
for diagnostic in &result.diagnostics {
assert!(
diagnostic.fix.is_some(),
"All MAKE005 diagnostics should have a fix"
);
}
}
#[test]
fn prop_make005_diagnostic_code_always_make005() {
let code = "V1 = $(shell cmd1)\nV2 = $(shell cmd2)";
let result = check(code);
for diagnostic in &result.diagnostics {
assert_eq!(&diagnostic.code, "MAKE005");
}
}
#[test]
fn prop_make005_diagnostic_severity_always_warning() {
let code = "VERSION = $(shell git describe)";
let result = check(code);
for diagnostic in &result.diagnostics {
assert_eq!(diagnostic.severity, Severity::Warning);
}
}
#[test]
fn prop_make005_empty_source_no_diagnostics() {
let result = check("");
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_MAKE005_detects_shell_with_recursive_expansion() {
let makefile = "VERSION = $(shell git describe)";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert_eq!(diag.code, "MAKE005");
assert_eq!(diag.severity, Severity::Warning);
assert!(diag.message.contains("VERSION"));
assert!(diag.message.contains(":="));
}
#[test]
fn test_MAKE005_no_warning_with_immediate_expansion() {
let makefile = "VERSION := $(shell git describe)";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_MAKE005_detects_timestamp_shell() {
let makefile = "TIMESTAMP = $(shell date +%s)";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 1);
assert!(result.diagnostics[0].message.contains("TIMESTAMP"));
}
#[test]
fn test_MAKE005_no_warning_for_simple_assignment() {
let makefile = "PREFIX = /usr/local";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_MAKE005_provides_fix() {
let makefile = "VERSION = $(shell git describe)";
let result = check(makefile);
assert!(result.diagnostics[0].fix.is_some());
let fix = result.diagnostics[0].fix.as_ref().unwrap();
assert_eq!(fix.replacement, ":=");
}
#[test]
fn test_MAKE005_no_false_positive_on_plus_equals() {
let makefile = "FLAGS += $(shell pkg-config --cflags)";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_MAKE005_no_false_positive_on_question_equals() {
let makefile = "CC ?= $(shell which gcc)";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_MAKE005_multiple_shell_assignments() {
let makefile = "VERSION = $(shell git describe)\nDATE = $(shell date +%Y%m%d)";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 2);
assert!(result.diagnostics[0].message.contains("VERSION"));
assert!(result.diagnostics[1].message.contains("DATE"));
}
}