use mdbook_lint_core::Document;
use mdbook_lint_core::rule::{Rule, RuleCategory, RuleMetadata};
use mdbook_lint_core::violation::{Severity, Violation};
use regex::Regex;
use std::sync::LazyLock;
const DEFAULT_MARKERS: &[&str] = &["TODO", "FIXME", "XXX", "HACK", "BUG", "WIP"];
static MARKER_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\b(TODO|FIXME|XXX|HACK|BUG|WIP)\b").unwrap()
});
pub struct CONTENT001 {
markers: Vec<String>,
include_defaults: bool,
check_code_blocks: bool,
}
impl Default for CONTENT001 {
fn default() -> Self {
Self {
markers: Vec::new(),
include_defaults: true,
check_code_blocks: false,
}
}
}
impl CONTENT001 {
#[allow(dead_code)]
pub fn with_markers(markers: Vec<String>) -> Self {
Self {
markers,
include_defaults: false,
check_code_blocks: false,
}
}
#[allow(dead_code)]
pub fn check_code_blocks(mut self, check: bool) -> Self {
self.check_code_blocks = check;
self
}
fn get_markers(&self) -> Vec<&str> {
let mut markers: Vec<&str> = Vec::new();
if self.include_defaults {
markers.extend(DEFAULT_MARKERS.iter().copied());
}
for marker in &self.markers {
markers.push(marker.as_str());
}
markers
}
fn build_pattern(&self) -> Regex {
let markers = self.get_markers();
if markers.is_empty() {
return MARKER_REGEX.clone();
}
let pattern = format!(r"(?i)\b({})\b", markers.join("|"));
Regex::new(&pattern).unwrap_or_else(|_| MARKER_REGEX.clone())
}
fn is_in_code_block(&self, lines: &[String], line_idx: usize) -> bool {
let mut in_fenced_block = false;
for (idx, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_fenced_block = !in_fenced_block;
}
if idx == line_idx {
return in_fenced_block;
}
}
false
}
fn is_in_inline_code(&self, line: &str, col: usize) -> bool {
let before = &line[..col.min(line.len())];
let backtick_count = before.chars().filter(|&c| c == '`').count();
backtick_count % 2 == 1
}
fn is_in_html_comment(&self, line: &str, col: usize) -> bool {
let before = &line[..col.min(line.len())];
let after = &line[col.min(line.len())..];
before.contains("<!--") && !before.contains("-->") && after.contains("-->")
}
}
impl Rule for CONTENT001 {
fn id(&self) -> &'static str {
"CONTENT001"
}
fn name(&self) -> &'static str {
"no-todo-comments"
}
fn description(&self) -> &'static str {
"TODO/FIXME/XXX comments should be resolved before publishing"
}
fn metadata(&self) -> RuleMetadata {
RuleMetadata::stable(RuleCategory::Content).introduced_in("mdbook-lint v0.11.0")
}
fn check_with_ast<'a>(
&self,
document: &Document,
_ast: Option<&'a comrak::nodes::AstNode<'a>>,
) -> mdbook_lint_core::error::Result<Vec<Violation>> {
let mut violations = Vec::new();
let pattern = self.build_pattern();
for (line_idx, line) in document.lines.iter().enumerate() {
let line_num = line_idx + 1;
if !self.check_code_blocks && self.is_in_code_block(&document.lines, line_idx) {
continue;
}
for mat in pattern.find_iter(line) {
let col = mat.start() + 1;
if !self.check_code_blocks && self.is_in_inline_code(line, mat.start()) {
continue;
}
let in_comment = self.is_in_html_comment(line, mat.start());
let marker = mat.as_str().to_uppercase();
let context = if in_comment {
format!("{} comment found in HTML comment", marker)
} else {
format!("{} comment found - resolve before publishing", marker)
};
violations.push(self.create_violation(context, line_num, col, Severity::Warning));
}
}
Ok(violations)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn create_test_document(content: &str) -> Document {
Document::new(content.to_string(), PathBuf::from("test.md")).unwrap()
}
#[test]
fn test_no_markers() {
let content = "# Title\n\nThis is clean documentation.";
let doc = create_test_document(content);
let rule = CONTENT001::default();
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_todo_detected() {
let content = "# Title\n\nTODO: Add more content here.";
let doc = create_test_document(content);
let rule = CONTENT001::default();
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("TODO"));
}
#[test]
fn test_fixme_detected() {
let content = "# Title\n\nFIXME: This section needs work.";
let doc = create_test_document(content);
let rule = CONTENT001::default();
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("FIXME"));
}
#[test]
fn test_xxx_detected() {
let content = "# Title\n\nXXX: Review this section.";
let doc = create_test_document(content);
let rule = CONTENT001::default();
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("XXX"));
}
#[test]
fn test_case_insensitive() {
let content = "# Title\n\ntodo: lowercase\nFixMe: mixed case";
let doc = create_test_document(content);
let rule = CONTENT001::default();
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 2);
}
#[test]
fn test_multiple_markers() {
let content = "# Title\n\nTODO: First thing\nFIXME: Second thing\nHACK: Third thing";
let doc = create_test_document(content);
let rule = CONTENT001::default();
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 3);
}
#[test]
fn test_skip_code_blocks_by_default() {
let content = "# Title\n\n```rust\n// TODO: This is in code\n```\n\nTODO: This is not";
let doc = create_test_document(content);
let rule = CONTENT001::default();
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].line, 7);
}
#[test]
fn test_check_code_blocks_when_enabled() {
let content = "# Title\n\n```rust\n// TODO: This is in code\n```";
let doc = create_test_document(content);
let rule = CONTENT001::default().check_code_blocks(true);
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 1);
}
#[test]
fn test_skip_inline_code() {
let content = "# Title\n\nUse `TODO` as a marker.\n\nTODO: Real marker";
let doc = create_test_document(content);
let rule = CONTENT001::default();
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].line, 5);
}
#[test]
fn test_html_comment() {
let content = "# Title\n\n<!-- TODO: Add content -->\n\nParagraph.";
let doc = create_test_document(content);
let rule = CONTENT001::default();
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("HTML comment"));
}
#[test]
fn test_word_boundary() {
let content = "# Title\n\nTODONOT a marker\nMYTODO not a marker";
let doc = create_test_document(content);
let rule = CONTENT001::default();
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 0, "Should not match partial words");
}
#[test]
fn test_wip_detected() {
let content = "# Title\n\nWIP: Work in progress section.";
let doc = create_test_document(content);
let rule = CONTENT001::default();
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("WIP"));
}
#[test]
fn test_custom_markers() {
let content = "# Title\n\nNEEDSREVIEW: Check this.";
let doc = create_test_document(content);
let rule = CONTENT001::with_markers(vec!["NEEDSREVIEW".to_string()]);
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 1);
}
#[test]
fn test_marker_with_colon() {
let content = "# Title\n\nTODO: With colon\nFIXME - With dash";
let doc = create_test_document(content);
let rule = CONTENT001::default();
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 2);
}
#[test]
fn test_marker_in_parentheses() {
let content = "# Title\n\n(TODO) In parens\n(FIXME) Also in parens";
let doc = create_test_document(content);
let rule = CONTENT001::default();
let violations = rule.check(&doc).unwrap();
assert_eq!(violations.len(), 2);
}
}