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 fix(&self, ctx: &LintContext) -> Result<String, LintError> {
253        let warnings = self.check(ctx)?;
254        if warnings.is_empty() {
255            return Ok(ctx.content.to_string());
256        }
257
258        let warnings =
259            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
260        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
261    }
262
263    fn as_any(&self) -> &dyn std::any::Any {
264        self
265    }
266
267    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
268    where
269        Self: Sized,
270    {
271        Box::new(Self)
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::config::MarkdownFlavor;
279    use pulldown_cmark::{Options, Parser};
280
281    fn warned_lines(content: &str, flavor: MarkdownFlavor) -> Vec<usize> {
282        let ctx = LintContext::new(content, flavor, None);
283        MD085ParagraphContinuationIndent::new()
284            .check(&ctx)
285            .unwrap()
286            .iter()
287            .map(|w| w.line)
288            .collect()
289    }
290
291    fn fixed(content: &str, flavor: MarkdownFlavor) -> String {
292        let ctx = LintContext::new(content, flavor, None);
293        let rule = MD085ParagraphContinuationIndent::new();
294        let out = rule.fix(&ctx).unwrap();
295        // A warning and a rewrite have to agree, in both directions.
296        assert_eq!(
297            rule.check(&ctx).unwrap().is_empty(),
298            out == content,
299            "warnings and fix disagree for {content:?}"
300        );
301        out
302    }
303
304    /// The document as a reader sees it. CommonMark already strips the indentation of a
305    /// paragraph continuation line, so a rewrite the rule is allowed to make renders
306    /// byte-for-byte identically and any other one does not.
307    fn render(markdown: &str) -> String {
308        let mut options = Options::empty();
309        options.insert(Options::ENABLE_TABLES);
310        options.insert(Options::ENABLE_FOOTNOTES);
311        options.insert(Options::ENABLE_STRIKETHROUGH);
312        options.insert(Options::ENABLE_TASKLISTS);
313        options.insert(Options::ENABLE_MATH);
314        options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
315        let mut out = String::new();
316        pulldown_cmark::html::push_html(&mut out, Parser::new_ext(markdown, options));
317        out
318    }
319
320    /// Content the rule must leave exactly as it found it.
321    fn assert_unchanged(content: &str, flavor: MarkdownFlavor) {
322        assert!(
323            warned_lines(content, flavor).is_empty(),
324            "unexpected warning for {content:?}"
325        );
326        assert_eq!(fixed(content, flavor), content);
327    }
328
329    #[test]
330    fn strips_indentation_from_continuation_lines() {
331        let content = "This is some paragraph\n with line breaks\n  and indentation.\n";
332        assert_eq!(warned_lines(content, MarkdownFlavor::Standard), vec![2, 3]);
333        assert_eq!(
334            fixed(content, MarkdownFlavor::Standard),
335            "This is some paragraph\nwith line breaks\nand indentation.\n"
336        );
337    }
338
339    #[test]
340    fn leaves_the_first_line_of_a_paragraph_alone() {
341        // The first line's indentation is what decides which block this is, so it is
342        // never free whitespace. Only the lines after it are.
343        assert_eq!(
344            fixed("  Indented start\n   continuation\n", MarkdownFlavor::Standard),
345            "  Indented start\ncontinuation\n"
346        );
347    }
348
349    #[test]
350    fn strips_a_continuation_indented_like_code() {
351        // An indented code block cannot interrupt a paragraph, so four spaces here are
352        // prose, and removing them renders identically.
353        assert_eq!(
354            fixed("para\n    four spaces\n", MarkdownFlavor::Standard),
355            "para\nfour spaces\n"
356        );
357        assert_eq!(fixed("para\n\ttab\n", MarkdownFlavor::Standard), "para\ntab\n");
358    }
359
360    #[test]
361    fn leaves_an_indented_code_block_alone() {
362        assert_unchanged("para\n\n    real code block\n", MarkdownFlavor::Standard);
363        assert_unchanged("```\n  fenced\n```\n", MarkdownFlavor::Standard);
364    }
365
366    #[test]
367    fn leaves_container_content_alone() {
368        // Indentation is structural in every one of these: removing it reparents or
369        // ends the construct.
370        for content in [
371            "- item\n  continuation\n",
372            "1. item\n   continuation\n",
373            "> quote\n>  continuation\n",
374            "| a | b |\n|---|---|\n|  1 |  2 |\n",
375            "[^1]: note\n  more of the note\n",
376            "<div>\n  inner\n</div>\n",
377            "$$\n  x = 1\n$$\n",
378        ] {
379            assert_unchanged(content, MarkdownFlavor::Standard);
380        }
381    }
382
383    #[test]
384    fn leaves_html_alone_until_the_blank_line_that_ends_it() {
385        // An HTML block runs to the next blank line whatever its tags do, so everything
386        // under one is raw HTML whose whitespace belongs to the author. These are the
387        // shapes rumdl's block detector does not mark past their first line: a tag that
388        // opens and closes on one line, a stray closing tag, an inline-level tag, and a
389        // comment.
390        for content in [
391            "<div>\nhtml\n</div>\npara\n  cont\n",
392            "<h2>x</h2>\npara\n  cont\n",
393            "</div>\npara\n  cont\n",
394            "<em>\n  cont\n",
395            "<!-- comment -->\npara\n  cont\n",
396        ] {
397            assert_unchanged(content, MarkdownFlavor::Standard);
398        }
399        // The blank line ends it, and the paragraph below is checked again.
400        assert_eq!(
401            fixed("<h2>x</h2>\n\npara\n  cont\n", MarkdownFlavor::Standard),
402            "<h2>x</h2>\n\npara\ncont\n"
403        );
404    }
405
406    #[test]
407    fn leaves_a_continuation_that_would_open_a_block_alone() {
408        // At four spaces none of these can interrupt the paragraph, so each one is prose
409        // that the rule sees. At column zero every one of them opens a block instead, so
410        // the indentation is what keeps the line where the author put it.
411        for line in [
412            "# heading",
413            "> quote",
414            "- item",
415            "+ item",
416            "* item",
417            "1. item",
418            "***",
419            "---",
420            "===",
421            "```",
422            "~~~",
423            "<div>",
424            "| a | b |",
425            "[^1]: note",
426            "{: .class }",
427            ": definition",
428            "$$",
429            "!!! note",
430        ] {
431            assert_unchanged(&format!("para\n    {line}\n"), MarkdownFlavor::Standard);
432        }
433    }
434
435    #[test]
436    fn a_stripped_continuation_renders_identically() {
437        // The rule's whole premise is that the indentation it removes is invisible.
438        // Sweeping every printable ASCII character through the shapes that open a block
439        // makes a missed block starter fail here instead of corrupting a document. Four
440        // spaces is the interesting width: nothing can interrupt a paragraph from there,
441        // so every one of these lines reaches the rule as prose.
442        for byte in 0x21u8..=0x7e {
443            let c = byte as char;
444            for template in [
445                "para\n    @ tail\n",
446                "para\n    @@@\n",
447                "para\n    @. tail\n",
448                "para\n    @tail\n",
449                "para\n    @ tail\n    more\n",
450            ] {
451                let content = template.replace('@', &c.to_string());
452                let out = fixed(&content, MarkdownFlavor::Standard);
453                assert_eq!(
454                    render(&out),
455                    render(&content),
456                    "removing the indentation changed the rendering of {content:?}"
457                );
458            }
459        }
460    }
461
462    #[test]
463    fn a_stripped_continuation_is_still_prose_in_every_flavor() {
464        // The render sweep above can only speak for standard Markdown, because the
465        // reference parser knows no flavor. Every flavor adds markers of its own, so the
466        // oracle here is rumdl's own reading of the document: a line the rule rewrote has
467        // to still be the top-level prose it was, and so does every other line, since a
468        // marker at the margin also reparents what follows it.
469        for flavor in [
470            MarkdownFlavor::Standard,
471            MarkdownFlavor::MkDocs,
472            MarkdownFlavor::MDX,
473            MarkdownFlavor::Pandoc,
474            MarkdownFlavor::Quarto,
475            MarkdownFlavor::Obsidian,
476            MarkdownFlavor::MyST,
477        ] {
478            for byte in 0x21u8..=0x7e {
479                let c = byte as char;
480                for template in [
481                    "para\n    @ tail\n",
482                    "para\n    @@ tail\n",
483                    "para\n    @@@\n",
484                    "para\n    @tail\n",
485                    "para\n    @ tail\n  more\n",
486                ] {
487                    let content = template.replace('@', &c.to_string());
488                    let out = fixed(&content, flavor);
489                    if out == content {
490                        continue;
491                    }
492                    let before = LintContext::new(&content, flavor, None);
493                    let after = LintContext::new(&out, flavor, None);
494                    for (idx, (was, now)) in before.lines.iter().zip(after.lines.iter()).enumerate() {
495                        assert_eq!(
496                            MD085ParagraphContinuationIndent::is_top_level_prose(was, &content),
497                            MD085ParagraphContinuationIndent::is_top_level_prose(now, &out),
498                            "line {} stopped being prose under {flavor:?} when {content:?} became {out:?}",
499                            idx + 1
500                        );
501                    }
502                }
503            }
504        }
505    }
506
507    #[test]
508    fn leaves_lazy_continuation_of_a_container_alone() {
509        // A lazy continuation line carries no marker of its own, so it reads exactly
510        // like top-level prose. What it continues is the container above it.
511        assert_unchanged("> quote\n  lazy one\n  lazy two\n", MarkdownFlavor::Standard);
512        assert_unchanged("- item\n lazy one space\n", MarkdownFlavor::Standard);
513    }
514
515    #[test]
516    fn leaves_a_setext_underline_and_what_follows_alone() {
517        // The underline carries no markers of its own, so without tracking the heading
518        // it would read as a paragraph and the line after it as a continuation.
519        assert_unchanged("Title\n=====\n  after underline\n", MarkdownFlavor::Standard);
520        assert_unchanged("Title\n---\n  after underline\n", MarkdownFlavor::Standard);
521    }
522
523    #[test]
524    fn a_paragraph_after_a_closed_block_is_still_checked() {
525        // None of these can own a lazy continuation line, so the prose below each one
526        // is a paragraph of its own even without a blank line between them.
527        for prefix in [
528            "# Heading\n",
529            "***\n",
530            "```\ncode\n```\n",
531            "$$\nx = 1\n$$\n",
532            "Setext heading\n==============\n",
533            "---\ntitle: front matter\n---\n",
534        ] {
535            let content = format!("{prefix}para\n  cont\n");
536            assert_eq!(
537                fixed(&content, MarkdownFlavor::Standard),
538                format!("{prefix}para\ncont\n"),
539                "continuation after {prefix:?} was not checked"
540            );
541        }
542    }
543
544    #[test]
545    fn a_paragraph_after_a_closed_block_inside_a_container_is_still_checked() {
546        // A lazy continuation line continues a paragraph. Neither of these is one, so the
547        // blockquote ends with it and the prose below starts a paragraph of its own.
548        for prefix in ["> ---\n", "> ```\n> code\n> ```\n"] {
549            let content = format!("{prefix}para\n  cont\n");
550            assert_eq!(
551                fixed(&content, MarkdownFlavor::Standard),
552                format!("{prefix}para\ncont\n"),
553                "continuation after {prefix:?} was not checked"
554            );
555        }
556    }
557
558    #[test]
559    fn real_world_documents_survive_the_fix() {
560        // Minimized from documents the rule rewrote into something that renders
561        // differently: a closing tag whose HTML block rumdl stops tracking, a list
562        // marker and a template tag under a paragraph, and a setext underline.
563        for content in [
564            "```\n```\n</div>\n  dateformat.i18n = require('./lang/' + l)\n  return true;\n",
565            ":   A site that uses `just-the-docs` automatically\n    - features that are likely to be removed.\n",
566            "```html\n```\n  {% for stylesheet in page.page_css %}\n    <link rel=\"stylesheet\" href=\"{{ stylesheet }}\">\n",
567            "![Image *\"alt\"* & \\\"\n    <em>html</em>\n    ---\n",
568        ] {
569            assert_unchanged(content, MarkdownFlavor::Standard);
570            assert_eq!(
571                render(&fixed(content, MarkdownFlavor::Standard)),
572                render(content),
573                "the fix changed the rendering of {content:?}"
574            );
575        }
576    }
577
578    #[test]
579    fn strips_only_the_whitespace_commonmark_strips() {
580        // CommonMark strips spaces and tabs from a continuation line. Other Unicode
581        // whitespace is content that renders, so removing it changes the document.
582        assert_unchanged("para\n\u{a0}cont\n", MarkdownFlavor::Standard);
583        assert_unchanged("para\n\u{3000}cont\n", MarkdownFlavor::Standard);
584        assert_eq!(
585            fixed("para\n  \u{3000}cont\n", MarkdownFlavor::Standard),
586            "para\n\u{3000}cont\n"
587        );
588    }
589
590    #[test]
591    fn leaves_a_multi_line_code_span_alone() {
592        // The leading whitespace on the closing line sits inside the span, so it is
593        // content rather than indentation.
594        assert_eq!(
595            fixed("para `code\n  more` tail\n  cont\n", MarkdownFlavor::Standard),
596            "para `code\n  more` tail\ncont\n"
597        );
598    }
599
600    #[test]
601    fn leaves_mkdocs_containers_alone() {
602        for content in [
603            "!!! note\n    body line\n    more body\n",
604            "=== \"Tab\"\n    body line\n    more body\n",
605            "Term\n\n: definition\n  more of the definition\n",
606        ] {
607            assert_unchanged(content, MarkdownFlavor::MkDocs);
608        }
609    }
610
611    #[test]
612    fn preserves_hard_line_breaks() {
613        // The rule only removes leading whitespace, so a two-space or backslash break
614        // at the end of the previous line survives.
615        assert_eq!(fixed("para  \n  next\n", MarkdownFlavor::Standard), "para  \nnext\n");
616        assert_eq!(fixed("para\\\n  next\n", MarkdownFlavor::Standard), "para\\\nnext\n");
617    }
618
619    #[test]
620    fn preserves_trailing_blank_lines_and_a_missing_final_newline() {
621        assert_eq!(
622            fixed("para\n  cont\n\n\n", MarkdownFlavor::Standard),
623            "para\ncont\n\n\n"
624        );
625        assert_eq!(fixed("para\n  cont", MarkdownFlavor::Standard), "para\ncont");
626    }
627
628    #[test]
629    fn fix_is_idempotent() {
630        for content in [
631            "This is some paragraph\n with line breaks\n  and indentation.\n",
632            "  Indented start\n   continuation\n",
633            "para\n  cont\n\n\n",
634            "> quote\n  lazy\n",
635        ] {
636            let once = fixed(content, MarkdownFlavor::Standard);
637            let ctx = LintContext::new(&once, MarkdownFlavor::Standard, None);
638            assert_eq!(
639                MD085ParagraphContinuationIndent::new().fix(&ctx).unwrap(),
640                once,
641                "fix is not idempotent for {content:?}"
642            );
643        }
644    }
645
646    #[test]
647    fn warning_spans_the_indentation_it_removes() {
648        let ctx = LintContext::new("para\n   cont\n", MarkdownFlavor::Standard, None);
649        let warnings = MD085ParagraphContinuationIndent::new().check(&ctx).unwrap();
650        assert_eq!(warnings.len(), 1);
651        assert_eq!(warnings[0].line, 2);
652        assert_eq!(warnings[0].column, 1);
653        assert_eq!(warnings[0].end_line, 2);
654        assert_eq!(warnings[0].end_column, 4);
655    }
656
657    #[test]
658    fn empty_and_blank_documents_are_untouched() {
659        for content in ["", "\n", "   \n", "\n\n\n"] {
660            assert_unchanged(content, MarkdownFlavor::Standard);
661        }
662    }
663}