1use crate::lint_context::{LintContext, ParsedHeading, is_setext_underline_content};
21use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
22use crate::rule_config_serde::RuleConfig;
23use crate::utils::range_utils::calculate_match_range;
24use crate::utils::skip_context::{compute_html_code_ranges, should_skip_emphasis_span};
25use serde::{Deserialize, Serialize};
26
27#[derive(Debug, Clone, Copy)]
29struct CountedSpan {
30 start: usize,
31 end: usize,
32 line: usize,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum EmphasisTarget {
39 #[default]
41 Strong,
42 Emphasis,
44 All,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
50#[serde(rename_all = "kebab-case")]
51pub struct MD081Config {
52 #[serde(default)]
55 pub targets: EmphasisTarget,
56
57 #[serde(default)]
61 pub max_per_paragraph: Option<usize>,
62
63 #[serde(default)]
67 pub max_consecutive: Option<usize>,
68}
69
70impl Default for MD081Config {
71 fn default() -> Self {
72 Self {
73 targets: EmphasisTarget::Strong,
74 max_per_paragraph: None,
75 max_consecutive: None,
76 }
77 }
78}
79
80impl RuleConfig for MD081Config {
81 const RULE_NAME: &'static str = "MD081";
82}
83
84#[derive(Debug, Clone, Default)]
85pub struct MD081NoExcessiveEmphasis {
86 config: MD081Config,
87}
88
89impl MD081NoExcessiveEmphasis {
90 pub fn new() -> Self {
91 Self::default()
92 }
93
94 pub fn from_config_struct(config: MD081Config) -> Self {
95 Self { config }
96 }
97
98 fn counted_spans(&self, ctx: &LintContext) -> Vec<CountedSpan> {
103 let html_tags = ctx.html_tags();
104 let html_code_ranges = compute_html_code_ranges(&html_tags);
105
106 let mut spans: Vec<CountedSpan> = ctx
107 .emphasis_spans()
108 .iter()
109 .filter(|s| match self.config.targets {
110 EmphasisTarget::Strong => s.is_strong,
111 EmphasisTarget::Emphasis => !s.is_strong,
112 EmphasisTarget::All => true,
113 })
114 .filter(|s| !should_skip_emphasis_span(ctx, &html_tags, &html_code_ranges, s.byte_offset, s.byte_end))
115 .map(|s| CountedSpan {
116 start: s.byte_offset,
117 end: s.byte_end,
118 line: s.line,
119 })
120 .collect();
121
122 spans.sort_by_key(|s| (s.start, std::cmp::Reverse(s.end)));
123
124 if self.config.targets == EmphasisTarget::All {
125 let mut deduped: Vec<CountedSpan> = Vec::with_capacity(spans.len());
129 let mut max_end = 0usize;
130 for span in spans {
131 if span.end <= max_end {
132 continue;
133 }
134 max_end = span.end;
135 deduped.push(span);
136 }
137 deduped
138 } else {
139 spans
140 }
141 }
142
143 fn setext_text_lines(ctx: &LintContext) -> Vec<bool> {
154 let mut flags = vec![false; ctx.lines.len()];
155 for heading in ctx.headings().filter(ParsedHeading::is_setext) {
156 for flag in flags
157 .iter_mut()
158 .take(heading.line_num)
159 .skip(heading.first_line_num() - 1)
160 {
161 *flag = true;
162 }
163 }
164
165 for idx in 1..ctx.lines.len() {
166 let line = &ctx.lines[idx];
167 if flags[idx - 1] || line.in_code_block || !is_setext_underline_content(Self::line_inner(line, ctx.content))
168 {
169 continue;
170 }
171 let level = Self::blockquote_level(line);
172
173 let mut first = idx;
176 while first > 0 {
177 let prev = &ctx.lines[first - 1];
178 if prev.is_blank || !prev.is_paragraph_context() || Self::blockquote_level(prev) != level {
179 break;
180 }
181 first -= 1;
182 if prev.list_item.is_some() {
183 break;
184 }
185 }
186 if first == idx {
187 continue;
188 }
189 let Some(item) = ctx.lines[first].list_item.as_ref() else {
190 continue;
191 };
192
193 if Self::content_column(line) < item.content_column {
196 continue;
197 }
198 flags[first..idx].fill(true);
199 }
200 flags
201 }
202
203 fn line_inner<'a>(line: &'a crate::lint_context::LineInfo, source: &'a str) -> &'a str {
205 match line.blockquote.as_ref() {
206 Some(bq) => bq.content.trim(),
207 None => line.content(source).trim(),
208 }
209 }
210
211 fn content_column(line: &crate::lint_context::LineInfo) -> usize {
214 match line.blockquote.as_ref() {
215 Some(bq) => bq.prefix.len(),
216 None => line.indent,
217 }
218 }
219
220 fn blockquote_level(line: &crate::lint_context::LineInfo) -> usize {
222 line.blockquote.as_ref().map_or(0, |b| b.nesting_level)
223 }
224
225 fn paragraph_ids(ctx: &LintContext) -> Vec<Option<usize>> {
231 let mut ids = vec![None; ctx.lines.len()];
232 let setext_text = Self::setext_text_lines(ctx);
233 let mut current: Option<usize> = None;
234 let mut next_id = 0usize;
235 let mut prev_bq_level = 0usize;
236
237 for (idx, line) in ctx.lines.iter().enumerate() {
238 let bq_level = Self::blockquote_level(line);
239 let is_prose =
240 !line.is_blank && line.is_paragraph_context() && !setext_text[idx] && !ctx.is_in_table_block(idx + 1);
241
242 if !is_prose {
243 current = None;
244 prev_bq_level = bq_level;
245 continue;
246 }
247
248 let starts_new = current.is_none() || line.list_item.is_some() || bq_level != prev_bq_level;
249 if starts_new {
250 current = Some(next_id);
251 next_id += 1;
252 }
253 ids[idx] = current;
254 prev_bq_level = bq_level;
255 }
256
257 ids
258 }
259
260 fn emit_run(&self, ctx: &LintContext, run: &[CountedSpan], limit: usize, warnings: &mut Vec<LintWarning>) {
263 if run.len() > limit
264 && let Some(first) = run.first()
265 {
266 warnings.push(self.warn_at(
267 ctx,
268 first,
269 format!(
270 "{} consecutive emphasis spans (limit {limit}); consider rephrasing to reduce emphasis",
271 run.len(),
272 ),
273 ));
274 }
275 }
276
277 fn warn_at(&self, ctx: &LintContext, span: &CountedSpan, message: String) -> LintWarning {
278 let line_content = ctx.lines.get(span.line - 1).map_or("", |l| l.content(ctx.content));
279 let line_start = ctx.lines.get(span.line - 1).map_or(0, |l| l.byte_offset);
280 let match_start_in_line = span.start.saturating_sub(line_start);
281 let (start_line, start_col, end_line, end_col) =
282 calculate_match_range(span.line, line_content, match_start_in_line, span.end - span.start);
283 LintWarning {
284 rule_name: Some(self.name().to_string()),
285 severity: Severity::Warning,
286 line: start_line,
287 column: start_col,
288 end_line,
289 end_column: end_col,
290 message,
291 fix: None,
292 }
293 }
294}
295
296impl Rule for MD081NoExcessiveEmphasis {
297 fn name(&self) -> &'static str {
298 "MD081"
299 }
300
301 fn description(&self) -> &'static str {
302 "Inline emphasis should not be excessive"
303 }
304
305 fn category(&self) -> RuleCategory {
306 RuleCategory::Emphasis
307 }
308
309 fn check(&self, ctx: &LintContext) -> LintResult {
310 if self.config.max_per_paragraph.is_none() && self.config.max_consecutive.is_none() {
311 return Ok(Vec::new());
312 }
313
314 let spans = self.counted_spans(ctx);
315 if spans.is_empty() {
316 return Ok(Vec::new());
317 }
318
319 let para_ids = Self::paragraph_ids(ctx);
320 let mut warnings = Vec::new();
321
322 if let Some(limit) = self.config.max_per_paragraph {
323 let mut counts: std::collections::HashMap<usize, (usize, CountedSpan)> = std::collections::HashMap::new();
327 for span in &spans {
328 let Some(pid) = para_ids.get(span.line - 1).copied().flatten() else {
329 continue;
330 };
331 counts.entry(pid).and_modify(|(n, _)| *n += 1).or_insert((1, *span));
332 }
333 let mut flagged: Vec<(usize, CountedSpan)> = counts
334 .into_iter()
335 .filter(|(_, (n, _))| *n > limit)
336 .map(|(_, (n, first))| (n, first))
337 .collect();
338 flagged.sort_by_key(|(_, first)| (first.line, first.start));
339 for (count, first) in flagged {
340 warnings.push(self.warn_at(
341 ctx,
342 &first,
343 format!(
344 "Paragraph contains {count} emphasis spans (limit {limit}); consider reducing emphasis to improve readability"
345 ),
346 ));
347 }
348 }
349
350 if let Some(limit) = self.config.max_consecutive {
351 let mut run_start = 0usize; for i in 0..spans.len() {
356 let breaks = if i == 0 {
357 true
358 } else {
359 let prev = &spans[i - 1];
360 let cur = &spans[i];
361 let same_para = para_ids.get(prev.line - 1).copied().flatten()
362 == para_ids.get(cur.line - 1).copied().flatten()
363 && para_ids.get(cur.line - 1).copied().flatten().is_some();
364 let between = ctx.content.get(prev.end..cur.start).unwrap_or("");
365 let only_filler = !between.chars().any(char::is_alphanumeric);
369 !(same_para && only_filler)
370 };
371
372 if breaks && i > run_start {
373 self.emit_run(ctx, &spans[run_start..i], limit, &mut warnings);
374 }
375 if breaks {
376 run_start = i;
377 }
378 }
379 if !spans.is_empty() {
380 self.emit_run(ctx, &spans[run_start..], limit, &mut warnings);
381 }
382 }
383
384 Ok(warnings)
385 }
386
387 fn fix_capability(&self) -> FixCapability {
388 FixCapability::Unfixable
389 }
390
391 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
392 Ok(ctx.content.to_string())
395 }
396
397 fn as_any(&self) -> &dyn std::any::Any {
398 self
399 }
400
401 crate::impl_rule_config_methods!(MD081Config);
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use crate::config::MarkdownFlavor;
408 use crate::rule::LintWarning;
409
410 fn check(content: &str, config: MD081Config) -> Vec<LintWarning> {
411 let rule = MD081NoExcessiveEmphasis::from_config_struct(config);
412 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
413 rule.check(&ctx).unwrap()
414 }
415
416 #[test]
417 fn test_shortcode_arguments_are_not_counted_as_emphasis() {
418 let rule = MD081NoExcessiveEmphasis::from_config_struct(MD081Config {
419 targets: EmphasisTarget::All,
420 max_per_paragraph: Some(0),
421 ..Default::default()
422 });
423 let source = "Before {{< note title=\"**literal** *literal*\" >}}\n\n**visible**\n";
424 let ctx = LintContext::new(source, MarkdownFlavor::Hugo, None);
425 let warnings = rule.check(&ctx).unwrap();
426 assert_eq!(warnings.len(), 1);
427 assert_eq!(warnings[0].line, 3);
428 }
429
430 #[test]
431 fn test_jinja_quoted_markers_are_not_counted_as_emphasis() {
432 let rule = MD081NoExcessiveEmphasis::from_config_struct(MD081Config {
433 targets: EmphasisTarget::All,
434 max_per_paragraph: Some(0),
435 ..Default::default()
436 });
437 for template in [r#"{{ "**literal** *literal*" }}"#, r#"**text {{ "**" }}"#] {
438 let source = format!("Before {template}\n\n**visible**\n");
439 let ctx = LintContext::new(&source, MarkdownFlavor::Standard, None);
440 let warnings = rule.check(&ctx).unwrap();
441 assert_eq!(warnings.len(), 1);
442 assert_eq!(warnings[0].line, 3);
443 }
444 }
445
446 #[test]
447 fn test_mdx_module_strings_are_not_counted_as_emphasis() {
448 let rule = MD081NoExcessiveEmphasis::from_config_struct(MD081Config {
449 targets: EmphasisTarget::All,
450 max_per_paragraph: Some(0),
451 ..Default::default()
452 });
453 let source = "export const text = \"**literal** *literal*\"\n\n**visible**\n";
454 let ctx = LintContext::new(source, MarkdownFlavor::MDX, None);
455 let warnings = rule.check(&ctx).unwrap();
456 assert_eq!(warnings.len(), 1);
457 assert_eq!(warnings[0].line, 3);
458 }
459
460 #[test]
461 fn flags_paragraph_over_max_per_paragraph() {
462 let config = MD081Config {
463 max_per_paragraph: Some(3),
464 ..Default::default()
465 };
466 let content = "The **a** is **b** and **c** plus **d**.";
467 let warnings = check(content, config);
468 assert_eq!(warnings.len(), 1, "4 bold spans should exceed max-per-paragraph=3");
469 assert_eq!(warnings[0].line, 1);
470 }
471
472 #[test]
473 fn flags_consecutive_run_separated_only_by_punctuation() {
474 let config = MD081Config {
475 max_consecutive: Some(2),
476 ..Default::default()
477 };
478 let content = "Tags: **one**, **two**, **three**.";
480 let warnings = check(content, config);
481 assert_eq!(
482 warnings.len(),
483 1,
484 "run of 3 adjacent bolds should exceed max-consecutive=2"
485 );
486 assert_eq!(warnings[0].line, 1);
487 }
488
489 #[test]
490 fn unicode_punctuation_does_not_break_consecutive_run() {
491 let config = MD081Config {
494 max_consecutive: Some(2),
495 ..Default::default()
496 };
497 let content = "Tags: **one** \u{2014} **two** \u{2014} **three**.";
498 let warnings = check(content, config);
499 assert_eq!(
500 warnings.len(),
501 1,
502 "em-dash-separated bolds form one run of 3, exceeding max-consecutive=2. Got: {warnings:?}"
503 );
504 }
505
506 #[test]
507 fn connector_word_breaks_consecutive_run() {
508 let config = MD081Config {
509 max_consecutive: Some(2),
510 ..Default::default()
511 };
512 let content = "Tags: **one**, **two**, and **three**.";
514 let warnings = check(content, config);
515 assert!(
516 warnings.is_empty(),
517 "a connector word should break the run below the limit. Got: {warnings:?}"
518 );
519 }
520
521 #[test]
522 fn disabled_by_default() {
523 let content = "**a** **b** **c** **d** **e** **f** **g** **h**.";
526 let warnings = check(content, MD081Config::default());
527 assert!(warnings.is_empty(), "rule must be off by default. Got: {warnings:?}");
528 }
529
530 #[test]
531 fn does_not_flag_setext_heading_text() {
532 let config = MD081Config {
535 max_per_paragraph: Some(2),
536 max_consecutive: Some(1),
537 ..Default::default()
538 };
539 let content = "**A** **B** **C**\n=================\n";
540 let warnings = check(content, config);
541 assert!(
542 warnings.is_empty(),
543 "emphasis in setext heading text must not be flagged. Got: {warnings:?}"
544 );
545 }
546
547 #[test]
548 fn does_not_flag_multi_line_setext_heading_text() {
549 let config = MD081Config {
553 max_per_paragraph: Some(2),
554 max_consecutive: Some(1),
555 ..Default::default()
556 };
557 let content = "**A** **B** **C**\n* \n===\n";
558 let warnings = check(content, config);
559 assert!(
560 warnings.is_empty(),
561 "emphasis in a multi-line setext heading must not be flagged. Got: {warnings:?}"
562 );
563 }
564
565 #[test]
566 fn does_not_flag_setext_heading_text_inside_a_list_item() {
567 let config = MD081Config {
572 max_per_paragraph: Some(2),
573 ..Default::default()
574 };
575 for content in [
576 "- intro\n **one** **two** **three**\n ===\n",
577 "- **one** **two** **three**\n ===\n",
578 "1. intro\n **one** **two** **three**\n ===\n",
579 "> - intro\n> **one** **two** **three**\n> ===\n",
580 ] {
581 let warnings = check(content, config.clone());
582 assert!(
583 warnings.is_empty(),
584 "{content:?} is a heading inside the item. Got: {warnings:?}"
585 );
586 }
587 }
588
589 #[test]
590 fn flags_a_nested_item_whose_underline_is_lazy_paragraph_text() {
591 let config = MD081Config {
594 max_per_paragraph: Some(2),
595 ..Default::default()
596 };
597 let content = "- a\n - **a** **b** **c**\n ===\n";
598 let warnings = check(content, config);
599 assert_eq!(
600 warnings.len(),
601 1,
602 "the nested item's paragraph holds 3 bolds and no heading. Got: {warnings:?}"
603 );
604 assert_eq!(warnings[0].line, 2);
605 }
606
607 #[test]
608 fn flags_list_item_before_thematic_break() {
609 let config = MD081Config {
613 max_per_paragraph: Some(1),
614 ..Default::default()
615 };
616 let content = "- **a** and **b**\n---\n";
617 let warnings = check(content, config);
618 assert_eq!(
619 warnings.len(),
620 1,
621 "list item with 2 bolds before a thematic break should be flagged. Got: {warnings:?}"
622 );
623 }
624
625 #[test]
626 fn parses_kebab_case_keys_and_lowercase_targets_from_config() {
627 let mut config = crate::config::Config::default();
631 let mut rule_config = crate::config::RuleConfig::default();
632 rule_config
633 .values
634 .insert("max-per-paragraph".to_string(), toml::Value::Integer(1));
635 rule_config
636 .values
637 .insert("targets".to_string(), toml::Value::String("all".to_string()));
638 config.rules.insert("MD081".to_string(), rule_config);
639
640 let rule = MD081NoExcessiveEmphasis::from_config(&config);
641 let ctx = LintContext::new("This is **bold** and *italic*.", MarkdownFlavor::Standard, None);
646 let warnings = rule.check(&ctx).unwrap();
647 assert_eq!(
648 warnings.len(),
649 1,
650 "kebab-case max-per-paragraph and targets=\"all\" must parse from config. Got: {warnings:?}"
651 );
652 }
653
654 #[test]
655 fn does_not_flag_setext_heading_inside_blockquote() {
656 let config = MD081Config {
659 max_per_paragraph: Some(1),
660 ..Default::default()
661 };
662 let content = "> **A** **B**\n> ===\n";
663 let warnings = check(content, config);
664 assert!(
665 warnings.is_empty(),
666 "emphasis in a blockquoted setext heading must not be flagged. Got: {warnings:?}"
667 );
668 }
669
670 #[test]
671 fn flags_blockquote_paragraph_before_top_level_break() {
672 let config = MD081Config {
675 max_per_paragraph: Some(1),
676 ..Default::default()
677 };
678 let content = "> **a** and **b**\n---\n";
679 let warnings = check(content, config);
680 assert_eq!(
681 warnings.len(),
682 1,
683 "blockquote paragraph with 2 bolds before a top-level break should be flagged. Got: {warnings:?}"
684 );
685 }
686
687 #[test]
688 fn does_not_flag_emphasis_in_table_rows() {
689 let config = MD081Config {
691 max_per_paragraph: Some(1),
692 ..Default::default()
693 };
694 let content = "| Col A | Col B |\n| ----- | ----- |\n| **a** | **b** |\n";
695 let warnings = check(content, config);
696 assert!(
697 warnings.is_empty(),
698 "emphasis in table cells must not be flagged. Got: {warnings:?}"
699 );
700 }
701
702 #[test]
703 fn does_not_flag_at_or_below_limit() {
704 let config = MD081Config {
705 max_per_paragraph: Some(3),
706 ..Default::default()
707 };
708 let content = "The **a** is **b** and **c**.";
709 assert!(check(content, config).is_empty(), "3 spans must not exceed limit 3");
710 }
711
712 #[test]
713 fn excludes_code_blocks_and_inline_code() {
714 let config = MD081Config {
715 max_per_paragraph: Some(1),
716 ..Default::default()
717 };
718 let content = "```python\nfoo(**a**, **b**, **c**, **d**)\n```\n\nText with `**x** **y** **z**` only.";
720 let warnings = check(content, config);
721 assert!(
722 warnings.is_empty(),
723 "emphasis inside code must be ignored. Got: {warnings:?}"
724 );
725 }
726
727 #[test]
728 fn counts_paragraphs_independently() {
729 let config = MD081Config {
730 max_per_paragraph: Some(2),
731 ..Default::default()
732 };
733 let content = "First **a** and **b** here.\n\nSecond **c** and **d** here.";
735 assert!(
736 check(content, config).is_empty(),
737 "spans must not aggregate across the blank-line paragraph boundary"
738 );
739 }
740
741 #[test]
742 fn counts_list_items_independently() {
743 let config = MD081Config {
744 max_per_paragraph: Some(2),
745 ..Default::default()
746 };
747 let content = "- item **a** and **b**\n- item **c** and **d**";
749 assert!(
750 check(content, config).is_empty(),
751 "each list item is its own paragraph and must be counted independently"
752 );
753 }
754
755 #[test]
756 fn targets_strong_ignores_italic() {
757 let config = MD081Config {
758 targets: EmphasisTarget::Strong,
759 max_per_paragraph: Some(1),
760 ..Default::default()
761 };
762 let content = "Here is *a* and *b* and *c* and *d* with one **bold**.";
764 assert!(
765 check(content, config).is_empty(),
766 "targets=strong must ignore italic spans"
767 );
768 }
769
770 #[test]
771 fn targets_emphasis_counts_italic_only() {
772 let config = MD081Config {
773 targets: EmphasisTarget::Emphasis,
774 max_per_paragraph: Some(2),
775 ..Default::default()
776 };
777 let content = "Lots of *a* and *b* and *c* italics, plus **bold**.";
778 let warnings = check(content, config);
779 assert_eq!(warnings.len(), 1, "3 italics exceed limit 2 under targets=emphasis");
780 }
781
782 #[test]
783 fn targets_all_dedups_combined_bold_italic() {
784 let config = MD081Config {
785 targets: EmphasisTarget::All,
786 max_per_paragraph: Some(1),
787 ..Default::default()
788 };
789 let content = "Just ***one region*** here.";
792 assert!(
793 check(content, config).is_empty(),
794 "combined ***...*** must count once under targets=all"
795 );
796 }
797
798 #[test]
799 fn targets_all_counts_distinct_regions() {
800 let config = MD081Config {
801 targets: EmphasisTarget::All,
802 max_per_paragraph: Some(1),
803 ..Default::default()
804 };
805 let content = "Mix ***a*** and **b** here.";
806 let warnings = check(content, config);
807 assert_eq!(warnings.len(), 1, "two distinct emphasis regions exceed limit 1");
808 }
809
810 #[test]
811 fn max_per_paragraph_zero_forbids_all_emphasis() {
812 let config = MD081Config {
815 max_per_paragraph: Some(0),
816 ..Default::default()
817 };
818 let content = "A paragraph with one **bold** word.";
819 let warnings = check(content, config);
820 assert_eq!(
821 warnings.len(),
822 1,
823 "max-per-paragraph=0 must flag even a single emphasis span. Got: {warnings:?}"
824 );
825 }
826
827 #[test]
828 fn max_consecutive_zero_forbids_all_emphasis() {
829 let config = MD081Config {
832 max_consecutive: Some(0),
833 ..Default::default()
834 };
835 let content = "A paragraph with one **bold** word.";
836 let warnings = check(content, config);
837 assert_eq!(
838 warnings.len(),
839 1,
840 "max-consecutive=0 must flag even a single emphasis span. Got: {warnings:?}"
841 );
842 }
843
844 #[test]
845 fn explicit_zero_in_toml_parses_as_forbid_all() {
846 let mut config = crate::config::Config::default();
849 let mut rule_config = crate::config::RuleConfig::default();
850 rule_config
851 .values
852 .insert("max-per-paragraph".to_string(), toml::Value::Integer(0));
853 config.rules.insert("MD081".to_string(), rule_config);
854
855 let rule = MD081NoExcessiveEmphasis::from_config(&config);
856 let ctx = LintContext::new("One **bold** here.", MarkdownFlavor::Standard, None);
857 let warnings = rule.check(&ctx).unwrap();
858 assert_eq!(
859 warnings.len(),
860 1,
861 "explicit max-per-paragraph = 0 must forbid all emphasis. Got: {warnings:?}"
862 );
863 }
864}