Skip to content

Commit 17c716a

Browse files
committed
Add lint for markdown lazy paragraph continuations
This is a follow-up for rust-lang/rust#121659, since most cases of unintended block quotes are lazy continuations. The lint is designed to be more generally useful than that, though, because it will also catch unintended list items and unintended block quotes that didn't coincidentally hit a pulldown-cmark bug.
1 parent 3ef3a13 commit 17c716a

File tree

10 files changed

+571
-5
lines changed

10 files changed

+571
-5
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5249,6 +5249,7 @@ Released 2018-09-13
52495249
[`disallowed_type`]: https://rust-lang.github.io/rust-clippy/master/index.html#disallowed_type
52505250
[`disallowed_types`]: https://rust-lang.github.io/rust-clippy/master/index.html#disallowed_types
52515251
[`diverging_sub_expression`]: https://rust-lang.github.io/rust-clippy/master/index.html#diverging_sub_expression
5252+
[`doc_blockquote_lazy`]: https://rust-lang.github.io/rust-clippy/master/index.html#doc_blockquote_lazy
52525253
[`doc_link_with_quotes`]: https://rust-lang.github.io/rust-clippy/master/index.html#doc_link_with_quotes
52535254
[`doc_markdown`]: https://rust-lang.github.io/rust-clippy/master/index.html#doc_markdown
52545255
[`double_comparisons`]: https://rust-lang.github.io/rust-clippy/master/index.html#double_comparisons

clippy_lints/src/declared_lints.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ pub(crate) static LINTS: &[&crate::LintInfo] = &[
140140
crate::disallowed_names::DISALLOWED_NAMES_INFO,
141141
crate::disallowed_script_idents::DISALLOWED_SCRIPT_IDENTS_INFO,
142142
crate::disallowed_types::DISALLOWED_TYPES_INFO,
143+
crate::doc::DOC_LAZY_CONTINUATION_INFO,
143144
crate::doc::DOC_LINK_WITH_QUOTES_INFO,
144145
crate::doc::DOC_MARKDOWN_INFO,
145146
crate::doc::EMPTY_DOCS_INFO,
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
use clippy_utils::diagnostics::span_lint_and_then;
2+
use itertools::Itertools;
3+
use rustc_errors::{Applicability, SuggestionStyle};
4+
use rustc_lint::LateContext;
5+
use rustc_span::{BytePos, Span};
6+
use std::ops::Range;
7+
8+
use super::DOC_LAZY_CONTINUATION;
9+
10+
fn map_container_to_text(c: &super::Container) -> &'static str {
11+
match c {
12+
super::Container::Blockquote => "> ",
13+
// numbered list can have up to nine digits, plus the dot, plus four spaces on either side
14+
super::Container::List(indent) => &" "[0..*indent],
15+
}
16+
}
17+
18+
// TODO: Adjust the parameters as necessary
19+
pub(super) fn check(
20+
cx: &LateContext<'_>,
21+
doc: &str,
22+
range: Range<usize>,
23+
mut span: Span,
24+
containers: &[super::Container],
25+
) {
26+
if doc[range.clone()].contains('\t') {
27+
// We don't do tab stops correctly.
28+
return;
29+
}
30+
31+
let ccount = doc[range.clone()].chars().filter(|c| *c == '>').count();
32+
let blockquote_level = containers
33+
.iter()
34+
.filter(|c| matches!(c, super::Container::Blockquote))
35+
.count();
36+
let lcount = doc[range.clone()].chars().filter(|c| *c == ' ').count();
37+
let list_indentation = containers
38+
.iter()
39+
.map(|c| {
40+
if let super::Container::List(indent) = c {
41+
*indent
42+
} else {
43+
0
44+
}
45+
})
46+
.sum();
47+
if ccount < blockquote_level || lcount < list_indentation {
48+
let msg = if ccount < blockquote_level {
49+
"doc quote missing `>` marker"
50+
} else {
51+
"doc list item missing indentation"
52+
};
53+
span_lint_and_then(cx, DOC_LAZY_CONTINUATION, span, msg, |diag| {
54+
if ccount == 0 && blockquote_level == 0 {
55+
// simpler suggestion style for indentation
56+
let indent = list_indentation - lcount;
57+
diag.span_suggestion_with_style(
58+
span.shrink_to_hi(),
59+
"indent this line",
60+
std::iter::repeat(" ").take(indent).join(""),
61+
Applicability::MachineApplicable,
62+
SuggestionStyle::ShowAlways,
63+
);
64+
diag.help("if this is supposed to be its own paragraph, add a blank line");
65+
return;
66+
}
67+
let mut doc_start_range = &doc[range];
68+
let mut suggested = String::new();
69+
for c in containers {
70+
let text = map_container_to_text(c);
71+
if doc_start_range.starts_with(text) {
72+
doc_start_range = &doc_start_range[text.len()..];
73+
span = span
74+
.with_lo(span.lo() + BytePos(u32::try_from(text.len()).expect("text is not 2**32 or bigger")));
75+
} else if matches!(c, super::Container::Blockquote)
76+
&& let Some(i) = doc_start_range.find('>')
77+
{
78+
doc_start_range = &doc_start_range[i + 1..];
79+
span =
80+
span.with_lo(span.lo() + BytePos(u32::try_from(i).expect("text is not 2**32 or bigger") + 1));
81+
} else {
82+
suggested.push_str(text);
83+
}
84+
}
85+
diag.span_suggestion_with_style(
86+
span,
87+
"add markers to start of line",
88+
suggested,
89+
Applicability::MachineApplicable,
90+
SuggestionStyle::ShowAlways,
91+
);
92+
diag.help("if this not intended to be a quote at all, escape it with `\\>`");
93+
});
94+
}
95+
}

clippy_lints/src/doc/mod.rs

Lines changed: 108 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
mod lazy_continuation;
12
use clippy_utils::attrs::is_doc_hidden;
23
use clippy_utils::diagnostics::{span_lint, span_lint_and_help};
34
use clippy_utils::macros::{is_panic, root_macro_call_first_node};
@@ -7,7 +8,7 @@ use clippy_utils::{is_entrypoint_fn, is_trait_impl_item, method_chain_args};
78
use pulldown_cmark::Event::{
89
Code, End, FootnoteReference, HardBreak, Html, Rule, SoftBreak, Start, TaskListMarker, Text,
910
};
10-
use pulldown_cmark::Tag::{BlockQuote, CodeBlock, Heading, Item, Link, Paragraph};
11+
use pulldown_cmark::Tag::{BlockQuote, CodeBlock, FootnoteDefinition, Heading, Item, Link, Paragraph};
1112
use pulldown_cmark::{BrokenLink, CodeBlockKind, CowStr, Options};
1213
use rustc_ast::ast::Attribute;
1314
use rustc_data_structures::fx::FxHashSet;
@@ -362,6 +363,61 @@ declare_clippy_lint! {
362363
"docstrings exist but documentation is empty"
363364
}
364365

366+
declare_clippy_lint! {
367+
/// ### What it does
368+
///
369+
/// In CommonMark Markdown, the language used to write doc comments, a
370+
/// paragraph nested within a list or block quote does not need any line
371+
/// after the first one to be indented or marked. The specification calls
372+
/// this a "lazy paragraph continuation."
373+
///
374+
/// ### Why is this bad?
375+
///
376+
/// This is easy to write but hard to read. Lazy continuations makes
377+
/// unintended markers hard to see, and make it harder to deduce the
378+
/// document's intended structure.
379+
///
380+
/// ### Example
381+
///
382+
/// This table is probably intended to have two rows,
383+
/// but it does not. It has zero rows, and is followed by
384+
/// a block quote.
385+
/// ```no_run
386+
/// /// Range | Description
387+
/// /// ----- | -----------
388+
/// /// >= 1 | fully opaque
389+
/// /// < 1 | partially see-through
390+
/// fn set_opacity(opacity: f32) {}
391+
/// ```
392+
///
393+
/// Fix it by escaping the marker:
394+
/// ```no_run
395+
/// /// Range | Description
396+
/// /// ----- | -----------
397+
/// /// \>= 1 | fully opaque
398+
/// /// < 1 | partially see-through
399+
/// fn set_opacity(opacity: f32) {}
400+
/// ```
401+
///
402+
/// This example is actually intended to be a list:
403+
/// ```no_run
404+
/// /// * Do nothing.
405+
/// /// * Then do something. Whatever it is needs done,
406+
/// /// it should be done right now.
407+
/// ```
408+
///
409+
/// Fix it by indenting the list contents:
410+
/// ```no_run
411+
/// /// * Do nothing.
412+
/// /// * Then do something. Whatever it is needs done,
413+
/// /// it should be done right now.
414+
/// ```
415+
#[clippy::version = "1.80.0"]
416+
pub DOC_LAZY_CONTINUATION,
417+
style,
418+
"require every line of a paragraph to be indented and marked"
419+
}
420+
365421
#[derive(Clone)]
366422
pub struct Documentation {
367423
valid_idents: FxHashSet<String>,
@@ -388,6 +444,7 @@ impl_lint_pass!(Documentation => [
388444
UNNECESSARY_SAFETY_DOC,
389445
SUSPICIOUS_DOC_COMMENTS,
390446
EMPTY_DOCS,
447+
DOC_LAZY_CONTINUATION,
391448
]);
392449

393450
impl<'tcx> LateLintPass<'tcx> for Documentation {
@@ -551,6 +608,7 @@ fn check_attrs(cx: &LateContext<'_>, valid_idents: &FxHashSet<String>, attrs: &[
551608
cx,
552609
valid_idents,
553610
parser.into_offset_iter(),
611+
&doc,
554612
Fragments {
555613
fragments: &fragments,
556614
doc: &doc,
@@ -560,6 +618,11 @@ fn check_attrs(cx: &LateContext<'_>, valid_idents: &FxHashSet<String>, attrs: &[
560618

561619
const RUST_CODE: &[&str] = &["rust", "no_run", "should_panic", "compile_fail"];
562620

621+
enum Container {
622+
Blockquote,
623+
List(usize),
624+
}
625+
563626
/// Checks parsed documentation.
564627
/// This walks the "events" (think sections of markdown) produced by `pulldown_cmark`,
565628
/// so lints here will generally access that information.
@@ -569,13 +632,15 @@ fn check_doc<'a, Events: Iterator<Item = (pulldown_cmark::Event<'a>, Range<usize
569632
cx: &LateContext<'_>,
570633
valid_idents: &FxHashSet<String>,
571634
events: Events,
635+
doc: &str,
572636
fragments: Fragments<'_>,
573637
) -> DocHeaders {
574638
// true if a safety header was found
575639
let mut headers = DocHeaders::default();
576640
let mut in_code = false;
577641
let mut in_link = None;
578642
let mut in_heading = false;
643+
let mut in_footnote_definition = false;
579644
let mut is_rust = false;
580645
let mut no_test = false;
581646
let mut ignore = false;
@@ -586,7 +651,11 @@ fn check_doc<'a, Events: Iterator<Item = (pulldown_cmark::Event<'a>, Range<usize
586651
let mut code_level = 0;
587652
let mut blockquote_level = 0;
588653

589-
for (event, range) in events {
654+
let mut containers = Vec::new();
655+
656+
let mut events = events.peekable();
657+
658+
while let Some((event, range)) = events.next() {
590659
match event {
591660
Html(tag) => {
592661
if tag.starts_with("<code") {
@@ -599,8 +668,14 @@ fn check_doc<'a, Events: Iterator<Item = (pulldown_cmark::Event<'a>, Range<usize
599668
blockquote_level -= 1;
600669
}
601670
},
602-
Start(BlockQuote) => blockquote_level += 1,
603-
End(BlockQuote) => blockquote_level -= 1,
671+
Start(BlockQuote) => {
672+
blockquote_level += 1;
673+
containers.push(Container::Blockquote);
674+
},
675+
End(BlockQuote) => {
676+
blockquote_level -= 1;
677+
containers.pop();
678+
},
604679
Start(CodeBlock(ref kind)) => {
605680
in_code = true;
606681
if let CodeBlockKind::Fenced(lang) = kind {
@@ -633,13 +708,23 @@ fn check_doc<'a, Events: Iterator<Item = (pulldown_cmark::Event<'a>, Range<usize
633708
if let Start(Heading(_, _, _)) = event {
634709
in_heading = true;
635710
}
711+
if let Start(Item) = event {
712+
if let Some((_next_event, next_range)) = events.peek() {
713+
containers.push(Container::List(next_range.start - range.start));
714+
} else {
715+
containers.push(Container::List(0));
716+
}
717+
}
636718
ticks_unbalanced = false;
637719
paragraph_range = range;
638720
},
639721
End(Heading(_, _, _) | Paragraph | Item) => {
640722
if let End(Heading(_, _, _)) = event {
641723
in_heading = false;
642724
}
725+
if let End(Item) = event {
726+
containers.pop();
727+
}
643728
if ticks_unbalanced && let Some(span) = fragments.span(cx, paragraph_range.clone()) {
644729
span_lint_and_help(
645730
cx,
@@ -658,8 +743,26 @@ fn check_doc<'a, Events: Iterator<Item = (pulldown_cmark::Event<'a>, Range<usize
658743
}
659744
text_to_check = Vec::new();
660745
},
746+
Start(FootnoteDefinition(..)) => in_footnote_definition = true,
747+
End(FootnoteDefinition(..)) => in_footnote_definition = false,
661748
Start(_tag) | End(_tag) => (), // We don't care about other tags
662-
SoftBreak | HardBreak | TaskListMarker(_) | Code(_) | Rule => (),
749+
SoftBreak | HardBreak => {
750+
if !containers.is_empty()
751+
&& let Some((_next_event, next_range)) = events.peek()
752+
&& let Some(next_span) = fragments.span(cx, next_range.clone())
753+
&& let Some(span) = fragments.span(cx, range.clone())
754+
&& !in_footnote_definition
755+
{
756+
lazy_continuation::check(
757+
cx,
758+
doc,
759+
range.end..next_range.start,
760+
Span::new(span.hi(), next_span.lo(), span.ctxt(), span.parent()),
761+
&containers[..],
762+
);
763+
}
764+
},
765+
TaskListMarker(_) | Code(_) | Rule => (),
663766
FootnoteReference(text) | Text(text) => {
664767
paragraph_range.end = range.end;
665768
ticks_unbalanced |= text.contains('`') && !in_code;
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#![warn(clippy::doc_lazy_continuation)]
2+
3+
/// > blockquote with
4+
/// > lazy continuation
5+
//~^ ERROR: doc quote missing `>` marker
6+
fn first() {}
7+
8+
/// > blockquote with no
9+
/// > lazy continuation
10+
fn first_nowarn() {}
11+
12+
/// > blockquote with no
13+
///
14+
/// lazy continuation
15+
fn two_nowarn() {}
16+
17+
/// > nest here
18+
/// >
19+
/// > > nest here
20+
/// > > lazy continuation
21+
//~^ ERROR: doc quote missing `>` marker
22+
fn two() {}
23+
24+
/// > nest here
25+
/// >
26+
/// > > nest here
27+
/// > > lazy continuation
28+
//~^ ERROR: doc quote missing `>` marker
29+
fn three() {}
30+
31+
/// > * > nest here
32+
/// > > lazy continuation
33+
//~^ ERROR: doc quote missing `>` marker
34+
fn four() {}
35+
36+
/// > * > nest here
37+
/// > > lazy continuation
38+
//~^ ERROR: doc quote missing `>` marker
39+
fn four_point_1() {}
40+
41+
/// * > nest here lazy continuation
42+
fn five() {}
43+
44+
/// 1. > nest here
45+
/// > lazy continuation (this results in strange indentation, but still works)
46+
//~^ ERROR: doc quote missing `>` marker
47+
fn six() {}

0 commit comments

Comments
 (0)