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