Set the diagnostic URL for lint errors (#21514)

Summary
--

This PR wires up the `Diagnostic::set_documentation_url` method from
#21502 to Ruff's lint diagnostics. This enables the links for the full
and concise output formats without any other changes.

I considered also including the URLs for the grouped and pylint output
formats, but the grouped format is still in `ruff_linter` instead of
`ruff_db`, so we'd have to export some additional functionality to wire
it up with `fmt_with_hyperlink`; and the pylint format doesn't currently
render with color, so I think it might actually be machine readable
rather than human readable?

The other ouput formats (json, json-lines, junit, github, gitlab,
rdjson, azure, sarif) seem more clearly not to need the links.

Test Plan
--

I guess you can't see my cursor or the browser opening, but it works for
lint rules, which have links, and doesn't include a link for syntax
errors, which don't have valid links.


![out](https://github.com/user-attachments/assets/a520c7f9-6d7b-4e5f-a1a9-3c5e21a51d3c)
This commit is contained in:
Brent Westbrook 2025-11-18 13:34:50 -05:00 committed by GitHub
parent 62343a101a
commit 0645418f00
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 36 additions and 33 deletions

View File

@ -452,28 +452,6 @@ impl Diagnostic {
.map(|sub| sub.inner.message.as_str()) .map(|sub| sub.inner.message.as_str())
} }
/// Returns the URL for the rule documentation, if it exists.
pub fn to_ruff_url(&self) -> Option<String> {
match self.id() {
DiagnosticId::Panic
| DiagnosticId::Io
| DiagnosticId::InvalidSyntax
| DiagnosticId::RevealedType
| DiagnosticId::UnknownRule
| DiagnosticId::InvalidGlob
| DiagnosticId::EmptyInclude
| DiagnosticId::UnnecessaryOverridesSection
| DiagnosticId::UselessOverridesSection
| DiagnosticId::DeprecatedSetting
| DiagnosticId::Unformatted
| DiagnosticId::InvalidCliOption
| DiagnosticId::InternalError => None,
DiagnosticId::Lint(lint_name) => {
Some(format!("{}/rules/{lint_name}", env!("CARGO_PKG_HOMEPAGE")))
}
}
}
/// Returns the filename for the message. /// Returns the filename for the message.
/// ///
/// Panics if the diagnostic has no primary span, or if its file is not a `SourceFile`. /// Panics if the diagnostic has no primary span, or if its file is not a `SourceFile`.

View File

@ -2881,6 +2881,12 @@ watermelon
self.diag.help(message); self.diag.help(message);
self self
} }
/// Set the documentation URL for the diagnostic.
pub(super) fn documentation_url(mut self, url: impl Into<String>) -> DiagnosticBuilder<'e> {
self.diag.set_documentation_url(Some(url.into()));
self
}
} }
/// A helper builder for tersely populating a `SubDiagnostic`. /// A helper builder for tersely populating a `SubDiagnostic`.
@ -2995,6 +3001,7 @@ def fibonacci(n):
TextSize::from(10), TextSize::from(10),
)))) ))))
.noqa_offset(TextSize::from(7)) .noqa_offset(TextSize::from(7))
.documentation_url("https://docs.astral.sh/ruff/rules/unused-import")
.build(), .build(),
env.builder( env.builder(
"unused-variable", "unused-variable",
@ -3009,11 +3016,13 @@ def fibonacci(n):
TextSize::from(99), TextSize::from(99),
))) )))
.noqa_offset(TextSize::from(94)) .noqa_offset(TextSize::from(94))
.documentation_url("https://docs.astral.sh/ruff/rules/unused-variable")
.build(), .build(),
env.builder("undefined-name", Severity::Error, "Undefined name `a`") env.builder("undefined-name", Severity::Error, "Undefined name `a`")
.primary("undef.py", "1:3", "1:4", "") .primary("undef.py", "1:3", "1:4", "")
.secondary_code("F821") .secondary_code("F821")
.noqa_offset(TextSize::from(3)) .noqa_offset(TextSize::from(3))
.documentation_url("https://docs.astral.sh/ruff/rules/undefined-name")
.build(), .build(),
]; ];
@ -3128,6 +3137,7 @@ if call(foo
TextSize::from(19), TextSize::from(19),
)))) ))))
.noqa_offset(TextSize::from(16)) .noqa_offset(TextSize::from(16))
.documentation_url("https://docs.astral.sh/ruff/rules/unused-import")
.build(), .build(),
env.builder( env.builder(
"unused-import", "unused-import",
@ -3142,6 +3152,7 @@ if call(foo
TextSize::from(40), TextSize::from(40),
)))) ))))
.noqa_offset(TextSize::from(35)) .noqa_offset(TextSize::from(35))
.documentation_url("https://docs.astral.sh/ruff/rules/unused-import")
.build(), .build(),
env.builder( env.builder(
"unused-variable", "unused-variable",
@ -3156,6 +3167,7 @@ if call(foo
TextSize::from(104), TextSize::from(104),
)))) ))))
.noqa_offset(TextSize::from(98)) .noqa_offset(TextSize::from(98))
.documentation_url("https://docs.astral.sh/ruff/rules/unused-variable")
.build(), .build(),
]; ];

View File

@ -100,7 +100,7 @@ pub(super) fn diagnostic_to_json<'a>(
if config.preview { if config.preview {
JsonDiagnostic { JsonDiagnostic {
code: diagnostic.secondary_code_or_id(), code: diagnostic.secondary_code_or_id(),
url: diagnostic.to_ruff_url(), url: diagnostic.documentation_url(),
message: diagnostic.body(), message: diagnostic.body(),
fix, fix,
cell: notebook_cell_index, cell: notebook_cell_index,
@ -112,7 +112,7 @@ pub(super) fn diagnostic_to_json<'a>(
} else { } else {
JsonDiagnostic { JsonDiagnostic {
code: diagnostic.secondary_code_or_id(), code: diagnostic.secondary_code_or_id(),
url: diagnostic.to_ruff_url(), url: diagnostic.documentation_url(),
message: diagnostic.body(), message: diagnostic.body(),
fix, fix,
cell: notebook_cell_index, cell: notebook_cell_index,
@ -228,7 +228,7 @@ pub(crate) struct JsonDiagnostic<'a> {
location: Option<JsonLocation>, location: Option<JsonLocation>,
message: &'a str, message: &'a str,
noqa_row: Option<OneIndexed>, noqa_row: Option<OneIndexed>,
url: Option<String>, url: Option<&'a str>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -294,7 +294,10 @@ mod tests {
env.format(DiagnosticFormat::Json); env.format(DiagnosticFormat::Json);
env.preview(false); env.preview(false);
let diag = env.err().build(); let diag = env
.err()
.documentation_url("https://docs.astral.sh/ruff/rules/test-diagnostic")
.build();
insta::assert_snapshot!( insta::assert_snapshot!(
env.render(&diag), env.render(&diag),
@ -328,7 +331,10 @@ mod tests {
env.format(DiagnosticFormat::Json); env.format(DiagnosticFormat::Json);
env.preview(true); env.preview(true);
let diag = env.err().build(); let diag = env
.err()
.documentation_url("https://docs.astral.sh/ruff/rules/test-diagnostic")
.build();
insta::assert_snapshot!( insta::assert_snapshot!(
env.render(&diag), env.render(&diag),

View File

@ -82,7 +82,7 @@ fn diagnostic_to_rdjson<'a>(
value: diagnostic value: diagnostic
.secondary_code() .secondary_code()
.map_or_else(|| diagnostic.name(), |code| code.as_str()), .map_or_else(|| diagnostic.name(), |code| code.as_str()),
url: diagnostic.to_ruff_url(), url: diagnostic.documentation_url(),
}, },
suggestions: rdjson_suggestions( suggestions: rdjson_suggestions(
edits, edits,
@ -182,7 +182,7 @@ impl RdjsonRange {
#[derive(Serialize)] #[derive(Serialize)]
struct RdjsonCode<'a> { struct RdjsonCode<'a> {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
url: Option<String>, url: Option<&'a str>,
value: &'a str, value: &'a str,
} }
@ -217,7 +217,10 @@ mod tests {
env.format(DiagnosticFormat::Rdjson); env.format(DiagnosticFormat::Rdjson);
env.preview(false); env.preview(false);
let diag = env.err().build(); let diag = env
.err()
.documentation_url("https://docs.astral.sh/ruff/rules/test-diagnostic")
.build();
insta::assert_snapshot!(env.render(&diag)); insta::assert_snapshot!(env.render(&diag));
} }
@ -228,7 +231,10 @@ mod tests {
env.format(DiagnosticFormat::Rdjson); env.format(DiagnosticFormat::Rdjson);
env.preview(true); env.preview(true);
let diag = env.err().build(); let diag = env
.err()
.documentation_url("https://docs.astral.sh/ruff/rules/test-diagnostic")
.build();
insta::assert_snapshot!(env.render(&diag)); insta::assert_snapshot!(env.render(&diag));
} }

View File

@ -125,6 +125,7 @@ where
} }
diagnostic.set_secondary_code(SecondaryCode::new(rule.noqa_code().to_string())); diagnostic.set_secondary_code(SecondaryCode::new(rule.noqa_code().to_string()));
diagnostic.set_documentation_url(rule.url());
diagnostic diagnostic
} }

View File

@ -301,9 +301,9 @@ fn to_lsp_diagnostic(
severity, severity,
tags, tags,
code, code,
code_description: diagnostic.to_ruff_url().and_then(|url| { code_description: diagnostic.documentation_url().and_then(|url| {
Some(lsp_types::CodeDescription { Some(lsp_types::CodeDescription {
href: lsp_types::Url::parse(&url).ok()?, href: lsp_types::Url::parse(url).ok()?,
}) })
}), }),
source: Some(DIAGNOSTIC_NAME.into()), source: Some(DIAGNOSTIC_NAME.into()),