1use crate::lint_context::{HeadingStyle, LineInfo, LintContext};
19use crate::rule::{Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
20
21#[derive(Clone, Copy, PartialEq, Eq)]
23enum Block {
24 Between,
26 Paragraph,
28 Container,
32}
33
34#[derive(Debug, Clone, Default)]
35pub struct MD085ParagraphContinuationIndent;
36
37impl MD085ParagraphContinuationIndent {
38 pub fn new() -> Self {
39 Self
40 }
41
42 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 fn looks_like_html(line: &LineInfo, content: &str) -> bool {
64 line.content(content).trim_start().starts_with('<')
65 }
66
67 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 fn ends_its_block(line: &LineInfo) -> bool {
103 if let Some(heading) = &line.heading {
104 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 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 fn strippable_indent(text: &str) -> usize {
128 text.len() - text.trim_start_matches([' ', '\t']).len()
129 }
130
131 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 !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 Block::Between => state = Block::Paragraph,
210 Block::Container => {}
213 Block::Paragraph => {
214 if line.in_code_span_continuation {
216 continue;
217 }
218 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}