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::{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, s.byte_end))
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. A setext heading's text
144    /// is the whole paragraph its underline ends, so every line of that
145    /// paragraph is heading text rather than prose.
146    ///
147    /// The parser records a heading only where its text starts at the line's
148    /// own left edge. A paragraph that opens on a list item's marker line and
149    /// ends at an underline indented into the item is a heading inside the
150    /// item, recorded nowhere, the same as `- # heading`; its lines are read
151    /// here from the underline, since to this rule they are heading text all
152    /// the same.
153    fn setext_text_lines(ctx: &LintContext) -> Vec<bool> {
154        let mut flags = vec![false; ctx.lines.len()];
155        for heading in ctx.headings().filter(ParsedHeading::is_setext) {
156            for flag in flags
157                .iter_mut()
158                .take(heading.line_num)
159                .skip(heading.first_line_num() - 1)
160            {
161                *flag = true;
162            }
163        }
164
165        for idx in 1..ctx.lines.len() {
166            let line = &ctx.lines[idx];
167            if flags[idx - 1] || line.in_code_block || !is_setext_underline_content(Self::line_inner(line, ctx.content))
168            {
169                continue;
170            }
171            let level = Self::blockquote_level(line);
172
173            // Walk back to the paragraph's first line, stopping on the marker
174            // line of the item that opens it.
175            let mut first = idx;
176            while first > 0 {
177                let prev = &ctx.lines[first - 1];
178                if prev.is_blank || !prev.is_paragraph_context() || Self::blockquote_level(prev) != level {
179                    break;
180                }
181                first -= 1;
182                if prev.list_item.is_some() {
183                    break;
184                }
185            }
186            if first == idx {
187                continue;
188            }
189            let Some(item) = ctx.lines[first].list_item.as_ref() else {
190                continue;
191            };
192
193            // An underline left of the item's content column cannot end the
194            // item's paragraph; it continues the paragraph lazily as text.
195            if Self::content_column(line) < item.content_column {
196                continue;
197            }
198            flags[first..idx].fill(true);
199        }
200        flags
201    }
202
203    /// A line's content with its indentation and any blockquote prefix removed.
204    fn line_inner<'a>(line: &'a crate::lint_context::LineInfo, source: &'a str) -> &'a str {
205        match line.blockquote.as_ref() {
206            Some(bq) => bq.content.trim(),
207            None => line.content(source).trim(),
208        }
209    }
210
211    /// The byte column a line's content starts at, past its indentation and
212    /// any blockquote prefix, in the coordinates of `ListItemInfo::content_column`.
213    fn content_column(line: &crate::lint_context::LineInfo) -> usize {
214        match line.blockquote.as_ref() {
215            Some(bq) => bq.prefix.len(),
216            None => line.indent,
217        }
218    }
219
220    /// The blockquote nesting level a line sits at (0 = top level).
221    fn blockquote_level(line: &crate::lint_context::LineInfo) -> usize {
222        line.blockquote.as_ref().map_or(0, |b| b.nesting_level)
223    }
224
225    /// Assign each line (0-indexed into `ctx.lines`) a paragraph id, or `None`
226    /// when the line is not paragraph prose. A new paragraph begins when prose
227    /// resumes after a boundary (blank line, heading, code block, ...), when a
228    /// list item starts, or when the blockquote nesting level changes - so list
229    /// items and nested quotes are counted independently.
230    fn paragraph_ids(ctx: &LintContext) -> Vec<Option<usize>> {
231        let mut ids = vec![None; ctx.lines.len()];
232        let setext_text = Self::setext_text_lines(ctx);
233        let mut current: Option<usize> = None;
234        let mut next_id = 0usize;
235        let mut prev_bq_level = 0usize;
236
237        for (idx, line) in ctx.lines.iter().enumerate() {
238            let bq_level = Self::blockquote_level(line);
239            let is_prose =
240                !line.is_blank && line.is_paragraph_context() && !setext_text[idx] && !ctx.is_in_table_block(idx + 1);
241
242            if !is_prose {
243                current = None;
244                prev_bq_level = bq_level;
245                continue;
246            }
247
248            let starts_new = current.is_none() || line.list_item.is_some() || bq_level != prev_bq_level;
249            if starts_new {
250                current = Some(next_id);
251                next_id += 1;
252            }
253            ids[idx] = current;
254            prev_bq_level = bq_level;
255        }
256
257        ids
258    }
259
260    /// Flag a run of adjacent emphasis spans if it exceeds `limit`, pointing at
261    /// the run's first span.
262    fn emit_run(&self, ctx: &LintContext, run: &[CountedSpan], limit: usize, warnings: &mut Vec<LintWarning>) {
263        if run.len() > limit
264            && let Some(first) = run.first()
265        {
266            warnings.push(self.warn_at(
267                ctx,
268                first,
269                format!(
270                    "{} consecutive emphasis spans (limit {limit}); consider rephrasing to reduce emphasis",
271                    run.len(),
272                ),
273            ));
274        }
275    }
276
277    fn warn_at(&self, ctx: &LintContext, span: &CountedSpan, message: String) -> LintWarning {
278        let line_content = ctx.lines.get(span.line - 1).map_or("", |l| l.content(ctx.content));
279        let line_start = ctx.lines.get(span.line - 1).map_or(0, |l| l.byte_offset);
280        let match_start_in_line = span.start.saturating_sub(line_start);
281        let (start_line, start_col, end_line, end_col) =
282            calculate_match_range(span.line, line_content, match_start_in_line, span.end - span.start);
283        LintWarning {
284            rule_name: Some(self.name().to_string()),
285            severity: Severity::Warning,
286            line: start_line,
287            column: start_col,
288            end_line,
289            end_column: end_col,
290            message,
291            fix: None,
292        }
293    }
294}
295
296impl Rule for MD081NoExcessiveEmphasis {
297    fn name(&self) -> &'static str {
298        "MD081"
299    }
300
301    fn description(&self) -> &'static str {
302        "Inline emphasis should not be excessive"
303    }
304
305    fn category(&self) -> RuleCategory {
306        RuleCategory::Emphasis
307    }
308
309    fn check(&self, ctx: &LintContext) -> LintResult {
310        if self.config.max_per_paragraph.is_none() && self.config.max_consecutive.is_none() {
311            return Ok(Vec::new());
312        }
313
314        let spans = self.counted_spans(ctx);
315        if spans.is_empty() {
316            return Ok(Vec::new());
317        }
318
319        let para_ids = Self::paragraph_ids(ctx);
320        let mut warnings = Vec::new();
321
322        if let Some(limit) = self.config.max_per_paragraph {
323            // Count spans per paragraph; flag the first span of any paragraph
324            // whose count exceeds the limit. Spans are ordered by position, so
325            // the first per paragraph is the earliest occurrence.
326            let mut counts: std::collections::HashMap<usize, (usize, CountedSpan)> = std::collections::HashMap::new();
327            for span in &spans {
328                let Some(pid) = para_ids.get(span.line - 1).copied().flatten() else {
329                    continue;
330                };
331                counts.entry(pid).and_modify(|(n, _)| *n += 1).or_insert((1, *span));
332            }
333            let mut flagged: Vec<(usize, CountedSpan)> = counts
334                .into_iter()
335                .filter(|(_, (n, _))| *n > limit)
336                .map(|(_, (n, first))| (n, first))
337                .collect();
338            flagged.sort_by_key(|(_, first)| (first.line, first.start));
339            for (count, first) in flagged {
340                warnings.push(self.warn_at(
341                    ctx,
342                    &first,
343                    format!(
344                        "Paragraph contains {count} emphasis spans (limit {limit}); consider reducing emphasis to improve readability"
345                    ),
346                ));
347            }
348        }
349
350        if let Some(limit) = self.config.max_consecutive {
351            // A run is a maximal sequence of spans in the same paragraph where
352            // the text between neighbours is only whitespace and punctuation.
353            // Anything else (including connector words like "and") breaks it.
354            let mut run_start = 0usize; // index into `spans` of the run's first span
355            for i in 0..spans.len() {
356                let breaks = if i == 0 {
357                    true
358                } else {
359                    let prev = &spans[i - 1];
360                    let cur = &spans[i];
361                    let same_para = para_ids.get(prev.line - 1).copied().flatten()
362                        == para_ids.get(cur.line - 1).copied().flatten()
363                        && para_ids.get(cur.line - 1).copied().flatten().is_some();
364                    let between = ctx.content.get(prev.end..cur.start).unwrap_or("");
365                    // Only whitespace and punctuation (any script - em dashes, CJK
366                    // punctuation, etc.) keeps a run together. Any word character
367                    // (a connector like "and") breaks it.
368                    let only_filler = !between.chars().any(char::is_alphanumeric);
369                    !(same_para && only_filler)
370                };
371
372                if breaks && i > run_start {
373                    self.emit_run(ctx, &spans[run_start..i], limit, &mut warnings);
374                }
375                if breaks {
376                    run_start = i;
377                }
378            }
379            if !spans.is_empty() {
380                self.emit_run(ctx, &spans[run_start..], limit, &mut warnings);
381            }
382        }
383
384        Ok(warnings)
385    }
386
387    fn fix_capability(&self) -> FixCapability {
388        FixCapability::Unfixable
389    }
390
391    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
392        // Diagnostic only: emphasis is never rewritten, so fixing is a no-op
393        // that returns the content unchanged.
394        Ok(ctx.content.to_string())
395    }
396
397    fn as_any(&self) -> &dyn std::any::Any {
398        self
399    }
400
401    crate::impl_rule_config_methods!(MD081Config);
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use crate::config::MarkdownFlavor;
408    use crate::rule::LintWarning;
409
410    fn check(content: &str, config: MD081Config) -> Vec<LintWarning> {
411        let rule = MD081NoExcessiveEmphasis::from_config_struct(config);
412        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
413        rule.check(&ctx).unwrap()
414    }
415
416    #[test]
417    fn test_shortcode_arguments_are_not_counted_as_emphasis() {
418        let rule = MD081NoExcessiveEmphasis::from_config_struct(MD081Config {
419            targets: EmphasisTarget::All,
420            max_per_paragraph: Some(0),
421            ..Default::default()
422        });
423        let source = "Before {{< note title=\"**literal** *literal*\" >}}\n\n**visible**\n";
424        let ctx = LintContext::new(source, MarkdownFlavor::Hugo, None);
425        let warnings = rule.check(&ctx).unwrap();
426        assert_eq!(warnings.len(), 1);
427        assert_eq!(warnings[0].line, 3);
428    }
429
430    #[test]
431    fn test_jinja_quoted_markers_are_not_counted_as_emphasis() {
432        let rule = MD081NoExcessiveEmphasis::from_config_struct(MD081Config {
433            targets: EmphasisTarget::All,
434            max_per_paragraph: Some(0),
435            ..Default::default()
436        });
437        for template in [r#"{{ "**literal** *literal*" }}"#, r#"**text {{ "**" }}"#] {
438            let source = format!("Before {template}\n\n**visible**\n");
439            let ctx = LintContext::new(&source, MarkdownFlavor::Standard, None);
440            let warnings = rule.check(&ctx).unwrap();
441            assert_eq!(warnings.len(), 1);
442            assert_eq!(warnings[0].line, 3);
443        }
444    }
445
446    #[test]
447    fn test_mdx_module_strings_are_not_counted_as_emphasis() {
448        let rule = MD081NoExcessiveEmphasis::from_config_struct(MD081Config {
449            targets: EmphasisTarget::All,
450            max_per_paragraph: Some(0),
451            ..Default::default()
452        });
453        let source = "export const text = \"**literal** *literal*\"\n\n**visible**\n";
454        let ctx = LintContext::new(source, MarkdownFlavor::MDX, None);
455        let warnings = rule.check(&ctx).unwrap();
456        assert_eq!(warnings.len(), 1);
457        assert_eq!(warnings[0].line, 3);
458    }
459
460    #[test]
461    fn flags_paragraph_over_max_per_paragraph() {
462        let config = MD081Config {
463            max_per_paragraph: Some(3),
464            ..Default::default()
465        };
466        let content = "The **a** is **b** and **c** plus **d**.";
467        let warnings = check(content, config);
468        assert_eq!(warnings.len(), 1, "4 bold spans should exceed max-per-paragraph=3");
469        assert_eq!(warnings[0].line, 1);
470    }
471
472    #[test]
473    fn flags_consecutive_run_separated_only_by_punctuation() {
474        let config = MD081Config {
475            max_consecutive: Some(2),
476            ..Default::default()
477        };
478        // Three bolds separated only by ", " - a run of 3 exceeds max-consecutive=2.
479        let content = "Tags: **one**, **two**, **three**.";
480        let warnings = check(content, config);
481        assert_eq!(
482            warnings.len(),
483            1,
484            "run of 3 adjacent bolds should exceed max-consecutive=2"
485        );
486        assert_eq!(warnings[0].line, 1);
487    }
488
489    #[test]
490    fn unicode_punctuation_does_not_break_consecutive_run() {
491        // Em dashes are punctuation, not words, so a run separated by them must
492        // still be treated as consecutive.
493        let config = MD081Config {
494            max_consecutive: Some(2),
495            ..Default::default()
496        };
497        let content = "Tags: **one** \u{2014} **two** \u{2014} **three**.";
498        let warnings = check(content, config);
499        assert_eq!(
500            warnings.len(),
501            1,
502            "em-dash-separated bolds form one run of 3, exceeding max-consecutive=2. Got: {warnings:?}"
503        );
504    }
505
506    #[test]
507    fn connector_word_breaks_consecutive_run() {
508        let config = MD081Config {
509            max_consecutive: Some(2),
510            ..Default::default()
511        };
512        // "and" between the second and third bold breaks the run into 2 + 1.
513        let content = "Tags: **one**, **two**, and **three**.";
514        let warnings = check(content, config);
515        assert!(
516            warnings.is_empty(),
517            "a connector word should break the run below the limit. Got: {warnings:?}"
518        );
519    }
520
521    #[test]
522    fn disabled_by_default() {
523        // Default config has both thresholds at 0, so the rule is silent even
524        // on heavily bolded prose.
525        let content = "**a** **b** **c** **d** **e** **f** **g** **h**.";
526        let warnings = check(content, MD081Config::default());
527        assert!(warnings.is_empty(), "rule must be off by default. Got: {warnings:?}");
528    }
529
530    #[test]
531    fn does_not_flag_setext_heading_text() {
532        // A setext heading's text line is a heading, not prose, so emphasis in
533        // it must not be counted - same as ATX headings.
534        let config = MD081Config {
535            max_per_paragraph: Some(2),
536            max_consecutive: Some(1),
537            ..Default::default()
538        };
539        let content = "**A** **B** **C**\n=================\n";
540        let warnings = check(content, config);
541        assert!(
542            warnings.is_empty(),
543            "emphasis in setext heading text must not be flagged. Got: {warnings:?}"
544        );
545    }
546
547    #[test]
548    fn does_not_flag_multi_line_setext_heading_text() {
549        // A setext heading's text is the whole paragraph its underline ends, so
550        // every line of that paragraph is heading text rather than prose. An
551        // empty list item cannot interrupt a paragraph, so `* ` stays inside it.
552        let config = MD081Config {
553            max_per_paragraph: Some(2),
554            max_consecutive: Some(1),
555            ..Default::default()
556        };
557        let content = "**A** **B** **C**\n* \n===\n";
558        let warnings = check(content, config);
559        assert!(
560            warnings.is_empty(),
561            "emphasis in a multi-line setext heading must not be flagged. Got: {warnings:?}"
562        );
563    }
564
565    #[test]
566    fn does_not_flag_setext_heading_text_inside_a_list_item() {
567        // A paragraph that opens on a list marker line and ends at an underline
568        // indented into the item is a heading inside the item, marker line
569        // included. The parser records it nowhere, the same as `- # heading`,
570        // so the rule reads the heading from the underline.
571        let config = MD081Config {
572            max_per_paragraph: Some(2),
573            ..Default::default()
574        };
575        for content in [
576            "- intro\n  **one** **two** **three**\n  ===\n",
577            "- **one** **two** **three**\n  ===\n",
578            "1. intro\n   **one** **two** **three**\n   ===\n",
579            "> - intro\n>   **one** **two** **three**\n>   ===\n",
580        ] {
581            let warnings = check(content, config.clone());
582            assert!(
583                warnings.is_empty(),
584                "{content:?} is a heading inside the item. Got: {warnings:?}"
585            );
586        }
587    }
588
589    #[test]
590    fn flags_a_nested_item_whose_underline_is_lazy_paragraph_text() {
591        // `  ===` sits left of the nested item's content column, so it cannot
592        // underline that item's paragraph and continues it lazily as text.
593        let config = MD081Config {
594            max_per_paragraph: Some(2),
595            ..Default::default()
596        };
597        let content = "- a\n  - **a** **b** **c**\n  ===\n";
598        let warnings = check(content, config);
599        assert_eq!(
600            warnings.len(),
601            1,
602            "the nested item's paragraph holds 3 bolds and no heading. Got: {warnings:?}"
603        );
604        assert_eq!(warnings[0].line, 2);
605    }
606
607    #[test]
608    fn flags_list_item_before_thematic_break() {
609        // `- ...\n---` is a list item followed by a thematic break, not a setext
610        // heading (setext underlines inside list items must be indented). The
611        // emphasis in the list item must still be counted.
612        let config = MD081Config {
613            max_per_paragraph: Some(1),
614            ..Default::default()
615        };
616        let content = "- **a** and **b**\n---\n";
617        let warnings = check(content, config);
618        assert_eq!(
619            warnings.len(),
620            1,
621            "list item with 2 bolds before a thematic break should be flagged. Got: {warnings:?}"
622        );
623    }
624
625    #[test]
626    fn parses_kebab_case_keys_and_lowercase_targets_from_config() {
627        // Exercise the production config path: kebab-case keys and the
628        // lowercase `targets` enum must round-trip through TOML, or real user
629        // configs would silently fall back to defaults (rule disabled).
630        let mut config = crate::config::Config::default();
631        let mut rule_config = crate::config::RuleConfig::default();
632        rule_config
633            .values
634            .insert("max-per-paragraph".to_string(), toml::Value::Integer(1));
635        rule_config
636            .values
637            .insert("targets".to_string(), toml::Value::String("all".to_string()));
638        config.rules.insert("MD081".to_string(), rule_config);
639
640        let rule = MD081NoExcessiveEmphasis::from_config(&config);
641        // One bold + one italic = two spans under `targets = all`, exceeding
642        // max-per-paragraph = 1. This only fires if both keys parsed: the
643        // kebab key (else the limit stays 0 and the rule is off) and the
644        // lowercase enum (else it defaults to `strong` and counts one span).
645        let ctx = LintContext::new("This is **bold** and *italic*.", MarkdownFlavor::Standard, None);
646        let warnings = rule.check(&ctx).unwrap();
647        assert_eq!(
648            warnings.len(),
649            1,
650            "kebab-case max-per-paragraph and targets=\"all\" must parse from config. Got: {warnings:?}"
651        );
652    }
653
654    #[test]
655    fn does_not_flag_setext_heading_inside_blockquote() {
656        // `> **A** **B**\n> ===` is a setext heading inside a blockquote; its
657        // text line must not be counted as prose.
658        let config = MD081Config {
659            max_per_paragraph: Some(1),
660            ..Default::default()
661        };
662        let content = "> **A** **B**\n> ===\n";
663        let warnings = check(content, config);
664        assert!(
665            warnings.is_empty(),
666            "emphasis in a blockquoted setext heading must not be flagged. Got: {warnings:?}"
667        );
668    }
669
670    #[test]
671    fn flags_blockquote_paragraph_before_top_level_break() {
672        // A top-level `---` after a blockquote is outside the quote, so the
673        // quoted paragraph is not a setext heading and its emphasis still counts.
674        let config = MD081Config {
675            max_per_paragraph: Some(1),
676            ..Default::default()
677        };
678        let content = "> **a** and **b**\n---\n";
679        let warnings = check(content, config);
680        assert_eq!(
681            warnings.len(),
682            1,
683            "blockquote paragraph with 2 bolds before a top-level break should be flagged. Got: {warnings:?}"
684        );
685    }
686
687    #[test]
688    fn does_not_flag_emphasis_in_table_rows() {
689        // Table cells are not prose; emphasis inside a table must not be counted.
690        let config = MD081Config {
691            max_per_paragraph: Some(1),
692            ..Default::default()
693        };
694        let content = "| Col A | Col B |\n| ----- | ----- |\n| **a** | **b** |\n";
695        let warnings = check(content, config);
696        assert!(
697            warnings.is_empty(),
698            "emphasis in table cells must not be flagged. Got: {warnings:?}"
699        );
700    }
701
702    #[test]
703    fn does_not_flag_at_or_below_limit() {
704        let config = MD081Config {
705            max_per_paragraph: Some(3),
706            ..Default::default()
707        };
708        let content = "The **a** is **b** and **c**.";
709        assert!(check(content, config).is_empty(), "3 spans must not exceed limit 3");
710    }
711
712    #[test]
713    fn excludes_code_blocks_and_inline_code() {
714        let config = MD081Config {
715            max_per_paragraph: Some(1),
716            ..Default::default()
717        };
718        // Bold markers inside fences and inline code must not count.
719        let content = "```python\nfoo(**a**, **b**, **c**, **d**)\n```\n\nText with `**x** **y** **z**` only.";
720        let warnings = check(content, config);
721        assert!(
722            warnings.is_empty(),
723            "emphasis inside code must be ignored. Got: {warnings:?}"
724        );
725    }
726
727    #[test]
728    fn counts_paragraphs_independently() {
729        let config = MD081Config {
730            max_per_paragraph: Some(2),
731            ..Default::default()
732        };
733        // Two paragraphs of 2 bolds each: neither exceeds the limit of 2.
734        let content = "First **a** and **b** here.\n\nSecond **c** and **d** here.";
735        assert!(
736            check(content, config).is_empty(),
737            "spans must not aggregate across the blank-line paragraph boundary"
738        );
739    }
740
741    #[test]
742    fn counts_list_items_independently() {
743        let config = MD081Config {
744            max_per_paragraph: Some(2),
745            ..Default::default()
746        };
747        // Each list item has 2 bolds; neither item alone exceeds the limit.
748        let content = "- item **a** and **b**\n- item **c** and **d**";
749        assert!(
750            check(content, config).is_empty(),
751            "each list item is its own paragraph and must be counted independently"
752        );
753    }
754
755    #[test]
756    fn targets_strong_ignores_italic() {
757        let config = MD081Config {
758            targets: EmphasisTarget::Strong,
759            max_per_paragraph: Some(1),
760            ..Default::default()
761        };
762        // Many italics but only one bold: strong-only must not flag.
763        let content = "Here is *a* and *b* and *c* and *d* with one **bold**.";
764        assert!(
765            check(content, config).is_empty(),
766            "targets=strong must ignore italic spans"
767        );
768    }
769
770    #[test]
771    fn targets_emphasis_counts_italic_only() {
772        let config = MD081Config {
773            targets: EmphasisTarget::Emphasis,
774            max_per_paragraph: Some(2),
775            ..Default::default()
776        };
777        let content = "Lots of *a* and *b* and *c* italics, plus **bold**.";
778        let warnings = check(content, config);
779        assert_eq!(warnings.len(), 1, "3 italics exceed limit 2 under targets=emphasis");
780    }
781
782    #[test]
783    fn targets_all_dedups_combined_bold_italic() {
784        let config = MD081Config {
785            targets: EmphasisTarget::All,
786            max_per_paragraph: Some(1),
787            ..Default::default()
788        };
789        // A single ***bold italic*** region is reported by the parser as both a
790        // strong and an emphasis span. It must count as one, not exceed limit 1.
791        let content = "Just ***one region*** here.";
792        assert!(
793            check(content, config).is_empty(),
794            "combined ***...*** must count once under targets=all"
795        );
796    }
797
798    #[test]
799    fn targets_all_counts_distinct_regions() {
800        let config = MD081Config {
801            targets: EmphasisTarget::All,
802            max_per_paragraph: Some(1),
803            ..Default::default()
804        };
805        let content = "Mix ***a*** and **b** here.";
806        let warnings = check(content, config);
807        assert_eq!(warnings.len(), 1, "two distinct emphasis regions exceed limit 1");
808    }
809
810    #[test]
811    fn max_per_paragraph_zero_forbids_all_emphasis() {
812        // `Some(0)` is distinct from unset: it forbids any emphasis, so a single
813        // bold span (count 1 > 0) must be flagged.
814        let config = MD081Config {
815            max_per_paragraph: Some(0),
816            ..Default::default()
817        };
818        let content = "A paragraph with one **bold** word.";
819        let warnings = check(content, config);
820        assert_eq!(
821            warnings.len(),
822            1,
823            "max-per-paragraph=0 must flag even a single emphasis span. Got: {warnings:?}"
824        );
825    }
826
827    #[test]
828    fn max_consecutive_zero_forbids_all_emphasis() {
829        // A lone span is a run of length 1; with limit 0 it exceeds the limit
830        // and must be flagged.
831        let config = MD081Config {
832            max_consecutive: Some(0),
833            ..Default::default()
834        };
835        let content = "A paragraph with one **bold** word.";
836        let warnings = check(content, config);
837        assert_eq!(
838            warnings.len(),
839            1,
840            "max-consecutive=0 must flag even a single emphasis span. Got: {warnings:?}"
841        );
842    }
843
844    #[test]
845    fn explicit_zero_in_toml_parses_as_forbid_all() {
846        // A user-set `max-per-paragraph = 0` must deserialize to Some(0)
847        // (forbid all), not be confused with the unset/disabled state.
848        let mut config = crate::config::Config::default();
849        let mut rule_config = crate::config::RuleConfig::default();
850        rule_config
851            .values
852            .insert("max-per-paragraph".to_string(), toml::Value::Integer(0));
853        config.rules.insert("MD081".to_string(), rule_config);
854
855        let rule = MD081NoExcessiveEmphasis::from_config(&config);
856        let ctx = LintContext::new("One **bold** here.", MarkdownFlavor::Standard, None);
857        let warnings = rule.check(&ctx).unwrap();
858        assert_eq!(
859            warnings.len(),
860            1,
861            "explicit max-per-paragraph = 0 must forbid all emphasis. Got: {warnings:?}"
862        );
863    }
864}