|
| 1 | +use rustc_ast::ast::{AttrKind, AttrStyle, Attribute}; |
| 2 | +use rustc_errors::Applicability; |
| 3 | +use rustc_lint::EarlyContext; |
| 4 | + |
| 5 | +use super::DOC_COMMENTS_MISSING_TERMINAL_PUNCTUATION; |
| 6 | + |
| 7 | +const MSG: &str = "doc comments should end with a terminal punctuation mark"; |
| 8 | +const PUNCTUATION_SUGGESTION: char = '.'; |
| 9 | + |
| 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 | + |
| 41 | + clippy_utils::diagnostics::span_lint_and_sugg( |
| 42 | + cx, |
| 43 | + DOC_COMMENTS_MISSING_TERMINAL_PUNCTUATION, |
| 44 | + span, |
| 45 | + MSG, |
| 46 | + "end the doc comment with some punctuation", |
| 47 | + suggestion, |
| 48 | + Applicability::MaybeIncorrect, |
| 49 | + ); |
| 50 | + } else { |
| 51 | + // Seems more difficult to preserve the formatting of block doc comments, so we do not provide |
| 52 | + // suggestions for them; they are much rarer anyway. |
| 53 | + clippy_utils::diagnostics::span_lint(cx, DOC_COMMENTS_MISSING_TERMINAL_PUNCTUATION, span, MSG); |
| 54 | + } |
| 55 | + } |
| 56 | +} |
| 57 | + |
| 58 | +#[must_use] |
| 59 | +fn is_missing_punctuation(attr: &Attribute) -> Option<&str> { |
| 60 | + 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 | + |
| 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) |
| 83 | + { |
| 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; |
| 177 | + } |
| 178 | + |
| 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.as_str() == "doc" |
| 187 | + { |
| 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 | + None |
| 200 | + } |
| 201 | +} |
| 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