diff --git a/preview/blocks/info.svelte b/preview/blocks/info.svelte index a31a796..79932e3 100644 --- a/preview/blocks/info.svelte +++ b/preview/blocks/info.svelte @@ -149,7 +149,7 @@ style={`--color: var(--color-additional-${colors[index]});`} class="legend-color" > - {kind} + {kind.toUpperCase()} {/each} diff --git a/readme.md b/readme.md index 227bcad..ce986bf 100644 --- a/readme.md +++ b/readme.md @@ -47,6 +47,10 @@ npx todoctor The program will automatically collect data and display the history of `TODO` / `FIXME` comments across commits. +## Report + +### Todos Graph + After running the tool, it generates a detailed graph showing the evolution of TODO comments over time. The graph visualizes how many todo comments were added, resolved, or modified across the project's history. This helps you track the technical debt and maintenance progress at a glance. @@ -67,6 +71,8 @@ This helps you track the technical debt and maintenance progress at a glance. /> +### Additional Information + In addition to the graph, the tool provides insightful statistics, such as: - The total number of todo comments. @@ -92,6 +98,8 @@ These insights help you better understand the state of your codebase and priorit /> +### List of Todos + Finally, the tool generates a detailed list of all todo comments in your project in a tabular format. The list includes the comment text, the file path, and additional metadata, such as line numbers and authorship information. This list helps you identify, review, and manage unresolved tasks more effectively. @@ -136,6 +144,49 @@ Example: todoctor --ignore src/deprecated/ --ignore tests/legacy.test.js ``` +### --include-keywords + +Allows you to specify additional keywords in comments that will be treated as technical debt. This option can be used multiple times. + +Example: + +```sh +todoctor --include-keywords eslint-disable-next-line +``` + +### --exclude-keywords + +Allows you to exclude keywords from the report. By default, the following keywords are used to define the technical debt comment: + +- `TODO` +- `FIXME` +- `XXX` +- `HACK` +- `BUG` +- `OPTIMIZE` +- `REFACTOR` +- `TEMP` +- `CHANGED` +- `IDEA` +- `NOTE` +- `REVIEW` +- `NB` +- `QUESTION` +- `DEBUG` +- `KLUDGE` +- `COMPAT` +- `WARNING` +- `DANGER` +- `INFO` +- `DEPRECATED` +- `COMBAK` + +Example: + +```sh +todoctor --exclude-keywords WARNING --exclude-keywords DEPRECATED +``` + ### --help Displays this help message with available options. diff --git a/src/identify_todo_comment.rs b/src/identify_todo_comment.rs index 3d68fae..2585eb1 100644 --- a/src/identify_todo_comment.rs +++ b/src/identify_todo_comment.rs @@ -35,11 +35,32 @@ lazy_static! { Regex::new(r"(?i)^[^\w]*(\w+)([\s\p{P}]*)(:|[\p{P}\s]|$)").unwrap(); } -pub fn identify_todo_comment(comment_text: &str) -> Option { +pub fn identify_todo_comment( + comment_text: &str, + include_keywords: Option<&[&str]>, + exclude_keywords: Option<&[&str]>, +) -> Option { let trimmed_text = comment_text.trim(); + if let Some(included) = include_keywords { + for keyword in included { + let re = Regex::new(&format!(r"(?i){}", regex::escape(keyword))) + .unwrap(); + if re.is_match(trimmed_text) { + return Some(keyword.to_string()); + } + } + } + for (i, re) in PRIMARY_KEYWORD_REGEXES.iter().enumerate() { if re.is_match(trimmed_text) { + if let Some(excluded) = exclude_keywords { + if excluded.iter().any(|&keyword| { + PRIMARY_TODO_KEYWORDS[i].eq_ignore_ascii_case(keyword) + }) { + return None; + } + } return Some(PRIMARY_TODO_KEYWORDS[i].to_string()); } } @@ -47,6 +68,24 @@ pub fn identify_todo_comment(comment_text: &str) -> Option { if let Some(captures) = SECONDARY_KEYWORD_REGEX.captures(trimmed_text) { let first_word = captures.get(1).unwrap().as_str(); + if let Some(excluded) = exclude_keywords { + if excluded + .iter() + .any(|&keyword| first_word.eq_ignore_ascii_case(keyword)) + { + return None; + } + } + + if let Some(excluded) = exclude_keywords { + if excluded + .iter() + .any(|&keyword| first_word.eq_ignore_ascii_case(keyword)) + { + return None; + } + } + for keyword in SECONDARY_TODO_KEYWORDS.iter() { if first_word.eq_ignore_ascii_case(keyword) { return Some(keyword.to_string()); diff --git a/src/main.rs b/src/main.rs index e63cce7..2fba346 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,7 +34,13 @@ const TODOCTOR_DIR: &str = "todoctor"; const HISTORY_TEMP_FILE: &str = "todo_history_temp.json"; #[derive(Parser, Debug)] -#[command(name = "todoctor", about = "Tracks TODO comments in code")] +#[command( + name = "todoctor", + about = " +Todoctor is a powerful tool for analyzing, tracking, and visualizing technical +debt in your codebase using Git. It collects and monitors TODO/FIXME comments +in your code, allowing you to observe changes over time." +)] struct Cli { /// Number of months to process #[arg(short, long, default_value_t = 3, value_parser = clap::value_parser!(u32).range(1..))] @@ -43,6 +49,14 @@ struct Cli { /// Paths to ignore (can be used multiple times) #[arg(short, long, action = ArgAction::Append)] ignore: Vec, + + /// Keywords to track for TODO comments (can be used multiple times) + #[arg(short = 'I', long, action = ArgAction::Append)] + include_keywords: Vec, + + /// Keywords to exclude from tracking (can be used multiple times) + #[arg(short = 'E', long, action = ArgAction::Append)] + exclude_keywords: Vec, } #[tokio::main] @@ -63,6 +77,16 @@ async fn main() { .map(|values| values.map(String::from).collect()) .unwrap_or_else(Vec::new); + let include_keywords: Vec = args + .get_many::("include_keywords") + .map(|values| values.map(String::from).collect()) + .unwrap_or_else(Vec::new); + + let exclude_keywords: Vec = args + .get_many::("exclude_keywords") + .map(|values| values.map(String::from).collect()) + .unwrap_or_else(Vec::new); + if !check_git_repository(".").await { eprintln!("Error: This is not a Git repository."); process::exit(1); @@ -81,6 +105,9 @@ async fn main() { let todo_data_tasks: Vec<_> = files .into_iter() .map(|source_file_name: String| { + let include_keywords = include_keywords.clone(); + let exclude_keywords = exclude_keywords.clone(); + tokio::spawn(async move { match fs::read_to_string(&source_file_name).await { Ok(source) => { @@ -88,8 +115,23 @@ async fn main() { comments .into_iter() .filter_map(|comment| { + let include_keywords_refs: Vec<&str> = + include_keywords + .iter() + .map(|s| s.as_str()) + .collect(); + let exclude_keywords_refs: Vec<&str> = + exclude_keywords + .iter() + .map(|s| s.as_str()) + .collect(); + if let Some(comment_kind) = - identify_todo_comment(&comment.text) + identify_todo_comment( + &comment.text, + Some(&include_keywords_refs), + Some(&exclude_keywords_refs), + ) { Some(TodoData { path: source_file_name.clone(), @@ -202,6 +244,10 @@ async fn main() { .map(|file_path| { let semaphore = semaphore.clone(); let commit_hash = commit_hash.clone(); + + let include_keywords = include_keywords.clone(); + let exclude_keywords = exclude_keywords.clone(); + tokio::spawn(async move { let permit = semaphore.acquire_owned().await.unwrap(); @@ -213,10 +259,25 @@ async fn main() { .await { let comments = get_comments(&file_content, &file_path); + + let include_keywords_refs: Vec<&str> = include_keywords + .iter() + .map(|s| s.as_str()) + .collect(); + let exclude_keywords_refs: Vec<&str> = exclude_keywords + .iter() + .map(|s| s.as_str()) + .collect(); + let todos: Vec<_> = comments .into_iter() .filter(|comment| { - identify_todo_comment(&comment.text).is_some() + identify_todo_comment( + &comment.text, + Some(&include_keywords_refs), + Some(&exclude_keywords_refs), + ) + .is_some() }) .collect(); diff --git a/tests/identify_todo_comment.rs b/tests/identify_todo_comment.rs index 5e2f737..7a9a565 100644 --- a/tests/identify_todo_comment.rs +++ b/tests/identify_todo_comment.rs @@ -3,89 +3,144 @@ use todoctor::identify_todo_comment::identify_todo_comment; #[tokio::test] async fn test_primary_keyword_found() { let comment = "// TODO: This is a test comment."; - assert_eq!(identify_todo_comment(comment), Some("TODO".to_string())); + assert_eq!( + identify_todo_comment(comment, None, None), + Some("TODO".to_string()) + ); } #[tokio::test] async fn test_primary_keyword_middle_of_sentence() { let comment = "// This is a test TODO comment."; - assert_eq!(identify_todo_comment(comment), Some("TODO".to_string())); + assert_eq!( + identify_todo_comment(comment, None, None), + Some("TODO".to_string()) + ); } #[tokio::test] async fn test_primary_keyword_at_end() { let comment = "// This is a test TODO"; - assert_eq!(identify_todo_comment(comment), Some("TODO".to_string())); + assert_eq!( + identify_todo_comment(comment, None, None), + Some("TODO".to_string()) + ); } #[tokio::test] async fn test_primary_keyword_lowercase() { let comment = "// todo: lowercase todo comment."; - assert_eq!(identify_todo_comment(comment), Some("TODO".to_string())); + assert_eq!( + identify_todo_comment(comment, None, None), + Some("TODO".to_string()) + ); } #[tokio::test] async fn test_secondary_keyword_at_start_with_colon() { let comment = "// NB: There is a nota bene."; - assert_eq!(identify_todo_comment(comment), Some("NB".to_string())); + assert_eq!( + identify_todo_comment(comment, None, None), + Some("NB".to_string()) + ); } #[tokio::test] async fn test_secondary_keyword_at_start_with_no_colon() { let comment = "// DEBUG: There is a debug."; - assert_eq!(identify_todo_comment(comment), Some("DEBUG".to_string())); + assert_eq!( + identify_todo_comment(comment, None, None), + Some("DEBUG".to_string()) + ); } #[tokio::test] async fn test_secondary_keyword_not_found_in_middle() { let comment = "// This is a REVIEW comment."; - assert_eq!(identify_todo_comment(comment), None); + assert_eq!(identify_todo_comment(comment, None, None), None); } #[tokio::test] async fn test_comment_without_keywords() { let comment = "// Just a regular comment."; - assert_eq!(identify_todo_comment(comment), None); + assert_eq!(identify_todo_comment(comment, None, None), None); } #[tokio::test] async fn test_primary_keyword_with_punctuation() { let comment = "// bla bla -- TODO: fix this."; - assert_eq!(identify_todo_comment(comment), Some("TODO".to_string())); + assert_eq!( + identify_todo_comment(comment, None, None), + Some("TODO".to_string()) + ); } #[tokio::test] async fn test_secondary_keyword_case_insensitive() { let comment = "// fixme: case insensitive."; - assert_eq!(identify_todo_comment(comment), Some("FIXME".to_string())); + assert_eq!( + identify_todo_comment(comment, None, None), + Some("FIXME".to_string()) + ); } #[tokio::test] async fn test_primary_keyword_with_dot() { let comment = "// This comment has a todo in the middle."; - assert_eq!(identify_todo_comment(comment), Some("TODO".to_string())); + assert_eq!( + identify_todo_comment(comment, None, None), + Some("TODO".to_string()) + ); } #[tokio::test] async fn test_secondary_keyword_with_case_variation() { let comment = "// FiXmE: Mixed case keyword at the start."; - assert_eq!(identify_todo_comment(comment), Some("FIXME".to_string())); + assert_eq!( + identify_todo_comment(comment, None, None), + Some("FIXME".to_string()) + ); } #[tokio::test] async fn test_no_keyword_found() { let comment = "// This is just a comment without anything."; - assert_eq!(identify_todo_comment(comment), None); + assert_eq!(identify_todo_comment(comment, None, None), None); } #[tokio::test] async fn test_no_keyword_found_with_similar_primary_word() { let comment = "// I love todoctor"; - assert_eq!(identify_todo_comment(comment), None); + assert_eq!(identify_todo_comment(comment, None, None), None); } #[tokio::test] async fn test_no_keyword_found_with_similar_secondary_word() { let comment = "// Dangerous stuff"; - assert_eq!(identify_todo_comment(comment), None); + assert_eq!(identify_todo_comment(comment, None, None), None); +} + +#[tokio::test] +async fn test_keywords_include() { + let comment = "// eslint-disable-next-line no-console"; + assert_eq!( + identify_todo_comment( + comment, + Some(&["eslint-disable-next-line"]), + None, + ), + Some("eslint-disable-next-line".to_string()) + ); +} + +#[tokio::test] +async fn test_keywords_primary_exclude() { + let comment = "// todo: This is a test comment."; + assert_eq!(identify_todo_comment(comment, None, Some(&["TODO"])), None); +} + +#[tokio::test] +async fn test_keywords_secondary_exclude() { + let comment = "// IDEA: This is an idea comment."; + assert_eq!(identify_todo_comment(comment, None, Some(&["IDEA"])), None); }