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