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 fn default_config_section(&self) -> Option<(String, toml::Value)> {
372 let table = crate::rule_config_serde::config_schema_table(&MD081Config::default())?;
373 if table.is_empty() {
374 None
375 } else {
376 Some((MD081Config::RULE_NAME.to_string(), toml::Value::Table(table)))
377 }
378 }
379
380 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
381 where
382 Self: Sized,
383 {
384 let rule_config = crate::rule_config_serde::load_rule_config::<MD081Config>(config);
385 Box::new(Self::from_config_struct(rule_config))
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392 use crate::config::MarkdownFlavor;
393 use crate::rule::LintWarning;
394
395 fn check(content: &str, config: MD081Config) -> Vec<LintWarning> {
396 let rule = MD081NoExcessiveEmphasis::from_config_struct(config);
397 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
398 rule.check(&ctx).unwrap()
399 }
400
401 #[test]
402 fn flags_paragraph_over_max_per_paragraph() {
403 let config = MD081Config {
404 max_per_paragraph: Some(3),
405 ..Default::default()
406 };
407 let content = "The **a** is **b** and **c** plus **d**.";
408 let warnings = check(content, config);
409 assert_eq!(warnings.len(), 1, "4 bold spans should exceed max-per-paragraph=3");
410 assert_eq!(warnings[0].line, 1);
411 }
412
413 #[test]
414 fn flags_consecutive_run_separated_only_by_punctuation() {
415 let config = MD081Config {
416 max_consecutive: Some(2),
417 ..Default::default()
418 };
419 let content = "Tags: **one**, **two**, **three**.";
421 let warnings = check(content, config);
422 assert_eq!(
423 warnings.len(),
424 1,
425 "run of 3 adjacent bolds should exceed max-consecutive=2"
426 );
427 assert_eq!(warnings[0].line, 1);
428 }
429
430 #[test]
431 fn unicode_punctuation_does_not_break_consecutive_run() {
432 let config = MD081Config {
435 max_consecutive: Some(2),
436 ..Default::default()
437 };
438 let content = "Tags: **one** \u{2014} **two** \u{2014} **three**.";
439 let warnings = check(content, config);
440 assert_eq!(
441 warnings.len(),
442 1,
443 "em-dash-separated bolds form one run of 3, exceeding max-consecutive=2. Got: {warnings:?}"
444 );
445 }
446
447 #[test]
448 fn connector_word_breaks_consecutive_run() {
449 let config = MD081Config {
450 max_consecutive: Some(2),
451 ..Default::default()
452 };
453 let content = "Tags: **one**, **two**, and **three**.";
455 let warnings = check(content, config);
456 assert!(
457 warnings.is_empty(),
458 "a connector word should break the run below the limit. Got: {warnings:?}"
459 );
460 }
461
462 #[test]
463 fn disabled_by_default() {
464 let content = "**a** **b** **c** **d** **e** **f** **g** **h**.";
467 let warnings = check(content, MD081Config::default());
468 assert!(warnings.is_empty(), "rule must be off by default. Got: {warnings:?}");
469 }
470
471 #[test]
472 fn does_not_flag_setext_heading_text() {
473 let config = MD081Config {
476 max_per_paragraph: Some(2),
477 max_consecutive: Some(1),
478 ..Default::default()
479 };
480 let content = "**A** **B** **C**\n=================\n";
481 let warnings = check(content, config);
482 assert!(
483 warnings.is_empty(),
484 "emphasis in setext heading text must not be flagged. Got: {warnings:?}"
485 );
486 }
487
488 #[test]
489 fn flags_list_item_before_thematic_break() {
490 let config = MD081Config {
494 max_per_paragraph: Some(1),
495 ..Default::default()
496 };
497 let content = "- **a** and **b**\n---\n";
498 let warnings = check(content, config);
499 assert_eq!(
500 warnings.len(),
501 1,
502 "list item with 2 bolds before a thematic break should be flagged. Got: {warnings:?}"
503 );
504 }
505
506 #[test]
507 fn parses_kebab_case_keys_and_lowercase_targets_from_config() {
508 let mut config = crate::config::Config::default();
512 let mut rule_config = crate::config::RuleConfig::default();
513 rule_config
514 .values
515 .insert("max-per-paragraph".to_string(), toml::Value::Integer(1));
516 rule_config
517 .values
518 .insert("targets".to_string(), toml::Value::String("all".to_string()));
519 config.rules.insert("MD081".to_string(), rule_config);
520
521 let rule = MD081NoExcessiveEmphasis::from_config(&config);
522 let ctx = LintContext::new("This is **bold** and *italic*.", MarkdownFlavor::Standard, None);
527 let warnings = rule.check(&ctx).unwrap();
528 assert_eq!(
529 warnings.len(),
530 1,
531 "kebab-case max-per-paragraph and targets=\"all\" must parse from config. Got: {warnings:?}"
532 );
533 }
534
535 #[test]
536 fn does_not_flag_setext_heading_inside_blockquote() {
537 let config = MD081Config {
540 max_per_paragraph: Some(1),
541 ..Default::default()
542 };
543 let content = "> **A** **B**\n> ===\n";
544 let warnings = check(content, config);
545 assert!(
546 warnings.is_empty(),
547 "emphasis in a blockquoted setext heading must not be flagged. Got: {warnings:?}"
548 );
549 }
550
551 #[test]
552 fn flags_blockquote_paragraph_before_top_level_break() {
553 let config = MD081Config {
556 max_per_paragraph: Some(1),
557 ..Default::default()
558 };
559 let content = "> **a** and **b**\n---\n";
560 let warnings = check(content, config);
561 assert_eq!(
562 warnings.len(),
563 1,
564 "blockquote paragraph with 2 bolds before a top-level break should be flagged. Got: {warnings:?}"
565 );
566 }
567
568 #[test]
569 fn does_not_flag_emphasis_in_table_rows() {
570 let config = MD081Config {
572 max_per_paragraph: Some(1),
573 ..Default::default()
574 };
575 let content = "| Col A | Col B |\n| ----- | ----- |\n| **a** | **b** |\n";
576 let warnings = check(content, config);
577 assert!(
578 warnings.is_empty(),
579 "emphasis in table cells must not be flagged. Got: {warnings:?}"
580 );
581 }
582
583 #[test]
584 fn does_not_flag_at_or_below_limit() {
585 let config = MD081Config {
586 max_per_paragraph: Some(3),
587 ..Default::default()
588 };
589 let content = "The **a** is **b** and **c**.";
590 assert!(check(content, config).is_empty(), "3 spans must not exceed limit 3");
591 }
592
593 #[test]
594 fn excludes_code_blocks_and_inline_code() {
595 let config = MD081Config {
596 max_per_paragraph: Some(1),
597 ..Default::default()
598 };
599 let content = "```python\nfoo(**a**, **b**, **c**, **d**)\n```\n\nText with `**x** **y** **z**` only.";
601 let warnings = check(content, config);
602 assert!(
603 warnings.is_empty(),
604 "emphasis inside code must be ignored. Got: {warnings:?}"
605 );
606 }
607
608 #[test]
609 fn counts_paragraphs_independently() {
610 let config = MD081Config {
611 max_per_paragraph: Some(2),
612 ..Default::default()
613 };
614 let content = "First **a** and **b** here.\n\nSecond **c** and **d** here.";
616 assert!(
617 check(content, config).is_empty(),
618 "spans must not aggregate across the blank-line paragraph boundary"
619 );
620 }
621
622 #[test]
623 fn counts_list_items_independently() {
624 let config = MD081Config {
625 max_per_paragraph: Some(2),
626 ..Default::default()
627 };
628 let content = "- item **a** and **b**\n- item **c** and **d**";
630 assert!(
631 check(content, config).is_empty(),
632 "each list item is its own paragraph and must be counted independently"
633 );
634 }
635
636 #[test]
637 fn targets_strong_ignores_italic() {
638 let config = MD081Config {
639 targets: EmphasisTarget::Strong,
640 max_per_paragraph: Some(1),
641 ..Default::default()
642 };
643 let content = "Here is *a* and *b* and *c* and *d* with one **bold**.";
645 assert!(
646 check(content, config).is_empty(),
647 "targets=strong must ignore italic spans"
648 );
649 }
650
651 #[test]
652 fn targets_emphasis_counts_italic_only() {
653 let config = MD081Config {
654 targets: EmphasisTarget::Emphasis,
655 max_per_paragraph: Some(2),
656 ..Default::default()
657 };
658 let content = "Lots of *a* and *b* and *c* italics, plus **bold**.";
659 let warnings = check(content, config);
660 assert_eq!(warnings.len(), 1, "3 italics exceed limit 2 under targets=emphasis");
661 }
662
663 #[test]
664 fn targets_all_dedups_combined_bold_italic() {
665 let config = MD081Config {
666 targets: EmphasisTarget::All,
667 max_per_paragraph: Some(1),
668 ..Default::default()
669 };
670 let content = "Just ***one region*** here.";
673 assert!(
674 check(content, config).is_empty(),
675 "combined ***...*** must count once under targets=all"
676 );
677 }
678
679 #[test]
680 fn targets_all_counts_distinct_regions() {
681 let config = MD081Config {
682 targets: EmphasisTarget::All,
683 max_per_paragraph: Some(1),
684 ..Default::default()
685 };
686 let content = "Mix ***a*** and **b** here.";
687 let warnings = check(content, config);
688 assert_eq!(warnings.len(), 1, "two distinct emphasis regions exceed limit 1");
689 }
690
691 #[test]
692 fn max_per_paragraph_zero_forbids_all_emphasis() {
693 let config = MD081Config {
696 max_per_paragraph: Some(0),
697 ..Default::default()
698 };
699 let content = "A paragraph with one **bold** word.";
700 let warnings = check(content, config);
701 assert_eq!(
702 warnings.len(),
703 1,
704 "max-per-paragraph=0 must flag even a single emphasis span. Got: {warnings:?}"
705 );
706 }
707
708 #[test]
709 fn max_consecutive_zero_forbids_all_emphasis() {
710 let config = MD081Config {
713 max_consecutive: Some(0),
714 ..Default::default()
715 };
716 let content = "A paragraph with one **bold** word.";
717 let warnings = check(content, config);
718 assert_eq!(
719 warnings.len(),
720 1,
721 "max-consecutive=0 must flag even a single emphasis span. Got: {warnings:?}"
722 );
723 }
724
725 #[test]
726 fn explicit_zero_in_toml_parses_as_forbid_all() {
727 let mut config = crate::config::Config::default();
730 let mut rule_config = crate::config::RuleConfig::default();
731 rule_config
732 .values
733 .insert("max-per-paragraph".to_string(), toml::Value::Integer(0));
734 config.rules.insert("MD081".to_string(), rule_config);
735
736 let rule = MD081NoExcessiveEmphasis::from_config(&config);
737 let ctx = LintContext::new("One **bold** here.", MarkdownFlavor::Standard, None);
738 let warnings = rule.check(&ctx).unwrap();
739 assert_eq!(
740 warnings.len(),
741 1,
742 "explicit max-per-paragraph = 0 must forbid all emphasis. Got: {warnings:?}"
743 );
744 }
745}