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