markdownlint-rs 0.2.4

A fast, flexible, configuration-based command-line interface for linting Markdown/CommonMark files
Documentation
use crate::lint::rule::Rule;
use crate::markdown::MarkdownParser;
use crate::types::Violation;
use serde_json::Value;

pub struct MD036;

impl Rule for MD036 {
    fn name(&self) -> &str {
        "MD036"
    }

    fn description(&self) -> &str {
        "Emphasis used instead of a heading"
    }

    fn tags(&self) -> &[&str] {
        &["headings", "emphasis"]
    }

    fn check(&self, parser: &MarkdownParser, config: Option<&Value>) -> Vec<Violation> {
        let punctuation = config
            .and_then(|c| c.get("punctuation"))
            .and_then(|v| v.as_str())
            .unwrap_or(".,;:!?。,;:!?");

        let mut violations = Vec::new();
        let lines = parser.lines();

        // Track if we're in emphasis on a line-by-line basis
        for (line_num, line) in lines.iter().enumerate() {
            let line_number = line_num + 1;
            let trimmed = line.trim();

            // Check if the line looks like emphasis-only content
            // Simple patterns: **text**, *text*, __text__, _text_
            if is_emphasis_only_line(trimmed) {
                // Check if it ends with punctuation (if so, likely not a heading)
                if let Some(last_char) = trimmed
                    .trim_end_matches('*')
                    .trim_end_matches('_')
                    .chars()
                    .last()
                    && !punctuation.contains(last_char)
                {
                    // Likely being used as a heading
                    violations.push(Violation {
                        line: line_number,
                        column: Some(1),
                        rule: self.name().to_string(),
                        message: "Emphasis used instead of a heading".to_string(),
                        fix: None,
                    });
                }
            }
        }

        violations
    }

    fn fixable(&self) -> bool {
        false
    }
}

fn is_emphasis_only_line(line: &str) -> bool {
    let trimmed = line.trim();

    // Check for **text** or __text__ (strong)
    if (trimmed.starts_with("**") && trimmed.ends_with("**") && trimmed.len() > 4)
        || (trimmed.starts_with("__") && trimmed.ends_with("__") && trimmed.len() > 4)
    {
        // Make sure it's not just asterisks/underscores
        let inner = trimmed
            .trim_start_matches('*')
            .trim_start_matches('_')
            .trim_end_matches('*')
            .trim_end_matches('_');
        return !inner.is_empty() && !inner.chars().all(|c| c == '*' || c == '_');
    }

    // Check for *text* or _text_ (emphasis)
    if (trimmed.starts_with('*')
        && trimmed.ends_with('*')
        && !trimmed.starts_with("**")
        && trimmed.len() > 2)
        || (trimmed.starts_with('_')
            && trimmed.ends_with('_')
            && !trimmed.starts_with("__")
            && trimmed.len() > 2)
    {
        let inner = trimmed
            .trim_start_matches('*')
            .trim_start_matches('_')
            .trim_end_matches('*')
            .trim_end_matches('_');
        return !inner.is_empty() && !inner.chars().all(|c| c == '*' || c == '_');
    }

    false
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_real_heading() {
        let content = "# Heading\n## Another Heading";
        let parser = MarkdownParser::new(content);
        let rule = MD036;
        let violations = rule.check(&parser, None);

        assert_eq!(violations.len(), 0);
    }

    #[test]
    fn test_emphasis_as_heading() {
        let content = "**Summary**\n\nSome content";
        let parser = MarkdownParser::new(content);
        let rule = MD036;
        let violations = rule.check(&parser, None);

        assert_eq!(violations.len(), 1);
    }

    #[test]
    fn test_emphasis_with_punctuation() {
        let content = "**Note:** This is fine.";
        let parser = MarkdownParser::new(content);
        let rule = MD036;
        let violations = rule.check(&parser, None);

        assert_eq!(violations.len(), 0); // Ends with punctuation, not a heading
    }

    #[test]
    fn test_inline_emphasis() {
        let content = "This has **bold text** in the middle.";
        let parser = MarkdownParser::new(content);
        let rule = MD036;
        let violations = rule.check(&parser, None);

        assert_eq!(violations.len(), 0); // Not emphasis-only line
    }
}