blob: 69c3b9150c300b6d161eaa61c2cb9bccf9df899d [file] [log] [blame] [edit]
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);
},
);
}
}