use std::fs::read_to_string;
use std::path::{Path, PathBuf};
use commentator::{Tokenizer, spec};
#[derive(Debug, Clone)]
pub struct TodoComment {
pub text: String,
pub line: usize,
}
pub fn extract_todo_comments_with_language_detection(
path: PathBuf,
keywords: &[String],
) -> Vec<TodoComment> {
let spec = detect_comment_spec(&path);
extract_individual_todo_comments(path, &spec, keywords)
}
fn extract_individual_todo_comments(
path: PathBuf,
spec: &spec::StandardSpec,
keywords: &[String],
) -> Vec<TodoComment> {
let mut todo_comments = Vec::new();
for (line_num, line) in string_content(path.clone()).lines().enumerate() {
let mut tkn = Tokenizer::new(spec);
tkn.update(line_num + 1, line);
while let Some(cmt) = tkn.take() {
if keywords
.iter()
.any(|keyword| comment_starts_with_keyword(&cmt.text, keyword))
{
let individual_todos =
extract_individual_todos_from_text(&cmt.text, line_num + 1, keywords);
todo_comments.extend(individual_todos);
}
}
tkn.finish();
if let Some(cmt) = tkn.take() {
if keywords
.iter()
.any(|keyword| comment_starts_with_keyword(&cmt.text, keyword))
{
let individual_todos =
extract_individual_todos_from_text(&cmt.text, line_num + 1, keywords);
todo_comments.extend(individual_todos);
}
}
}
todo_comments
}
fn comment_starts_with_keyword(comment_text: &str, keyword: &str) -> bool {
let trimmed = comment_text.trim();
let upper = trimmed.to_uppercase();
if upper.starts_with(keyword) {
let after_keyword = &trimmed[keyword.len()..];
after_keyword.is_empty()
|| after_keyword.starts_with(':')
|| after_keyword.starts_with(' ')
|| after_keyword.starts_with('\t')
} else {
false
}
}
fn extract_individual_todos_from_text(
comment_text: &str,
line_num: usize,
keywords: &[String],
) -> Vec<TodoComment> {
let mut todos = Vec::new();
for keyword in keywords {
if comment_starts_with_keyword(comment_text, keyword) {
todos.push(TodoComment {
text: comment_text.trim().to_string(),
line: line_num,
});
break;
}
}
todos
}
fn detect_comment_spec(path: &Path) -> spec::StandardSpec {
if let Some(extension) = path.extension().and_then(|e| e.to_str()) {
match extension {
"rs" | "js" | "ts" | "jsx" | "tsx" | "c" | "cpp" | "cc" | "cxx" | "h" | "hpp"
| "java" | "go" | "cs" => spec::StandardSpec::C,
"py" | "sh" | "bash" | "zsh" | "fish" | "rb" | "pl" | "yaml" | "yml" | "toml"
| "cfg" | "ini" | "nix" => spec::StandardSpec::Bash,
"html" | "htm" | "xml" | "svg" => spec::StandardSpec::HTML,
_ => spec::StandardSpec::C,
}
} else {
spec::StandardSpec::C
}
}
fn string_content(path: PathBuf) -> String {
match read_to_string(&path) {
Ok(s) => s,
Err(e) => {
tracing::debug!("Error reading file {:?}: {}", path, e);
String::new()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_individual_todo_extraction() {
let rust_file = PathBuf::from("tests/sample.rs");
let keywords = vec![
"TODO".to_string(),
"FIXME".to_string(),
"NOTE".to_string(),
"HACK".to_string(),
"BUG".to_string(),
];
let todos = extract_todo_comments_with_language_detection(rust_file, &keywords);
assert!(todos.len() >= 5);
assert!(todos.iter().any(|t| t.line > 0));
let todo_texts: Vec<&str> = todos.iter().map(|t| t.text.as_str()).collect();
assert!(
todo_texts
.iter()
.any(|&text| text.contains("TODO: Implement error handling"))
);
assert!(
todo_texts
.iter()
.any(|&text| text.contains("FIXME: This is inefficient"))
);
assert!(
todo_texts
.iter()
.any(|&text| text.contains("NOTE: Consider using"))
);
}
#[test]
fn test_python_todo_extraction() {
let python_file = PathBuf::from("tests/sample.py");
let keywords = vec![
"TODO".to_string(),
"FIXME".to_string(),
"NOTE".to_string(),
"HACK".to_string(),
"BUG".to_string(),
];
let todos = extract_todo_comments_with_language_detection(python_file, &keywords);
assert!(todos.len() >= 4);
let todo_texts: Vec<&str> = todos.iter().map(|t| t.text.as_str()).collect();
assert!(
todo_texts
.iter()
.any(|&text| text.contains("TODO: Add proper logging"))
);
assert!(todo_texts.iter().any(|&text| text.contains("FIXME:")));
}
#[test]
fn test_todo_comment_structure() {
let rust_file = PathBuf::from("tests/sample.rs");
let keywords = vec![
"TODO".to_string(),
"FIXME".to_string(),
"NOTE".to_string(),
"HACK".to_string(),
"BUG".to_string(),
];
let todos = extract_todo_comments_with_language_detection(rust_file, &keywords);
let todo_with_line = todos
.iter()
.find(|t| t.text.contains("TODO: Implement error handling"));
assert!(todo_with_line.is_some());
let todo = todo_with_line.unwrap();
assert!(todo.line > 0, "Should have valid line number");
assert!(!todo.text.is_empty(), "Should have non-empty text");
}
}