]> git.lizzy.rs Git - rust.git/blob - clippy_lints/src/doc.rs
Auto merge of #3978 - phansch:rustfix_len_zero, r=flip1995
[rust.git] / clippy_lints / src / doc.rs
1 use crate::utils::span_lint;
2 use itertools::Itertools;
3 use pulldown_cmark;
4 use rustc::lint::{EarlyContext, EarlyLintPass, LintArray, LintPass};
5 use rustc::{declare_tool_lint, impl_lint_pass};
6 use rustc_data_structures::fx::FxHashSet;
7 use syntax::ast;
8 use syntax::source_map::{BytePos, Span};
9 use syntax_pos::Pos;
10 use url::Url;
11
12 declare_clippy_lint! {
13     /// **What it does:** Checks for the presence of `_`, `::` or camel-case words
14     /// outside ticks in documentation.
15     ///
16     /// **Why is this bad?** *Rustdoc* supports markdown formatting, `_`, `::` and
17     /// camel-case probably indicates some code which should be included between
18     /// ticks. `_` can also be used for emphasis in markdown, this lint tries to
19     /// consider that.
20     ///
21     /// **Known problems:** Lots of bad docs won’t be fixed, what the lint checks
22     /// for is limited, and there are still false positives.
23     ///
24     /// **Examples:**
25     /// ```rust
26     /// /// Do something with the foo_bar parameter. See also
27     /// /// that::other::module::foo.
28     /// // ^ `foo_bar` and `that::other::module::foo` should be ticked.
29     /// fn doit(foo_bar) { .. }
30     /// ```
31     pub DOC_MARKDOWN,
32     pedantic,
33     "presence of `_`, `::` or camel-case outside backticks in documentation"
34 }
35
36 #[allow(clippy::module_name_repetitions)]
37 #[derive(Clone)]
38 pub struct DocMarkdown {
39     valid_idents: FxHashSet<String>,
40 }
41
42 impl DocMarkdown {
43     pub fn new(valid_idents: FxHashSet<String>) -> Self {
44         Self { valid_idents }
45     }
46 }
47
48 impl_lint_pass!(DocMarkdown => [DOC_MARKDOWN]);
49
50 impl EarlyLintPass for DocMarkdown {
51     fn check_crate(&mut self, cx: &EarlyContext<'_>, krate: &ast::Crate) {
52         check_attrs(cx, &self.valid_idents, &krate.attrs);
53     }
54
55     fn check_item(&mut self, cx: &EarlyContext<'_>, item: &ast::Item) {
56         check_attrs(cx, &self.valid_idents, &item.attrs);
57     }
58 }
59
60 struct Parser<'a> {
61     parser: pulldown_cmark::Parser<'a>,
62 }
63
64 impl<'a> Parser<'a> {
65     fn new(parser: pulldown_cmark::Parser<'a>) -> Self {
66         Self { parser }
67     }
68 }
69
70 impl<'a> Iterator for Parser<'a> {
71     type Item = (usize, pulldown_cmark::Event<'a>);
72
73     fn next(&mut self) -> Option<Self::Item> {
74         let offset = self.parser.get_offset();
75         self.parser.next().map(|event| (offset, event))
76     }
77 }
78
79 /// Cleanup documentation decoration (`///` and such).
80 ///
81 /// We can't use `syntax::attr::AttributeMethods::with_desugared_doc` or
82 /// `syntax::parse::lexer::comments::strip_doc_comment_decoration` because we
83 /// need to keep track of
84 /// the spans but this function is inspired from the later.
85 #[allow(clippy::cast_possible_truncation)]
86 pub fn strip_doc_comment_decoration(comment: &str, span: Span) -> (String, Vec<(usize, Span)>) {
87     // one-line comments lose their prefix
88     const ONELINERS: &[&str] = &["///!", "///", "//!", "//"];
89     for prefix in ONELINERS {
90         if comment.starts_with(*prefix) {
91             let doc = &comment[prefix.len()..];
92             let mut doc = doc.to_owned();
93             doc.push('\n');
94             return (
95                 doc.to_owned(),
96                 vec![(doc.len(), span.with_lo(span.lo() + BytePos(prefix.len() as u32)))],
97             );
98         }
99     }
100
101     if comment.starts_with("/*") {
102         let doc = &comment[3..comment.len() - 2];
103         let mut sizes = vec![];
104         let mut contains_initial_stars = false;
105         for line in doc.lines() {
106             let offset = line.as_ptr() as usize - comment.as_ptr() as usize;
107             debug_assert_eq!(offset as u32 as usize, offset);
108             contains_initial_stars |= line.trim_start().starts_with('*');
109             // +1 for the newline
110             sizes.push((line.len() + 1, span.with_lo(span.lo() + BytePos(offset as u32))));
111         }
112         if !contains_initial_stars {
113             return (doc.to_string(), sizes);
114         }
115         // remove the initial '*'s if any
116         let mut no_stars = String::with_capacity(doc.len());
117         for line in doc.lines() {
118             let mut chars = line.chars();
119             while let Some(c) = chars.next() {
120                 if c.is_whitespace() {
121                     no_stars.push(c);
122                 } else {
123                     no_stars.push(if c == '*' { ' ' } else { c });
124                     break;
125                 }
126             }
127             no_stars.push_str(chars.as_str());
128             no_stars.push('\n');
129         }
130         return (no_stars, sizes);
131     }
132
133     panic!("not a doc-comment: {}", comment);
134 }
135
136 pub fn check_attrs<'a>(cx: &EarlyContext<'_>, valid_idents: &FxHashSet<String>, attrs: &'a [ast::Attribute]) {
137     let mut doc = String::new();
138     let mut spans = vec![];
139
140     for attr in attrs {
141         if attr.is_sugared_doc {
142             if let Some(ref current) = attr.value_str() {
143                 let current = current.to_string();
144                 let (current, current_spans) = strip_doc_comment_decoration(&current, attr.span);
145                 spans.extend_from_slice(&current_spans);
146                 doc.push_str(&current);
147             }
148         } else if attr.check_name("doc") {
149             // ignore mix of sugared and non-sugared doc
150             return;
151         }
152     }
153
154     let mut current = 0;
155     for &mut (ref mut offset, _) in &mut spans {
156         let offset_copy = *offset;
157         *offset = current;
158         current += offset_copy;
159     }
160
161     if !doc.is_empty() {
162         let parser = Parser::new(pulldown_cmark::Parser::new(&doc));
163         let parser = parser.coalesce(|x, y| {
164             use pulldown_cmark::Event::*;
165
166             let x_offset = x.0;
167             let y_offset = y.0;
168
169             match (x.1, y.1) {
170                 (Text(x), Text(y)) => {
171                     let mut x = x.into_owned();
172                     x.push_str(&y);
173                     Ok((x_offset, Text(x.into())))
174                 },
175                 (x, y) => Err(((x_offset, x), (y_offset, y))),
176             }
177         });
178         check_doc(cx, valid_idents, parser, &spans);
179     }
180 }
181
182 fn check_doc<'a, Events: Iterator<Item = (usize, pulldown_cmark::Event<'a>)>>(
183     cx: &EarlyContext<'_>,
184     valid_idents: &FxHashSet<String>,
185     docs: Events,
186     spans: &[(usize, Span)],
187 ) {
188     use pulldown_cmark::Event::*;
189     use pulldown_cmark::Tag::*;
190
191     let mut in_code = false;
192     let mut in_link = None;
193
194     for (offset, event) in docs {
195         match event {
196             Start(CodeBlock(_)) | Start(Code) => in_code = true,
197             End(CodeBlock(_)) | End(Code) => in_code = false,
198             Start(Link(link, _)) => in_link = Some(link),
199             End(Link(_, _)) => in_link = None,
200             Start(_tag) | End(_tag) => (),         // We don't care about other tags
201             Html(_html) | InlineHtml(_html) => (), // HTML is weird, just ignore it
202             SoftBreak | HardBreak => (),
203             FootnoteReference(text) | Text(text) => {
204                 if Some(&text) == in_link.as_ref() {
205                     // Probably a link of the form `<http://example.com>`
206                     // Which are represented as a link to "http://example.com" with
207                     // text "http://example.com" by pulldown-cmark
208                     continue;
209                 }
210
211                 if !in_code {
212                     let index = match spans.binary_search_by(|c| c.0.cmp(&offset)) {
213                         Ok(o) => o,
214                         Err(e) => e - 1,
215                     };
216
217                     let (begin, span) = spans[index];
218
219                     // Adjust for the beginning of the current `Event`
220                     let span = span.with_lo(span.lo() + BytePos::from_usize(offset - begin));
221
222                     check_text(cx, valid_idents, &text, span);
223                 }
224             },
225         }
226     }
227 }
228
229 fn check_text(cx: &EarlyContext<'_>, valid_idents: &FxHashSet<String>, text: &str, span: Span) {
230     for word in text.split(|c: char| c.is_whitespace() || c == '\'') {
231         // Trim punctuation as in `some comment (see foo::bar).`
232         //                                                   ^^
233         // Or even as in `_foo bar_` which is emphasized.
234         let word = word.trim_matches(|c: char| !c.is_alphanumeric());
235
236         if valid_idents.contains(word) {
237             continue;
238         }
239
240         // Adjust for the current word
241         let offset = word.as_ptr() as usize - text.as_ptr() as usize;
242         let span = Span::new(
243             span.lo() + BytePos::from_usize(offset),
244             span.lo() + BytePos::from_usize(offset + word.len()),
245             span.ctxt(),
246         );
247
248         check_word(cx, word, span);
249     }
250 }
251
252 fn check_word(cx: &EarlyContext<'_>, word: &str, span: Span) {
253     /// Checks if a string is camel-case, i.e., contains at least two uppercase
254     /// letters (`Clippy` is ok) and one lower-case letter (`NASA` is ok).
255     /// Plurals are also excluded (`IDs` is ok).
256     fn is_camel_case(s: &str) -> bool {
257         if s.starts_with(|c: char| c.is_digit(10)) {
258             return false;
259         }
260
261         let s = if s.ends_with('s') { &s[..s.len() - 1] } else { s };
262
263         s.chars().all(char::is_alphanumeric)
264             && s.chars().filter(|&c| c.is_uppercase()).take(2).count() > 1
265             && s.chars().filter(|&c| c.is_lowercase()).take(1).count() > 0
266     }
267
268     fn has_underscore(s: &str) -> bool {
269         s != "_" && !s.contains("\\_") && s.contains('_')
270     }
271
272     fn has_hyphen(s: &str) -> bool {
273         s != "-" && s.contains('-')
274     }
275
276     if let Ok(url) = Url::parse(word) {
277         // try to get around the fact that `foo::bar` parses as a valid URL
278         if !url.cannot_be_a_base() {
279             span_lint(
280                 cx,
281                 DOC_MARKDOWN,
282                 span,
283                 "you should put bare URLs between `<`/`>` or make a proper Markdown link",
284             );
285
286             return;
287         }
288     }
289
290     // We assume that mixed-case words are not meant to be put inside bacticks. (Issue #2343)
291     if has_underscore(word) && has_hyphen(word) {
292         return;
293     }
294
295     if has_underscore(word) || word.contains("::") || is_camel_case(word) {
296         span_lint(
297             cx,
298             DOC_MARKDOWN,
299             span,
300             &format!("you should put `{}` between ticks in the documentation", word),
301         );
302     }
303 }