Skip to main content

rumdl_lib/rules/
md081_no_excessive_emphasis.rs

1//! Rule MD081: Flag excessive inline emphasis.
2//!
3//! AI-generated Markdown tends to sprinkle inline `**bold**` across running
4//! prose (`**this** and **that** and **the other**`), which hurts readability
5//! in both raw and rendered form without adding meaning. This rule flags
6//! paragraphs that exceed a configurable density of emphasis spans, and runs of
7//! adjacent emphasis spans separated only by whitespace and punctuation.
8//!
9//! Scope is controlled by `targets`:
10//! - `strong` (default) - only `**bold**` / `__bold__`
11//! - `emphasis` - only `*italic*` / `_italic_`
12//! - `all` - both, counting a combined `***bold italic***` once
13//!
14//! Diagnostic only: stripping or down-converting emphasis is semantically lossy
15//! (`**critical**` may be deliberate), so there is no auto-fix. Both thresholds
16//! are unset by default, so the rule is silent until a project opts in by setting
17//! a limit. Setting a limit to `0` forbids the construct entirely (a paragraph or
18//! run may contain no emphasis at all).
19
20use 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/// A counted emphasis span: byte range plus its 1-indexed line.
28#[derive(Debug, Clone, Copy)]
29struct CountedSpan {
30    start: usize,
31    end: usize,
32    line: usize,
33}
34
35/// Which inline emphasis spans the rule counts.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum EmphasisTarget {
39    /// Only strong emphasis (`**bold**`, `__bold__`).
40    #[default]
41    Strong,
42    /// Only ordinary emphasis (`*italic*`, `_italic_`).
43    Emphasis,
44    /// Both strong and ordinary emphasis.
45    All,
46}
47
48/// Configuration for MD081 (Excessive emphasis).
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
50#[serde(rename_all = "kebab-case")]
51pub struct MD081Config {
52    /// Which emphasis spans to count. Defaults to `strong` (bold only), the
53    /// pattern reported as the primary readability problem.
54    #[serde(default)]
55    pub targets: EmphasisTarget,
56
57    /// Maximum emphasis spans allowed in a single paragraph. A paragraph with
58    /// more than this many spans is flagged. Unset disables the check; `Some(0)`
59    /// forbids all emphasis in a paragraph.
60    #[serde(default)]
61    pub max_per_paragraph: Option<usize>,
62
63    /// Maximum length of a run of adjacent emphasis spans separated only by
64    /// whitespace and punctuation. A longer run is flagged. Unset disables the
65    /// check; `Some(0)` forbids any emphasis (every span is at least a run of one).
66    #[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    /// Collect the emphasis spans the rule counts: filtered by `targets`,
99    /// stripped of non-prose contexts (code, links, HTML, math, ...), and -
100    /// for `targets = all` - deduplicated so a nested `***bold italic***`
101    /// region counts once rather than as overlapping strong + emphasis spans.
102    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            // Drop spans fully contained within an earlier (outer) span so a
125            // combined `***x***` - reported as both a strong and an emphasis
126            // span over overlapping ranges - is counted only once.
127            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    /// Mark lines that are the text of a setext heading. A setext heading's text
143    /// is the whole paragraph its underline ends, so every line of that
144    /// paragraph is heading text rather than prose.
145    ///
146    /// The parser records a heading only where its text starts at the line's
147    /// own left edge. A paragraph that opens on a list item's marker line and
148    /// ends at an underline indented into the item is a heading inside the
149    /// item, recorded nowhere, the same as `- # heading`; its lines are read
150    /// here from the underline, since to this rule they are heading text all
151    /// the same.
152    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            // Walk back to the paragraph's first line, stopping on the marker
173            // line of the item that opens it.
174            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            // An underline left of the item's content column cannot end the
193            // item's paragraph; it continues the paragraph lazily as text.
194            if Self::content_column(line) < item.content_column {
195                continue;
196            }
197            flags[first..idx].fill(true);
198        }
199        flags
200    }
201
202    /// A line's content with its indentation and any blockquote prefix removed.
203    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    /// The byte column a line's content starts at, past its indentation and
211    /// any blockquote prefix, in the coordinates of `ListItemInfo::content_column`.
212    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    /// The blockquote nesting level a line sits at (0 = top level).
220    fn blockquote_level(line: &crate::lint_context::LineInfo) -> usize {
221        line.blockquote.as_ref().map_or(0, |b| b.nesting_level)
222    }
223
224    /// Assign each line (0-indexed into `ctx.lines`) a paragraph id, or `None`
225    /// when the line is not paragraph prose. A new paragraph begins when prose
226    /// resumes after a boundary (blank line, heading, code block, ...), when a
227    /// list item starts, or when the blockquote nesting level changes - so list
228    /// items and nested quotes are counted independently.
229    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    /// Flag a run of adjacent emphasis spans if it exceeds `limit`, pointing at
260    /// the run's first span.
261    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            // Count spans per paragraph; flag the first span of any paragraph
323            // whose count exceeds the limit. Spans are ordered by position, so
324            // the first per paragraph is the earliest occurrence.
325            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            // A run is a maximal sequence of spans in the same paragraph where
351            // the text between neighbours is only whitespace and punctuation.
352            // Anything else (including connector words like "and") breaks it.
353            let mut run_start = 0usize; // index into `spans` of the run's first span
354            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                    // Only whitespace and punctuation (any script - em dashes, CJK
365                    // punctuation, etc.) keeps a run together. Any word character
366                    // (a connector like "and") breaks it.
367                    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        // Diagnostic only: emphasis is never rewritten, so fixing is a no-op
392        // that returns the content unchanged.
393        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        // Three bolds separated only by ", " - a run of 3 exceeds max-consecutive=2.
478        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        // Em dashes are punctuation, not words, so a run separated by them must
491        // still be treated as consecutive.
492        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        // "and" between the second and third bold breaks the run into 2 + 1.
512        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        // Default config has both thresholds at 0, so the rule is silent even
523        // on heavily bolded prose.
524        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        // A setext heading's text line is a heading, not prose, so emphasis in
532        // it must not be counted - same as ATX headings.
533        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        // A setext heading's text is the whole paragraph its underline ends, so
549        // every line of that paragraph is heading text rather than prose. An
550        // empty list item cannot interrupt a paragraph, so `* ` stays inside it.
551        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        // A paragraph that opens on a list marker line and ends at an underline
567        // indented into the item is a heading inside the item, marker line
568        // included. The parser records it nowhere, the same as `- # heading`,
569        // so the rule reads the heading from the underline.
570        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        // `  ===` sits left of the nested item's content column, so it cannot
591        // underline that item's paragraph and continues it lazily as text.
592        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        // `- ...\n---` is a list item followed by a thematic break, not a setext
609        // heading (setext underlines inside list items must be indented). The
610        // emphasis in the list item must still be counted.
611        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        // Exercise the production config path: kebab-case keys and the
627        // lowercase `targets` enum must round-trip through TOML, or real user
628        // configs would silently fall back to defaults (rule disabled).
629        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        // One bold + one italic = two spans under `targets = all`, exceeding
641        // max-per-paragraph = 1. This only fires if both keys parsed: the
642        // kebab key (else the limit stays 0 and the rule is off) and the
643        // lowercase enum (else it defaults to `strong` and counts one span).
644        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        // `> **A** **B**\n> ===` is a setext heading inside a blockquote; its
656        // text line must not be counted as prose.
657        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        // A top-level `---` after a blockquote is outside the quote, so the
672        // quoted paragraph is not a setext heading and its emphasis still counts.
673        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        // Table cells are not prose; emphasis inside a table must not be counted.
689        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        // Bold markers inside fences and inline code must not count.
718        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        // Two paragraphs of 2 bolds each: neither exceeds the limit of 2.
733        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        // Each list item has 2 bolds; neither item alone exceeds the limit.
747        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        // Many italics but only one bold: strong-only must not flag.
762        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        // A single ***bold italic*** region is reported by the parser as both a
789        // strong and an emphasis span. It must count as one, not exceed limit 1.
790        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        // `Some(0)` is distinct from unset: it forbids any emphasis, so a single
812        // bold span (count 1 > 0) must be flagged.
813        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        // A lone span is a run of length 1; with limit 0 it exceeds the limit
829        // and must be flagged.
830        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        // A user-set `max-per-paragraph = 0` must deserialize to Some(0)
846        // (forbid all), not be confused with the unset/disabled state.
847        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}