1use crate::lint_context::LintContext;
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> {
149 let mut flags = vec![false; ctx.lines.len()];
150 for (idx, line) in ctx.lines.iter().enumerate() {
151 if idx == 0 || line.in_code_block {
152 continue;
153 }
154 let text = Self::line_inner(line, ctx.content);
155 let is_underline = !text.is_empty() && (text.bytes().all(|b| b == b'=') || text.bytes().all(|b| b == b'-'));
156 if !is_underline {
157 continue;
158 }
159 let level = Self::blockquote_level(line);
160 let mut j = idx;
166 while j > 0 {
167 let prev = &ctx.lines[j - 1];
168 if prev.is_blank
169 || !prev.is_paragraph_context()
170 || prev.list_item.is_some()
171 || Self::blockquote_level(prev) != level
172 {
173 break;
174 }
175 flags[j - 1] = true;
176 j -= 1;
177 }
178 }
179 flags
180 }
181
182 fn line_inner<'a>(line: &'a crate::lint_context::LineInfo, source: &'a str) -> &'a str {
184 match line.blockquote.as_ref() {
185 Some(bq) => bq.content.trim(),
186 None => line.content(source).trim(),
187 }
188 }
189
190 fn blockquote_level(line: &crate::lint_context::LineInfo) -> usize {
192 line.blockquote.as_ref().map_or(0, |b| b.nesting_level)
193 }
194
195 fn paragraph_ids(ctx: &LintContext) -> Vec<Option<usize>> {
201 let mut ids = vec![None; ctx.lines.len()];
202 let setext_text = Self::setext_text_lines(ctx);
203 let mut current: Option<usize> = None;
204 let mut next_id = 0usize;
205 let mut prev_bq_level = 0usize;
206
207 for (idx, line) in ctx.lines.iter().enumerate() {
208 let bq_level = Self::blockquote_level(line);
209 let is_prose =
210 !line.is_blank && line.is_paragraph_context() && !setext_text[idx] && !ctx.is_in_table_block(idx + 1);
211
212 if !is_prose {
213 current = None;
214 prev_bq_level = bq_level;
215 continue;
216 }
217
218 let starts_new = current.is_none() || line.list_item.is_some() || bq_level != prev_bq_level;
219 if starts_new {
220 current = Some(next_id);
221 next_id += 1;
222 }
223 ids[idx] = current;
224 prev_bq_level = bq_level;
225 }
226
227 ids
228 }
229
230 fn emit_run(&self, ctx: &LintContext, run: &[CountedSpan], limit: usize, warnings: &mut Vec<LintWarning>) {
233 if run.len() > limit
234 && let Some(first) = run.first()
235 {
236 warnings.push(self.warn_at(
237 ctx,
238 first,
239 format!(
240 "{} consecutive emphasis spans (limit {limit}); consider rephrasing to reduce emphasis",
241 run.len(),
242 ),
243 ));
244 }
245 }
246
247 fn warn_at(&self, ctx: &LintContext, span: &CountedSpan, message: String) -> LintWarning {
248 let line_content = ctx.lines.get(span.line - 1).map_or("", |l| l.content(ctx.content));
249 let line_start = ctx.lines.get(span.line - 1).map_or(0, |l| l.byte_offset);
250 let match_start_in_line = span.start.saturating_sub(line_start);
251 let (start_line, start_col, end_line, end_col) =
252 calculate_match_range(span.line, line_content, match_start_in_line, span.end - span.start);
253 LintWarning {
254 rule_name: Some(self.name().to_string()),
255 severity: Severity::Warning,
256 line: start_line,
257 column: start_col,
258 end_line,
259 end_column: end_col,
260 message,
261 fix: None,
262 }
263 }
264}
265
266impl Rule for MD081NoExcessiveEmphasis {
267 fn name(&self) -> &'static str {
268 "MD081"
269 }
270
271 fn description(&self) -> &'static str {
272 "Inline emphasis should not be excessive"
273 }
274
275 fn category(&self) -> RuleCategory {
276 RuleCategory::Emphasis
277 }
278
279 fn check(&self, ctx: &LintContext) -> LintResult {
280 if self.config.max_per_paragraph.is_none() && self.config.max_consecutive.is_none() {
281 return Ok(Vec::new());
282 }
283
284 let spans = self.counted_spans(ctx);
285 if spans.is_empty() {
286 return Ok(Vec::new());
287 }
288
289 let para_ids = Self::paragraph_ids(ctx);
290 let mut warnings = Vec::new();
291
292 if let Some(limit) = self.config.max_per_paragraph {
293 let mut counts: std::collections::HashMap<usize, (usize, CountedSpan)> = std::collections::HashMap::new();
297 for span in &spans {
298 let Some(pid) = para_ids.get(span.line - 1).copied().flatten() else {
299 continue;
300 };
301 counts.entry(pid).and_modify(|(n, _)| *n += 1).or_insert((1, *span));
302 }
303 let mut flagged: Vec<(usize, CountedSpan)> = counts
304 .into_iter()
305 .filter(|(_, (n, _))| *n > limit)
306 .map(|(_, (n, first))| (n, first))
307 .collect();
308 flagged.sort_by_key(|(_, first)| (first.line, first.start));
309 for (count, first) in flagged {
310 warnings.push(self.warn_at(
311 ctx,
312 &first,
313 format!(
314 "Paragraph contains {count} emphasis spans (limit {limit}); consider reducing emphasis to improve readability"
315 ),
316 ));
317 }
318 }
319
320 if let Some(limit) = self.config.max_consecutive {
321 let mut run_start = 0usize; for i in 0..spans.len() {
326 let breaks = if i == 0 {
327 true
328 } else {
329 let prev = &spans[i - 1];
330 let cur = &spans[i];
331 let same_para = para_ids.get(prev.line - 1).copied().flatten()
332 == para_ids.get(cur.line - 1).copied().flatten()
333 && para_ids.get(cur.line - 1).copied().flatten().is_some();
334 let between = ctx.content.get(prev.end..cur.start).unwrap_or("");
335 let only_filler = !between.chars().any(char::is_alphanumeric);
339 !(same_para && only_filler)
340 };
341
342 if breaks && i > run_start {
343 self.emit_run(ctx, &spans[run_start..i], limit, &mut warnings);
344 }
345 if breaks {
346 run_start = i;
347 }
348 }
349 if !spans.is_empty() {
350 self.emit_run(ctx, &spans[run_start..], limit, &mut warnings);
351 }
352 }
353
354 Ok(warnings)
355 }
356
357 fn fix_capability(&self) -> FixCapability {
358 FixCapability::Unfixable
359 }
360
361 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
362 Ok(ctx.content.to_string())
365 }
366
367 fn as_any(&self) -> &dyn std::any::Any {
368 self
369 }
370
371 crate::impl_rule_config_methods!(MD081Config, nullable);
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377 use crate::config::MarkdownFlavor;
378 use crate::rule::LintWarning;
379
380 fn check(content: &str, config: MD081Config) -> Vec<LintWarning> {
381 let rule = MD081NoExcessiveEmphasis::from_config_struct(config);
382 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
383 rule.check(&ctx).unwrap()
384 }
385
386 #[test]
387 fn flags_paragraph_over_max_per_paragraph() {
388 let config = MD081Config {
389 max_per_paragraph: Some(3),
390 ..Default::default()
391 };
392 let content = "The **a** is **b** and **c** plus **d**.";
393 let warnings = check(content, config);
394 assert_eq!(warnings.len(), 1, "4 bold spans should exceed max-per-paragraph=3");
395 assert_eq!(warnings[0].line, 1);
396 }
397
398 #[test]
399 fn flags_consecutive_run_separated_only_by_punctuation() {
400 let config = MD081Config {
401 max_consecutive: Some(2),
402 ..Default::default()
403 };
404 let content = "Tags: **one**, **two**, **three**.";
406 let warnings = check(content, config);
407 assert_eq!(
408 warnings.len(),
409 1,
410 "run of 3 adjacent bolds should exceed max-consecutive=2"
411 );
412 assert_eq!(warnings[0].line, 1);
413 }
414
415 #[test]
416 fn unicode_punctuation_does_not_break_consecutive_run() {
417 let config = MD081Config {
420 max_consecutive: Some(2),
421 ..Default::default()
422 };
423 let content = "Tags: **one** \u{2014} **two** \u{2014} **three**.";
424 let warnings = check(content, config);
425 assert_eq!(
426 warnings.len(),
427 1,
428 "em-dash-separated bolds form one run of 3, exceeding max-consecutive=2. Got: {warnings:?}"
429 );
430 }
431
432 #[test]
433 fn connector_word_breaks_consecutive_run() {
434 let config = MD081Config {
435 max_consecutive: Some(2),
436 ..Default::default()
437 };
438 let content = "Tags: **one**, **two**, and **three**.";
440 let warnings = check(content, config);
441 assert!(
442 warnings.is_empty(),
443 "a connector word should break the run below the limit. Got: {warnings:?}"
444 );
445 }
446
447 #[test]
448 fn disabled_by_default() {
449 let content = "**a** **b** **c** **d** **e** **f** **g** **h**.";
452 let warnings = check(content, MD081Config::default());
453 assert!(warnings.is_empty(), "rule must be off by default. Got: {warnings:?}");
454 }
455
456 #[test]
457 fn does_not_flag_setext_heading_text() {
458 let config = MD081Config {
461 max_per_paragraph: Some(2),
462 max_consecutive: Some(1),
463 ..Default::default()
464 };
465 let content = "**A** **B** **C**\n=================\n";
466 let warnings = check(content, config);
467 assert!(
468 warnings.is_empty(),
469 "emphasis in setext heading text must not be flagged. Got: {warnings:?}"
470 );
471 }
472
473 #[test]
474 fn flags_list_item_before_thematic_break() {
475 let config = MD081Config {
479 max_per_paragraph: Some(1),
480 ..Default::default()
481 };
482 let content = "- **a** and **b**\n---\n";
483 let warnings = check(content, config);
484 assert_eq!(
485 warnings.len(),
486 1,
487 "list item with 2 bolds before a thematic break should be flagged. Got: {warnings:?}"
488 );
489 }
490
491 #[test]
492 fn parses_kebab_case_keys_and_lowercase_targets_from_config() {
493 let mut config = crate::config::Config::default();
497 let mut rule_config = crate::config::RuleConfig::default();
498 rule_config
499 .values
500 .insert("max-per-paragraph".to_string(), toml::Value::Integer(1));
501 rule_config
502 .values
503 .insert("targets".to_string(), toml::Value::String("all".to_string()));
504 config.rules.insert("MD081".to_string(), rule_config);
505
506 let rule = MD081NoExcessiveEmphasis::from_config(&config);
507 let ctx = LintContext::new("This is **bold** and *italic*.", MarkdownFlavor::Standard, None);
512 let warnings = rule.check(&ctx).unwrap();
513 assert_eq!(
514 warnings.len(),
515 1,
516 "kebab-case max-per-paragraph and targets=\"all\" must parse from config. Got: {warnings:?}"
517 );
518 }
519
520 #[test]
521 fn does_not_flag_setext_heading_inside_blockquote() {
522 let config = MD081Config {
525 max_per_paragraph: Some(1),
526 ..Default::default()
527 };
528 let content = "> **A** **B**\n> ===\n";
529 let warnings = check(content, config);
530 assert!(
531 warnings.is_empty(),
532 "emphasis in a blockquoted setext heading must not be flagged. Got: {warnings:?}"
533 );
534 }
535
536 #[test]
537 fn flags_blockquote_paragraph_before_top_level_break() {
538 let config = MD081Config {
541 max_per_paragraph: Some(1),
542 ..Default::default()
543 };
544 let content = "> **a** and **b**\n---\n";
545 let warnings = check(content, config);
546 assert_eq!(
547 warnings.len(),
548 1,
549 "blockquote paragraph with 2 bolds before a top-level break should be flagged. Got: {warnings:?}"
550 );
551 }
552
553 #[test]
554 fn does_not_flag_emphasis_in_table_rows() {
555 let config = MD081Config {
557 max_per_paragraph: Some(1),
558 ..Default::default()
559 };
560 let content = "| Col A | Col B |\n| ----- | ----- |\n| **a** | **b** |\n";
561 let warnings = check(content, config);
562 assert!(
563 warnings.is_empty(),
564 "emphasis in table cells must not be flagged. Got: {warnings:?}"
565 );
566 }
567
568 #[test]
569 fn does_not_flag_at_or_below_limit() {
570 let config = MD081Config {
571 max_per_paragraph: Some(3),
572 ..Default::default()
573 };
574 let content = "The **a** is **b** and **c**.";
575 assert!(check(content, config).is_empty(), "3 spans must not exceed limit 3");
576 }
577
578 #[test]
579 fn excludes_code_blocks_and_inline_code() {
580 let config = MD081Config {
581 max_per_paragraph: Some(1),
582 ..Default::default()
583 };
584 let content = "```python\nfoo(**a**, **b**, **c**, **d**)\n```\n\nText with `**x** **y** **z**` only.";
586 let warnings = check(content, config);
587 assert!(
588 warnings.is_empty(),
589 "emphasis inside code must be ignored. Got: {warnings:?}"
590 );
591 }
592
593 #[test]
594 fn counts_paragraphs_independently() {
595 let config = MD081Config {
596 max_per_paragraph: Some(2),
597 ..Default::default()
598 };
599 let content = "First **a** and **b** here.\n\nSecond **c** and **d** here.";
601 assert!(
602 check(content, config).is_empty(),
603 "spans must not aggregate across the blank-line paragraph boundary"
604 );
605 }
606
607 #[test]
608 fn counts_list_items_independently() {
609 let config = MD081Config {
610 max_per_paragraph: Some(2),
611 ..Default::default()
612 };
613 let content = "- item **a** and **b**\n- item **c** and **d**";
615 assert!(
616 check(content, config).is_empty(),
617 "each list item is its own paragraph and must be counted independently"
618 );
619 }
620
621 #[test]
622 fn targets_strong_ignores_italic() {
623 let config = MD081Config {
624 targets: EmphasisTarget::Strong,
625 max_per_paragraph: Some(1),
626 ..Default::default()
627 };
628 let content = "Here is *a* and *b* and *c* and *d* with one **bold**.";
630 assert!(
631 check(content, config).is_empty(),
632 "targets=strong must ignore italic spans"
633 );
634 }
635
636 #[test]
637 fn targets_emphasis_counts_italic_only() {
638 let config = MD081Config {
639 targets: EmphasisTarget::Emphasis,
640 max_per_paragraph: Some(2),
641 ..Default::default()
642 };
643 let content = "Lots of *a* and *b* and *c* italics, plus **bold**.";
644 let warnings = check(content, config);
645 assert_eq!(warnings.len(), 1, "3 italics exceed limit 2 under targets=emphasis");
646 }
647
648 #[test]
649 fn targets_all_dedups_combined_bold_italic() {
650 let config = MD081Config {
651 targets: EmphasisTarget::All,
652 max_per_paragraph: Some(1),
653 ..Default::default()
654 };
655 let content = "Just ***one region*** here.";
658 assert!(
659 check(content, config).is_empty(),
660 "combined ***...*** must count once under targets=all"
661 );
662 }
663
664 #[test]
665 fn targets_all_counts_distinct_regions() {
666 let config = MD081Config {
667 targets: EmphasisTarget::All,
668 max_per_paragraph: Some(1),
669 ..Default::default()
670 };
671 let content = "Mix ***a*** and **b** here.";
672 let warnings = check(content, config);
673 assert_eq!(warnings.len(), 1, "two distinct emphasis regions exceed limit 1");
674 }
675
676 #[test]
677 fn max_per_paragraph_zero_forbids_all_emphasis() {
678 let config = MD081Config {
681 max_per_paragraph: Some(0),
682 ..Default::default()
683 };
684 let content = "A paragraph with one **bold** word.";
685 let warnings = check(content, config);
686 assert_eq!(
687 warnings.len(),
688 1,
689 "max-per-paragraph=0 must flag even a single emphasis span. Got: {warnings:?}"
690 );
691 }
692
693 #[test]
694 fn max_consecutive_zero_forbids_all_emphasis() {
695 let config = MD081Config {
698 max_consecutive: Some(0),
699 ..Default::default()
700 };
701 let content = "A paragraph with one **bold** word.";
702 let warnings = check(content, config);
703 assert_eq!(
704 warnings.len(),
705 1,
706 "max-consecutive=0 must flag even a single emphasis span. Got: {warnings:?}"
707 );
708 }
709
710 #[test]
711 fn explicit_zero_in_toml_parses_as_forbid_all() {
712 let mut config = crate::config::Config::default();
715 let mut rule_config = crate::config::RuleConfig::default();
716 rule_config
717 .values
718 .insert("max-per-paragraph".to_string(), toml::Value::Integer(0));
719 config.rules.insert("MD081".to_string(), rule_config);
720
721 let rule = MD081NoExcessiveEmphasis::from_config(&config);
722 let ctx = LintContext::new("One **bold** here.", MarkdownFlavor::Standard, None);
723 let warnings = rule.check(&ctx).unwrap();
724 assert_eq!(
725 warnings.len(),
726 1,
727 "explicit max-per-paragraph = 0 must forbid all emphasis. Got: {warnings:?}"
728 );
729 }
730}