1use crate::utils::range_utils::calculate_line_range;
7
8use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
9use toml;
10
11mod md035_config;
12use md035_config::MD035Config;
13
14#[derive(Clone, Default)]
16pub struct MD035HRStyle {
17 config: MD035Config,
18}
19
20impl MD035HRStyle {
21 pub fn new(style: String) -> Self {
22 Self {
23 config: MD035Config { style },
24 }
25 }
26
27 pub fn from_config_struct(config: MD035Config) -> Self {
28 Self { config }
29 }
30
31 pub(crate) fn is_horizontal_rule(line: &str) -> bool {
32 crate::utils::thematic_break::is_thematic_break(line)
33 }
34
35 fn is_potential_setext_heading(lines: &[&str], i: usize) -> bool {
37 if i == 0 {
38 return false; }
40
41 let line = lines[i].trim();
42 let prev_line = lines[i - 1].trim();
43
44 let is_dash_line = !line.is_empty() && line.chars().all(|c| c == '-');
45 let is_equals_line = !line.is_empty() && line.chars().all(|c| c == '=');
46 let prev_line_has_content = !prev_line.is_empty() && !Self::is_horizontal_rule(prev_line);
47 (is_dash_line || is_equals_line) && prev_line_has_content
48 }
49
50 fn is_setext_underline_text(replacement: &str) -> bool {
54 let trimmed = replacement.trim_matches(|c| c == ' ' || c == '\t');
55 !trimmed.is_empty() && trimmed.chars().all(|c| c == '-')
56 }
57
58 fn would_form_setext_underline(lines: &[&str], index: usize, replacement: &str) -> bool {
63 Self::is_setext_underline_text(replacement)
64 && index > 0
65 && lines.get(index - 1).is_some_and(|prev| !prev.trim().is_empty())
66 }
67
68 fn would_open_front_matter(ctx: &crate::lint_context::LintContext, index: usize, replacement: &str) -> bool {
73 if index != 0 || ctx.front_matter_end_line() > 0 {
74 return false;
75 }
76 let rest = ctx.content.find('\n').map_or("", |end| &ctx.content[end..]);
77 let candidate = format!("{replacement}{rest}");
78 crate::lint_context::LintContext::new(&candidate, ctx.flavor, None).front_matter_end_line() > 0
79 }
80
81 fn container_break_lines(ctx: &crate::lint_context::LintContext) -> std::collections::HashSet<usize> {
85 use crate::lint_context::document_parser::blocks::BlockKind;
86 let mut depth = 0usize;
87 let mut result = std::collections::HashSet::new();
88 for (kind, range) in &ctx.parser_blocks().events {
89 match kind {
90 BlockKind::ItemStart | BlockKind::QuoteStart => depth += 1,
91 BlockKind::ItemEnd | BlockKind::QuoteEnd => depth = depth.saturating_sub(1),
92 BlockKind::Rule if depth > 0 => {
93 let line = ctx.lines.partition_point(|l| l.byte_offset <= range.start);
94 result.insert(line.saturating_sub(1));
95 }
96 _ => {}
97 }
98 }
99 result
100 }
101
102 fn should_skip_hr_line(ctx: &crate::lint_context::LintContext, index: usize) -> bool {
104 let Some(line) = ctx.lines.get(index) else {
105 return false;
106 };
107 let marker_offset = line.byte_offset + line.indent;
108 line.in_front_matter
109 || line.in_code_block
110 || line.in_mkdocs_html_markdown
111 || line.in_html_block
112 || ctx.line_holds_html_block(index)
113 || line.in_html_comment
114 || ctx.is_inside_template_code(marker_offset)
115 || ctx.is_inside_mdx_code(marker_offset)
116 }
117
118 fn most_prevalent_hr_style(lines: &[&str], ctx: &crate::lint_context::LintContext) -> Option<String> {
120 use std::collections::HashMap;
121 let mut counts: HashMap<&str, usize> = HashMap::new();
122 let mut order: Vec<&str> = Vec::new();
123 for (i, line) in lines.iter().enumerate() {
124 if !Self::is_horizontal_rule(line) || Self::should_skip_hr_line(ctx, i) {
125 continue;
126 }
127
128 if !Self::is_potential_setext_heading(lines, i) {
129 let style = line.trim();
130 let counter = counts.entry(style).or_insert(0);
131 *counter += 1;
132 if *counter == 1 {
133 order.push(style);
134 }
135 }
136 }
137 counts
139 .iter()
140 .max_by_key(|&(style, count)| {
141 (
142 *count,
143 -(order.iter().position(|&s| s == *style).unwrap_or(usize::MAX) as isize),
144 )
145 })
146 .map(|(style, _)| style.to_string())
147 }
148}
149
150impl Rule for MD035HRStyle {
151 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
152 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(35))
153 }
154
155 fn name(&self) -> &'static str {
156 "MD035"
157 }
158
159 fn description(&self) -> &'static str {
160 "Horizontal rule style"
161 }
162
163 fn category(&self) -> RuleCategory {
164 RuleCategory::Whitespace
165 }
166
167 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
168 let mut warnings = Vec::new();
169 let lines = ctx.raw_lines();
170
171 let expected_style = if self.config.style.is_empty() || self.config.style == "consistent" {
173 Self::most_prevalent_hr_style(lines, ctx).unwrap_or_else(|| "---".to_string())
174 } else {
175 self.config.style.clone()
176 };
177
178 let mut container_breaks: Option<std::collections::HashSet<usize>> = None;
179
180 for (i, line) in lines.iter().enumerate() {
181 if !Self::is_horizontal_rule(line) || Self::should_skip_hr_line(ctx, i) {
182 continue;
183 }
184
185 if Self::is_potential_setext_heading(lines, i) {
187 continue;
188 }
189
190 let leading = &line[..line.len() - line.trim_start().len()];
192 let in_container = !leading.is_empty()
195 && container_breaks
196 .get_or_insert_with(|| Self::container_break_lines(ctx))
197 .contains(&i);
198 let has_indentation = !leading.is_empty() && !in_container;
199 let style_mismatch = line.trim() != expected_style;
200
201 if style_mismatch || has_indentation {
202 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, line);
204
205 let replacement = if in_container {
206 format!("{leading}{expected_style}")
207 } else {
208 expected_style.clone()
209 };
210 let fix = if Self::would_form_setext_underline(lines, i, &replacement)
211 || Self::would_open_front_matter(ctx, i, &replacement)
212 {
213 None
214 } else {
215 Some(Fix::new(
216 ctx.line_column_byte_range_with_length(i + 1, 1, line.chars().count()),
217 replacement,
218 ))
219 };
220
221 warnings.push(LintWarning {
222 rule_name: Some(self.name().to_string()),
223 line: start_line,
224 column: start_col,
225 end_line,
226 end_column: end_col,
227 message: if has_indentation {
228 "Horizontal rule should not be indented".to_string()
229 } else {
230 format!("Horizontal rule style should be \"{expected_style}\"")
231 },
232 severity: Severity::Warning,
233 fix,
234 });
235 }
236 }
237
238 Ok(warnings)
239 }
240
241 fn fixes_from_diagnostics(&self) -> bool {
242 true
243 }
244
245 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
246 if self.should_skip(ctx) {
247 return Ok(ctx.content.to_string());
248 }
249 let warnings = self.check(ctx)?;
250 if warnings.is_empty() {
251 return Ok(ctx.content.to_string());
252 }
253 let warnings =
254 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
255 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
256 .map_err(crate::rule::LintError::InvalidInput)
257 }
258
259 fn as_any(&self) -> &dyn std::any::Any {
260 self
261 }
262
263 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
265 ctx.content.is_empty() || (!ctx.has_char('-') && !ctx.has_char('*') && !ctx.has_char('_'))
267 }
268
269 crate::impl_rule_config_methods!(MD035Config);
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use crate::lint_context::LintContext;
276
277 #[test]
278 fn test_is_horizontal_rule() {
279 assert!(MD035HRStyle::is_horizontal_rule("---"));
281 assert!(MD035HRStyle::is_horizontal_rule("----"));
282 assert!(MD035HRStyle::is_horizontal_rule("***"));
283 assert!(MD035HRStyle::is_horizontal_rule("****"));
284 assert!(MD035HRStyle::is_horizontal_rule("___"));
285 assert!(MD035HRStyle::is_horizontal_rule("____"));
286 assert!(MD035HRStyle::is_horizontal_rule("- - -"));
287 assert!(MD035HRStyle::is_horizontal_rule("* * *"));
288 assert!(MD035HRStyle::is_horizontal_rule("_ _ _"));
289 assert!(MD035HRStyle::is_horizontal_rule(" --- ")); assert!(!MD035HRStyle::is_horizontal_rule("--")); assert!(!MD035HRStyle::is_horizontal_rule("**"));
294 assert!(!MD035HRStyle::is_horizontal_rule("__"));
295 assert!(!MD035HRStyle::is_horizontal_rule("- -")); assert!(!MD035HRStyle::is_horizontal_rule("* *"));
297 assert!(!MD035HRStyle::is_horizontal_rule("_ _"));
298 assert!(!MD035HRStyle::is_horizontal_rule("text"));
299 assert!(!MD035HRStyle::is_horizontal_rule(""));
300 }
301
302 #[test]
303 fn test_is_potential_setext_heading() {
304 let lines = vec!["Heading 1", "=========", "Content", "Heading 2", "---", "More content"];
305
306 assert!(MD035HRStyle::is_potential_setext_heading(&lines, 1)); assert!(MD035HRStyle::is_potential_setext_heading(&lines, 4)); assert!(!MD035HRStyle::is_potential_setext_heading(&lines, 0)); assert!(!MD035HRStyle::is_potential_setext_heading(&lines, 2)); let lines2 = vec!["", "---", "Content"];
315 assert!(!MD035HRStyle::is_potential_setext_heading(&lines2, 1)); let lines3 = vec!["***", "---"];
318 assert!(!MD035HRStyle::is_potential_setext_heading(&lines3, 1)); }
320
321 #[test]
322 fn test_most_prevalent_hr_style() {
323 let content = "Content\n\n---\n\nMore\n\n---\n\nText";
325 let lines: Vec<&str> = content.lines().collect();
326 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
327 assert_eq!(
328 MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
329 Some("---".to_string())
330 );
331
332 let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
334 let lines: Vec<&str> = content.lines().collect();
335 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
336 assert_eq!(
337 MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
338 Some("---".to_string())
339 );
340
341 let content = "Content\n\n***\n\nMore\n\n---\n\nText";
343 let lines: Vec<&str> = content.lines().collect();
344 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
345 assert_eq!(
346 MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
347 Some("***".to_string())
348 );
349
350 let content = "Just\nRegular\nContent";
352 let lines: Vec<&str> = content.lines().collect();
353 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
354 assert_eq!(MD035HRStyle::most_prevalent_hr_style(&lines, &ctx), None);
355
356 let content = "Heading\n---\nContent\n\n***";
358 let lines: Vec<&str> = content.lines().collect();
359 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
360 assert_eq!(
361 MD035HRStyle::most_prevalent_hr_style(&lines, &ctx),
362 Some("***".to_string())
363 );
364 }
365
366 #[test]
367 fn test_consistent_style() {
368 let rule = MD035HRStyle::new("consistent".to_string());
369 let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
370 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
371 let result = rule.check(&ctx).unwrap();
372
373 assert_eq!(result.len(), 1);
375 assert_eq!(result[0].line, 7);
376 assert!(result[0].message.contains("Horizontal rule style should be \"---\""));
377 }
378
379 #[test]
380 fn test_specific_style_dashes() {
381 let rule = MD035HRStyle::new("---".to_string());
382 let content = "Content\n\n***\n\nMore\n\n___\n\nText";
383 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
384 let result = rule.check(&ctx).unwrap();
385
386 assert_eq!(result.len(), 2);
388 assert_eq!(result[0].line, 3);
389 assert_eq!(result[1].line, 7);
390 assert!(result[0].message.contains("Horizontal rule style should be \"---\""));
391 }
392
393 #[test]
394 fn test_indented_horizontal_rule() {
395 let rule = MD035HRStyle::new("---".to_string());
396 let content = "Content\n\n ---\n\nMore";
397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
398 let result = rule.check(&ctx).unwrap();
399
400 assert_eq!(result.len(), 1);
401 assert_eq!(result[0].line, 3);
402 assert_eq!(result[0].message, "Horizontal rule should not be indented");
403 }
404
405 #[test]
406 fn test_setext_heading_not_flagged() {
407 let rule = MD035HRStyle::new("***".to_string());
408 let content = "Heading\n---\nContent\n***";
409 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
410 let result = rule.check(&ctx).unwrap();
411
412 assert_eq!(result.len(), 0);
414 }
415
416 #[test]
417 fn test_fix_consistent_style() {
418 let rule = MD035HRStyle::new("consistent".to_string());
419 let content = "Content\n\n---\n\nMore\n\n***\n\nText\n\n---";
420 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
421 let fixed = rule.fix(&ctx).unwrap();
422
423 let expected = "Content\n\n---\n\nMore\n\n---\n\nText\n\n---";
424 assert_eq!(fixed, expected);
425 }
426
427 #[test]
428 fn test_fix_specific_style() {
429 let rule = MD035HRStyle::new("***".to_string());
430 let content = "Content\n\n---\n\nMore\n\n___\n\nText";
431 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
432 let fixed = rule.fix(&ctx).unwrap();
433
434 let expected = "Content\n\n***\n\nMore\n\n***\n\nText";
435 assert_eq!(fixed, expected);
436 }
437
438 #[test]
439 fn test_fix_preserves_setext_headings() {
440 let rule = MD035HRStyle::new("***".to_string());
441 let content = "Heading 1\n=========\nHeading 2\n---\nContent\n\n---";
442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
443 let fixed = rule.fix(&ctx).unwrap();
444
445 let expected = "Heading 1\n=========\nHeading 2\n---\nContent\n\n***";
446 assert_eq!(fixed, expected);
447 }
448
449 #[test]
450 fn test_fix_removes_indentation() {
451 let rule = MD035HRStyle::new("---".to_string());
452 let content = "Content\n\n ***\n\nMore\n\n ___\n\nText";
453 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
454 let fixed = rule.fix(&ctx).unwrap();
455
456 let expected = "Content\n\n---\n\nMore\n\n---\n\nText";
457 assert_eq!(fixed, expected);
458 }
459
460 #[test]
461 fn test_spaced_styles() {
462 let rule = MD035HRStyle::new("* * *".to_string());
463 let content = "Content\n\n- - -\n\nMore\n\n_ _ _\n\nText";
464 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
465 let result = rule.check(&ctx).unwrap();
466
467 assert_eq!(result.len(), 2);
468 assert!(result[0].message.contains("Horizontal rule style should be \"* * *\""));
469 }
470
471 #[test]
472 fn test_empty_style_uses_consistent() {
473 let rule = MD035HRStyle::new("".to_string());
474 let content = "Content\n\n---\n\nMore\n\n***\n\nText";
475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
476 let result = rule.check(&ctx).unwrap();
477
478 assert_eq!(result.len(), 1);
480 assert_eq!(result[0].line, 7);
481 }
482
483 #[test]
484 fn test_all_hr_styles_consistent() {
485 let rule = MD035HRStyle::new("consistent".to_string());
486 let content = "Content\n---\nMore\n---\nText\n---";
487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
488 let result = rule.check(&ctx).unwrap();
489
490 assert_eq!(result.len(), 0);
492 }
493
494 #[test]
495 fn test_no_horizontal_rules() {
496 let rule = MD035HRStyle::new("---".to_string());
497 let content = "Just regular content\nNo horizontal rules here";
498 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
499 let result = rule.check(&ctx).unwrap();
500
501 assert_eq!(result.len(), 0);
502 }
503
504 #[test]
505 fn test_mixed_spaced_and_unspaced() {
506 let rule = MD035HRStyle::new("consistent".to_string());
507 let content = "Content\n\n---\n\nMore\n\n- - -\n\nText";
508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
509 let result = rule.check(&ctx).unwrap();
510
511 assert_eq!(result.len(), 1);
513 assert_eq!(result[0].line, 7);
514 }
515
516 #[test]
517 fn test_trailing_whitespace_in_hr() {
518 let rule = MD035HRStyle::new("---".to_string());
519 let content = "Content\n\n--- \n\nMore";
520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
521 let result = rule.check(&ctx).unwrap();
522
523 assert_eq!(result.len(), 0);
525 }
526
527 #[test]
528 fn test_hr_in_code_block_not_flagged() {
529 let rule = MD035HRStyle::new("---".to_string());
530 let content =
531 "Text\n\n```bash\n----------------------------------------------------------------------\n```\n\nMore";
532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
533 let result = rule.check(&ctx).unwrap();
534
535 assert_eq!(result.len(), 0);
537 }
538
539 #[test]
540 fn test_hr_in_code_span_not_flagged() {
541 let rule = MD035HRStyle::new("---".to_string());
542 let content = "Text with inline `---` code span";
543 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
544 let result = rule.check(&ctx).unwrap();
545
546 assert_eq!(result.len(), 0);
548 }
549
550 #[test]
551 fn test_hr_with_extra_characters() {
552 let rule = MD035HRStyle::new("---".to_string());
553 let content = "Content\n-----\nMore\n--------\nText";
554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
555 let result = rule.check(&ctx).unwrap();
556
557 assert_eq!(result.len(), 0);
559 }
560
561 #[test]
562 fn test_default_config() {
563 let style = |rule: &MD035HRStyle| {
567 let (name, config) = rule.default_config_section().unwrap();
568 assert_eq!(name, "MD035");
569 config
570 .as_table()
571 .unwrap()
572 .get("style")
573 .unwrap()
574 .as_str()
575 .unwrap()
576 .to_string()
577 };
578
579 assert_eq!(style(&MD035HRStyle::default()), "consistent");
582 assert_eq!(
583 style(&MD035HRStyle::new("***".to_string())),
584 "consistent",
585 "a configured instance must still publish the default"
586 );
587 }
588
589 #[test]
590 fn test_fix_skips_mkdocs_html_markdown() {
591 let rule = MD035HRStyle::new("***".to_string());
594
595 let content = "Some content\n\n***\n\n<div class=\"grid cards\" markdown>\n\n- Card 1 content\n\n ---\n\n Card 1 footer\n\n</div>\n";
596 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
597
598 let warnings = rule.check(&ctx).unwrap();
600 for w in &warnings {
601 assert_ne!(w.line, 9, "check() should not flag --- inside <div markdown> block");
602 }
603
604 let fixed = rule.fix(&ctx).unwrap();
606 assert!(
607 fixed.contains(" ---"),
608 "fix() should preserve --- inside <div markdown> block, got: {fixed}"
609 );
610 }
611
612 #[test]
613 fn test_is_horizontal_rule_edge_cases() {
614 assert!(MD035HRStyle::is_horizontal_rule("----------"));
616 assert!(MD035HRStyle::is_horizontal_rule("**********"));
617 assert!(MD035HRStyle::is_horizontal_rule("__________"));
618
619 assert!(MD035HRStyle::is_horizontal_rule("- - - -"));
621 assert!(MD035HRStyle::is_horizontal_rule("* * * * *"));
622 assert!(MD035HRStyle::is_horizontal_rule("_ _ _ _ _ _"));
623
624 assert!(MD035HRStyle::is_horizontal_rule("* * *"));
626 assert!(MD035HRStyle::is_horizontal_rule("- - -"));
627 assert!(MD035HRStyle::is_horizontal_rule("_ _ _"));
628
629 assert!(MD035HRStyle::is_horizontal_rule("--- "));
631 assert!(MD035HRStyle::is_horizontal_rule("*** "));
632 assert!(MD035HRStyle::is_horizontal_rule("___ "));
633
634 assert!(MD035HRStyle::is_horizontal_rule("- - - "));
636 assert!(MD035HRStyle::is_horizontal_rule("* * * "));
637
638 assert!(!MD035HRStyle::is_horizontal_rule("-*-"));
640 assert!(!MD035HRStyle::is_horizontal_rule("- * -"));
641 assert!(!MD035HRStyle::is_horizontal_rule("_-_"));
642 assert!(!MD035HRStyle::is_horizontal_rule("*_*"));
643
644 assert!(!MD035HRStyle::is_horizontal_rule("---text"));
646 assert!(!MD035HRStyle::is_horizontal_rule("***text"));
647 assert!(!MD035HRStyle::is_horizontal_rule("- - - text"));
648
649 assert!(!MD035HRStyle::is_horizontal_rule("- -"));
651 assert!(!MD035HRStyle::is_horizontal_rule("* *"));
652 assert!(!MD035HRStyle::is_horizontal_rule("_ _"));
653
654 assert!(!MD035HRStyle::is_horizontal_rule("-a-b-"));
656 assert!(!MD035HRStyle::is_horizontal_rule("*x*x*"));
657
658 assert!(!MD035HRStyle::is_horizontal_rule("-"));
660 assert!(!MD035HRStyle::is_horizontal_rule("*"));
661 assert!(!MD035HRStyle::is_horizontal_rule("_"));
662
663 assert!(MD035HRStyle::is_horizontal_rule("*\t*\t*"));
665 assert!(MD035HRStyle::is_horizontal_rule("-\t-\t-"));
666
667 let long_hr = "-".repeat(200);
669 assert!(MD035HRStyle::is_horizontal_rule(&long_hr));
670 }
671
672 #[test]
673 fn test_frontmatter_not_treated_as_hr() {
674 let rule = MD035HRStyle::new("***".to_string());
675 let content = "---\ntitle: Test\n---\n\n***\n\nContent";
676 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
677 let result = rule.check(&ctx).unwrap();
678
679 assert_eq!(result.len(), 0);
681 }
682
683 #[test]
684 fn test_fix_skips_mkdocs_html_markdown_preserves_outside() {
685 let rule = MD035HRStyle::new("***".to_string());
687
688 let content = "Some content\n\n---\n\n<div class=\"grid cards\" markdown>\n\n- Card content\n\n ---\n\n Card footer\n\n</div>\n";
689 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
690
691 let fixed = rule.fix(&ctx).unwrap();
692 let lines: Vec<&str> = fixed.lines().collect();
694 assert_eq!(lines[2], "***", "fix() should change --- outside <div markdown> to ***");
695 assert!(
697 fixed.contains(" ---"),
698 "fix() should preserve --- inside <div markdown>"
699 );
700 }
701
702 fn assert_fix_roundtrip(rule: &MD035HRStyle, content: &str, flavor: crate::config::MarkdownFlavor) {
704 let ctx = LintContext::new(content, flavor, None);
705 let fixed = rule.fix(&ctx).unwrap();
706 let ctx2 = LintContext::new(&fixed, flavor, None);
707 let warnings = rule.check(&ctx2).unwrap();
708 assert!(
709 warnings.is_empty(),
710 "fix() output should produce zero check() warnings.\nOriginal:\n{content}\nFixed:\n{fixed}\nWarnings: {warnings:?}"
711 );
712 }
713
714 #[test]
715 fn test_roundtrip_consistent_style() {
716 let rule = MD035HRStyle::new("consistent".to_string());
717 assert_fix_roundtrip(
718 &rule,
719 "Content\n\n---\n\nMore\n\n***\n\nText\n\n---",
720 crate::config::MarkdownFlavor::Standard,
721 );
722 }
723
724 #[test]
725 fn test_roundtrip_specific_style() {
726 let rule = MD035HRStyle::new("***".to_string());
727 assert_fix_roundtrip(
728 &rule,
729 "Content\n\n---\n\nMore\n\n___\n\nText",
730 crate::config::MarkdownFlavor::Standard,
731 );
732 }
733
734 #[test]
735 fn test_roundtrip_indented_hr() {
736 let rule = MD035HRStyle::new("---".to_string());
737 assert_fix_roundtrip(
738 &rule,
739 "Content\n\n ***\n\nMore\n\n ___\n\nText",
740 crate::config::MarkdownFlavor::Standard,
741 );
742 }
743
744 #[test]
745 fn test_roundtrip_setext_headings() {
746 let rule = MD035HRStyle::new("***".to_string());
747 assert_fix_roundtrip(
748 &rule,
749 "Heading 1\n=========\nHeading 2\n---\nContent\n\n---",
750 crate::config::MarkdownFlavor::Standard,
751 );
752 }
753
754 #[test]
755 fn test_roundtrip_frontmatter() {
756 let rule = MD035HRStyle::new("***".to_string());
757 assert_fix_roundtrip(
758 &rule,
759 "---\ntitle: Test\n---\n\n***\n\nContent",
760 crate::config::MarkdownFlavor::Standard,
761 );
762 }
763
764 #[test]
765 fn test_roundtrip_mkdocs_html_markdown() {
766 let rule = MD035HRStyle::new("***".to_string());
767 let content = "Some content\n\n---\n\n<div class=\"grid cards\" markdown>\n\n- Card content\n\n ---\n\n Card footer\n\n</div>\n";
768 assert_fix_roundtrip(&rule, content, crate::config::MarkdownFlavor::MkDocs);
769 }
770
771 #[test]
772 fn test_roundtrip_spaced_styles() {
773 let rule = MD035HRStyle::new("* * *".to_string());
774 assert_fix_roundtrip(
775 &rule,
776 "Content\n\n- - -\n\nMore\n\n_ _ _\n\nText",
777 crate::config::MarkdownFlavor::Standard,
778 );
779 }
780
781 #[test]
782 fn test_roundtrip_no_warnings() {
783 let rule = MD035HRStyle::new("---".to_string());
784 assert_fix_roundtrip(
785 &rule,
786 "Content\n\n---\n\nMore\n\n---\n\nText",
787 crate::config::MarkdownFlavor::Standard,
788 );
789 }
790
791 #[test]
792 fn test_roundtrip_trailing_newline() {
793 let rule = MD035HRStyle::new("***".to_string());
794 assert_fix_roundtrip(
795 &rule,
796 "Content\n\n---\n\nMore\n",
797 crate::config::MarkdownFlavor::Standard,
798 );
799 }
800
801 #[test]
802 fn test_literal_rules_are_preserved_beside_visible_rules() {
803 use crate::config::MarkdownFlavor;
804 let rule = MD035HRStyle::new("---".to_string());
805 for (flavor, code) in [
806 (
807 MarkdownFlavor::Standard,
808 "{% set unused=\"first\n\n***\n\nlast\" %}{{ unused|length }}",
809 ),
810 (MarkdownFlavor::Hugo, "{{< note title=`first\n\n***\n\nlast` >}}"),
811 (
812 MarkdownFlavor::MDX,
813 "export const text = `first\n\n***\n\nlast`\n\n{text}",
814 ),
815 (MarkdownFlavor::MDX, "{`first\n\n***\n\nlast`}"),
816 (MarkdownFlavor::Standard, "<pre>\n\n***\n\n</pre>"),
817 (MarkdownFlavor::Standard, "<!--\n\n***\n\n-->"),
818 ] {
819 for ending in ["\n", "\r\n"] {
820 let source = format!("{code}\n\n***\n").replace('\n', ending);
821 let expected = format!("{code}\n\n---\n").replace('\n', ending);
822 let ctx = LintContext::new(&source, flavor, None);
823 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{source}");
824 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source}");
825 }
826 }
827 }
828
829 #[test]
830 fn test_literal_rules_do_not_choose_visible_style() {
831 use crate::config::MarkdownFlavor;
832 let rule = MD035HRStyle::default();
833 for (flavor, code) in [
834 (
835 MarkdownFlavor::Standard,
836 "{% set unused=\"first\n\n___\n\n___\n\nlast\" %}{{ unused|length }}",
837 ),
838 (
839 MarkdownFlavor::MDX,
840 "export const text = `first\n\n___\n\n___\n\nlast`\n\n{text}",
841 ),
842 (MarkdownFlavor::Standard, "<pre>\n\n___\n\n___\n\n</pre>"),
843 (MarkdownFlavor::Standard, "<!--\n\n___\n\n___\n\n-->"),
844 ] {
845 let source = format!("{code}\n\n***\n");
846 let ctx = LintContext::new(&source, flavor, None);
847 assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
848 assert_eq!(rule.fix(&ctx).unwrap(), source);
849 }
850 }
851
852 #[test]
853 fn test_visible_mdx_jsx_body_rules_remain_fixable() {
854 let rule = MD035HRStyle::new("---".to_string());
855 for tag in ["div", "pre"] {
856 let source = format!("<{tag}>\n\n***\n\n</{tag}>\n\n***\n");
857 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::MDX, None);
858 assert_eq!(rule.check(&ctx).unwrap().len(), 2);
859 assert_eq!(rule.fix(&ctx).unwrap(), source.replace("***", "---"));
860 }
861 }
862
863 fn structure(text: &str) -> Vec<String> {
866 pulldown_cmark::Parser::new_ext(text, crate::utils::parser_options::rumdl_parser_options_for(text))
867 .map(|event| format!("{event:?}"))
868 .collect()
869 }
870
871 fn fix_with_style(style: &str, content: &str) -> (String, Vec<crate::rule::LintWarning>) {
872 let rule = MD035HRStyle::new(style.to_string());
873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
874 (rule.fix(&ctx).unwrap(), rule.check(&ctx).unwrap())
875 }
876
877 #[test]
878 fn test_fix_declined_when_dash_run_would_underline_paragraph() {
879 let cases: &[(&str, &str)] = &[
880 ("underscores", "Text\n___\nMore\n"),
881 ("asterisks", "Text\n***\nMore\n"),
882 ("spaced asterisks", "Text\n* * *\nMore\n"),
883 ("spaced underscores", "Text\n_ _ _\nMore\n"),
884 ("long underscores", "Text\n_____\nMore\n"),
885 ("indent 1", "Text\n ___\nMore\n"),
886 ("indent 3", "Text\n ***\nMore\n"),
887 ("trailing whitespace", "Text\n*** \nMore\n"),
888 (
889 "two-line paragraph",
890 "First line.\nSecond line.\n___\nNext paragraph.\n",
891 ),
892 ("one-row table", "| a |\n***\n"),
893 ("div marker above", "Text\n:::\n***\n"),
894 ("reference definition above", "[a]: http://example.com\n***\n"),
895 ("heading above", "# H\n***\n\nB\n"),
896 ("fence close above", "```\ncode\n```\n***\n"),
897 ("list item paragraph", "- item\n para\n ___\n"),
898 ("ordered list item paragraph", "1. one\n two\n ***\n"),
899 ("list item at column 0", "- item\n___\n"),
900 ("lazy blockquote", "> quote\n___\n"),
901 ];
902 for (name, content) in cases {
903 let (fixed, warnings) = fix_with_style("---", content);
904 assert_eq!(&fixed, content, "{name}: the fix must leave the document unchanged");
905 assert_eq!(warnings.len(), 1, "{name}: the style warning must remain");
906 assert!(warnings[0].fix.is_none(), "{name}: the warning must carry no fix");
907 }
908 }
909
910 #[test]
911 fn test_fix_declined_on_issue_input_keeps_paragraph() {
912 let content = "# Doc\n\nIntro.\n\n---\n\nFirst line.\nSecond line.\n___\nNext paragraph.\n";
913 let rule = MD035HRStyle::new("consistent".to_string());
914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
915 let warnings = rule.check(&ctx).unwrap();
916 assert_eq!(warnings.len(), 1);
917 assert_eq!(warnings[0].line, 9);
918 assert!(warnings[0].fix.is_none());
919 let fixed = rule.fix(&ctx).unwrap();
920 assert_eq!(fixed, content);
921 assert!(
922 !structure(&fixed)
923 .iter()
924 .any(|e| e.contains("Heading") && e.contains("H2")),
925 "the paragraph must not become a heading"
926 );
927 }
928
929 #[test]
930 fn test_fix_declined_with_padded_dash_style() {
931 for style in ["--- ", " ---", "\t---\t"] {
932 let content = "Text\n***\nMore\n";
933 let (fixed, warnings) = fix_with_style(style, content);
934 assert_eq!(fixed, content, "style {style:?}");
935 assert!(warnings.iter().all(|w| w.fix.is_none()), "style {style:?}");
936 }
937 }
938
939 #[test]
940 fn test_fix_still_applies_when_replacement_is_not_an_underline() {
941 for (style, content, expected) in [
943 ("***", "Text\n___\nMore\n", "Text\n***\nMore\n"),
944 ("___", "Text\n***\nMore\n", "Text\n___\nMore\n"),
945 ("- - -", "Text\n***\nMore\n", "Text\n- - -\nMore\n"),
946 ] {
947 let (fixed, _) = fix_with_style(style, content);
948 assert_eq!(fixed, expected, "style {style:?}");
949 assert_eq!(structure(&fixed), structure(content), "style {style:?}");
950 }
951 }
952
953 #[test]
954 fn test_fix_still_applies_after_blank_line() {
955 for (content, expected) in [
956 ("Text\n\n***\n\nMore\n", "Text\n\n---\n\nMore\n"),
957 ("***\n\n***\n", "---\n\n---\n"),
958 ] {
959 let (fixed, _) = fix_with_style("---", content);
960 assert_eq!(fixed, expected);
961 assert_eq!(structure(&fixed), structure(content));
962 }
963 }
964
965 #[test]
966 fn test_indentation_inside_list_item_is_kept() {
967 let content = "- a\n\n ***\n\n more\n";
968 let (fixed, warnings) = fix_with_style("---", content);
969 assert_eq!(fixed, "- a\n\n ---\n\n more\n");
970 assert_eq!(
971 structure(&fixed),
972 structure(content),
973 "the break must stay inside the item"
974 );
975 assert_eq!(warnings.len(), 1);
976 assert!(
977 warnings[0].message.contains("style should be"),
978 "{}",
979 warnings[0].message
980 );
981
982 let ordered = "1. one\n\n ___\n\n more\n";
983 let (fixed, _) = fix_with_style("---", ordered);
984 assert_eq!(fixed, "1. one\n\n ---\n\n more\n");
985 assert_eq!(structure(&fixed), structure(ordered));
986 }
987
988 #[test]
989 fn test_conforming_break_inside_list_item_is_not_indentation_error() {
990 let (_, warnings) = fix_with_style("---", "- a\n\n ---\n\n more\n");
991 assert!(warnings.is_empty(), "{warnings:?}");
992 let (_, warnings) = fix_with_style("---", "1. one\n\n ---\n\n more\n");
993 assert!(warnings.is_empty(), "{warnings:?}");
994 }
995
996 #[test]
997 fn test_indentation_outside_list_item_is_still_stripped() {
998 let content = "- a\n\n ***\n\nmore\n";
1000 let (fixed, _) = fix_with_style("---", content);
1001 assert_eq!(fixed, "- a\n\n---\n\nmore\n");
1002 assert_eq!(structure(&fixed), structure(content));
1003
1004 let content = "Text\n\n ---\n\nMore\n";
1005 let (fixed, _) = fix_with_style("---", content);
1006 assert_eq!(fixed, "Text\n\n---\n\nMore\n");
1007 }
1008
1009 #[test]
1010 fn test_fix_is_idempotent_on_consecutive_breaks_with_declined_fix() {
1011 let content = "___\n---\n***\n---\n";
1012 let rule = MD035HRStyle::default();
1013 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1014 let fixed1 = rule.fix(&ctx).unwrap();
1015 let ctx = LintContext::new(&fixed1, crate::config::MarkdownFlavor::Standard, None);
1016 let fixed2 = rule.fix(&ctx).unwrap();
1017 assert_eq!(fixed1, fixed2, "first fix: {fixed1:?}");
1018 }
1019
1020 #[test]
1021 fn test_fix_declined_when_first_line_would_open_front_matter() {
1022 for content in ["___\n---\n***\n---\n", "***\n---\ntext\n\n---\n"] {
1023 let rule = MD035HRStyle::new("---".to_string());
1024 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1025 let warnings = rule.check(&ctx).unwrap();
1026 assert!(
1027 warnings.iter().any(|w| w.line == 1 && w.fix.is_none()),
1028 "{content:?}: {warnings:?}"
1029 );
1030 assert_eq!(rule.fix(&ctx).unwrap().lines().next(), content.lines().next());
1031 }
1032 }
1033
1034 #[test]
1035 fn test_fix_applies_on_first_line_when_no_front_matter_results() {
1036 let rule = MD035HRStyle::new("---".to_string());
1037 let content = "***\n\ntext\n";
1038 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1039 assert_eq!(rule.fix(&ctx).unwrap(), "---\n\ntext\n");
1040 }
1041}