use crate::linter::{Diagnostic, Fix, LintResult, Severity, Span};
fn is_target_line(line: &str) -> bool {
line.contains(':')
&& !line.starts_with(char::is_whitespace)
&& !line.trim_start().starts_with('#')
}
fn extract_target_name(line: &str) -> Option<String> {
line.find(':')
.map(|colon_pos| line[..colon_pos].trim().to_string())
}
fn is_recipe_with_spaces(line: &str) -> bool {
line.starts_with(' ') && !line.starts_with('\t')
}
fn count_leading_spaces(line: &str) -> usize {
line.chars().take_while(|c| *c == ' ').count()
}
fn create_tab_fix(line: &str) -> String {
let rest_of_line = line.trim_start();
format!("\t{}", rest_of_line)
}
fn build_diagnostic(
line_num: usize,
leading_spaces: usize,
fix_replacement: &str,
current_target: &str,
) -> Diagnostic {
let span = Span::new(line_num + 1, 1, line_num + 1, leading_spaces + 1);
let message = if !current_target.is_empty() {
format!(
"Recipe line starts with spaces instead of tab (fatal Make error) in target '{}'",
current_target
)
} else {
"Recipe line starts with spaces instead of tab (fatal Make error)".to_string()
};
Diagnostic::new("MAKE008", Severity::Error, message, span).with_fix(Fix::new(fix_replacement))
}
fn should_exit_recipe(line: &str) -> bool {
!line.starts_with('\t') && !line.starts_with(' ')
}
fn is_empty_or_comment(line: &str) -> bool {
line.trim().is_empty() || line.trim_start().starts_with('#')
}
fn is_continuation_line(line: &str) -> bool {
line.trim_end().ends_with('\\')
}
fn try_enter_target(line: &str, current_target: &mut String, in_recipe: &mut bool) {
if is_target_line(line) {
if let Some(target) = extract_target_name(line) {
*current_target = target;
*in_recipe = true;
}
}
}
fn process_recipe_line(
line: &str,
line_num: usize,
current_target: &str,
result: &mut LintResult,
) -> bool {
if is_recipe_with_spaces(line) {
let leading_spaces = count_leading_spaces(line);
let fix_replacement = create_tab_fix(line);
let diag = build_diagnostic(line_num, leading_spaces, &fix_replacement, current_target);
result.add(diag);
false
} else {
should_exit_recipe(line)
}
}
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
let lines: Vec<&str> = source.lines().collect();
let mut in_recipe = false;
let mut current_target = String::new();
let mut in_continuation = false;
for (line_num, line) in lines.iter().enumerate() {
if in_continuation {
in_continuation = is_continuation_line(line);
continue;
}
if is_continuation_line(line) {
in_continuation = true;
try_enter_target(line, &mut current_target, &mut in_recipe);
continue;
}
if is_target_line(line) {
try_enter_target(line, &mut current_target, &mut in_recipe);
} else if in_recipe && !line.is_empty() && !line.trim().is_empty() {
if process_recipe_line(line, line_num, ¤t_target, &mut result) {
in_recipe = false;
current_target.clear();
}
} else if is_empty_or_comment(line) {
} else if should_exit_recipe(line) {
in_recipe = false;
current_target.clear();
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_MAKE008_detects_spaces_in_recipe() {
let makefile = "build:\n gcc main.c -o app";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert_eq!(diag.code, "MAKE008");
assert_eq!(diag.severity, Severity::Error); assert!(diag.message.contains("tab"));
}
#[test]
fn test_MAKE008_detects_multiple_space_lines() {
let makefile = "build:\n gcc main.c\n strip app";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 2);
}
#[test]
fn test_MAKE008_no_warning_with_tab() {
let makefile = "build:\n\tgcc main.c -o app";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_MAKE008_provides_fix() {
let makefile = "build:\n gcc main.c -o app";
let result = check(makefile);
assert!(result.diagnostics[0].fix.is_some());
let fix = result.diagnostics[0].fix.as_ref().unwrap();
assert!(fix.replacement.starts_with('\t'));
}
#[test]
fn test_MAKE008_no_false_positive_on_target_line() {
let makefile = "build: main.c utils.c\n\tgcc main.c utils.c -o app";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_MAKE008_detects_mixed_spaces_tabs() {
let makefile = "build:\n \tgcc main.c"; let result = check(makefile);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_MAKE008_no_warning_for_empty_lines() {
let makefile = "build:\n\tgcc main.c\n\ninstall:\n\tcp app /usr/bin";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_MAKE008_multiple_targets() {
let makefile = r#"build:
gcc main.c
install:
cp app /usr/bin"#;
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 1);
assert!(result.diagnostics[0].message.contains("install"));
}
#[test]
fn test_F039_MAKE008_phony_continuation() {
let makefile = r#".PHONY: clean \
test \
install
clean:
rm -f *.o"#;
let result = check(makefile);
assert_eq!(
result.diagnostics.len(),
0,
"F039 FALSIFIED: MAKE008 must NOT flag continuation lines. Got: {:?}",
result.diagnostics
);
}
#[test]
fn test_F039_MAKE008_target_continuation() {
let makefile = r#"SRCS = main.c \
utils.c \
helpers.c
build:
gcc $(SRCS) -o app"#;
let result = check(makefile);
assert_eq!(
result.diagnostics.len(),
0,
"F039 FALSIFIED: MAKE008 must NOT flag variable continuation lines. Got: {:?}",
result.diagnostics
);
}
}