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;
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/// 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 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            // Drop spans fully contained within an earlier (outer) span so a
126            // combined `***x***` - reported as both a strong and an emphasis
127            // span over overlapping ranges - is counted only once.
128            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    /// Mark lines that are the text of a setext heading. The shared heading
144    /// detector skips text lines that start with `-`/`*`/`+` (to avoid
145    /// misreading list items), which leaves `**bold**\n===` looking like prose.
146    /// Here a line is setext heading text if a contiguous run of prose lines
147    /// ending at it is immediately followed by a `=`/`-` underline.
148    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            // Walk back over the heading's text lines (prose, non-blank). The
161            // underline only heads text at its own blockquote level, so stop at a
162            // level change. A list item is never setext heading text either: an
163            // unindented `=`/`-` after a list item is a thematic break / list
164            // boundary.
165            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    /// The trimmed text of a line, ignoring any blockquote markers.
183    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    /// The blockquote nesting level a line sits at (0 = top level).
191    fn blockquote_level(line: &crate::lint_context::LineInfo) -> usize {
192        line.blockquote.as_ref().map_or(0, |b| b.nesting_level)
193    }
194
195    /// Assign each line (0-indexed into `ctx.lines`) a paragraph id, or `None`
196    /// when the line is not paragraph prose. A new paragraph begins when prose
197    /// resumes after a boundary (blank line, heading, code block, ...), when a
198    /// list item starts, or when the blockquote nesting level changes - so list
199    /// items and nested quotes are counted independently.
200    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    /// Flag a run of adjacent emphasis spans if it exceeds `limit`, pointing at
231    /// the run's first span.
232    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            // Count spans per paragraph; flag the first span of any paragraph
294            // whose count exceeds the limit. Spans are ordered by position, so
295            // the first per paragraph is the earliest occurrence.
296            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            // A run is a maximal sequence of spans in the same paragraph where
322            // the text between neighbours is only whitespace and punctuation.
323            // Anything else (including connector words like "and") breaks it.
324            let mut run_start = 0usize; // index into `spans` of the run's first span
325            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                    // Only whitespace and punctuation (any script - em dashes, CJK
336                    // punctuation, etc.) keeps a run together. Any word character
337                    // (a connector like "and") breaks it.
338                    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        // Diagnostic only: emphasis is never rewritten, so fixing is a no-op
363        // that returns the content unchanged.
364        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);
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        // Three bolds separated only by ", " - a run of 3 exceeds max-consecutive=2.
405        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        // Em dashes are punctuation, not words, so a run separated by them must
418        // still be treated as consecutive.
419        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        // "and" between the second and third bold breaks the run into 2 + 1.
439        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        // Default config has both thresholds at 0, so the rule is silent even
450        // on heavily bolded prose.
451        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        // A setext heading's text line is a heading, not prose, so emphasis in
459        // it must not be counted - same as ATX headings.
460        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        // `- ...\n---` is a list item followed by a thematic break, not a setext
476        // heading (setext underlines inside list items must be indented). The
477        // emphasis in the list item must still be counted.
478        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        // Exercise the production config path: kebab-case keys and the
494        // lowercase `targets` enum must round-trip through TOML, or real user
495        // configs would silently fall back to defaults (rule disabled).
496        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        // One bold + one italic = two spans under `targets = all`, exceeding
508        // max-per-paragraph = 1. This only fires if both keys parsed: the
509        // kebab key (else the limit stays 0 and the rule is off) and the
510        // lowercase enum (else it defaults to `strong` and counts one span).
511        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        // `> **A** **B**\n> ===` is a setext heading inside a blockquote; its
523        // text line must not be counted as prose.
524        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        // A top-level `---` after a blockquote is outside the quote, so the
539        // quoted paragraph is not a setext heading and its emphasis still counts.
540        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        // Table cells are not prose; emphasis inside a table must not be counted.
556        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        // Bold markers inside fences and inline code must not count.
585        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        // Two paragraphs of 2 bolds each: neither exceeds the limit of 2.
600        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        // Each list item has 2 bolds; neither item alone exceeds the limit.
614        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        // Many italics but only one bold: strong-only must not flag.
629        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        // A single ***bold italic*** region is reported by the parser as both a
656        // strong and an emphasis span. It must count as one, not exceed limit 1.
657        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        // `Some(0)` is distinct from unset: it forbids any emphasis, so a single
679        // bold span (count 1 > 0) must be flagged.
680        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        // A lone span is a run of length 1; with limit 0 it exceeds the limit
696        // and must be flagged.
697        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        // A user-set `max-per-paragraph = 0` must deserialize to Some(0)
713        // (forbid all), not be confused with the unset/disabled state.
714        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}