Skip to content

Commit f3dd31e

Browse files
committed
Add lint for markdown lazy paragraph continuations
This is a follow-up for rust-lang#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 f3dd31e

File tree

10 files changed

+573
-5
lines changed

10 files changed

+573
-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_lazy_continuation`]: https://rust-lang.github.io/rust-clippy/master/index.html#doc_lazy_continuation
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: 110 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,63 @@ 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+
/// # fn do_stuff() {}
408+
/// ```
409+
///
410+
/// Fix it by indenting the list contents:
411+
/// ```no_run
412+
/// /// * Do nothing.
413+
/// /// * Then do something. Whatever it is needs done,
414+
/// /// it should be done right now.
415+
/// # fn do_stuff() {}
416+
/// ```
417+
#[clippy::version = "1.80.0"]
418+
pub DOC_LAZY_CONTINUATION,
419+
style,
420+
"require every line of a paragraph to be indented and marked"
421+
}
422+
365423
#[derive(Clone)]
366424
pub struct Documentation {
367425
valid_idents: FxHashSet<String>,
@@ -388,6 +446,7 @@ impl_lint_pass!(Documentation => [
388446
UNNECESSARY_SAFETY_DOC,
389447
SUSPICIOUS_DOC_COMMENTS,
390448
EMPTY_DOCS,
449+
DOC_LAZY_CONTINUATION,
391450
]);
392451

393452
impl<'tcx> LateLintPass<'tcx> for Documentation {
@@ -551,6 +610,7 @@ fn check_attrs(cx: &LateContext<'_>, valid_idents: &FxHashSet<String>, attrs: &[
551610
cx,
552611
valid_idents,
553612
parser.into_offset_iter(),
613+
&doc,
554614
Fragments {
555615
fragments: &fragments,
556616
doc: &doc,
@@ -560,6 +620,11 @@ fn check_attrs(cx: &LateContext<'_>, valid_idents: &FxHashSet<String>, attrs: &[
560620

561621
const RUST_CODE: &[&str] = &["rust", "no_run", "should_panic", "compile_fail"];
562622

623+
enum Container {
624+
Blockquote,
625+
List(usize),
626+
}
627+
563628
/// Checks parsed documentation.
564629
/// This walks the "events" (think sections of markdown) produced by `pulldown_cmark`,
565630
/// so lints here will generally access that information.
@@ -569,13 +634,15 @@ fn check_doc<'a, Events: Iterator<Item = (pulldown_cmark::Event<'a>, Range<usize
569634
cx: &LateContext<'_>,
570635
valid_idents: &FxHashSet<String>,
571636
events: Events,
637+
doc: &str,
572638
fragments: Fragments<'_>,
573639
) -> DocHeaders {
574640
// true if a safety header was found
575641
let mut headers = DocHeaders::default();
576642
let mut in_code = false;
577643
let mut in_link = None;
578644
let mut in_heading = false;
645+
let mut in_footnote_definition = false;
579646
let mut is_rust = false;
580647
let mut no_test = false;
581648
let mut ignore = false;
@@ -586,7 +653,11 @@ fn check_doc<'a, Events: Iterator<Item = (pulldown_cmark::Event<'a>, Range<usize
586653
let mut code_level = 0;
587654
let mut blockquote_level = 0;
588655

589-
for (event, range) in events {
656+
let mut containers = Vec::new();
657+
658+
let mut events = events.peekable();
659+
660+
while let Some((event, range)) = events.next() {
590661
match event {
591662
Html(tag) => {
592663
if tag.starts_with("<code") {
@@ -599,8 +670,14 @@ fn check_doc<'a, Events: Iterator<Item = (pulldown_cmark::Event<'a>, Range<usize
599670
blockquote_level -= 1;
600671
}
601672
},
602-
Start(BlockQuote) => blockquote_level += 1,
603-
End(BlockQuote) => blockquote_level -= 1,
673+
Start(BlockQuote) => {
674+
blockquote_level += 1;
675+
containers.push(Container::Blockquote);
676+
},
677+
End(BlockQuote) => {
678+
blockquote_level -= 1;
679+
containers.pop();
680+
},
604681
Start(CodeBlock(ref kind)) => {
605682
in_code = true;
606683
if let CodeBlockKind::Fenced(lang) = kind {
@@ -633,13 +710,23 @@ fn check_doc<'a, Events: Iterator<Item = (pulldown_cmark::Event<'a>, Range<usize
633710
if let Start(Heading(_, _, _)) = event {
634711
in_heading = true;
635712
}
713+
if let Start(Item) = event {
714+
if let Some((_next_event, next_range)) = events.peek() {
715+
containers.push(Container::List(next_range.start - range.start));
716+
} else {
717+
containers.push(Container::List(0));
718+
}
719+
}
636720
ticks_unbalanced = false;
637721
paragraph_range = range;
638722
},
639723
End(Heading(_, _, _) | Paragraph | Item) => {
640724
if let End(Heading(_, _, _)) = event {
641725
in_heading = false;
642726
}
727+
if let End(Item) = event {
728+
containers.pop();
729+
}
643730
if ticks_unbalanced && let Some(span) = fragments.span(cx, paragraph_range.clone()) {
644731
span_lint_and_help(
645732
cx,
@@ -658,8 +745,26 @@ fn check_doc<'a, Events: Iterator<Item = (pulldown_cmark::Event<'a>, Range<usize
658745
}
659746
text_to_check = Vec::new();
660747
},
748+
Start(FootnoteDefinition(..)) => in_footnote_definition = true,
749+
End(FootnoteDefinition(..)) => in_footnote_definition = false,
661750
Start(_tag) | End(_tag) => (), // We don't care about other tags
662-
SoftBreak | HardBreak | TaskListMarker(_) | Code(_) | Rule => (),
751+
SoftBreak | HardBreak => {
752+
if !containers.is_empty()
753+
&& let Some((_next_event, next_range)) = events.peek()
754+
&& let Some(next_span) = fragments.span(cx, next_range.clone())
755+
&& let Some(span) = fragments.span(cx, range.clone())
756+
&& !in_footnote_definition
757+
{
758+
lazy_continuation::check(
759+
cx,
760+
doc,
761+
range.end..next_range.start,
762+
Span::new(span.hi(), next_span.lo(), span.ctxt(), span.parent()),
763+
&containers[..],
764+
);
765+
}
766+
},
767+
TaskListMarker(_) | Code(_) | Rule => (),
663768
FootnoteReference(text) | Text(text) => {
664769
paragraph_range.end = range.end;
665770
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)