use clippy_utils::diagnostics::span_lint_hir_and_then;
use rustc_errors::Applicability;
use rustc_hir::HirId;
use rustc_lint::{LateContext, LateLintPass, LintStore};
use rustc_session::{declare_tool_lint, impl_lint_pass};
use rustc_span::Span;
use crate::comment_walk::{CommentChunk, CommentSurface, walk_local_comments};
use crate::common::{DefaultState, resolved_state};
use crate::enclosing_hir::emit_at_enclosing_hir;
use crate::markdown::{position_in_skip, scan_skip_regions, utf8_char_len};
use crate::url_scan::back_scan_url_fragment;
mod repo_url;
declare_tool_lint! {
pub perfectionist::BARE_ISSUE_REFERENCE,
Warn,
"ambiguous bare `#NNN` issue / PR reference in comment",
report_in_external_macro: false
}
const CONFIG_KEY: &str = "perfectionist::bare_issue_reference";
#[derive(Debug, Clone, Copy, Eq, PartialEq, Default, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
enum DocForm {
#[default]
Inline,
Reference,
BareUrl,
BracketedUrl,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Default, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
enum PlainForm {
#[default]
BareUrl,
BracketedUrl,
}
#[derive(Debug, serde::Deserialize)]
#[serde(default, deny_unknown_fields, rename_all = "snake_case")]
struct Config {
forge: Option<Forge>,
repository: Option<String>,
suggest_issue_url: bool,
suggest_pr_url: bool,
doc_comment_form: DocForm,
include_plain_comments: bool,
plain_comment_form: PlainForm,
}
impl Default for Config {
fn default() -> Self {
Self {
forge: None,
repository: None,
suggest_issue_url: true,
suggest_pr_url: true,
doc_comment_form: DocForm::Inline,
include_plain_comments: false,
plain_comment_form: PlainForm::BareUrl,
}
}
}
pub struct BareIssueReference {
forge: Option<Forge>,
repo_web_base: Option<String>,
suggest_issue_url: bool,
suggest_pr_url: bool,
doc_comment_form: DocForm,
include_plain_comments: bool,
plain_comment_form: PlainForm,
}
fn resolve_repository(repository: Option<&str>) -> Option<String> {
repository.map(|raw| {
repo_url::normalize(raw).unwrap_or_else(|| {
panic!(
"perfectionist::bare_issue_reference: `repository = {raw:?}` is not a \
parseable git URL; expected an HTTP(S) URL, an `ssh://` URL, or the \
scp-like `git@host:owner/repo.git` form, with an `owner/repo` path",
)
})
})
}
impl BareIssueReference {
fn new() -> Self {
let config: Config = dylint_linting::config_or_default(CONFIG_KEY);
Self {
forge: config.forge,
repo_web_base: resolve_repository(config.repository.as_deref()),
suggest_issue_url: config.suggest_issue_url,
suggest_pr_url: config.suggest_pr_url,
doc_comment_form: config.doc_comment_form,
include_plain_comments: config.include_plain_comments,
plain_comment_form: config.plain_comment_form,
}
}
fn effective_forge(&self) -> Option<Forge> {
self.forge
.or_else(|| self.repo_web_base.as_deref().and_then(Forge::detect))
}
fn issue_url(&self, number: &str) -> Option<String> {
let base = self.repo_web_base.as_deref()?;
let forge = self.effective_forge()?;
Some(join_url(base, forge.issue_path(), number))
}
fn pr_url(&self, number: &str) -> Option<String> {
let base = self.repo_web_base.as_deref()?;
let forge = self.effective_forge()?;
Some(join_url(base, forge.pr_path(), number))
}
fn hash_can_mean_pr(&self) -> bool {
self.effective_forge() != Some(Forge::GitLab)
}
}
fn join_url(base_url: &str, path: &str, number: &str) -> String {
let base = base_url.trim_end_matches('/');
let path = path.replace("{number}", number);
format!("{base}/{}", path.trim_start_matches('/'))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
enum Forge {
GitHub,
GitLab,
Gitea,
}
impl Forge {
fn issue_path(self) -> &'static str {
match self {
Forge::GitHub | Forge::Gitea => "/issues/{number}",
Forge::GitLab => "/-/issues/{number}",
}
}
fn pr_path(self) -> &'static str {
match self {
Forge::GitHub => "/pull/{number}",
Forge::Gitea => "/pulls/{number}",
Forge::GitLab => "/-/merge_requests/{number}",
}
}
fn detect(repo_url: &str) -> Option<Forge> {
let host = host_of(repo_url)?.to_ascii_lowercase();
match host.as_str() {
"github.com" => return Some(Forge::GitHub),
"gitlab.com" => return Some(Forge::GitLab),
"codeberg.org" | "gitea.com" => return Some(Forge::Gitea),
_ => {}
}
match host.split('.').next()? {
"gitlab" => Some(Forge::GitLab),
"github" => Some(Forge::GitHub),
"gitea" | "forgejo" => Some(Forge::Gitea),
_ => None,
}
}
}
fn host_of(url: &str) -> Option<&str> {
let after_scheme = url.find("://")?;
let rest = &url[after_scheme + 3..];
let end = rest.find(['/', '?', '#', ':']).unwrap_or(rest.len());
Some(&rest[..end])
}
impl_lint_pass!(BareIssueReference => [BARE_ISSUE_REFERENCE]);
pub fn register_lint(lint_store: &mut LintStore) {
lint_store.register_lints(&[BARE_ISSUE_REFERENCE]);
}
pub fn register_pass(lint_store: &mut LintStore) {
if let DefaultState::Inactive = resolved_state("bare_issue_reference", DefaultState::Active) {
return;
}
lint_store.register_late_pass(|_| Box::new(BareIssueReference::new()));
}
struct IssueRefViolation {
token: String,
issue_url: Option<String>,
pr_url: Option<String>,
is_doc: bool,
ref_site: Option<RefDefinitionSite>,
}
#[derive(Clone, Copy)]
struct EmitOptions {
suggest_issue: bool,
suggest_pr: bool,
doc_form: DocForm,
plain_form: PlainForm,
}
impl<'tcx> LateLintPass<'tcx> for BareIssueReference {
fn check_crate_post(&mut self, lint_context: &LateContext<'tcx>) {
let mut violations: Vec<(Span, IssueRefViolation)> = Vec::new();
walk_local_comments(lint_context, |chunk| match chunk.surface {
CommentSurface::DocBlock | CommentSurface::DocBlockBlock => {
self.scan_doc(chunk, &mut violations);
}
CommentSurface::PlainLine => {
if self.include_plain_comments {
self.scan_plain(chunk, &mut violations);
}
}
CommentSurface::PlainBlock => {
}
});
let options = EmitOptions {
suggest_issue: self.suggest_issue_url,
suggest_pr: self.suggest_pr_url && self.hash_can_mean_pr(),
doc_form: self.doc_comment_form,
plain_form: self.plain_comment_form,
};
emit_at_enclosing_hir(lint_context.tcx, violations, |hir_id, span, violation| {
emit_issue_ref(lint_context, hir_id, span, violation, options);
});
}
}
impl BareIssueReference {
fn scan_doc(&self, chunk: &CommentChunk<'_>, out: &mut Vec<(Span, IssueRefViolation)>) {
let skips = scan_skip_regions(&chunk.rendered);
self.scan(chunk, &skips, true, out);
}
fn scan_plain(&self, chunk: &CommentChunk<'_>, out: &mut Vec<(Span, IssueRefViolation)>) {
self.scan(chunk, &[], false, out);
}
fn scan(
&self,
chunk: &CommentChunk<'_>,
skips: &[std::ops::Range<usize>],
is_doc: bool,
out: &mut Vec<(Span, IssueRefViolation)>,
) {
let text = &chunk.rendered;
let bytes = text.as_bytes();
let mut index = 0;
while index < bytes.len() {
if bytes[index] != b'#' {
index += utf8_char_len(bytes, index);
continue;
}
if index > 0 {
let prev = bytes[index - 1];
if prev.is_ascii_alphanumeric() || prev == b'_' || prev == b'[' || prev == b'`' {
index += 1;
continue;
}
}
let digits_start = index + 1;
let mut end = digits_start;
while end < bytes.len() && bytes[end].is_ascii_digit() {
end += 1;
}
if end == digits_start {
index += 1;
continue;
}
if end < bytes.len() {
let next = bytes[end];
if next.is_ascii_alphanumeric() || next == b'_' {
index = end;
continue;
}
}
if back_scan_url_fragment(text, index) {
index = end;
continue;
}
if position_in_skip(skips, index) {
index = end;
continue;
}
let number = &text[digits_start..end];
self.collect(chunk, index, end - index, number, is_doc, out);
index = end;
}
}
fn collect(
&self,
chunk: &CommentChunk<'_>,
rendered_pos: usize,
len: usize,
number: &str,
is_doc: bool,
out: &mut Vec<(Span, IssueRefViolation)>,
) {
let Some(span) = chunk.span_for(rendered_pos, len as u32) else {
return;
};
let ref_site = (self.doc_comment_form == DocForm::Reference)
.then(|| reference_definition_site(chunk, rendered_pos, number))
.flatten();
out.push((
span,
IssueRefViolation {
token: format!("#{number}"),
issue_url: self.issue_url(number),
pr_url: self.pr_url(number),
is_doc,
ref_site,
},
));
}
}
fn emit_issue_ref(
lint_context: &LateContext<'_>,
hir_id: HirId,
span: Span,
violation: IssueRefViolation,
options: EmitOptions,
) {
let IssueRefViolation {
token,
issue_url,
pr_url,
is_doc,
ref_site,
} = violation;
let EmitOptions {
suggest_issue,
suggest_pr,
doc_form,
plain_form,
} = options;
span_lint_hir_and_then(
lint_context,
BARE_ISSUE_REFERENCE,
hir_id,
span,
format!(
"ambiguous `{token}`; a bare `#NNN` could be an issue or pull request, \
a colour, or any other numbered item",
),
move |diag| {
match issue_url {
None => {
diag.help(
"set `repository` (and `forge`, if its host isn't a recognised \
service) in dylint.toml under \
`[perfectionist::bare_issue_reference]` to enable issue / PR link \
suggestions",
);
}
Some(_) if !(suggest_issue || suggest_pr) => {
diag.help(
"enable `suggest_issue_url` and/or `suggest_pr_url` in dylint.toml \
under `[perfectionist::bare_issue_reference]` to get a link \
suggestion",
);
}
Some(issue_url) => {
if suggest_issue {
emit_one(
diag,
span,
&token,
&issue_url,
"issue",
is_doc,
doc_form,
plain_form,
ref_site.as_ref(),
);
}
if suggest_pr {
let pr_url = pr_url.expect("pr_url renders whenever issue_url does");
emit_one(
diag,
span,
&token,
&pr_url,
"pull request",
is_doc,
doc_form,
plain_form,
ref_site.as_ref(),
);
}
}
}
diag.help(format!(
"if `{token}` is not an issue or pull request, enclose it in backticks \
so it renders as code",
));
diag.help("or refer to the numbered item with a spelling that has no leading `#`");
},
);
}
struct RefDefinitionSite {
block_end: Span,
doc_prefix: String,
}
fn reference_definition_site(
chunk: &CommentChunk<'_>,
rendered_pos: usize,
number: &str,
) -> Option<RefDefinitionSite> {
if chunk.surface != CommentSurface::DocBlock {
return None;
}
if block_defines_reference(&chunk.rendered, number) {
return None;
}
Some(RefDefinitionSite {
block_end: chunk.source_span.shrink_to_hi(),
doc_prefix: doc_line_prefix(chunk, rendered_pos)?,
})
}
fn block_defines_reference(rendered: &str, number: &str) -> bool {
let label = format!("[#{number}]:");
rendered
.lines()
.any(|line| line.trim_start().starts_with(&label))
}
fn doc_line_prefix(chunk: &CommentChunk<'_>, rendered_pos: usize) -> Option<String> {
let line = chunk.lines.iter().find(|line| {
rendered_pos >= line.rendered_start
&& rendered_pos < line.rendered_start + line.rendered_len
})?;
let src = chunk.source_file.src.as_deref()?;
let content_offset = line.source_offset as usize;
let line_start = src
.get(..content_offset)?
.rfind('\n')
.map_or(0, |nl| nl + 1);
let prefix = src.get(line_start..content_offset)?;
Some(prefix.trim_end_matches(' ').to_owned())
}
#[expect(
clippy::too_many_arguments,
reason = "a small private emit helper; bundling these into a struct would obscure the call"
)]
fn emit_one(
diag: &mut rustc_errors::Diag<'_, ()>,
span: Span,
token: &str,
url: &str,
target_label: &str,
is_doc: bool,
doc_form: DocForm,
plain_form: PlainForm,
ref_site: Option<&RefDefinitionSite>,
) {
if is_doc {
if let (DocForm::Reference, Some(site)) = (doc_form, ref_site) {
let definition = format!(
"\n{prefix}\n{prefix} [{token}]: {url}",
prefix = site.doc_prefix,
);
diag.multipart_suggestion(
format!(
"use a reference-style markdown link to the {target_label} \
(its `[#N]: URL` definition is appended to the doc block)",
),
vec![
(span, render_doc_suggestion(DocForm::Reference, token, url)),
(site.block_end, definition),
],
Applicability::MaybeIncorrect,
);
return;
}
let message = match doc_form {
DocForm::Inline => format!("use an inline markdown link to the {target_label}"),
DocForm::Reference => format!(
"use a reference-style markdown link to the {target_label} \
(ensure a `[#N]: URL` definition exists in the doc block)",
),
DocForm::BareUrl => format!("substitute the {target_label} URL"),
DocForm::BracketedUrl => {
format!("substitute the {target_label} URL wrapped in `<...>`")
}
};
diag.span_suggestion(
span,
message,
render_doc_suggestion(doc_form, token, url),
Applicability::MaybeIncorrect,
);
} else {
let message = match plain_form {
PlainForm::BareUrl => format!("substitute the {target_label} URL"),
PlainForm::BracketedUrl => {
format!("substitute the {target_label} URL wrapped in `<...>`")
}
};
diag.span_suggestion(
span,
message,
render_plain_suggestion(plain_form, url),
Applicability::MaybeIncorrect,
);
}
}
fn render_doc_suggestion(form: DocForm, token: &str, url: &str) -> String {
match form {
DocForm::Inline => format!("[{token}]({url})"),
DocForm::Reference => format!("[{token}]"),
DocForm::BareUrl => url.to_owned(),
DocForm::BracketedUrl => format!("<{url}>"),
}
}
fn render_plain_suggestion(form: PlainForm, url: &str) -> String {
match form {
PlainForm::BareUrl => url.to_owned(),
PlainForm::BracketedUrl => format!("<{url}>"),
}
}
#[cfg(test)]
mod tests;