Skip to main content

rumdl_lib/rules/
md085_paragraph_continuation_indent.rs

1//! Rule MD085: Paragraph continuation lines should not be indented.
2//!
3//! CommonMark strips leading whitespace from every line of a paragraph after the
4//! first, so indentation there is invisible in the rendered output and only makes
5//! the source inconsistent. This rule removes it.
6//!
7//! Indentation is structural everywhere else, so the rule is deliberately narrow:
8//! it only touches paragraphs that no container owns. Inside a list item, a
9//! blockquote, a definition list, a footnote body, a table or an MkDocs container,
10//! the leading whitespace carries meaning and is left alone.
11//!
12//! Two more things keep it invisible. A continuation line whose indentation is the
13//! only reason it is not a block start stays where it is, because at the margin it
14//! would interrupt the paragraph. And a region opened by a line beginning with `<`
15//! stays untouched until the blank line that ends it, because that is where an HTML
16//! block ends and everything inside one is the author's raw markup.
17
18use crate::lint_context::{HeadingStyle, LineInfo, LintContext};
19use crate::rule::{Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
20
21/// What the line just examined was, which decides how to read the next one.
22#[derive(Clone, Copy, PartialEq, Eq)]
23enum Block {
24    /// No block is open: the next non-blank line starts one.
25    Between,
26    /// A top-level paragraph is open, so its next line is a continuation.
27    Paragraph,
28    /// A container is open. Its later lines can look like bare prose (a lazy
29    /// blockquote or list continuation carries no markers of its own), so the state
30    /// has to outlive them.
31    Container,
32}
33
34#[derive(Debug, Clone, Default)]
35pub struct MD085ParagraphContinuationIndent;
36
37impl MD085ParagraphContinuationIndent {
38    pub fn new() -> Self {
39        Self
40    }
41
42    /// Whether this line can belong to a paragraph that no container owns.
43    ///
44    /// `is_paragraph_context` rules out the non-paragraph blocks (code, HTML, math,
45    /// headings, front matter, extension blocks); `in_container` rules out the
46    /// containers, where the leading whitespace is what puts the line inside them;
47    /// `looks_like_html` rules out the raw HTML the block detector leaves unmarked.
48    fn is_top_level_prose(line: &LineInfo, content: &str) -> bool {
49        line.is_paragraph_context()
50            && !line.is_blank
51            && !Self::in_container(line)
52            && !Self::looks_like_html(line, content)
53    }
54
55    /// Whether this line is raw HTML rather than prose.
56    ///
57    /// An HTML block runs to the next blank line, so every line under one is raw HTML
58    /// whose whitespace is the author's. rumdl marks a block that opens and closes on
59    /// the same line, and a stray closing tag, on that line alone, and does not treat
60    /// an inline-level tag as a block start at all. A line that begins with `<`
61    /// therefore opens the region here, and the state machine keeps it open until the
62    /// blank line that ends it.
63    fn looks_like_html(line: &LineInfo, content: &str) -> bool {
64        line.content(content).trim_start().starts_with('<')
65    }
66
67    /// Whether a container owns this line.
68    fn in_container(line: &LineInfo) -> bool {
69        line.in_list_block
70            || line.blockquote.is_some()
71            || line.in_table_block
72            || line.in_definition_list
73            || line.in_footnote_definition
74            || line.in_admonition
75            || line.in_content_tab
76            || line.in_mkdocs_html_markdown
77            || line.in_pandoc_div
78            || line.in_mkdocstrings
79            || line.in_myst_directive
80            || line.in_jsx_block
81            || line.in_jsx_expression
82            || line.in_esm_block
83            || line.in_mdx_comment
84            || line.in_obsidian_comment
85    }
86
87    /// Whether a non-paragraph line ends its block, so the line after it starts a new
88    /// one.
89    ///
90    /// Only a block that can own a lazy continuation has to outlive its last line: a
91    /// lazy line carries no marker of its own, so it reads exactly like top-level
92    /// prose. What a lazy line continues is a paragraph, so a container whose last
93    /// line is one of these has no lazy continuation to expect either.
94    ///
95    /// A heading inside a container is the same case but is invisible here, because
96    /// headings are recorded only at the top level. Prose below one therefore keeps the
97    /// container's benefit of the doubt and goes unchecked.
98    ///
99    /// Every block listed here carries its own end: a closing fence, a dedent, a
100    /// delimiter line. HTML is the exception and is deliberately absent, because an
101    /// HTML block ends at a blank line rather than at its closing tag.
102    fn ends_its_block(line: &LineInfo) -> bool {
103        if let Some(heading) = &line.heading {
104            // A setext heading's underline is still to come.
105            return heading.style == HeadingStyle::ATX;
106        }
107        line.in_code_block
108            || line.in_front_matter
109            || line.in_math_block
110            || line.is_horizontal_rule
111            || line.is_kramdown_block_ial
112            || line.is_myst_comment
113    }
114
115    /// Whether this line is the text of a setext heading, whose underline follows on
116    /// the next line and carries no markers of its own.
117    fn opens_setext_heading(line: &LineInfo) -> bool {
118        match &line.heading {
119            Some(heading) => heading.style != HeadingStyle::ATX,
120            None => false,
121        }
122    }
123
124    /// The leading whitespace CommonMark strips from a paragraph continuation line,
125    /// which is spaces and tabs only. Other Unicode whitespace is content and renders,
126    /// so removing it would change the document.
127    fn strippable_indent(text: &str) -> usize {
128        text.len() - text.trim_start_matches([' ', '\t']).len()
129    }
130
131    /// Whether the line would open a block once its indentation is gone.
132    ///
133    /// A continuation line is free whitespace only for as long as it stays inside the
134    /// paragraph. At column zero `#` opens a heading, `>` a blockquote, `-`, `+`, `*`
135    /// and a digit a list item, `-`, `*` and `_` a thematic break, `-` and `=` a setext
136    /// underline, `` ` `` and `~` a fence, `<` an HTML block, `|` a table row, `$` a math
137    /// block, `[` a link reference or footnote definition, `:` a definition, `!` an
138    /// admonition, `{` an attribute list and `%` a comment. Each of those interrupts the
139    /// paragraph in at least one of the dialects rumdl reads, so here the indentation is
140    /// what keeps the line prose and removing it would restructure the document.
141    ///
142    /// The set is not conditioned on the flavor in force, because a document is often read
143    /// by more than one tool and a marker that is inert to one is structural to the next.
144    ///
145    /// The test is the first byte rather than the whole marker. That accepts lines which
146    /// would have stayed prose, which costs a finding and never a document.
147    fn would_open_a_block(text: &str) -> bool {
148        const BLOCK_STARTERS: &[u8] = b"#>-+*_=`~<|[{:$!%0123456789";
149        match text.trim_start_matches([' ', '\t']).as_bytes().first() {
150            Some(byte) => BLOCK_STARTERS.contains(byte),
151            None => false,
152        }
153    }
154}
155
156impl Rule for MD085ParagraphContinuationIndent {
157    fn name(&self) -> &'static str {
158        "MD085"
159    }
160
161    fn description(&self) -> &'static str {
162        "Paragraph continuation lines should not be indented"
163    }
164
165    fn category(&self) -> RuleCategory {
166        RuleCategory::Whitespace
167    }
168
169    fn fix_capability(&self) -> FixCapability {
170        FixCapability::FullyFixable
171    }
172
173    fn should_skip(&self, ctx: &LintContext) -> bool {
174        // Nothing to strip unless some line begins with a space or a tab.
175        !ctx.lines
176            .iter()
177            .any(|line| !line.is_blank && Self::strippable_indent(line.content(ctx.content)) > 0)
178    }
179
180    fn check(&self, ctx: &LintContext) -> LintResult {
181        let mut warnings = Vec::new();
182        let mut state = Block::Between;
183        let mut expect_setext_underline = false;
184
185        for (idx, line) in ctx.lines.iter().enumerate() {
186            if std::mem::take(&mut expect_setext_underline) {
187                state = Block::Between;
188                continue;
189            }
190
191            if line.is_blank {
192                state = Block::Between;
193                continue;
194            }
195
196            if !Self::is_top_level_prose(line, ctx.content) {
197                expect_setext_underline = Self::opens_setext_heading(line);
198                state = if Self::ends_its_block(line) {
199                    Block::Between
200                } else {
201                    Block::Container
202                };
203                continue;
204            }
205
206            match state {
207                // The line that opens a paragraph is its first line, whose indentation
208                // decides what the block is. Only later lines are free whitespace.
209                Block::Between => state = Block::Paragraph,
210                // Prose reached while a container is open is a lazy continuation of
211                // that container, not a paragraph of its own.
212                Block::Container => {}
213                Block::Paragraph => {
214                    // Leading whitespace inside a multi-line code span is span content.
215                    if line.in_code_span_continuation {
216                        continue;
217                    }
218                    // A shortcode tag spanning several lines is one token to the
219                    // renderer, so how its arguments are laid out inside it is the
220                    // author's, not paragraph indentation to strip.
221                    if ctx.is_in_shortcode(line.byte_offset) {
222                        continue;
223                    }
224                    let text = line.content(ctx.content);
225                    let indent = Self::strippable_indent(text);
226                    if indent == 0 {
227                        continue;
228                    }
229                    // At column zero the line would stop being prose.
230                    if Self::would_open_a_block(text) {
231                        continue;
232                    }
233
234                    let line_num = idx + 1;
235                    warnings.push(LintWarning {
236                        rule_name: Some(self.name().to_string()),
237                        line: line_num,
238                        column: 1,
239                        end_line: line_num,
240                        end_column: 1 + indent,
241                        severity: Severity::Warning,
242                        message: "Paragraph continuation line should not be indented".to_string(),
243                        fix: Some(Fix::new(line.byte_offset..line.byte_offset + indent, String::new())),
244                    });
245                }
246            }
247        }
248
249        Ok(warnings)
250    }
251
252    fn fixes_from_diagnostics(&self) -> bool {
253        true
254    }
255
256    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
257        let warnings = self.check(ctx)?;
258        if warnings.is_empty() {
259            return Ok(ctx.content.to_string());
260        }
261
262        let warnings =
263            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
264        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
265    }
266
267    fn as_any(&self) -> &dyn std::any::Any {
268        self
269    }
270
271    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
272    where
273        Self: Sized,
274    {
275        Box::new(Self)
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::config::MarkdownFlavor;
283    use pulldown_cmark::{Options, Parser};
284
285    fn warned_lines(content: &str, flavor: MarkdownFlavor) -> Vec<usize> {
286        let ctx = LintContext::new(content, flavor, None);
287        MD085ParagraphContinuationIndent::new()
288            .check(&ctx)
289            .unwrap()
290            .iter()
291            .map(|w| w.line)
292            .collect()
293    }
294
295    fn fixed(content: &str, flavor: MarkdownFlavor) -> String {
296        let ctx = LintContext::new(content, flavor, None);
297        let rule = MD085ParagraphContinuationIndent::new();
298        let out = rule.fix(&ctx).unwrap();
299        // A warning and a rewrite have to agree, in both directions.
300        assert_eq!(
301            rule.check(&ctx).unwrap().is_empty(),
302            out == content,
303            "warnings and fix disagree for {content:?}"
304        );
305        out
306    }
307
308    /// The document as a reader sees it. CommonMark already strips the indentation of a
309    /// paragraph continuation line, so a rewrite the rule is allowed to make renders
310    /// byte-for-byte identically and any other one does not.
311    fn render(markdown: &str) -> String {
312        let mut options = Options::empty();
313        options.insert(Options::ENABLE_TABLES);
314        options.insert(Options::ENABLE_FOOTNOTES);
315        options.insert(Options::ENABLE_STRIKETHROUGH);
316        options.insert(Options::ENABLE_TASKLISTS);
317        options.insert(Options::ENABLE_MATH);
318        options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
319        let mut out = String::new();
320        pulldown_cmark::html::push_html(&mut out, Parser::new_ext(markdown, options));
321        out
322    }
323
324    /// Content the rule must leave exactly as it found it.
325    fn assert_unchanged(content: &str, flavor: MarkdownFlavor) {
326        assert!(
327            warned_lines(content, flavor).is_empty(),
328            "unexpected warning for {content:?}"
329        );
330        assert_eq!(fixed(content, flavor), content);
331    }
332
333    #[test]
334    fn strips_indentation_from_continuation_lines() {
335        let content = "This is some paragraph\n with line breaks\n  and indentation.\n";
336        assert_eq!(warned_lines(content, MarkdownFlavor::Standard), vec![2, 3]);
337        assert_eq!(
338            fixed(content, MarkdownFlavor::Standard),
339            "This is some paragraph\nwith line breaks\nand indentation.\n"
340        );
341    }
342
343    #[test]
344    fn leaves_the_first_line_of_a_paragraph_alone() {
345        // The first line's indentation is what decides which block this is, so it is
346        // never free whitespace. Only the lines after it are.
347        assert_eq!(
348            fixed("  Indented start\n   continuation\n", MarkdownFlavor::Standard),
349            "  Indented start\ncontinuation\n"
350        );
351    }
352
353    #[test]
354    fn strips_a_continuation_indented_like_code() {
355        // An indented code block cannot interrupt a paragraph, so four spaces here are
356        // prose, and removing them renders identically.
357        assert_eq!(
358            fixed("para\n    four spaces\n", MarkdownFlavor::Standard),
359            "para\nfour spaces\n"
360        );
361        assert_eq!(fixed("para\n\ttab\n", MarkdownFlavor::Standard), "para\ntab\n");
362    }
363
364    #[test]
365    fn leaves_an_indented_code_block_alone() {
366        assert_unchanged("para\n\n    real code block\n", MarkdownFlavor::Standard);
367        assert_unchanged("```\n  fenced\n```\n", MarkdownFlavor::Standard);
368    }
369
370    #[test]
371    fn leaves_container_content_alone() {
372        // Indentation is structural in every one of these: removing it reparents or
373        // ends the construct.
374        for content in [
375            "- item\n  continuation\n",
376            "1. item\n   continuation\n",
377            "> quote\n>  continuation\n",
378            "| a | b |\n|---|---|\n|  1 |  2 |\n",
379            "[^1]: note\n  more of the note\n",
380            "<div>\n  inner\n</div>\n",
381            "$$\n  x = 1\n$$\n",
382        ] {
383            assert_unchanged(content, MarkdownFlavor::Standard);
384        }
385    }
386
387    #[test]
388    fn leaves_html_alone_until_the_blank_line_that_ends_it() {
389        // An HTML block runs to the next blank line whatever its tags do, so everything
390        // under one is raw HTML whose whitespace belongs to the author. These are the
391        // shapes rumdl's block detector does not mark past their first line: a tag that
392        // opens and closes on one line, a stray closing tag, an inline-level tag, and a
393        // comment.
394        for content in [
395            "<div>\nhtml\n</div>\npara\n  cont\n",
396            "<h2>x</h2>\npara\n  cont\n",
397            "</div>\npara\n  cont\n",
398            "<em>\n  cont\n",
399            "<!-- comment -->\npara\n  cont\n",
400        ] {
401            assert_unchanged(content, MarkdownFlavor::Standard);
402        }
403        // The blank line ends it, and the paragraph below is checked again.
404        assert_eq!(
405            fixed("<h2>x</h2>\n\npara\n  cont\n", MarkdownFlavor::Standard),
406            "<h2>x</h2>\n\npara\ncont\n"
407        );
408    }
409
410    #[test]
411    fn leaves_a_continuation_that_would_open_a_block_alone() {
412        // At four spaces none of these can interrupt the paragraph, so each one is prose
413        // that the rule sees. At column zero every one of them opens a block instead, so
414        // the indentation is what keeps the line where the author put it.
415        for line in [
416            "# heading",
417            "> quote",
418            "- item",
419            "+ item",
420            "* item",
421            "1. item",
422            "***",
423            "---",
424            "===",
425            "```",
426            "~~~",
427            "<div>",
428            "| a | b |",
429            "[^1]: note",
430            "{: .class }",
431            ": definition",
432            "$$",
433            "!!! note",
434        ] {
435            assert_unchanged(&format!("para\n    {line}\n"), MarkdownFlavor::Standard);
436        }
437    }
438
439    #[test]
440    fn a_stripped_continuation_renders_identically() {
441        // The rule's whole premise is that the indentation it removes is invisible.
442        // Sweeping every printable ASCII character through the shapes that open a block
443        // makes a missed block starter fail here instead of corrupting a document. Four
444        // spaces is the interesting width: nothing can interrupt a paragraph from there,
445        // so every one of these lines reaches the rule as prose.
446        for byte in 0x21u8..=0x7e {
447            let c = byte as char;
448            for template in [
449                "para\n    @ tail\n",
450                "para\n    @@@\n",
451                "para\n    @. tail\n",
452                "para\n    @tail\n",
453                "para\n    @ tail\n    more\n",
454            ] {
455                let content = template.replace('@', &c.to_string());
456                let out = fixed(&content, MarkdownFlavor::Standard);
457                assert_eq!(
458                    render(&out),
459                    render(&content),
460                    "removing the indentation changed the rendering of {content:?}"
461                );
462            }
463        }
464    }
465
466    #[test]
467    fn a_stripped_continuation_is_still_prose_in_every_flavor() {
468        // The render sweep above can only speak for standard Markdown, because the
469        // reference parser knows no flavor. Every flavor adds markers of its own, so the
470        // oracle here is rumdl's own reading of the document: a line the rule rewrote has
471        // to still be the top-level prose it was, and so does every other line, since a
472        // marker at the margin also reparents what follows it.
473        for flavor in [
474            MarkdownFlavor::Standard,
475            MarkdownFlavor::MkDocs,
476            MarkdownFlavor::MDX,
477            MarkdownFlavor::Pandoc,
478            MarkdownFlavor::Quarto,
479            MarkdownFlavor::Obsidian,
480            MarkdownFlavor::MyST,
481        ] {
482            for byte in 0x21u8..=0x7e {
483                let c = byte as char;
484                for template in [
485                    "para\n    @ tail\n",
486                    "para\n    @@ tail\n",
487                    "para\n    @@@\n",
488                    "para\n    @tail\n",
489                    "para\n    @ tail\n  more\n",
490                ] {
491                    let content = template.replace('@', &c.to_string());
492                    let out = fixed(&content, flavor);
493                    if out == content {
494                        continue;
495                    }
496                    let before = LintContext::new(&content, flavor, None);
497                    let after = LintContext::new(&out, flavor, None);
498                    for (idx, (was, now)) in before.lines.iter().zip(after.lines.iter()).enumerate() {
499                        assert_eq!(
500                            MD085ParagraphContinuationIndent::is_top_level_prose(was, &content),
501                            MD085ParagraphContinuationIndent::is_top_level_prose(now, &out),
502                            "line {} stopped being prose under {flavor:?} when {content:?} became {out:?}",
503                            idx + 1
504                        );
505                    }
506                }
507            }
508        }
509    }
510
511    #[test]
512    fn leaves_lazy_continuation_of_a_container_alone() {
513        // A lazy continuation line carries no marker of its own, so it reads exactly
514        // like top-level prose. What it continues is the container above it.
515        assert_unchanged("> quote\n  lazy one\n  lazy two\n", MarkdownFlavor::Standard);
516        assert_unchanged("- item\n lazy one space\n", MarkdownFlavor::Standard);
517    }
518
519    #[test]
520    fn leaves_a_setext_underline_and_what_follows_alone() {
521        // The underline carries no markers of its own, so without tracking the heading
522        // it would read as a paragraph and the line after it as a continuation.
523        assert_unchanged("Title\n=====\n  after underline\n", MarkdownFlavor::Standard);
524        assert_unchanged("Title\n---\n  after underline\n", MarkdownFlavor::Standard);
525    }
526
527    #[test]
528    fn a_paragraph_after_a_closed_block_is_still_checked() {
529        // None of these can own a lazy continuation line, so the prose below each one
530        // is a paragraph of its own even without a blank line between them.
531        for prefix in [
532            "# Heading\n",
533            "***\n",
534            "```\ncode\n```\n",
535            "$$\nx = 1\n$$\n",
536            "Setext heading\n==============\n",
537            "---\ntitle: front matter\n---\n",
538        ] {
539            let content = format!("{prefix}para\n  cont\n");
540            assert_eq!(
541                fixed(&content, MarkdownFlavor::Standard),
542                format!("{prefix}para\ncont\n"),
543                "continuation after {prefix:?} was not checked"
544            );
545        }
546    }
547
548    #[test]
549    fn a_paragraph_after_a_closed_block_inside_a_container_is_still_checked() {
550        // A lazy continuation line continues a paragraph. Neither of these is one, so the
551        // blockquote ends with it and the prose below starts a paragraph of its own.
552        for prefix in ["> ---\n", "> ```\n> code\n> ```\n"] {
553            let content = format!("{prefix}para\n  cont\n");
554            assert_eq!(
555                fixed(&content, MarkdownFlavor::Standard),
556                format!("{prefix}para\ncont\n"),
557                "continuation after {prefix:?} was not checked"
558            );
559        }
560    }
561
562    #[test]
563    fn real_world_documents_survive_the_fix() {
564        // Minimized from documents the rule rewrote into something that renders
565        // differently: a closing tag whose HTML block rumdl stops tracking, a list
566        // marker and a template tag under a paragraph, and a setext underline.
567        for content in [
568            "```\n```\n</div>\n  dateformat.i18n = require('./lang/' + l)\n  return true;\n",
569            ":   A site that uses `just-the-docs` automatically\n    - features that are likely to be removed.\n",
570            "```html\n```\n  {% for stylesheet in page.page_css %}\n    <link rel=\"stylesheet\" href=\"{{ stylesheet }}\">\n",
571            "![Image *\"alt\"* & \\\"\n    <em>html</em>\n    ---\n",
572        ] {
573            assert_unchanged(content, MarkdownFlavor::Standard);
574            assert_eq!(
575                render(&fixed(content, MarkdownFlavor::Standard)),
576                render(content),
577                "the fix changed the rendering of {content:?}"
578            );
579        }
580    }
581
582    #[test]
583    fn strips_only_the_whitespace_commonmark_strips() {
584        // CommonMark strips spaces and tabs from a continuation line. Other Unicode
585        // whitespace is content that renders, so removing it changes the document.
586        assert_unchanged("para\n\u{a0}cont\n", MarkdownFlavor::Standard);
587        assert_unchanged("para\n\u{3000}cont\n", MarkdownFlavor::Standard);
588        assert_eq!(
589            fixed("para\n  \u{3000}cont\n", MarkdownFlavor::Standard),
590            "para\n\u{3000}cont\n"
591        );
592    }
593
594    #[test]
595    fn leaves_a_multi_line_code_span_alone() {
596        // The leading whitespace on the closing line sits inside the span, so it is
597        // content rather than indentation.
598        assert_eq!(
599            fixed("para `code\n  more` tail\n  cont\n", MarkdownFlavor::Standard),
600            "para `code\n  more` tail\ncont\n"
601        );
602    }
603
604    #[test]
605    fn leaves_mkdocs_containers_alone() {
606        for content in [
607            "!!! note\n    body line\n    more body\n",
608            "=== \"Tab\"\n    body line\n    more body\n",
609            "Term\n\n: definition\n  more of the definition\n",
610        ] {
611            assert_unchanged(content, MarkdownFlavor::MkDocs);
612        }
613    }
614
615    #[test]
616    fn preserves_hard_line_breaks() {
617        // The rule only removes leading whitespace, so a two-space or backslash break
618        // at the end of the previous line survives.
619        assert_eq!(fixed("para  \n  next\n", MarkdownFlavor::Standard), "para  \nnext\n");
620        assert_eq!(fixed("para\\\n  next\n", MarkdownFlavor::Standard), "para\\\nnext\n");
621    }
622
623    #[test]
624    fn preserves_trailing_blank_lines_and_a_missing_final_newline() {
625        assert_eq!(
626            fixed("para\n  cont\n\n\n", MarkdownFlavor::Standard),
627            "para\ncont\n\n\n"
628        );
629        assert_eq!(fixed("para\n  cont", MarkdownFlavor::Standard), "para\ncont");
630    }
631
632    #[test]
633    fn fix_is_idempotent() {
634        for content in [
635            "This is some paragraph\n with line breaks\n  and indentation.\n",
636            "  Indented start\n   continuation\n",
637            "para\n  cont\n\n\n",
638            "> quote\n  lazy\n",
639        ] {
640            let once = fixed(content, MarkdownFlavor::Standard);
641            let ctx = LintContext::new(&once, MarkdownFlavor::Standard, None);
642            assert_eq!(
643                MD085ParagraphContinuationIndent::new().fix(&ctx).unwrap(),
644                once,
645                "fix is not idempotent for {content:?}"
646            );
647        }
648    }
649
650    #[test]
651    fn warning_spans_the_indentation_it_removes() {
652        let ctx = LintContext::new("para\n   cont\n", MarkdownFlavor::Standard, None);
653        let warnings = MD085ParagraphContinuationIndent::new().check(&ctx).unwrap();
654        assert_eq!(warnings.len(), 1);
655        assert_eq!(warnings[0].line, 2);
656        assert_eq!(warnings[0].column, 1);
657        assert_eq!(warnings[0].end_line, 2);
658        assert_eq!(warnings[0].end_column, 4);
659    }
660
661    #[test]
662    fn empty_and_blank_documents_are_untouched() {
663        for content in ["", "\n", "   \n", "\n\n\n"] {
664            assert_unchanged(content, MarkdownFlavor::Standard);
665        }
666    }
667}