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 let text = line.content(ctx.content);
219 let indent = Self::strippable_indent(text);
220 if indent == 0 {
221 continue;
222 }
223 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}