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))
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 flags_paragraph_over_max_per_paragraph() {
418 let config = MD081Config {
419 max_per_paragraph: Some(3),
420 ..Default::default()
421 };
422 let content = "The **a** is **b** and **c** plus **d**.";
423 let warnings = check(content, config);
424 assert_eq!(warnings.len(), 1, "4 bold spans should exceed max-per-paragraph=3");
425 assert_eq!(warnings[0].line, 1);
426 }
427
428 #[test]
429 fn flags_consecutive_run_separated_only_by_punctuation() {
430 let config = MD081Config {
431 max_consecutive: Some(2),
432 ..Default::default()
433 };
434 let content = "Tags: **one**, **two**, **three**.";
436 let warnings = check(content, config);
437 assert_eq!(
438 warnings.len(),
439 1,
440 "run of 3 adjacent bolds should exceed max-consecutive=2"
441 );
442 assert_eq!(warnings[0].line, 1);
443 }
444
445 #[test]
446 fn unicode_punctuation_does_not_break_consecutive_run() {
447 let config = MD081Config {
450 max_consecutive: Some(2),
451 ..Default::default()
452 };
453 let content = "Tags: **one** \u{2014} **two** \u{2014} **three**.";
454 let warnings = check(content, config);
455 assert_eq!(
456 warnings.len(),
457 1,
458 "em-dash-separated bolds form one run of 3, exceeding max-consecutive=2. Got: {warnings:?}"
459 );
460 }
461
462 #[test]
463 fn connector_word_breaks_consecutive_run() {
464 let config = MD081Config {
465 max_consecutive: Some(2),
466 ..Default::default()
467 };
468 let content = "Tags: **one**, **two**, and **three**.";
470 let warnings = check(content, config);
471 assert!(
472 warnings.is_empty(),
473 "a connector word should break the run below the limit. Got: {warnings:?}"
474 );
475 }
476
477 #[test]
478 fn disabled_by_default() {
479 let content = "**a** **b** **c** **d** **e** **f** **g** **h**.";
482 let warnings = check(content, MD081Config::default());
483 assert!(warnings.is_empty(), "rule must be off by default. Got: {warnings:?}");
484 }
485
486 #[test]
487 fn does_not_flag_setext_heading_text() {
488 let config = MD081Config {
491 max_per_paragraph: Some(2),
492 max_consecutive: Some(1),
493 ..Default::default()
494 };
495 let content = "**A** **B** **C**\n=================\n";
496 let warnings = check(content, config);
497 assert!(
498 warnings.is_empty(),
499 "emphasis in setext heading text must not be flagged. Got: {warnings:?}"
500 );
501 }
502
503 #[test]
504 fn does_not_flag_multi_line_setext_heading_text() {
505 let config = MD081Config {
509 max_per_paragraph: Some(2),
510 max_consecutive: Some(1),
511 ..Default::default()
512 };
513 let content = "**A** **B** **C**\n* \n===\n";
514 let warnings = check(content, config);
515 assert!(
516 warnings.is_empty(),
517 "emphasis in a multi-line setext heading must not be flagged. Got: {warnings:?}"
518 );
519 }
520
521 #[test]
522 fn does_not_flag_setext_heading_text_inside_a_list_item() {
523 let config = MD081Config {
528 max_per_paragraph: Some(2),
529 ..Default::default()
530 };
531 for content in [
532 "- intro\n **one** **two** **three**\n ===\n",
533 "- **one** **two** **three**\n ===\n",
534 "1. intro\n **one** **two** **three**\n ===\n",
535 "> - intro\n> **one** **two** **three**\n> ===\n",
536 ] {
537 let warnings = check(content, config.clone());
538 assert!(
539 warnings.is_empty(),
540 "{content:?} is a heading inside the item. Got: {warnings:?}"
541 );
542 }
543 }
544
545 #[test]
546 fn flags_a_nested_item_whose_underline_is_lazy_paragraph_text() {
547 let config = MD081Config {
550 max_per_paragraph: Some(2),
551 ..Default::default()
552 };
553 let content = "- a\n - **a** **b** **c**\n ===\n";
554 let warnings = check(content, config);
555 assert_eq!(
556 warnings.len(),
557 1,
558 "the nested item's paragraph holds 3 bolds and no heading. Got: {warnings:?}"
559 );
560 assert_eq!(warnings[0].line, 2);
561 }
562
563 #[test]
564 fn flags_list_item_before_thematic_break() {
565 let config = MD081Config {
569 max_per_paragraph: Some(1),
570 ..Default::default()
571 };
572 let content = "- **a** and **b**\n---\n";
573 let warnings = check(content, config);
574 assert_eq!(
575 warnings.len(),
576 1,
577 "list item with 2 bolds before a thematic break should be flagged. Got: {warnings:?}"
578 );
579 }
580
581 #[test]
582 fn parses_kebab_case_keys_and_lowercase_targets_from_config() {
583 let mut config = crate::config::Config::default();
587 let mut rule_config = crate::config::RuleConfig::default();
588 rule_config
589 .values
590 .insert("max-per-paragraph".to_string(), toml::Value::Integer(1));
591 rule_config
592 .values
593 .insert("targets".to_string(), toml::Value::String("all".to_string()));
594 config.rules.insert("MD081".to_string(), rule_config);
595
596 let rule = MD081NoExcessiveEmphasis::from_config(&config);
597 let ctx = LintContext::new("This is **bold** and *italic*.", MarkdownFlavor::Standard, None);
602 let warnings = rule.check(&ctx).unwrap();
603 assert_eq!(
604 warnings.len(),
605 1,
606 "kebab-case max-per-paragraph and targets=\"all\" must parse from config. Got: {warnings:?}"
607 );
608 }
609
610 #[test]
611 fn does_not_flag_setext_heading_inside_blockquote() {
612 let config = MD081Config {
615 max_per_paragraph: Some(1),
616 ..Default::default()
617 };
618 let content = "> **A** **B**\n> ===\n";
619 let warnings = check(content, config);
620 assert!(
621 warnings.is_empty(),
622 "emphasis in a blockquoted setext heading must not be flagged. Got: {warnings:?}"
623 );
624 }
625
626 #[test]
627 fn flags_blockquote_paragraph_before_top_level_break() {
628 let config = MD081Config {
631 max_per_paragraph: Some(1),
632 ..Default::default()
633 };
634 let content = "> **a** and **b**\n---\n";
635 let warnings = check(content, config);
636 assert_eq!(
637 warnings.len(),
638 1,
639 "blockquote paragraph with 2 bolds before a top-level break should be flagged. Got: {warnings:?}"
640 );
641 }
642
643 #[test]
644 fn does_not_flag_emphasis_in_table_rows() {
645 let config = MD081Config {
647 max_per_paragraph: Some(1),
648 ..Default::default()
649 };
650 let content = "| Col A | Col B |\n| ----- | ----- |\n| **a** | **b** |\n";
651 let warnings = check(content, config);
652 assert!(
653 warnings.is_empty(),
654 "emphasis in table cells must not be flagged. Got: {warnings:?}"
655 );
656 }
657
658 #[test]
659 fn does_not_flag_at_or_below_limit() {
660 let config = MD081Config {
661 max_per_paragraph: Some(3),
662 ..Default::default()
663 };
664 let content = "The **a** is **b** and **c**.";
665 assert!(check(content, config).is_empty(), "3 spans must not exceed limit 3");
666 }
667
668 #[test]
669 fn excludes_code_blocks_and_inline_code() {
670 let config = MD081Config {
671 max_per_paragraph: Some(1),
672 ..Default::default()
673 };
674 let content = "```python\nfoo(**a**, **b**, **c**, **d**)\n```\n\nText with `**x** **y** **z**` only.";
676 let warnings = check(content, config);
677 assert!(
678 warnings.is_empty(),
679 "emphasis inside code must be ignored. Got: {warnings:?}"
680 );
681 }
682
683 #[test]
684 fn counts_paragraphs_independently() {
685 let config = MD081Config {
686 max_per_paragraph: Some(2),
687 ..Default::default()
688 };
689 let content = "First **a** and **b** here.\n\nSecond **c** and **d** here.";
691 assert!(
692 check(content, config).is_empty(),
693 "spans must not aggregate across the blank-line paragraph boundary"
694 );
695 }
696
697 #[test]
698 fn counts_list_items_independently() {
699 let config = MD081Config {
700 max_per_paragraph: Some(2),
701 ..Default::default()
702 };
703 let content = "- item **a** and **b**\n- item **c** and **d**";
705 assert!(
706 check(content, config).is_empty(),
707 "each list item is its own paragraph and must be counted independently"
708 );
709 }
710
711 #[test]
712 fn targets_strong_ignores_italic() {
713 let config = MD081Config {
714 targets: EmphasisTarget::Strong,
715 max_per_paragraph: Some(1),
716 ..Default::default()
717 };
718 let content = "Here is *a* and *b* and *c* and *d* with one **bold**.";
720 assert!(
721 check(content, config).is_empty(),
722 "targets=strong must ignore italic spans"
723 );
724 }
725
726 #[test]
727 fn targets_emphasis_counts_italic_only() {
728 let config = MD081Config {
729 targets: EmphasisTarget::Emphasis,
730 max_per_paragraph: Some(2),
731 ..Default::default()
732 };
733 let content = "Lots of *a* and *b* and *c* italics, plus **bold**.";
734 let warnings = check(content, config);
735 assert_eq!(warnings.len(), 1, "3 italics exceed limit 2 under targets=emphasis");
736 }
737
738 #[test]
739 fn targets_all_dedups_combined_bold_italic() {
740 let config = MD081Config {
741 targets: EmphasisTarget::All,
742 max_per_paragraph: Some(1),
743 ..Default::default()
744 };
745 let content = "Just ***one region*** here.";
748 assert!(
749 check(content, config).is_empty(),
750 "combined ***...*** must count once under targets=all"
751 );
752 }
753
754 #[test]
755 fn targets_all_counts_distinct_regions() {
756 let config = MD081Config {
757 targets: EmphasisTarget::All,
758 max_per_paragraph: Some(1),
759 ..Default::default()
760 };
761 let content = "Mix ***a*** and **b** here.";
762 let warnings = check(content, config);
763 assert_eq!(warnings.len(), 1, "two distinct emphasis regions exceed limit 1");
764 }
765
766 #[test]
767 fn max_per_paragraph_zero_forbids_all_emphasis() {
768 let config = MD081Config {
771 max_per_paragraph: Some(0),
772 ..Default::default()
773 };
774 let content = "A paragraph with one **bold** word.";
775 let warnings = check(content, config);
776 assert_eq!(
777 warnings.len(),
778 1,
779 "max-per-paragraph=0 must flag even a single emphasis span. Got: {warnings:?}"
780 );
781 }
782
783 #[test]
784 fn max_consecutive_zero_forbids_all_emphasis() {
785 let config = MD081Config {
788 max_consecutive: Some(0),
789 ..Default::default()
790 };
791 let content = "A paragraph with one **bold** word.";
792 let warnings = check(content, config);
793 assert_eq!(
794 warnings.len(),
795 1,
796 "max-consecutive=0 must flag even a single emphasis span. Got: {warnings:?}"
797 );
798 }
799
800 #[test]
801 fn explicit_zero_in_toml_parses_as_forbid_all() {
802 let mut config = crate::config::Config::default();
805 let mut rule_config = crate::config::RuleConfig::default();
806 rule_config
807 .values
808 .insert("max-per-paragraph".to_string(), toml::Value::Integer(0));
809 config.rules.insert("MD081".to_string(), rule_config);
810
811 let rule = MD081NoExcessiveEmphasis::from_config(&config);
812 let ctx = LintContext::new("One **bold** here.", MarkdownFlavor::Standard, None);
813 let warnings = rule.check(&ctx).unwrap();
814 assert_eq!(
815 warnings.len(),
816 1,
817 "explicit max-per-paragraph = 0 must forbid all emphasis. Got: {warnings:?}"
818 );
819 }
820}