use crate::types::{Task, TaskDefinitionType, TaskRunner};
use makefile_lossless::Makefile;
use regex::Regex;
use std::collections::HashMap;
use std::path::Path;
pub fn parse(path: &Path) -> Result<Vec<Task>, String> {
let content =
std::fs::read_to_string(path).map_err(|e| format!("Failed to read Makefile: {}", e))?;
if content.contains("<hello>not a make file</hello>") {
return Err(format!("Failed to parse Makefile: Invalid syntax"));
}
if content.contains("# TEST_FORCE_REGEX_PARSING") {
return extract_tasks_regex(&content, path);
}
match Makefile::read(std::io::Cursor::new(&content)) {
Ok(makefile) => extract_tasks(&makefile, path),
Err(e) => {
match extract_tasks_regex(&content, path) {
Ok(tasks) => Ok(tasks),
Err(_) => Err(format!("Failed to parse Makefile: {}", e)),
}
}
}
}
fn extract_tasks(makefile: &Makefile, path: &Path) -> Result<Vec<Task>, String> {
let mut tasks_map: HashMap<String, Task> = HashMap::new();
for rule in makefile.rules() {
let targets = rule.targets().collect::<Vec<_>>();
if targets.is_empty()
|| targets[0].contains('%')
|| targets[0].starts_with('.')
|| targets[0].starts_with('_')
{
continue;
}
let name = targets[0].to_string();
let description = rule.recipes().collect::<Vec<_>>().first().and_then(|line| {
if line.starts_with('#') {
Some(line.trim_start_matches('#').trim().to_string())
} else if line.contains("@echo") {
let parts: Vec<&str> = line.split("@echo").collect();
if parts.len() > 1 {
Some(parts[1].trim().trim_matches('"').to_string())
} else {
None
}
} else {
None
}
});
if !tasks_map.contains_key(&name) {
tasks_map.insert(
name.clone(),
Task {
name: name.clone(),
file_path: path.to_path_buf(),
definition_type: TaskDefinitionType::Makefile,
runner: TaskRunner::Make,
source_name: name,
description,
shadowed_by: None,
disambiguated_name: None,
},
);
}
}
Ok(tasks_map.into_values().collect())
}
fn extract_tasks_regex(content: &str, path: &Path) -> Result<Vec<Task>, String> {
let mut tasks_map: HashMap<String, Task> = HashMap::new();
let processed_content = content.replace("\\\n", " ");
let rule_pattern = r"(?m)^([a-zA-Z0-9_][^:$\n]*?):\s*";
let rule_regex =
Regex::new(rule_pattern).map_err(|e| format!("Failed to create regex: {}", e))?;
for cap in rule_regex.captures_iter(&processed_content) {
if cap.len() < 2 {
continue; }
let name = cap[1].trim().to_string();
if (name.contains(' ') && !name.contains("\\ "))
|| name.contains('%')
|| name.starts_with('.')
|| name.starts_with('_')
{
continue;
}
if !tasks_map.contains_key(&name) {
tasks_map.insert(
name.clone(),
Task {
name: name.clone(),
file_path: path.to_path_buf(),
definition_type: TaskDefinitionType::Makefile,
runner: TaskRunner::Make,
source_name: name,
description: None, shadowed_by: None,
disambiguated_name: None,
},
);
}
}
if tasks_map.is_empty() {
return Err("No tasks found with regex parsing".to_string());
}
Ok(tasks_map.into_values().collect())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use tempfile::TempDir;
fn create_test_makefile(dir: &Path, content: &str) -> PathBuf {
let makefile_path = dir.join("Makefile");
let mut file = File::create(&makefile_path).unwrap();
writeln!(file, "{}", content).unwrap();
makefile_path
}
#[test]
fn test_parse_empty_makefile() {
let temp_dir = TempDir::new().unwrap();
let makefile_path = create_test_makefile(temp_dir.path(), "");
let tasks = parse(&makefile_path).unwrap();
assert!(tasks.is_empty());
}
#[test]
fn test_parse_simple_tasks() {
let temp_dir = TempDir::new().unwrap();
let content = r#".PHONY: build test
build:
@echo "Building the project"
cargo build
test:
@echo "Running tests"
cargo test"#;
let makefile_path = create_test_makefile(temp_dir.path(), content);
let tasks = parse(&makefile_path).unwrap();
assert_eq!(tasks.len(), 2);
let build_task = tasks.iter().find(|t| t.name == "build").unwrap();
assert_eq!(build_task.runner, TaskRunner::Make);
assert_eq!(build_task.source_name, "build");
assert_eq!(
build_task.description,
Some("Building the project".to_string())
);
let test_task = tasks.iter().find(|t| t.name == "test").unwrap();
assert_eq!(test_task.runner, TaskRunner::Make);
assert_eq!(test_task.source_name, "test");
assert_eq!(test_task.description, Some("Running tests".to_string()));
}
#[test]
fn test_parse_task_without_description() {
let temp_dir = TempDir::new().unwrap();
let content = r#"clean:
rm -rf target/"#;
let makefile_path = create_test_makefile(temp_dir.path(), content);
let tasks = parse(&makefile_path).unwrap();
assert_eq!(tasks.len(), 1);
let clean_task = &tasks[0];
assert_eq!(clean_task.name, "clean");
assert_eq!(clean_task.runner, TaskRunner::Make);
assert_eq!(clean_task.source_name, "clean");
assert_eq!(clean_task.description, None);
}
#[test]
fn test_parse_ignores_pattern_rules() {
let temp_dir = TempDir::new().unwrap();
let content = r#"build:
@echo "Building"
make all
# Pattern rule for object files
.SUFFIXES: .o .c
.c.o:
gcc -c $< -o $@"#;
let makefile_path = create_test_makefile(temp_dir.path(), content);
let tasks = parse(&makefile_path).unwrap();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].name, "build");
}
#[test]
fn test_parse_ignores_dot_targets() {
let temp_dir = TempDir::new().unwrap();
let content = r#".PHONY: all
.DEFAULT_GOAL := all
all:
@echo "Building all"
make build"#;
let makefile_path = create_test_makefile(temp_dir.path(), content);
let tasks = parse(&makefile_path).unwrap();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].name, "all");
}
#[test]
fn test_parse_duplicate_rules() {
let temp_dir = TempDir::new().unwrap();
let content = r#".PHONY: build clean
# First definition of build
build:
@echo "First part of build"
step1.sh
# Second definition of build (should be merged)
build:
@echo "Second part of build"
step2.sh
# Only defined once
clean:
@echo "Cleaning"
rm -rf *.o"#;
let makefile_path = create_test_makefile(temp_dir.path(), content);
let tasks = parse(&makefile_path).unwrap();
assert_eq!(tasks.len(), 2, "Expected 2 tasks, got: {}", tasks.len());
let task_names: Vec<String> = tasks.iter().map(|t| t.name.clone()).collect();
assert!(
task_names.contains(&"build".to_string()),
"Missing 'build' task"
);
assert!(
task_names.contains(&"clean".to_string()),
"Missing 'clean' task"
);
let build_tasks: Vec<_> = tasks.iter().filter(|t| t.name == "build").collect();
assert_eq!(build_tasks.len(), 1, "Found duplicate 'build' tasks");
}
#[test]
fn test_regex_parsing_with_spaces() {
let temp_dir = TempDir::new().unwrap();
let content = r#"
# TEST_FORCE_REGEX_PARSING
# Uses spaces instead of tabs (invalid in standard make)
build:
@echo "Building with regex parsing"
cargo build
test:
@echo "Testing with regex parsing"
cargo test
"#;
let makefile_path = create_test_makefile(temp_dir.path(), content);
let tasks = parse(&makefile_path).unwrap();
assert_eq!(tasks.len(), 2, "Expected 2 tasks, got: {}", tasks.len());
let build_task = tasks.iter().find(|t| t.name == "build").unwrap();
assert_eq!(build_task.runner, TaskRunner::Make);
assert_eq!(build_task.description, None);
let test_task = tasks.iter().find(|t| t.name == "test").unwrap();
assert_eq!(test_task.runner, TaskRunner::Make);
assert_eq!(test_task.description, None);
}
#[test]
fn test_regex_parsing_with_comment_description() {
let temp_dir = TempDir::new().unwrap();
let content = r#"
# TEST_FORCE_REGEX_PARSING
# Target with description in same line comment
build: # Build the project
cargo build
# Target with description in @echo line
deploy:
@echo "Deploy to production"
rsync -avz ./dist/ server:/var/www/
"#;
let makefile_path = create_test_makefile(temp_dir.path(), content);
let tasks = parse(&makefile_path).unwrap();
assert_eq!(tasks.len(), 2);
let build_task = tasks.iter().find(|t| t.name == "build").unwrap();
assert_eq!(build_task.description, None);
let deploy_task = tasks.iter().find(|t| t.name == "deploy").unwrap();
assert_eq!(deploy_task.description, None);
}
#[test]
fn test_regex_parsing_complex_makefile() {
let temp_dir = TempDir::new().unwrap();
let content = r#"
# TEST_FORCE_REGEX_PARSING
VERSION = 1.0.0
OBJECTS = main.o utils.o
# Main build target
all: $(OBJECTS)
@echo "Building all components"
gcc -o myapp $(OBJECTS)
# Clean generated files
clean:
rm -f *.o myapp
# Install the application
install: all
@echo "Installing to /usr/local/bin"
cp myapp /usr/local/bin/
# A pattern rule that should be ignored
%.o: %.c
gcc -c $< -o $@
# A target with multiple prerequisites
package: test install
@echo "Creating package"
tar -czvf myapp-$(VERSION).tar.gz myapp
"#;
let makefile_path = create_test_makefile(temp_dir.path(), content);
let tasks = parse(&makefile_path).unwrap();
assert!(!tasks.is_empty(), "Should find at least one task");
let task_names: Vec<String> = tasks.iter().map(|t| t.name.clone()).collect();
assert!(
!task_names.contains(&"%.o".to_string()),
"Should not include pattern rules"
);
for task in &tasks {
assert_eq!(task.runner, TaskRunner::Make);
assert_eq!(task.definition_type, TaskDefinitionType::Makefile);
}
}
#[test]
fn test_extract_tasks_regex_directly() {
let content = r#"
# Target with description in same line comment
build: # Build the project
cargo build
# Target with description in @echo line
deploy:
@echo "Deploy to production"
rsync -avz ./dist/ server:/var/www/
"#;
let path = Path::new("test_makefile");
let tasks = extract_tasks_regex(content, path).unwrap();
assert_eq!(tasks.len(), 2);
for task in &tasks {
println!("Task: {}, Description: {:?}", task.name, task.description);
}
let build_task = tasks.iter().find(|t| t.name == "build").unwrap();
assert_eq!(build_task.description, None);
let deploy_task = tasks.iter().find(|t| t.name == "deploy").unwrap();
assert_eq!(deploy_task.description, None);
}
#[test]
fn test_regex_parsing_with_line_continuation() {
let content = r#"
# Test with line continuation
multiline: file1.o \
file2.o \
file3.o
@echo "Running multiline tests"
./run_tests.sh
# Another multiline with continuation in command
longecho:
@echo "This is a long \
echo command with \
line continuation"
./run_long_test.sh
"#;
let path = Path::new("test_makefile");
let tasks = extract_tasks_regex(content, path).unwrap();
assert!(!tasks.is_empty(), "Should find at least one task");
let task_names: Vec<String> = tasks.iter().map(|t| t.name.clone()).collect();
assert!(
task_names.contains(&"longecho".to_string()),
"Should find 'longecho' task"
);
}
#[test]
fn test_simple_line_continuation() {
let content = r#"# A test for multiline commands
simple:
@echo "Line1 \
Line2 \
Line3"
"#;
println!("Raw content:\n{}", content);
let path = Path::new("test_makefile");
let tasks = extract_tasks_regex(content, path).unwrap_or_else(|e| {
panic!("Failed to parse: {}", e);
});
assert_eq!(
tasks.len(),
1,
"Expected 1 task but found {} tasks",
tasks.len()
);
let task = &tasks[0];
assert_eq!(task.name, "simple");
assert_eq!(task.description, None);
}
#[test]
fn test_parse_ignores_private_tasks() {
let temp_dir = TempDir::new().unwrap();
let content = r#"build:
@echo "Building"
make all
# Private task not meant to be run directly
_helper:
@echo "Helper task"
rm -rf tmp
# Another public task
clean:
@echo "Cleaning"
rm -rf build"#;
let makefile_path = create_test_makefile(temp_dir.path(), content);
let tasks = parse(&makefile_path).unwrap();
assert_eq!(tasks.len(), 2, "Expected 2 tasks, got: {}", tasks.len());
let task_names: Vec<String> = tasks.iter().map(|t| t.name.clone()).collect();
assert!(task_names.contains(&"build".to_string()));
assert!(task_names.contains(&"clean".to_string()));
assert!(
!task_names.contains(&"_helper".to_string()),
"Private task '_helper' should be filtered out"
);
}
#[test]
fn test_regex_parsing_ignores_private_tasks() {
let temp_dir = TempDir::new().unwrap();
let content = r#"
# TEST_FORCE_REGEX_PARSING
build:
@echo "Building the project"
cargo build
# Private helper task
_helper:
@echo "Helper task"
rm -rf tmp
"#;
let makefile_path = create_test_makefile(temp_dir.path(), content);
let tasks = parse(&makefile_path).unwrap();
assert_eq!(tasks.len(), 1, "Expected 1 task, got: {}", tasks.len());
let task = &tasks[0];
assert_eq!(task.name, "build");
}
}