|
1 | | -use rustc_ast::ast::{AttrKind, AttrStyle, Attribute}; |
2 | 1 | use rustc_errors::Applicability; |
3 | | -use rustc_lint::EarlyContext; |
| 2 | +use rustc_lint::LateContext; |
| 3 | +use rustc_resolve::rustdoc::main_body_opts; |
4 | 4 |
|
5 | | -use super::DOC_COMMENTS_MISSING_TERMINAL_PUNCTUATION; |
| 5 | +use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; |
| 6 | + |
| 7 | +use super::{DOC_COMMENTS_MISSING_TERMINAL_PUNCTUATION, Fragments}; |
6 | 8 |
|
7 | 9 | const MSG: &str = "doc comments should end with a terminal punctuation mark"; |
8 | 10 | const PUNCTUATION_SUGGESTION: char = '.'; |
9 | 11 |
|
10 | | -pub fn check(cx: &EarlyContext<'_>, attrs: &[Attribute]) { |
11 | | - let mut doc_comment_attrs = attrs.iter().enumerate().filter(|(_, a)| is_doc_comment(a)); |
12 | | - |
13 | | - let Some((i, mut last_doc_attr)) = doc_comment_attrs.next_back() else { |
14 | | - return; |
15 | | - }; |
16 | | - |
17 | | - // Check that the next attribute is not a `#[doc]` attribute. |
18 | | - if let Some(next_attr) = attrs.get(i + 1) |
19 | | - && is_doc_attr(next_attr) |
20 | | - { |
21 | | - return; |
22 | | - } |
23 | | - |
24 | | - // Find the last, non-blank, non-refdef line of multiline doc comments: this is enough to check that |
25 | | - // the doc comment ends with proper punctuation. |
26 | | - while is_doc_comment_trailer(last_doc_attr) { |
27 | | - if let Some(doc_attr) = doc_comment_attrs.next_back() { |
28 | | - (_, last_doc_attr) = doc_attr; |
29 | | - } else { |
30 | | - // The doc comment looks (functionally) empty. |
31 | | - return; |
32 | | - } |
33 | | - } |
34 | | - |
35 | | - if let Some(doc_string) = is_missing_punctuation(last_doc_attr) { |
36 | | - let span = last_doc_attr.span; |
37 | | - |
38 | | - if is_line_doc_comment(last_doc_attr) { |
39 | | - let suggestion = generate_suggestion(last_doc_attr, doc_string); |
40 | | - |
| 12 | +pub fn check(cx: &LateContext<'_>, doc: &str, fragments: Fragments<'_>) { |
| 13 | + if let Some(offset) = is_missing_punctuation(doc) { |
| 14 | + if let Some(span) = fragments.span(cx, offset..offset) { |
41 | 15 | clippy_utils::diagnostics::span_lint_and_sugg( |
42 | 16 | cx, |
43 | 17 | DOC_COMMENTS_MISSING_TERMINAL_PUNCTUATION, |
44 | 18 | span, |
45 | 19 | MSG, |
46 | 20 | "end the doc comment with some punctuation", |
47 | | - suggestion, |
| 21 | + PUNCTUATION_SUGGESTION.to_string(), |
48 | 22 | Applicability::MaybeIncorrect, |
49 | 23 | ); |
50 | 24 | } else { |
51 | | - // Seems more difficult to preserve the formatting of block doc comments, so we do not provide |
| 25 | + let span = fragments.fragments.last().unwrap().span; |
| 26 | + // Seems more difficult to preserve the formatting of `#[doc]` attrs, so we do not provide |
52 | 27 | // suggestions for them; they are much rarer anyway. |
53 | 28 | clippy_utils::diagnostics::span_lint(cx, DOC_COMMENTS_MISSING_TERMINAL_PUNCTUATION, span, MSG); |
54 | 29 | } |
55 | 30 | } |
56 | 31 | } |
57 | 32 |
|
58 | 33 | #[must_use] |
59 | | -fn is_missing_punctuation(attr: &Attribute) -> Option<&str> { |
| 34 | +/// If punctuation is missing, returns the docstring and the offset |
| 35 | +/// where new punctuation should be inserted. |
| 36 | +fn is_missing_punctuation(doc_string: &str) -> Option<usize> { |
60 | 37 | const TERMINAL_PUNCTUATION_MARKS: &[char] = &['.', '?', '!', '…']; |
61 | | - const EXCEPTIONS: &[char] = &[ |
62 | | - '>', // Raw HTML or (unfortunately) Markdown autolinks. |
63 | | - '|', // Markdown tables. |
64 | | - ]; |
65 | | - |
66 | | - let doc_string = get_doc_string(attr)?; |
67 | 38 |
|
68 | | - // Doc comments could have some trailing whitespace, but that is not this lint's job. |
69 | | - let trimmed = doc_string.trim_end(); |
70 | | - |
71 | | - // Doc comments are also allowed to end with fenced code blocks. |
72 | | - if trimmed.ends_with(TERMINAL_PUNCTUATION_MARKS) || trimmed.ends_with(EXCEPTIONS) || trimmed.ends_with("```") { |
73 | | - return None; |
74 | | - } |
75 | | - |
76 | | - // Ignore single-line list items: they may not require any terminal punctuation. |
77 | | - if looks_like_list_item(trimmed) { |
78 | | - return None; |
79 | | - } |
80 | | - |
81 | | - if let Some(stripped) = strip_sentence_trailers(trimmed) |
82 | | - && stripped.ends_with(TERMINAL_PUNCTUATION_MARKS) |
| 39 | + let mut no_report_depth = 0; |
| 40 | + let mut text_offset = None; |
| 41 | + for (event, offset) in |
| 42 | + Parser::new_ext(doc_string, main_body_opts() - Options::ENABLE_SMART_PUNCTUATION).into_offset_iter() |
83 | 43 | { |
84 | | - return None; |
85 | | - } |
86 | | - |
87 | | - Some(doc_string) |
88 | | -} |
89 | | - |
90 | | -#[must_use] |
91 | | -fn generate_suggestion(doc_attr: &Attribute, doc_string: &str) -> String { |
92 | | - let doc_comment_prefix = match doc_attr.style { |
93 | | - AttrStyle::Outer => "///", |
94 | | - AttrStyle::Inner => "//!", |
95 | | - }; |
96 | | - |
97 | | - let mut original_line = format!("{doc_comment_prefix}{doc_string}"); |
98 | | - |
99 | | - if let Some(stripped) = strip_sentence_trailers(doc_string) { |
100 | | - // Insert the punctuation mark just before the sentence trailer. |
101 | | - original_line.insert(doc_comment_prefix.len() + stripped.len(), PUNCTUATION_SUGGESTION); |
102 | | - } else { |
103 | | - original_line.push(PUNCTUATION_SUGGESTION); |
104 | | - } |
105 | | - |
106 | | - original_line |
107 | | -} |
108 | | - |
109 | | -/// Strips closing parentheses and Markdown emphasis delimiters. |
110 | | -#[must_use] |
111 | | -fn strip_sentence_trailers(string: &str) -> Option<&str> { |
112 | | - // The std has a few occurrences of doc comments ending with a sentence in parentheses. |
113 | | - const TRAILERS: &[char] = &[')', '*', '_']; |
114 | | - |
115 | | - if let Some(stripped) = string.strip_suffix("**") { |
116 | | - return Some(stripped); |
117 | | - } |
118 | | - |
119 | | - if let Some(stripped) = string.strip_suffix("__") { |
120 | | - return Some(stripped); |
121 | | - } |
122 | | - |
123 | | - // Markdown inline links should not be mistaken for sentences in parentheses. |
124 | | - if looks_like_inline_link(string) { |
125 | | - return None; |
126 | | - } |
127 | | - |
128 | | - string.strip_suffix(TRAILERS) |
129 | | -} |
130 | | - |
131 | | -/// Returns whether the doc comment looks like a Markdown reference definition or a blank line. |
132 | | -#[must_use] |
133 | | -fn is_doc_comment_trailer(attr: &Attribute) -> bool { |
134 | | - let Some(doc_string) = get_doc_string(attr) else { |
135 | | - return false; |
136 | | - }; |
137 | | - |
138 | | - super::looks_like_refdef(doc_string, 0..doc_string.len()).is_some() || doc_string.trim_end().is_empty() |
139 | | -} |
140 | | - |
141 | | -/// Returns whether the string looks like it ends with a Markdown inline link. |
142 | | -#[must_use] |
143 | | -fn looks_like_inline_link(string: &str) -> bool { |
144 | | - let Some(sub) = string.strip_suffix(')') else { |
145 | | - return false; |
146 | | - }; |
147 | | - let Some((sub, _)) = sub.rsplit_once('(') else { |
148 | | - return false; |
149 | | - }; |
150 | | - |
151 | | - // Check whether there is closing bracket just before the opening parenthesis. |
152 | | - sub.ends_with(']') |
153 | | -} |
154 | | - |
155 | | -/// Returns whether the string looks like a Markdown list item. |
156 | | -#[must_use] |
157 | | -fn looks_like_list_item(string: &str) -> bool { |
158 | | - const BULLET_LIST_MARKERS: &[char] = &['-', '+', '*']; |
159 | | - const ORDERED_LIST_MARKER_SYMBOL: &[char] = &['.', ')']; |
160 | | - |
161 | | - let trimmed = string.trim_start(); |
162 | | - |
163 | | - if let Some(sub) = trimmed.strip_prefix(BULLET_LIST_MARKERS) |
164 | | - && sub.starts_with(char::is_whitespace) |
165 | | - { |
166 | | - return true; |
167 | | - } |
168 | | - |
169 | | - let mut stripped = trimmed; |
170 | | - while let Some(sub) = stripped.strip_prefix(|c| char::is_digit(c, 10)) { |
171 | | - stripped = sub; |
172 | | - } |
173 | | - if let Some(sub) = stripped.strip_prefix(ORDERED_LIST_MARKER_SYMBOL) |
174 | | - && sub.starts_with(char::is_whitespace) |
175 | | - { |
176 | | - return true; |
| 44 | + match event { |
| 45 | + Event::Start( |
| 46 | + Tag::CodeBlock(..) |
| 47 | + | Tag::FootnoteDefinition(_) |
| 48 | + | Tag::Heading { .. } |
| 49 | + | Tag::HtmlBlock |
| 50 | + | Tag::List(..) |
| 51 | + | Tag::Table(_), |
| 52 | + ) => { |
| 53 | + no_report_depth += 1; |
| 54 | + }, |
| 55 | + Event::End( |
| 56 | + TagEnd::CodeBlock |
| 57 | + | TagEnd::FootnoteDefinition |
| 58 | + | TagEnd::Heading(_) |
| 59 | + | TagEnd::HtmlBlock |
| 60 | + | TagEnd::List(_) |
| 61 | + | TagEnd::Table, |
| 62 | + ) => { |
| 63 | + no_report_depth -= 1; |
| 64 | + }, |
| 65 | + Event::InlineHtml(_) | Event::Start(Tag::Image { .. }) | Event::End(TagEnd::Image) => { |
| 66 | + text_offset = None; |
| 67 | + }, |
| 68 | + Event::Text(..) | Event::Start(Tag::Link { .. }) | Event::End(TagEnd::Link) |
| 69 | + if no_report_depth == 0 && !offset.is_empty() => |
| 70 | + { |
| 71 | + text_offset = Some(offset); |
| 72 | + }, |
| 73 | + _ => {}, |
| 74 | + } |
177 | 75 | } |
178 | 76 |
|
179 | | - false |
180 | | -} |
181 | | - |
182 | | -#[must_use] |
183 | | -fn is_doc_attr(attr: &Attribute) -> bool { |
184 | | - if let AttrKind::Normal(normal_attr) = &attr.kind |
185 | | - && let Some(segment) = &normal_attr.item.path.segments.first() |
186 | | - && segment.ident.name == clippy_utils::sym::doc |
| 77 | + let text_offset = text_offset?; |
| 78 | + if doc_string[..text_offset.end] |
| 79 | + .trim_end_matches(|c: char| c.is_whitespace() || c == ')' || c == ']' || c == '}') |
| 80 | + .ends_with(TERMINAL_PUNCTUATION_MARKS) |
187 | 81 | { |
188 | | - true |
189 | | - } else { |
190 | | - false |
191 | | - } |
192 | | -} |
193 | | - |
194 | | -#[must_use] |
195 | | -fn get_doc_string(attr: &Attribute) -> Option<&str> { |
196 | | - if let AttrKind::DocComment(_, symbol) = &attr.kind { |
197 | | - Some(symbol.as_str()) |
198 | | - } else { |
199 | 82 | None |
| 83 | + } else { |
| 84 | + Some(text_offset.end) |
200 | 85 | } |
201 | 86 | } |
202 | | - |
203 | | -#[must_use] |
204 | | -fn is_doc_comment(attr: &Attribute) -> bool { |
205 | | - matches!(attr.kind, AttrKind::DocComment(_, _)) |
206 | | -} |
207 | | - |
208 | | -#[must_use] |
209 | | -fn is_line_doc_comment(attr: &Attribute) -> bool { |
210 | | - matches!(attr.kind, AttrKind::DocComment(rustc_ast::token::CommentKind::Line, _)) |
211 | | -} |
0 commit comments