use crate::utils::range_utils::calculate_match_range;
use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::rules::strong_style::StrongStyle;
use crate::utils::code_block_utils::StrongSpanDetail;
use crate::utils::skip_context::{compute_html_code_ranges, should_skip_emphasis_span};
use regex::Regex;
use std::sync::LazyLock;
static UNICODE_PUNCTUATION: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[\p{P}\p{S}]$").unwrap());
fn can_use_underscore_strong(content: &str, start: usize, end: usize) -> bool {
let allows_boundary = |ch: char| {
ch.is_whitespace()
|| ch.is_ascii_punctuation()
|| (!ch.is_ascii() && UNICODE_PUNCTUATION.is_match(ch.encode_utf8(&mut [0; 4])))
};
content[..start]
.trim_end_matches('_')
.chars()
.next_back()
.is_none_or(allows_boundary)
&& content[end..]
.trim_start_matches('_')
.chars()
.next()
.is_none_or(allows_boundary)
}
fn span_style(span: &StrongSpanDetail) -> StrongStyle {
if span.is_asterisk {
StrongStyle::Asterisk
} else {
StrongStyle::Underscore
}
}
mod md050_config;
use md050_config::MD050Config;
#[derive(Debug, Default, Clone)]
pub struct MD050StrongStyle {
config: MD050Config,
}
impl MD050StrongStyle {
pub fn new(style: StrongStyle) -> Self {
Self {
config: MD050Config { style },
}
}
pub fn from_config_struct(config: MD050Config) -> Self {
Self { config }
}
#[cfg(test)]
fn detect_style(&self, ctx: &crate::lint_context::LintContext) -> Option<StrongStyle> {
let html_tags = ctx.html_tags();
let html_code_ranges = compute_html_code_ranges(&html_tags);
self.detect_style_from_spans(ctx, &html_tags, &html_code_ranges, &ctx.strong_spans)
}
fn detect_style_from_spans(
&self,
ctx: &crate::lint_context::LintContext,
html_tags: &[crate::lint_context::HtmlTag],
html_code_ranges: &[(usize, usize)],
spans: &[StrongSpanDetail],
) -> Option<StrongStyle> {
let mut asterisk_count = 0;
let mut underscore_count = 0;
for span in spans {
if should_skip_emphasis_span(ctx, html_tags, html_code_ranges, span.start, span.end) {
continue;
}
match span_style(span) {
StrongStyle::Asterisk => asterisk_count += 1,
StrongStyle::Underscore => underscore_count += 1,
StrongStyle::Consistent => {}
}
}
match (asterisk_count, underscore_count) {
(0, 0) => None,
(_, 0) => Some(StrongStyle::Asterisk),
(0, _) => Some(StrongStyle::Underscore),
(a, u) => {
if a >= u {
Some(StrongStyle::Asterisk)
} else {
Some(StrongStyle::Underscore)
}
}
}
}
}
impl Rule for MD050StrongStyle {
fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
(self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(50))
}
fn name(&self) -> &'static str {
"MD050"
}
fn description(&self) -> &'static str {
"Strong emphasis style should be consistent"
}
fn category(&self) -> RuleCategory {
RuleCategory::Emphasis
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let content = ctx.content;
let lines = ctx.raw_lines();
let mut warnings = Vec::new();
let spans = &ctx.strong_spans;
let html_tags = ctx.html_tags();
let html_code_ranges = compute_html_code_ranges(&html_tags);
let target_style = match self.config.style {
StrongStyle::Consistent => self
.detect_style_from_spans(ctx, &html_tags, &html_code_ranges, spans)
.unwrap_or(StrongStyle::Asterisk),
_ => self.config.style,
};
for span in spans {
if span_style(span) == target_style {
continue;
}
if span.end - span.start < 4 {
continue;
}
if should_skip_emphasis_span(ctx, &html_tags, &html_code_ranges, span.start, span.end) {
continue;
}
let (line_num, _col) = ctx.offset_to_line_col(span.start);
let line_start = ctx.line_start_byte(line_num).unwrap_or(0);
let line_content = lines.get(line_num - 1).unwrap_or(&"");
let match_start_in_line = span.start - line_start;
let match_len = span.end - span.start;
let inner_text = &content[span.start + 2..span.end - 2];
let message = match target_style {
StrongStyle::Asterisk => "Strong emphasis should use ** instead of __",
StrongStyle::Underscore => "Strong emphasis should use __ instead of **",
StrongStyle::Consistent => "Strong emphasis should use ** instead of __",
};
let (start_line, start_col, end_line, end_col) =
calculate_match_range(line_num, line_content, match_start_in_line, match_len);
let fix = if target_style == StrongStyle::Underscore
&& !can_use_underscore_strong(content, span.start, span.end)
{
None
} else {
Some(Fix::new(
span.start..span.end,
match target_style {
StrongStyle::Underscore => format!("__{inner_text}__"),
_ => format!("**{inner_text}**"),
},
))
};
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
message: message.to_string(),
severity: Severity::Warning,
fix,
});
}
Ok(warnings)
}
fn fixes_from_diagnostics(&self) -> bool {
true
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
if self.should_skip(ctx) {
return Ok(ctx.content.to_string());
}
let warnings = self.check(ctx)?;
if warnings.is_empty() {
return Ok(ctx.content.to_string());
}
let warnings =
crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
.map_err(crate::rule::LintError::InvalidInput)
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
ctx.content.is_empty() || !ctx.likely_has_emphasis()
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
crate::impl_rule_config_methods!(MD050Config);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
#[test]
fn test_shortcode_markers_are_not_restyled() {
for (style, original, replacement) in [
(StrongStyle::Underscore, "**", "__"),
(StrongStyle::Asterisk, "__", "**"),
] {
let rule = MD050StrongStyle::new(style);
for shortcode in [
format!("{{{{< note title=\"{original}literal{original}\" >}}}}"),
format!("{{{{% note title=\"{original}内容{original}\" %}}}}"),
format!("{{{{< note\n title=\"{original}literal{original}\" >}}}}"),
format!("{original}text {{{{< note title=\"{original}\" >}}}}"),
] {
let source = format!("Before {shortcode}\n\n{original}visible{original}\n");
let expected = format!("Before {shortcode}\n\n{replacement}visible{replacement}\n");
let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Hugo, None);
assert_eq!(
rule.check(&ctx).unwrap().len(),
1,
"shortcode arguments are literal: {source}"
);
assert_eq!(rule.fix(&ctx).unwrap(), expected);
}
}
}
#[test]
fn test_shortcode_arguments_do_not_determine_consistent_style() {
let rule = MD050StrongStyle::new(StrongStyle::Consistent);
let source = "Before {{< note title=\"**a** **b** **c**\" >}}\n\n__first__ __second__ **visible**\n";
let expected = "Before {{< note title=\"**a** **b** **c**\" >}}\n\n__first__ __second__ __visible__\n";
let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Hugo, None);
assert_eq!(rule.check(&ctx).unwrap().len(), 1);
assert_eq!(rule.fix(&ctx).unwrap(), expected);
}
#[test]
fn test_myst_comments_do_not_determine_consistent_style() {
let rule = MD050StrongStyle::new(StrongStyle::Consistent);
let source = "% **a** **b** **c**\n\n__first__ __second__ **visible**\n";
let expected = "% **a** **b** **c**\n\n__first__ __second__ __visible__\n";
let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::MyST, None);
assert_eq!(rule.check(&ctx).unwrap().len(), 1);
assert_eq!(rule.fix(&ctx).unwrap(), expected);
}
#[test]
fn test_myst_comment_contents_are_not_linted() {
let rule = MD050StrongStyle::new(StrongStyle::Underscore);
for prefix in ["% ", "%", " %\t", " % "] {
let source = format!("{prefix}**literal**\n\nBefore **visible**\n");
let expected = format!("{prefix}**literal**\n\nBefore __visible__\n");
let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::MyST, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1, "comments are not visible prose: {source}");
assert_eq!(warnings[0].line, 3);
assert_eq!(rule.fix(&ctx).unwrap(), expected);
}
}
#[test]
fn test_jinja_quoted_markers_are_not_restyled() {
for (style, original, replacement) in [
(StrongStyle::Underscore, "**", "__"),
(StrongStyle::Asterisk, "__", "**"),
] {
let rule = MD050StrongStyle::new(style);
for template in [
format!("{{{{ \"{original}literal{original}\" }}}}"),
format!("{{% set text = '{original}内容{original}' %}}"),
format!("{{{{\n \"{original}literal{original}\"\n}}}}"),
format!("{original}text {{{{ \"{original}\" }}}}"),
] {
let source = format!("Before {template}\n\n{original}visible{original}\n");
let expected = format!("Before {template}\n\n{replacement}visible{replacement}\n");
let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(
rule.check(&ctx).unwrap().len(),
1,
"quoted markers are literal: {source}"
);
assert_eq!(rule.fix(&ctx).unwrap(), expected);
}
}
}
#[test]
fn test_jinja_quoted_markers_do_not_determine_consistent_style() {
let rule = MD050StrongStyle::new(StrongStyle::Consistent);
let source = "Before {{ \"**a** **b** **c**\" }}\n\n__first__ __second__ **visible**\n";
let expected = "Before {{ \"**a** **b** **c**\" }}\n\n__first__ __second__ __visible__\n";
let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(rule.check(&ctx).unwrap().len(), 1);
assert_eq!(rule.fix(&ctx).unwrap(), expected);
}
#[test]
fn test_mdx_module_strings_are_not_restyled() {
for (style, original, replacement) in [
(StrongStyle::Underscore, "**", "__"),
(StrongStyle::Asterisk, "__", "**"),
] {
let rule = MD050StrongStyle::new(style);
for module in [
format!("export const text = \"{original}literal{original}\""),
format!("export const data = {{\n text: \"{original}内容{original}\"\n}}"),
format!("import text from \"./{original}literal{original}.js\""),
] {
let source = format!("{module}\n\n{original}visible{original}\n");
let expected = format!("{module}\n\n{replacement}visible{replacement}\n");
let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1, "module strings must stay literal: {source}");
assert!(warnings[0].line > module.lines().count());
assert_eq!(rule.fix(&ctx).unwrap(), expected);
}
}
}
#[test]
fn test_mdx_module_strings_do_not_determine_consistent_style() {
let rule = MD050StrongStyle::new(StrongStyle::Consistent);
let source = "export const text = \"**a** **b** **c**\"\n\n__first__ __second__ **visible**\n";
let expected = "export const text = \"**a** **b** **c**\"\n\n__first__ __second__ __visible__\n";
let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
assert_eq!(rule.check(&ctx).unwrap().len(), 1);
assert_eq!(rule.fix(&ctx).unwrap(), expected);
}
#[test]
fn test_underscore_style_reports_intraword_strong_without_removing_it() {
for text in [
"pre**fix**post",
"**fix**post",
"pre**fix**",
"日本語**強調**後",
"3**fix**5",
"foo_**bar**_baz",
] {
let content = format!("__one__ __two__ {text}\n");
for style in [StrongStyle::Underscore, StrongStyle::Consistent] {
let rule = MD050StrongStyle::new(style);
let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1, "the style mismatch remains visible: {content}");
assert!(warnings[0].fix.is_none(), "underscores would remove bold: {content}");
assert_eq!(rule.fix(&ctx).unwrap(), content);
let mut original_html = String::new();
pulldown_cmark::html::push_html(&mut original_html, pulldown_cmark::Parser::new(&content));
assert_eq!(original_html.matches("<strong>").count(), 3);
}
}
}
#[test]
fn test_underscore_style_still_fixes_safe_boundaries() {
let rule = MD050StrongStyle::new(StrongStyle::Underscore);
for (source, expected) in [
("**word**", "__word__"),
("(**word**)", "(__word__)"),
("x-**word**-y", "x-__word__-y"),
("“**word**”", "“__word__”"),
("💡**word**💡", "💡__word__💡"),
("**word**\u{a0}after", "__word__\u{a0}after"),
] {
let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].fix.is_some());
assert_eq!(rule.fix(&ctx).unwrap(), expected);
let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
assert!(rule.check(&fixed_ctx).unwrap().is_empty());
assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
let mut original_html = String::new();
let mut fixed_html = String::new();
pulldown_cmark::html::push_html(&mut original_html, pulldown_cmark::Parser::new(source));
pulldown_cmark::html::push_html(&mut fixed_html, pulldown_cmark::Parser::new(expected));
assert_eq!(original_html, fixed_html, "bold formatting must be preserved");
}
}
#[test]
fn test_underscore_style_fixes_safe_spans_beside_intraword_strong() {
let rule = MD050StrongStyle::new(StrongStyle::Consistent);
let content = "__one__ __two__ __three__ pre**fix**post **safe**\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 2);
assert!(warnings[0].fix.is_none());
assert!(warnings[1].fix.is_some());
let expected = "__one__ __two__ __three__ pre**fix**post __safe__\n";
assert_eq!(rule.fix(&ctx).unwrap(), expected);
let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(rule.check(&fixed_ctx).unwrap().len(), 1);
assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
}
#[test]
fn test_asterisk_style_with_asterisks() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "This is **strong text** here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_asterisk_style_with_underscores() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "This is __strong text__ here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(
result[0]
.message
.contains("Strong emphasis should use ** instead of __")
);
assert_eq!(result[0].line, 1);
assert_eq!(result[0].column, 9);
}
#[test]
fn test_underscore_style_with_underscores() {
let rule = MD050StrongStyle::new(StrongStyle::Underscore);
let content = "This is __strong text__ here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_underscore_style_with_asterisks() {
let rule = MD050StrongStyle::new(StrongStyle::Underscore);
let content = "This is **strong text** here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(
result[0]
.message
.contains("Strong emphasis should use __ instead of **")
);
}
#[test]
fn test_consistent_style_first_asterisk() {
let rule = MD050StrongStyle::new(StrongStyle::Consistent);
let content = "First **strong** then __also strong__.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(
result[0]
.message
.contains("Strong emphasis should use ** instead of __")
);
}
#[test]
fn test_consistent_style_tie_prefers_asterisk() {
let rule = MD050StrongStyle::new(StrongStyle::Consistent);
let content = "First __strong__ then **also strong**.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(
result[0]
.message
.contains("Strong emphasis should use ** instead of __")
);
}
#[test]
fn test_detect_style_asterisk() {
let rule = MD050StrongStyle::new(StrongStyle::Consistent);
let ctx = LintContext::new(
"This has **strong** text.",
crate::config::MarkdownFlavor::Standard,
None,
);
let style = rule.detect_style(&ctx);
assert_eq!(style, Some(StrongStyle::Asterisk));
}
#[test]
fn test_detect_style_underscore() {
let rule = MD050StrongStyle::new(StrongStyle::Consistent);
let ctx = LintContext::new(
"This has __strong__ text.",
crate::config::MarkdownFlavor::Standard,
None,
);
let style = rule.detect_style(&ctx);
assert_eq!(style, Some(StrongStyle::Underscore));
}
#[test]
fn test_detect_style_none() {
let rule = MD050StrongStyle::new(StrongStyle::Consistent);
let ctx = LintContext::new("No strong text here.", crate::config::MarkdownFlavor::Standard, None);
let style = rule.detect_style(&ctx);
assert_eq!(style, None);
}
#[test]
fn test_strong_in_code_block() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "```\n__strong__ in code\n```\n__strong__ outside";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 4);
}
#[test]
fn test_strong_in_inline_code() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "Text with `__strong__` in code and __strong__ outside.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_escaped_strong() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "This is \\__not strong\\__ but __this is__.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 1);
assert_eq!(result[0].column, 30);
}
#[test]
fn test_fix_asterisks_to_underscores() {
let rule = MD050StrongStyle::new(StrongStyle::Underscore);
let content = "This is **strong** text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "This is __strong__ text.");
}
#[test]
fn test_fix_underscores_to_asterisks() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "This is __strong__ text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "This is **strong** text.");
}
#[test]
fn test_fix_multiple_strong() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "First __strong__ and second __also strong__.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "First **strong** and second **also strong**.");
}
#[test]
fn test_fix_preserves_code_blocks() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "```\n__strong__ in code\n```\n__strong__ outside";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "```\n__strong__ in code\n```\n**strong** outside");
}
#[test]
fn test_multiline_content() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "Line 1 with __strong__\nLine 2 with __another__\nLine 3 normal";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].line, 1);
assert_eq!(result[1].line, 2);
}
#[test]
fn test_nested_emphasis() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "This has __strong with *emphasis* inside__.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_empty_content() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_default_config() {
let rule = MD050StrongStyle::new(StrongStyle::Consistent);
let (name, _config) = rule.default_config_section().unwrap();
assert_eq!(name, "MD050");
}
#[test]
fn test_strong_in_links_not_flagged() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = r#"Instead of assigning to `self.value`, we're relying on the [`__dict__`][__dict__] in our object to hold that value instead.
Hint:
- [An article on something](https://blog.yuo.be/2018/08/16/__init_subclass__-a-simpler-way-to-implement-class-registries-in-python/ "Some details on using `__init_subclass__`")
[__dict__]: https://www.pythonmorsels.com/where-are-attributes-stored/"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_strong_in_links_vs_outside_links() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = r#"We're doing this because generator functions return a generator object which [is an iterator][generators are iterators] and **we need `__iter__` to return an [iterator][]**.
Instead of assigning to `self.value`, we're relying on the [`__dict__`][__dict__] in our object to hold that value instead.
This is __real strong text__ that should be flagged.
[__dict__]: https://www.pythonmorsels.com/where-are-attributes-stored/"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(
result[0]
.message
.contains("Strong emphasis should use ** instead of __")
);
assert!(result[0].line > 4); }
#[test]
fn test_front_matter_not_flagged() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "---\ntitle: What's __init__.py?\nother: __value__\n---\n\nThis __should be flagged__.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 6);
assert!(
result[0]
.message
.contains("Strong emphasis should use ** instead of __")
);
}
#[test]
fn test_html_tags_not_flagged() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = r#"# Test
This has HTML with underscores:
<iframe src="https://example.com/__init__/__repr__"> </iframe>
This __should be flagged__ as inconsistent."#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 7);
assert!(
result[0]
.message
.contains("Strong emphasis should use ** instead of __")
);
}
#[test]
fn test_mkdocs_keys_notation_not_flagged() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "Press ++ctrl+alt+del++ to restart.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Keys notation should not be flagged as strong emphasis. Got: {result:?}"
);
}
#[test]
fn test_mkdocs_caret_notation_not_flagged() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "This is ^^inserted^^ text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Insert notation should not be flagged as strong emphasis. Got: {result:?}"
);
}
#[test]
fn test_mkdocs_mark_notation_not_flagged() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "This is ==highlighted== text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Mark notation should not be flagged as strong emphasis. Got: {result:?}"
);
}
#[test]
fn test_mkdocs_mixed_content_with_real_strong() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "Press ++ctrl++ and __underscore strong__ here.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
assert!(
result[0]
.message
.contains("Strong emphasis should use ** instead of __")
);
}
#[test]
fn test_mkdocs_icon_shortcode_not_flagged() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "Click :material-check: and __this should be flagged__.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert!(
result[0]
.message
.contains("Strong emphasis should use ** instead of __")
);
}
#[test]
fn test_math_block_not_flagged() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = r#"# Math Section
$$
E = mc^2
x_1 + x_2 = y
a**b = c
$$
This __should be flagged__ outside math.
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Expected 1 warning, got: {result:?}");
assert!(result[0].line > 7, "Warning should be on line after math block");
}
#[test]
fn test_math_block_with_underscores_not_flagged() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = r#"$$
x_1 + x_2 + x__3 = y
\alpha__\beta
$$
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Math block content should not be flagged. Got: {result:?}"
);
}
#[test]
fn test_math_block_with_asterisks_not_flagged() {
let rule = MD050StrongStyle::new(StrongStyle::Underscore);
let content = r#"$$
a**b = c
2 ** 3 = 8
x***y
$$
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Math block content should not be flagged. Got: {result:?}"
);
}
#[test]
fn test_math_block_fix_preserves_content() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = r#"$$
x__y = z
$$
This __word__ should change.
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("x__y = z"), "Math block content should be preserved");
assert!(fixed.contains("**word**"), "Strong outside math should be fixed");
}
#[test]
fn test_inline_math_simple() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "The formula $E = mc^2$ is famous and __this__ is strong.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Expected 1 warning for strong outside math. Got: {result:?}"
);
}
#[test]
fn test_multiple_math_blocks_and_strong() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = r#"# Document
$$
a = b
$$
This __should be flagged__ text.
$$
c = d
$$
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Expected 1 warning. Got: {result:?}");
assert!(result[0].message.contains("**"));
}
#[test]
fn test_html_tag_skip_consistency_between_check_and_fix() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = r#"<a href="__test__">link</a>
This __should be flagged__ text."#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let check_result = rule.check(&ctx).unwrap();
let fix_result = rule.fix(&ctx).unwrap();
assert_eq!(
check_result.len(),
1,
"check() should flag exactly one emphasis outside HTML tags"
);
assert!(check_result[0].message.contains("**"));
assert!(
fix_result.contains("**should be flagged**"),
"fix() should convert the flagged emphasis"
);
assert!(
fix_result.contains("__test__"),
"fix() should not modify emphasis inside HTML tags"
);
}
#[test]
fn test_detect_style_ignores_emphasis_in_inline_code_on_table_lines() {
let rule = MD050StrongStyle::new(StrongStyle::Consistent);
let content = "| `__code__` | **real** |\n| --- | --- |\n| data | data |";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let style = rule.detect_style(&ctx);
assert_eq!(style, Some(StrongStyle::Asterisk));
}
#[test]
fn test_five_underscores_not_flagged() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "This is a series of underscores: _____";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"_____ should not be flagged as strong emphasis. Got: {result:?}"
);
}
#[test]
fn test_five_asterisks_not_flagged() {
let rule = MD050StrongStyle::new(StrongStyle::Underscore);
let content = "This is a series of asterisks: *****";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"***** should not be flagged as strong emphasis. Got: {result:?}"
);
}
#[test]
fn test_five_underscores_with_frontmatter_not_flagged() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "---\ntitle: Level 1 heading\n---\n\nThis is a series of underscores: _____\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "_____ should not be flagged. Got: {result:?}");
}
#[test]
fn test_four_underscores_not_flagged() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "This is: ____";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "____ should not be flagged. Got: {result:?}");
}
#[test]
fn test_four_asterisks_not_flagged() {
let rule = MD050StrongStyle::new(StrongStyle::Underscore);
let content = "This is: ****";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "**** should not be flagged. Got: {result:?}");
}
#[test]
fn test_detect_style_ignores_underscore_sequences() {
let rule = MD050StrongStyle::new(StrongStyle::Consistent);
let content = "This is: _____ and also **real bold**";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let style = rule.detect_style(&ctx);
assert_eq!(style, Some(StrongStyle::Asterisk));
}
#[test]
fn test_fix_does_not_modify_underscore_sequences() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "Some _____ sequence and __real bold__ text.";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("_____"), "_____ should be preserved");
assert!(fixed.contains("**real bold**"), "Real bold should be converted");
}
#[test]
fn test_six_or_more_consecutive_markers_not_flagged() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
for count in [6, 7, 8, 10] {
let underscores = "_".repeat(count);
let asterisks = "*".repeat(count);
let content_u = format!("Text with {underscores} here");
let content_a = format!("Text with {asterisks} here");
let ctx_u = LintContext::new(&content_u, crate::config::MarkdownFlavor::Standard, None);
let ctx_a = LintContext::new(&content_a, crate::config::MarkdownFlavor::Standard, None);
let result_u = rule.check(&ctx_u).unwrap();
let result_a = rule.check(&ctx_a).unwrap();
assert!(
result_u.is_empty(),
"{count} underscores should not be flagged. Got: {result_u:?}"
);
assert!(
result_a.is_empty(),
"{count} asterisks should not be flagged. Got: {result_a:?}"
);
}
}
#[test]
fn test_mkdocstrings_block_not_flagged() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "# Example\n\nWe have here some **bold text**.\n\n::: my_module.MyClass\n options:\n members:\n - __init__\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"__init__ inside mkdocstrings block should not be flagged. Got: {result:?}"
);
}
#[test]
fn test_mkdocstrings_block_fix_preserves_content() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "# Example\n\nWe have here some **bold text**.\n\n::: my_module.MyClass\n options:\n members:\n - __init__\n - __repr__\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("__init__"),
"__init__ in mkdocstrings block should be preserved"
);
assert!(
fixed.contains("__repr__"),
"__repr__ in mkdocstrings block should be preserved"
);
assert!(fixed.contains("**bold text**"), "Real bold text should be unchanged");
}
#[test]
fn test_mkdocstrings_block_with_strong_outside() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "::: my_module.MyClass\n options:\n members:\n - __init__\n\nThis __should be flagged__ outside.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Only strong outside mkdocstrings should be flagged. Got: {result:?}"
);
assert_eq!(result[0].line, 6);
}
#[test]
fn test_thematic_break_not_flagged() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
let content = "Before\n\n*****\n\nAfter";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Thematic break (*****) should not be flagged. Got: {result:?}"
);
let content2 = "Before\n\n_____\n\nAfter";
let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
let result2 = rule.check(&ctx2).unwrap();
assert!(
result2.is_empty(),
"Thematic break (_____) should not be flagged. Got: {result2:?}"
);
}
}
#[cfg(test)]
mod mdx_component_value_tests {
use super::*;
#[test]
fn md050_mdx_component_values_are_literal() {
let rule = MD050StrongStyle::new(StrongStyle::Asterisk);
for name in ["span", "Card", "ui.Card", "svg:path"] {
let tag = format!("<{name} title='__literal__'>text</{name}>");
let source = format!("内容 Before {tag} after __visible__\n");
let expected = format!("内容 Before {tag} after **visible**\n");
let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{source}");
assert_eq!(rule.fix(&ctx).unwrap(), expected);
}
}
#[test]
fn md050_mdx_literals_do_not_choose_consistent_style() {
let rule = MD050StrongStyle::new(StrongStyle::Consistent);
let source =
"Before <ui.Card title='**a** **b** **c**'>text</ui.Card> after\n\n__first__ __second__ **visible**\n";
let expected = source.replace("**visible**", "__visible__");
let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::MDX, None);
assert_eq!(rule.check(&ctx).unwrap().len(), 1);
assert_eq!(rule.fix(&ctx).unwrap(), expected);
}
}