use crate::linter::{Diagnostic, Fix, LintResult, Severity, Span};
const SILENT_COMMANDS: &[&str] = &["echo", "printf"];
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
let lines: Vec<&str> = source.lines().collect();
for (line_num, line) in lines.iter().enumerate() {
if !line.starts_with('\t') {
continue;
}
if line_num > 0 && is_continuation_line(&lines, line_num) {
continue;
}
if let Some(diag) = check_recipe_line(line, line_num) {
result.add(diag);
}
}
result
}
fn is_continuation_line(lines: &[&str], line_num: usize) -> bool {
if line_num == 0 {
return false;
}
let prev_line = lines[line_num - 1].trim_end();
if prev_line.ends_with('\\') {
return true;
}
false
}
fn check_recipe_line(line: &str, line_num: usize) -> Option<Diagnostic> {
let trimmed = line.trim_start_matches('\t').trim_start();
if trimmed.starts_with('@') {
return None;
}
for cmd in SILENT_COMMANDS {
if is_command(trimmed, cmd) {
let span = Span::new(line_num + 1, 1, line_num + 1, line.len() + 1);
let fix_replacement = line.replacen('\t', "\t@", 1);
return Some(
Diagnostic::new(
"MAKE007",
Severity::Warning,
format!(
"Command '{}' without @ prefix - will show duplicate output",
cmd
),
span,
)
.with_fix(Fix::new(&fix_replacement)),
);
}
}
None
}
fn is_command(line: &str, cmd: &str) -> bool {
if line.starts_with(cmd) {
if line.len() == cmd.len() {
return true;
}
let next_char = line.chars().nth(cmd.len());
matches!(next_char, Some(' ' | '\t'))
} else {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_MAKE007_detects_echo_without_at() {
let makefile = "build:\n\techo Building...";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert_eq!(diag.code, "MAKE007");
assert_eq!(diag.severity, Severity::Warning);
assert!(diag.message.contains("@"));
}
#[test]
fn test_MAKE007_no_warning_with_at_prefix() {
let makefile = "build:\n\t@echo Building...";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_MAKE007_provides_fix() {
let makefile = "build:\n\techo Building...";
let result = check(makefile);
assert!(result.diagnostics[0].fix.is_some());
let fix = result.diagnostics[0].fix.as_ref().unwrap();
assert!(fix.replacement.contains("@echo"));
}
#[test]
fn test_MAKE007_detects_printf_without_at() {
let makefile = "test:\n\tprintf \"Testing...\\n\"";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_MAKE007_detects_multiple_echo() {
let makefile = "build:\n\techo Starting...\n\tgcc main.c\n\techo Done!";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 2);
}
#[test]
fn test_MAKE007_no_warning_for_non_echo_commands() {
let makefile = "build:\n\tgcc main.c -o app";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_MAKE007_detects_echo_with_flags() {
let makefile = "build:\n\techo -n Building...";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_MAKE007_empty_makefile() {
let makefile = "";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_MAKE007_no_warning_for_continuation_lines() {
let makefile = "check:\n\t@if test -f file; then \\\n\t\techo Found; \\\n\telse \\\n\t\techo Not found; \\\n\tfi";
let result = check(makefile);
assert_eq!(
result.diagnostics.len(),
0,
"Continuation lines should not trigger MAKE007"
);
}
#[test]
fn test_MAKE007_no_warning_for_multiline_shell() {
let makefile = r#"validate:
@if command -v tool >/dev/null 2>&1; then \
echo Tool found; \
else \
echo Tool not found; \
fi"#;
let result = check(makefile);
assert_eq!(
result.diagnostics.len(),
0,
"Should not warn about echo in shell conditionals"
);
}
#[test]
fn test_MAKE007_warns_for_top_level_echo() {
let makefile = "build:\n\techo Starting\n\t@if true; then \\\n\t\techo Inside; \\\n\tfi";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 1);
assert!(result.diagnostics[0].message.contains("echo"));
}
}