use crate::error::Result;
use crate::rule::{Rule, RuleCategory, RuleMetadata};
use crate::{
Document,
violation::{Severity, Violation},
};
pub struct MD049 {
style: EmphasisStyle,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum EmphasisStyle {
Asterisk,
Underscore,
Consistent,
}
impl MD049 {
pub fn new() -> Self {
Self {
style: EmphasisStyle::Consistent,
}
}
#[allow(dead_code)]
pub fn with_style(style: EmphasisStyle) -> Self {
Self { style }
}
fn check_line_emphasis(
&self,
line: &str,
line_number: usize,
expected_style: Option<EmphasisStyle>,
) -> (Vec<Violation>, Option<EmphasisStyle>) {
let mut violations = Vec::new();
let mut detected_style = expected_style;
let chars: Vec<char> = line.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] == '*' || chars[i] == '_' {
let marker = chars[i];
if i + 1 < chars.len() && chars[i + 1] == marker {
i += 2;
continue;
}
if i > 0 && chars[i - 1] == marker {
i += 1;
continue;
}
if let Some(end_pos) = self.find_closing_emphasis_marker(&chars, i + 1, marker) {
let current_style = if marker == '*' {
EmphasisStyle::Asterisk
} else {
EmphasisStyle::Underscore
};
if let Some(ref expected) = detected_style {
if *expected != current_style {
let expected_marker = if *expected == EmphasisStyle::Asterisk {
'*'
} else {
'_'
};
violations.push(self.create_violation(
format!(
"Emphasis style inconsistent - expected '{expected_marker}' but found '{marker}'"
),
line_number,
i + 1, Severity::Warning,
));
}
} else {
detected_style = Some(current_style);
}
i = end_pos + 1;
} else {
i += 1;
}
} else {
i += 1;
}
}
(violations, detected_style)
}
fn find_closing_emphasis_marker(
&self,
chars: &[char],
start: usize,
marker: char,
) -> Option<usize> {
let mut i = start;
while i < chars.len() {
if chars[i] == marker {
if i + 1 < chars.len() && chars[i + 1] == marker {
i += 2;
continue;
}
if i > 0 && chars[i - 1] == marker {
i += 1;
continue;
}
return Some(i);
}
i += 1;
}
None
}
fn get_code_block_ranges(&self, lines: &[&str]) -> Vec<bool> {
let mut in_code_block = vec![false; lines.len()];
let mut in_fenced_block = false;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_fenced_block = !in_fenced_block;
in_code_block[i] = true;
continue;
}
if in_fenced_block {
in_code_block[i] = true;
continue;
}
}
in_code_block
}
}
impl Default for MD049 {
fn default() -> Self {
Self::new()
}
}
impl Rule for MD049 {
fn id(&self) -> &'static str {
"MD049"
}
fn name(&self) -> &'static str {
"emphasis-style"
}
fn description(&self) -> &'static str {
"Emphasis style should be consistent"
}
fn metadata(&self) -> RuleMetadata {
RuleMetadata::stable(RuleCategory::Formatting).introduced_in("mdbook-lint v0.1.0")
}
fn check_with_ast<'a>(
&self,
document: &Document,
_ast: Option<&'a comrak::nodes::AstNode<'a>>,
) -> Result<Vec<Violation>> {
let mut violations = Vec::new();
let lines: Vec<&str> = document.content.lines().collect();
let in_code_block = self.get_code_block_ranges(&lines);
let mut expected_style = match self.style {
EmphasisStyle::Asterisk => Some(EmphasisStyle::Asterisk),
EmphasisStyle::Underscore => Some(EmphasisStyle::Underscore),
EmphasisStyle::Consistent => None, };
for (line_number, line) in lines.iter().enumerate() {
let line_number = line_number + 1;
if in_code_block[line_number - 1] {
continue;
}
let (line_violations, detected_style) =
self.check_line_emphasis(line, line_number, expected_style);
violations.extend(line_violations);
if expected_style.is_none() && detected_style.is_some() {
expected_style = detected_style;
}
}
Ok(violations)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rule::Rule;
use std::path::PathBuf;
fn create_test_document(content: &str) -> Document {
Document::new(content.to_string(), PathBuf::from("test.md")).unwrap()
}
#[test]
fn test_md049_consistent_asterisk_style() {
let content = r#"This has *emphasis* and more *italic text* here.
Another paragraph with *more emphasis* text.
"#;
let document = create_test_document(content);
let rule = MD049::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md049_consistent_underscore_style() {
let content = r#"This has _emphasis_ and more _italic text_ here.
Another paragraph with _more emphasis_ text.
"#;
let document = create_test_document(content);
let rule = MD049::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md049_mixed_styles_violation() {
let content = r#"This has *emphasis* and more _italic text_ here.
Another paragraph with *more emphasis* text.
"#;
let document = create_test_document(content);
let rule = MD049::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].rule_id, "MD049");
assert_eq!(violations[0].line, 1);
assert!(violations[0].message.contains("expected '*' but found '_'"));
}
#[test]
fn test_md049_preferred_asterisk_style() {
let content = r#"This has _emphasis_ text.
"#;
let document = create_test_document(content);
let rule = MD049::with_style(EmphasisStyle::Asterisk);
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("expected '*' but found '_'"));
}
#[test]
fn test_md049_preferred_underscore_style() {
let content = r#"This has *emphasis* text.
"#;
let document = create_test_document(content);
let rule = MD049::with_style(EmphasisStyle::Underscore);
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("expected '_' but found '*'"));
}
#[test]
fn test_md049_strong_emphasis_ignored() {
let content = r#"This has **strong text** and _italic text_.
More **strong** and _italic_ here.
"#;
let document = create_test_document(content);
let rule = MD049::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0); }
#[test]
fn test_md049_mixed_strong_and_emphasis() {
let content = r#"This has **strong** and *italic* and _also italic_.
More text here.
"#;
let document = create_test_document(content);
let rule = MD049::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("expected '*' but found '_'"));
}
#[test]
fn test_md049_code_blocks_ignored() {
let content = r#"This has *italic* text.
```
Code with *asterisks* and _underscores_ should be ignored.
```
This has _different style_ which should trigger violation.
"#;
let document = create_test_document(content);
let rule = MD049::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].line, 7);
}
#[test]
fn test_md049_inline_code_spans() {
let content = r#"This has *italic* and `code with *asterisks*` text.
More *italic* text here.
"#;
let document = create_test_document(content);
let rule = MD049::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md049_no_emphasis() {
let content = r#"This document has no emphasis at all.
Just regular text with **strong** formatting.
"#;
let document = create_test_document(content);
let rule = MD049::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md049_multiple_violations() {
let content = r#"Start with *italic* text.
Then switch to _different style_.
Back to *original style*.
And _different again_.
"#;
let document = create_test_document(content);
let rule = MD049::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2); assert_eq!(violations[0].line, 3);
assert_eq!(violations[1].line, 7);
}
#[test]
fn test_md049_unclosed_emphasis() {
let content = r#"This has *unclosed emphasis and _closed emphasis_.
More text here.
"#;
let document = create_test_document(content);
let rule = MD049::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0); }
}