diff --git a/crates/ruff_db/src/diagnostic/mod.rs b/crates/ruff_db/src/diagnostic/mod.rs index d810a2d431e83..f93aa608acc58 100644 --- a/crates/ruff_db/src/diagnostic/mod.rs +++ b/crates/ruff_db/src/diagnostic/mod.rs @@ -1382,6 +1382,18 @@ impl Display for SubDiagnosticSeverity { } } +/// Controls whether colored diagnostic output includes hyperlinks. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum HyperlinkMode { + /// Detect hyperlink support from the environment. + #[default] + Auto, + /// Always emit hyperlinks. + Always, + /// Never emit hyperlinks. + Never, +} + /// Configuration for rendering diagnostics. #[derive(Clone, Debug)] pub struct DisplayDiagnosticConfig { @@ -1395,6 +1407,10 @@ pub struct DisplayDiagnosticConfig { /// /// Disabled by default. color: bool, + /// Whether to emit hyperlinks in colored diagnostic output. + /// + /// By default, hyperlink support is detected from the environment. + hyperlinks: HyperlinkMode, /// Whether to anonymize line numbers in full diagnostic output. /// /// Disabled by default. @@ -1438,6 +1454,7 @@ impl DisplayDiagnosticConfig { program, format: DiagnosticFormat::default(), color: false, + hyperlinks: HyperlinkMode::Auto, anonymized_line_numbers: false, context: 2, merge_window: 2, @@ -1460,6 +1477,14 @@ impl DisplayDiagnosticConfig { DisplayDiagnosticConfig { color: yes, ..self } } + /// Configures hyperlink rendering for colored diagnostic output. + pub fn hyperlinks(self, mode: HyperlinkMode) -> DisplayDiagnosticConfig { + DisplayDiagnosticConfig { + hyperlinks: mode, + ..self + } + } + /// Whether to anonymize line numbers in full diagnostic output. pub fn anonymized_line_numbers(self, yes: bool) -> DisplayDiagnosticConfig { DisplayDiagnosticConfig { diff --git a/crates/ruff_db/src/diagnostic/render/concise.rs b/crates/ruff_db/src/diagnostic/render/concise.rs index 79a83952ef3aa..2fa922a90011c 100644 --- a/crates/ruff_db/src/diagnostic/render/concise.rs +++ b/crates/ruff_db/src/diagnostic/render/concise.rs @@ -21,7 +21,7 @@ impl<'a> ConciseRenderer<'a> { diagnostics: &[Diagnostic], ) -> std::fmt::Result { let stylesheet = if self.config.color { - DiagnosticStylesheet::styled() + DiagnosticStylesheet::styled().hyperlinks(self.config.hyperlinks) } else { DiagnosticStylesheet::plain() }; diff --git a/crates/ruff_db/src/diagnostic/render/full.rs b/crates/ruff_db/src/diagnostic/render/full.rs index 9280a7ade7342..45406fe23196d 100644 --- a/crates/ruff_db/src/diagnostic/render/full.rs +++ b/crates/ruff_db/src/diagnostic/render/full.rs @@ -29,7 +29,7 @@ impl<'a> FullRenderer<'a> { diagnostics: &[Diagnostic], ) -> std::fmt::Result { let stylesheet = if self.config.color { - DiagnosticStylesheet::styled() + DiagnosticStylesheet::styled().hyperlinks(self.config.hyperlinks) } else { DiagnosticStylesheet::plain() }; diff --git a/crates/ruff_db/src/diagnostic/stylesheet.rs b/crates/ruff_db/src/diagnostic/stylesheet.rs index c86ad430b6244..f0e13d0cd42ca 100644 --- a/crates/ruff_db/src/diagnostic/stylesheet.rs +++ b/crates/ruff_db/src/diagnostic/stylesheet.rs @@ -1,6 +1,8 @@ use anstyle::{AnsiColor, Effects, Style}; use std::fmt::Formatter; +use crate::diagnostic::HyperlinkMode; + pub(super) const fn fmt_styled<'a, T>( content: T, style: anstyle::Style, @@ -118,6 +120,15 @@ impl DiagnosticStylesheet { } } + pub(super) fn hyperlinks(mut self, mode: HyperlinkMode) -> Self { + match mode { + HyperlinkMode::Auto => {} + HyperlinkMode::Always => self.hyperlink = true, + HyperlinkMode::Never => self.hyperlink = false, + } + self + } + pub fn plain() -> Self { Self { error: Style::new(), diff --git a/crates/ty_server/src/capabilities.rs b/crates/ty_server/src/capabilities.rs index 361e227924cde..5b0a838622457 100644 --- a/crates/ty_server/src/capabilities.rs +++ b/crates/ty_server/src/capabilities.rs @@ -258,7 +258,7 @@ impl ResolvedClientCapabilities { if client_capabilities .experimental .as_ref() - // Protocol: crates/ty_server/README.md#full-diagnostic-output + // Protocol: https://docs.astral.sh/ty/features/language-server/#full-diagnostic-output .and_then(|experimental| experimental.get("fullDiagnosticOutput")) .and_then(serde_json::Value::as_bool) .unwrap_or_default() diff --git a/crates/ty_server/src/server/api/diagnostics.rs b/crates/ty_server/src/server/api/diagnostics.rs index 2654109f6a343..6c1236b2c7c76 100644 --- a/crates/ty_server/src/server/api/diagnostics.rs +++ b/crates/ty_server/src/server/api/diagnostics.rs @@ -12,7 +12,9 @@ use ruff_text_size::Ranged; use rustc_hash::{FxHashMap, FxHashSet}; use ty_ide::{Hint, hints}; -use ruff_db::diagnostic::{Annotation, DisplayDiagnosticConfig, Severity, SubDiagnostic}; +use ruff_db::diagnostic::{ + Annotation, DisplayDiagnosticConfig, HyperlinkMode, Severity, SubDiagnostic, +}; use ruff_db::files::{File, FileRange}; use ruff_db::source::source_text; use ruff_db::system::SystemPathBuf; @@ -668,7 +670,14 @@ impl DiagnosticData { rendered: diagnostic .display( &(db as &dyn ruff_db::Db), - &DisplayDiagnosticConfig::new("ty").color(false), + &DisplayDiagnosticConfig::new("ty") + .color(true) + // The styled renderer can enable OSC-8 hyperlinks based on the process + // environment, even though this output is sent over LSP rather than to a + // terminal. The ANSI parser used by ty-vscode does not strip OSC-8 + // sequences, so their escape codes would appear in the virtual diagnostic + // document. + .hyperlinks(HyperlinkMode::Never), ) .to_string(), diagnostic_id: diagnostic.id().to_string(), diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__full_diagnostic_output.snap b/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__full_diagnostic_output.snap index 01097162951da..02d34942a05c6 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__full_diagnostic_output.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__publish_diagnostics__full_diagnostic_output.snap @@ -69,7 +69,7 @@ PublishDiagnosticsParams { data: Some( Object { "diagnostic_id": String("invalid-return-type"), - "rendered": String("error[invalid-return-type]: Return type does not match returned value\n --> src/foo.py:1:14\n |\n1 | def foo() -> str:\n | --- Expected `str` because of return type\n2 | return 42\n | ^^ expected `str`, found `Literal[42]`\n |\n\n"), + "rendered": String("\u{1b}[1m\u{1b}[91merror[invalid-return-type]\u{1b}[0m: \u{1b}[1mReturn type does not match returned value\u{1b}[0m\n \u{1b}[1m\u{1b}[94m-->\u{1b}[0m src/foo.py:1:14\n \u{1b}[1m\u{1b}[94m|\u{1b}[0m\n\u{1b}[1m\u{1b}[94m1 |\u{1b}[0m def foo() -> str:\n \u{1b}[1m\u{1b}[94m|\u{1b}[0m \u{1b}[1m\u{1b}[33m---\u{1b}[0m \u{1b}[1m\u{1b}[33mExpected `str` because of return type\u{1b}[0m\n\u{1b}[1m\u{1b}[94m2 |\u{1b}[0m return 42\n \u{1b}[1m\u{1b}[94m|\u{1b}[0m \u{1b}[1m\u{1b}[91m^^\u{1b}[0m \u{1b}[1m\u{1b}[91mexpected `str`, found `Literal[42]`\u{1b}[0m\n \u{1b}[1m\u{1b}[94m|\u{1b}[0m\n\n"), }, ), }, diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__pull_diagnostics__document_diagnostic_caching_rendered_source_after.snap b/crates/ty_server/tests/e2e/snapshots/e2e__pull_diagnostics__document_diagnostic_caching_rendered_source_after.snap index a98b655898f18..ff9985b20df9b 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__pull_diagnostics__document_diagnostic_caching_rendered_source_after.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__pull_diagnostics__document_diagnostic_caching_rendered_source_after.snap @@ -61,7 +61,7 @@ RelatedFullDocumentDiagnosticReport( data: Some( Object { "diagnostic_id": String("invalid-return-type"), - "rendered": String("error[invalid-return-type]: Return type does not match returned value\n --> src/foo.py:1:14\n |\n1 | def foo() -> str:\n | --- Expected `str` because of return type\n2 | return 42 # after!\n | ^^ expected `str`, found `Literal[42]`\n |\n\n"), + "rendered": String("\u{1b}[1m\u{1b}[91merror[invalid-return-type]\u{1b}[0m: \u{1b}[1mReturn type does not match returned value\u{1b}[0m\n \u{1b}[1m\u{1b}[94m-->\u{1b}[0m src/foo.py:1:14\n \u{1b}[1m\u{1b}[94m|\u{1b}[0m\n\u{1b}[1m\u{1b}[94m1 |\u{1b}[0m def foo() -> str:\n \u{1b}[1m\u{1b}[94m|\u{1b}[0m \u{1b}[1m\u{1b}[33m---\u{1b}[0m \u{1b}[1m\u{1b}[33mExpected `str` because of return type\u{1b}[0m\n\u{1b}[1m\u{1b}[94m2 |\u{1b}[0m return 42 # after!\n \u{1b}[1m\u{1b}[94m|\u{1b}[0m \u{1b}[1m\u{1b}[91m^^\u{1b}[0m \u{1b}[1m\u{1b}[91mexpected `str`, found `Literal[42]`\u{1b}[0m\n \u{1b}[1m\u{1b}[94m|\u{1b}[0m\n\n"), }, ), }, diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__pull_diagnostics__document_diagnostic_caching_rendered_source_before.snap b/crates/ty_server/tests/e2e/snapshots/e2e__pull_diagnostics__document_diagnostic_caching_rendered_source_before.snap index 13c1227d07fa1..81151c4ae34e1 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__pull_diagnostics__document_diagnostic_caching_rendered_source_before.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__pull_diagnostics__document_diagnostic_caching_rendered_source_before.snap @@ -61,7 +61,7 @@ RelatedFullDocumentDiagnosticReport( data: Some( Object { "diagnostic_id": String("invalid-return-type"), - "rendered": String("error[invalid-return-type]: Return type does not match returned value\n --> src/foo.py:1:14\n |\n1 | def foo() -> str:\n | --- Expected `str` because of return type\n2 | return 42 # before\n | ^^ expected `str`, found `Literal[42]`\n |\n\n"), + "rendered": String("\u{1b}[1m\u{1b}[91merror[invalid-return-type]\u{1b}[0m: \u{1b}[1mReturn type does not match returned value\u{1b}[0m\n \u{1b}[1m\u{1b}[94m-->\u{1b}[0m src/foo.py:1:14\n \u{1b}[1m\u{1b}[94m|\u{1b}[0m\n\u{1b}[1m\u{1b}[94m1 |\u{1b}[0m def foo() -> str:\n \u{1b}[1m\u{1b}[94m|\u{1b}[0m \u{1b}[1m\u{1b}[33m---\u{1b}[0m \u{1b}[1m\u{1b}[33mExpected `str` because of return type\u{1b}[0m\n\u{1b}[1m\u{1b}[94m2 |\u{1b}[0m return 42 # before\n \u{1b}[1m\u{1b}[94m|\u{1b}[0m \u{1b}[1m\u{1b}[91m^^\u{1b}[0m \u{1b}[1m\u{1b}[91mexpected `str`, found `Literal[42]`\u{1b}[0m\n \u{1b}[1m\u{1b}[94m|\u{1b}[0m\n\n"), }, ), },