| use clippy_utils::diagnostics::{span_lint_and_sugg, span_lint_and_then}; |
| use clippy_utils::source::snippet_with_applicability; |
| use rustc_data_structures::fx::FxHashSet; |
| use rustc_errors::Applicability; |
| use rustc_lint::LateContext; |
| use rustc_span::{BytePos, Pos, Span}; |
| use url::Url; |
| |
| use crate::doc::{DOC_MARKDOWN, Fragments}; |
| use std::ops::Range; |
| |
| pub fn check( |
| cx: &LateContext<'_>, |
| valid_idents: &FxHashSet<String>, |
| text: &str, |
| fragments: &Fragments<'_>, |
| fragment_range: Range<usize>, |
| code_level: isize, |
| blockquote_level: isize, |
| ) { |
| for orig_word in text.split(|c: char| c.is_whitespace() || c == '\'') { |
| // Trim punctuation as in `some comment (see foo::bar).` |
| // ^^ |
| // Or even as in `_foo bar_` which is emphasized. Also preserve `::` as a prefix/suffix. |
| let trim_pattern = |c: char| !c.is_alphanumeric() && c != ':'; |
| let mut word = orig_word.trim_end_matches(trim_pattern); |
| |
| // If word is immediately followed by `()`, claw it back. |
| if let Some(tmp_word) = orig_word.get(..word.len() + 2) |
| && tmp_word.ends_with("()") |
| { |
| word = tmp_word; |
| } |
| |
| let original_len = word.len(); |
| word = word.trim_start_matches(trim_pattern); |
| |
| // Remove leading or trailing single `:` which may be part of a sentence. |
| if word.starts_with(':') && !word.starts_with("::") { |
| word = word.trim_start_matches(':'); |
| } |
| if word.ends_with(':') && !word.ends_with("::") { |
| word = word.trim_end_matches(':'); |
| } |
| |
| if valid_idents.contains(word) || word.chars().all(|c| c == ':') { |
| continue; |
| } |
| |
| // Ensure that all reachable matching closing parens are included as well. |
| let size_diff = original_len - word.len(); |
| let mut open_parens = 0; |
| let mut close_parens = 0; |
| for c in word.chars() { |
| if c == '(' { |
| open_parens += 1; |
| } else if c == ')' { |
| close_parens += 1; |
| } |
| } |
| while close_parens < open_parens |
| && let Some(tmp_word) = orig_word.get(size_diff..=(word.len() + size_diff)) |
| && tmp_word.ends_with(')') |
| { |
| word = tmp_word; |
| close_parens += 1; |
| } |
| |
| // We'll use this offset to calculate the span to lint. |
| let fragment_offset = word.as_ptr() as usize - text.as_ptr() as usize; |
| |
| // Adjust for the current word |
| check_word( |
| cx, |
| word, |
| fragments, |
| &fragment_range, |
| fragment_offset, |
| code_level, |
| blockquote_level, |
| ); |
| } |
| } |
| |
| fn check_word( |
| cx: &LateContext<'_>, |
| word: &str, |
| fragments: &Fragments<'_>, |
| range: &Range<usize>, |
| fragment_offset: usize, |
| code_level: isize, |
| blockquote_level: isize, |
| ) { |
| /// Checks if a string is upper-camel-case, i.e., starts with an uppercase and |
| /// contains at least two uppercase letters (`Clippy` is ok) and one lower-case |
| /// letter (`NASA` is ok). |
| /// Plurals are also excluded (`IDs` is ok). |
| fn is_camel_case(s: &str) -> bool { |
| if s.starts_with(|c: char| c.is_ascii_digit() | c.is_ascii_lowercase()) { |
| return false; |
| } |
| |
| let s = if let Some(prefix) = s.strip_suffix("es") |
| && prefix.chars().all(|c| c.is_ascii_uppercase()) |
| && matches!(prefix.chars().last(), Some('S' | 'X')) |
| { |
| prefix |
| } else if let Some(prefix) = s.strip_suffix("ified") |
| && prefix.chars().all(|c| c.is_ascii_uppercase()) |
| { |
| prefix |
| } else { |
| s.strip_suffix('s').unwrap_or(s) |
| }; |
| |
| s.chars().all(char::is_alphanumeric) |
| && s.chars().filter(|&c| c.is_uppercase()).take(2).count() > 1 |
| && s.chars().filter(|&c| c.is_lowercase()).take(1).count() > 0 |
| } |
| |
| fn has_underscore(s: &str) -> bool { |
| s != "_" && !s.contains("\\_") && s.contains('_') |
| } |
| |
| fn has_hyphen(s: &str) -> bool { |
| s != "-" && s.contains('-') |
| } |
| |
| if let Ok(url) = Url::parse(word) |
| // try to get around the fact that `foo::bar` parses as a valid URL |
| && !url.cannot_be_a_base() |
| { |
| let Some(fragment_span) = fragments.span(cx, range.clone()) else { |
| return; |
| }; |
| let span = Span::new( |
| fragment_span.lo() + BytePos::from_usize(fragment_offset), |
| fragment_span.lo() + BytePos::from_usize(fragment_offset + word.len()), |
| fragment_span.ctxt(), |
| fragment_span.parent(), |
| ); |
| |
| span_lint_and_sugg( |
| cx, |
| DOC_MARKDOWN, |
| span, |
| "you should put bare URLs between `<`/`>` or make a proper Markdown link", |
| "try", |
| format!("<{word}>"), |
| Applicability::MachineApplicable, |
| ); |
| return; |
| } |
| |
| // We assume that mixed-case words are not meant to be put inside backticks. (Issue #2343) |
| // |
| // We also assume that backticks are not necessary if inside a quote. (Issue #10262) |
| if code_level > 0 || blockquote_level > 0 || (has_underscore(word) && has_hyphen(word)) { |
| return; |
| } |
| |
| if has_underscore(word) || word.contains("::") || is_camel_case(word) || word.ends_with("()") { |
| let Some(fragment_span) = fragments.span(cx, range.clone()) else { |
| return; |
| }; |
| |
| let span = Span::new( |
| fragment_span.lo() + BytePos::from_usize(fragment_offset), |
| fragment_span.lo() + BytePos::from_usize(fragment_offset + word.len()), |
| fragment_span.ctxt(), |
| fragment_span.parent(), |
| ); |
| |
| span_lint_and_then( |
| cx, |
| DOC_MARKDOWN, |
| span, |
| "item in documentation is missing backticks", |
| |diag| { |
| let mut applicability = Applicability::MachineApplicable; |
| let snippet = snippet_with_applicability(cx, span, "..", &mut applicability); |
| diag.span_suggestion_verbose(span, "try", format!("`{snippet}`"), applicability); |
| }, |
| ); |
| } |
| } |