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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}