diff --git a/crates/ruff_db/src/diagnostic/mod.rs b/crates/ruff_db/src/diagnostic/mod.rs index 7adfe2fc9f..1c08c6781f 100644 --- a/crates/ruff_db/src/diagnostic/mod.rs +++ b/crates/ruff_db/src/diagnostic/mod.rs @@ -21,7 +21,7 @@ mod stylesheet; /// characteristics in the inputs given to the tool. Typically, but not always, /// a characteristic is a deficiency. An example of a characteristic that is /// _not_ a deficiency is the `reveal_type` diagnostic for our type checker. -#[derive(Debug, Clone, Eq, PartialEq, get_size2::GetSize)] +#[derive(Debug, Clone, Eq, PartialEq, Hash, get_size2::GetSize)] pub struct Diagnostic { /// The actual diagnostic. /// @@ -479,7 +479,7 @@ impl Diagnostic { } } -#[derive(Debug, Clone, Eq, PartialEq, get_size2::GetSize)] +#[derive(Debug, Clone, Eq, PartialEq, Hash, get_size2::GetSize)] struct DiagnosticInner { id: DiagnosticId, severity: Severity, @@ -555,7 +555,7 @@ impl Eq for RenderingSortKey<'_> {} /// Currently, the order in which sub-diagnostics are rendered relative to one /// another (for a single parent diagnostic) is the order in which they were /// attached to the diagnostic. -#[derive(Debug, Clone, Eq, PartialEq, get_size2::GetSize)] +#[derive(Debug, Clone, Eq, PartialEq, Hash, get_size2::GetSize)] pub struct SubDiagnostic { /// Like with `Diagnostic`, we box the `SubDiagnostic` to make it /// pointer-sized. @@ -659,7 +659,7 @@ impl SubDiagnostic { } } -#[derive(Debug, Clone, Eq, PartialEq, get_size2::GetSize)] +#[derive(Debug, Clone, Eq, PartialEq, Hash, get_size2::GetSize)] struct SubDiagnosticInner { severity: SubDiagnosticSeverity, message: DiagnosticMessage, @@ -687,7 +687,7 @@ struct SubDiagnosticInner { /// /// Messages attached to annotations should also be as brief and specific as /// possible. Long messages could negative impact the quality of rendering. -#[derive(Debug, Clone, Eq, PartialEq, get_size2::GetSize)] +#[derive(Debug, Clone, Eq, PartialEq, Hash, get_size2::GetSize)] pub struct Annotation { /// The span of this annotation, corresponding to some subsequence of the /// user's input that we want to highlight. @@ -807,7 +807,7 @@ impl Annotation { /// /// These tags are used to provide additional information about the annotation. /// and are passed through to the language server protocol. -#[derive(Debug, Clone, Eq, PartialEq, get_size2::GetSize)] +#[derive(Debug, Clone, Eq, PartialEq, Hash, get_size2::GetSize)] pub enum DiagnosticTag { /// Unused or unnecessary code. Used for unused parameters, unreachable code, etc. Unnecessary, @@ -1016,7 +1016,7 @@ impl std::fmt::Display for DiagnosticId { /// /// This enum presents a unified interface to these two types for the sake of creating [`Span`]s and /// emitting diagnostics from both ty and ruff. -#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, get_size2::GetSize)] pub enum UnifiedFile { Ty(File), Ruff(SourceFile), @@ -1080,7 +1080,7 @@ impl DiagnosticSource { /// It consists of a `File` and an optional range into that file. When the /// range isn't present, it semantically implies that the diagnostic refers to /// the entire file. For example, when the file should be executable but isn't. -#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, get_size2::GetSize)] pub struct Span { file: UnifiedFile, range: Option, @@ -1158,7 +1158,7 @@ impl From for Span { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, get_size2::GetSize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Hash, get_size2::GetSize)] pub enum Severity { Info, Warning, @@ -1193,7 +1193,7 @@ impl Severity { /// This type only exists to add an additional `Help` severity that isn't present in `Severity` or /// used for main diagnostics. If we want to add `Severity::Help` in the future, this type could be /// deleted and the two combined again. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, get_size2::GetSize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Hash, get_size2::GetSize)] pub enum SubDiagnosticSeverity { Help, Info, @@ -1428,7 +1428,7 @@ impl std::fmt::Display for ConciseMessage<'_> { /// In most cases, callers shouldn't need to use this. Instead, there is /// a blanket trait implementation for `IntoDiagnosticMessage` for /// anything that implements `std::fmt::Display`. -#[derive(Clone, Debug, Eq, PartialEq, get_size2::GetSize)] +#[derive(Clone, Debug, Eq, PartialEq, Hash, get_size2::GetSize)] pub struct DiagnosticMessage(Box); impl DiagnosticMessage { diff --git a/crates/ruff_diagnostics/src/fix.rs b/crates/ruff_diagnostics/src/fix.rs index 2fbc152253..79cd3e6dff 100644 --- a/crates/ruff_diagnostics/src/fix.rs +++ b/crates/ruff_diagnostics/src/fix.rs @@ -43,7 +43,7 @@ pub enum IsolationLevel { } /// A collection of [`Edit`] elements to be applied to a source file. -#[derive(Debug, PartialEq, Eq, Clone, get_size2::GetSize)] +#[derive(Debug, PartialEq, Eq, Clone, Hash, get_size2::GetSize)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct Fix { /// The [`Edit`] elements to be applied, sorted by [`Edit::start`] in ascending order. diff --git a/crates/ruff_source_file/src/lib.rs b/crates/ruff_source_file/src/lib.rs index c7c652bc64..fdc6cfa601 100644 --- a/crates/ruff_source_file/src/lib.rs +++ b/crates/ruff_source_file/src/lib.rs @@ -1,5 +1,6 @@ use std::cmp::Ordering; use std::fmt::{Debug, Display, Formatter}; +use std::hash::Hash; use std::sync::{Arc, OnceLock}; #[cfg(feature = "serde")] @@ -162,7 +163,7 @@ impl SourceFileBuilder { /// A source file that is identified by its name. Optionally stores the source code and [`LineIndex`]. /// /// Cloning a [`SourceFile`] is cheap, because it only requires bumping a reference count. -#[derive(Clone, Eq, PartialEq)] +#[derive(Clone, Eq, PartialEq, Hash)] #[cfg_attr(feature = "get-size", derive(get_size2::GetSize))] pub struct SourceFile { inner: Arc, @@ -241,6 +242,13 @@ impl PartialEq for SourceFileInner { impl Eq for SourceFileInner {} +impl Hash for SourceFileInner { + fn hash(&self, state: &mut H) { + self.name.hash(state); + self.code.hash(state); + } +} + /// The line and column of an offset in a source file. /// /// See [`LineIndex::line_column`] for more information. diff --git a/crates/ty_server/src/lib.rs b/crates/ty_server/src/lib.rs index 8c3d4626ae..9b3d01964d 100644 --- a/crates/ty_server/src/lib.rs +++ b/crates/ty_server/src/lib.rs @@ -6,7 +6,7 @@ use ruff_db::system::{OsSystem, SystemPathBuf}; pub use crate::logging::{LogLevel, init_logging}; pub use crate::server::Server; -pub use crate::session::ClientOptions; +pub use crate::session::{ClientOptions, DiagnosticMode}; pub use document::{NotebookDocument, PositionEncoding, TextDocument}; pub(crate) use session::{DocumentQuery, Session}; diff --git a/crates/ty_server/src/logging.rs b/crates/ty_server/src/logging.rs index a571087340..467455cdbb 100644 --- a/crates/ty_server/src/logging.rs +++ b/crates/ty_server/src/logging.rs @@ -98,7 +98,10 @@ impl tracing_subscriber::layer::Filter for LogLevelFilter { meta: &tracing::Metadata<'_>, _: &tracing_subscriber::layer::Context<'_, S>, ) -> bool { - let filter = if meta.target().starts_with("ty") || meta.target().starts_with("ruff") { + let filter = if meta.target().starts_with("ty") + || meta.target().starts_with("ruff") + || meta.target().starts_with("e2e") + { self.filter.trace_level() } else { tracing::Level::WARN diff --git a/crates/ty_server/src/server/api/diagnostics.rs b/crates/ty_server/src/server/api/diagnostics.rs index 11e39796ad..10229b1d0e 100644 --- a/crates/ty_server/src/server/api/diagnostics.rs +++ b/crates/ty_server/src/server/api/diagnostics.rs @@ -1,3 +1,5 @@ +use std::hash::{DefaultHasher, Hash as _, Hasher as _}; + use lsp_types::notification::PublishDiagnostics; use lsp_types::{ CodeDescription, Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, DiagnosticTag, @@ -15,17 +17,77 @@ use crate::document::{DocumentKey, FileRangeExt, ToRangeExt}; use crate::session::DocumentSnapshot; use crate::session::client::Client; use crate::system::{AnySystemPath, file_to_url}; -use crate::{PositionEncoding, Session}; +use crate::{DocumentQuery, PositionEncoding, Session}; + +pub(super) struct Diagnostics<'a> { + items: Vec, + encoding: PositionEncoding, + document: &'a DocumentQuery, +} + +impl Diagnostics<'_> { + pub(super) fn result_id_from_hash(diagnostics: &[ruff_db::diagnostic::Diagnostic]) -> String { + // Generate result ID based on raw diagnostic content only + let mut hasher = DefaultHasher::new(); + + // Hash the length first to ensure different numbers of diagnostics produce different hashes + diagnostics.hash(&mut hasher); + + format!("{:x}", hasher.finish()) + } + + pub(super) fn result_id(&self) -> String { + Self::result_id_from_hash(&self.items) + } + + pub(super) fn to_lsp_diagnostics(&self, db: &ProjectDatabase) -> LspDiagnostics { + if let Some(notebook) = self.document.as_notebook() { + let mut cell_diagnostics: FxHashMap> = FxHashMap::default(); + + // Populates all relevant URLs with an empty diagnostic list. This ensures that documents + // without diagnostics still get updated. + for cell_url in notebook.cell_urls() { + cell_diagnostics.entry(cell_url.clone()).or_default(); + } + + for (cell_index, diagnostic) in self.items.iter().map(|diagnostic| { + ( + // TODO: Use the cell index instead using `SourceKind` + usize::default(), + to_lsp_diagnostic(db, diagnostic, self.encoding), + ) + }) { + let Some(cell_uri) = notebook.cell_uri_by_index(cell_index) else { + tracing::warn!("Unable to find notebook cell at index {cell_index}"); + continue; + }; + cell_diagnostics + .entry(cell_uri.clone()) + .or_default() + .push(diagnostic); + } + + LspDiagnostics::NotebookDocument(cell_diagnostics) + } else { + LspDiagnostics::TextDocument( + self.items + .iter() + .map(|diagnostic| to_lsp_diagnostic(db, diagnostic, self.encoding)) + .collect(), + ) + } + } +} /// Represents the diagnostics for a text document or a notebook document. -pub(super) enum Diagnostics { +pub(super) enum LspDiagnostics { TextDocument(Vec), /// A map of cell URLs to the diagnostics for that cell. NotebookDocument(FxHashMap>), } -impl Diagnostics { +impl LspDiagnostics { /// Returns the diagnostics for a text document. /// /// # Panics @@ -33,8 +95,8 @@ impl Diagnostics { /// Panics if the diagnostics are for a notebook document. pub(super) fn expect_text_document(self) -> Vec { match self { - Diagnostics::TextDocument(diagnostics) => diagnostics, - Diagnostics::NotebookDocument(_) => { + LspDiagnostics::TextDocument(diagnostics) => diagnostics, + LspDiagnostics::NotebookDocument(_) => { panic!("Expected a text document diagnostics, but got notebook diagnostics") } } @@ -99,11 +161,11 @@ pub(super) fn publish_diagnostics(session: &Session, key: &DocumentKey, client: }); }; - match diagnostics { - Diagnostics::TextDocument(diagnostics) => { + match diagnostics.to_lsp_diagnostics(db) { + LspDiagnostics::TextDocument(diagnostics) => { publish_diagnostics_notification(url, diagnostics); } - Diagnostics::NotebookDocument(cell_diagnostics) => { + LspDiagnostics::NotebookDocument(cell_diagnostics) => { for (cell_url, diagnostics) in cell_diagnostics { publish_diagnostics_notification(cell_url, diagnostics); } @@ -187,10 +249,10 @@ pub(crate) fn publish_settings_diagnostics( } } -pub(super) fn compute_diagnostics( +pub(super) fn compute_diagnostics<'a>( db: &ProjectDatabase, - snapshot: &DocumentSnapshot, -) -> Option { + snapshot: &'a DocumentSnapshot, +) -> Option> { let document = match snapshot.document() { Ok(document) => document, Err(err) => { @@ -206,41 +268,11 @@ pub(super) fn compute_diagnostics( let diagnostics = db.check_file(file); - if let Some(notebook) = document.as_notebook() { - let mut cell_diagnostics: FxHashMap> = FxHashMap::default(); - - // Populates all relevant URLs with an empty diagnostic list. This ensures that documents - // without diagnostics still get updated. - for cell_url in notebook.cell_urls() { - cell_diagnostics.entry(cell_url.clone()).or_default(); - } - - for (cell_index, diagnostic) in diagnostics.iter().map(|diagnostic| { - ( - // TODO: Use the cell index instead using `SourceKind` - usize::default(), - to_lsp_diagnostic(db, diagnostic, snapshot.encoding()), - ) - }) { - let Some(cell_uri) = notebook.cell_uri_by_index(cell_index) else { - tracing::warn!("Unable to find notebook cell at index {cell_index}"); - continue; - }; - cell_diagnostics - .entry(cell_uri.clone()) - .or_default() - .push(diagnostic); - } - - Some(Diagnostics::NotebookDocument(cell_diagnostics)) - } else { - Some(Diagnostics::TextDocument( - diagnostics - .iter() - .map(|diagnostic| to_lsp_diagnostic(db, diagnostic, snapshot.encoding())) - .collect(), - )) - } + Some(Diagnostics { + items: diagnostics, + encoding: snapshot.encoding(), + document, + }) } /// Converts the tool specific [`Diagnostic`][ruff_db::diagnostic::Diagnostic] to an LSP diff --git a/crates/ty_server/src/server/api/requests/diagnostic.rs b/crates/ty_server/src/server/api/requests/diagnostic.rs index b455c3b0a4..6638890416 100644 --- a/crates/ty_server/src/server/api/requests/diagnostic.rs +++ b/crates/ty_server/src/server/api/requests/diagnostic.rs @@ -3,11 +3,12 @@ use std::borrow::Cow; use lsp_types::request::DocumentDiagnosticRequest; use lsp_types::{ DocumentDiagnosticParams, DocumentDiagnosticReport, DocumentDiagnosticReportResult, - FullDocumentDiagnosticReport, RelatedFullDocumentDiagnosticReport, Url, + FullDocumentDiagnosticReport, RelatedFullDocumentDiagnosticReport, + RelatedUnchangedDocumentDiagnosticReport, UnchangedDocumentDiagnosticReport, Url, }; use crate::server::Result; -use crate::server::api::diagnostics::{Diagnostics, compute_diagnostics}; +use crate::server::api::diagnostics::compute_diagnostics; use crate::server::api::traits::{ BackgroundDocumentRequestHandler, RequestHandler, RetriableRequestHandler, }; @@ -30,20 +31,38 @@ impl BackgroundDocumentRequestHandler for DocumentDiagnosticRequestHandler { db: &ProjectDatabase, snapshot: DocumentSnapshot, _client: &Client, - _params: DocumentDiagnosticParams, + params: DocumentDiagnosticParams, ) -> Result { - Ok(DocumentDiagnosticReportResult::Report( + let diagnostics = compute_diagnostics(db, &snapshot); + + let Some(diagnostics) = diagnostics else { + return Ok(DocumentDiagnosticReportResult::Report( + DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport::default()), + )); + }; + + let result_id = diagnostics.result_id(); + + let report = if params.previous_result_id.as_deref() == Some(&result_id) { + DocumentDiagnosticReport::Unchanged(RelatedUnchangedDocumentDiagnosticReport { + related_documents: None, + unchanged_document_diagnostic_report: UnchangedDocumentDiagnosticReport { + result_id, + }, + }) + } else { DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport { related_documents: None, full_document_diagnostic_report: FullDocumentDiagnosticReport { - result_id: None, + result_id: Some(result_id), // SAFETY: Pull diagnostic requests are only called for text documents, not for // notebook documents. - items: compute_diagnostics(db, &snapshot) - .map_or_else(Vec::new, Diagnostics::expect_text_document), + items: diagnostics.to_lsp_diagnostics(db).expect_text_document(), }, - }), - )) + }) + }; + + Ok(DocumentDiagnosticReportResult::Report(report)) } } diff --git a/crates/ty_server/src/server/api/requests/workspace_diagnostic.rs b/crates/ty_server/src/server/api/requests/workspace_diagnostic.rs index 724e8fe353..c3a2b77bd3 100644 --- a/crates/ty_server/src/server/api/requests/workspace_diagnostic.rs +++ b/crates/ty_server/src/server/api/requests/workspace_diagnostic.rs @@ -1,19 +1,20 @@ -use lsp_types::request::WorkspaceDiagnosticRequest; -use lsp_types::{ - FullDocumentDiagnosticReport, Url, WorkspaceDiagnosticParams, WorkspaceDiagnosticReport, - WorkspaceDiagnosticReportResult, WorkspaceDocumentDiagnosticReport, - WorkspaceFullDocumentDiagnosticReport, -}; -use rustc_hash::FxHashMap; +use std::collections::BTreeMap; use crate::server::Result; -use crate::server::api::diagnostics::to_lsp_diagnostic; +use crate::server::api::diagnostics::{Diagnostics, to_lsp_diagnostic}; use crate::server::api::traits::{ BackgroundRequestHandler, RequestHandler, RetriableRequestHandler, }; use crate::session::SessionSnapshot; use crate::session::client::Client; use crate::system::file_to_url; +use lsp_types::request::WorkspaceDiagnosticRequest; +use lsp_types::{ + FullDocumentDiagnosticReport, UnchangedDocumentDiagnosticReport, Url, + WorkspaceDiagnosticParams, WorkspaceDiagnosticReport, WorkspaceDiagnosticReportResult, + WorkspaceDocumentDiagnosticReport, WorkspaceFullDocumentDiagnosticReport, + WorkspaceUnchangedDocumentDiagnosticReport, +}; pub(crate) struct WorkspaceDiagnosticRequestHandler; @@ -25,26 +26,31 @@ impl BackgroundRequestHandler for WorkspaceDiagnosticRequestHandler { fn run( snapshot: SessionSnapshot, _client: &Client, - _params: WorkspaceDiagnosticParams, + params: WorkspaceDiagnosticParams, ) -> Result { let index = snapshot.index(); if !index.global_settings().diagnostic_mode().is_workspace() { - // VS Code sends us the workspace diagnostic request every 2 seconds, so these logs can - // be quite verbose. - tracing::trace!("Workspace diagnostics is disabled; returning empty report"); + tracing::debug!("Workspace diagnostics is disabled; returning empty report"); return Ok(WorkspaceDiagnosticReportResult::Report( WorkspaceDiagnosticReport { items: vec![] }, )); } + // Create a map of previous result IDs for efficient lookup + let mut previous_results: BTreeMap<_, _> = params + .previous_result_ids + .into_iter() + .map(|prev| (prev.uri, prev.value)) + .collect(); + let mut items = Vec::new(); for db in snapshot.projects() { let diagnostics = db.check(); // Group diagnostics by URL - let mut diagnostics_by_url: FxHashMap> = FxHashMap::default(); + let mut diagnostics_by_url: BTreeMap> = BTreeMap::default(); for diagnostic in diagnostics { if let Some(span) = diagnostic.primary_span() { @@ -66,6 +72,23 @@ impl BackgroundRequestHandler for WorkspaceDiagnosticRequestHandler { .ok() .and_then(|key| index.make_document_ref(key).ok()) .map(|doc| i64::from(doc.version())); + let result_id = Diagnostics::result_id_from_hash(&file_diagnostics); + + // Check if this file's diagnostics have changed since the previous request + if let Some(previous_result_id) = previous_results.remove(&url) { + if previous_result_id == result_id { + // Diagnostics haven't changed, return unchanged report + items.push(WorkspaceDocumentDiagnosticReport::Unchanged( + WorkspaceUnchangedDocumentDiagnosticReport { + uri: url, + version, + unchanged_document_diagnostic_report: + UnchangedDocumentDiagnosticReport { result_id }, + }, + )); + continue; + } + } // Convert diagnostics to LSP format let lsp_diagnostics = file_diagnostics @@ -75,13 +98,13 @@ impl BackgroundRequestHandler for WorkspaceDiagnosticRequestHandler { }) .collect::>(); + // Diagnostics have changed or this is the first request, return full report items.push(WorkspaceDocumentDiagnosticReport::Full( WorkspaceFullDocumentDiagnosticReport { uri: url, version, full_document_diagnostic_report: FullDocumentDiagnosticReport { - // TODO: We don't implement result ID caching yet - result_id: None, + result_id: Some(result_id), items: lsp_diagnostics, }, }, @@ -89,6 +112,28 @@ impl BackgroundRequestHandler for WorkspaceDiagnosticRequestHandler { } } + // Handle files that had diagnostics in previous request but no longer have any + // Any remaining entries in previous_results are files that were fixed + for (previous_url, _previous_result_id) in previous_results { + // This file had diagnostics before but doesn't now, so we need to report it as having no diagnostics + let version = index + .key_from_url(previous_url.clone()) + .ok() + .and_then(|key| index.make_document_ref(key).ok()) + .map(|doc| i64::from(doc.version())); + + items.push(WorkspaceDocumentDiagnosticReport::Full( + WorkspaceFullDocumentDiagnosticReport { + uri: previous_url, + version, + full_document_diagnostic_report: FullDocumentDiagnosticReport { + result_id: None, // No result ID needed for empty diagnostics + items: vec![], // No diagnostics + }, + }, + )); + } + Ok(WorkspaceDiagnosticReportResult::Report( WorkspaceDiagnosticReport { items }, )) diff --git a/crates/ty_server/src/session.rs b/crates/ty_server/src/session.rs index 40801ab37a..2fb386aa9f 100644 --- a/crates/ty_server/src/session.rs +++ b/crates/ty_server/src/session.rs @@ -21,8 +21,8 @@ use ty_project::{ChangeResult, Db as _, ProjectDatabase, ProjectMetadata}; pub(crate) use self::capabilities::ResolvedClientCapabilities; pub(crate) use self::index::DocumentQuery; -pub use self::options::ClientOptions; -pub(crate) use self::options::{AllOptions, DiagnosticMode}; +pub(crate) use self::options::AllOptions; +pub use self::options::{ClientOptions, DiagnosticMode}; pub(crate) use self::settings::ClientSettings; use crate::document::{DocumentKey, DocumentVersion, NotebookDocument}; use crate::server::publish_settings_diagnostics; diff --git a/crates/ty_server/src/session/options.rs b/crates/ty_server/src/session/options.rs index 0301720835..c4c0e496c8 100644 --- a/crates/ty_server/src/session/options.rs +++ b/crates/ty_server/src/session/options.rs @@ -65,7 +65,7 @@ pub struct ClientOptions { #[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)] #[cfg_attr(test, derive(PartialEq, Eq))] #[serde(rename_all = "camelCase")] -pub(crate) enum DiagnosticMode { +pub enum DiagnosticMode { /// Check only currently open files. #[default] OpenFilesOnly, @@ -140,6 +140,13 @@ impl ClientOptions { overrides, } } + + /// Create a new `ClientOptions` with the specified diagnostic mode + #[must_use] + pub fn with_diagnostic_mode(mut self, mode: DiagnosticMode) -> Self { + self.diagnostic_mode = Some(mode); + self + } } // TODO(dhruvmanila): We need to mirror the "python.*" namespace on the server side but ideally it diff --git a/crates/ty_server/tests/e2e/main.rs b/crates/ty_server/tests/e2e/main.rs index 6082ad7295..8080bfdcb0 100644 --- a/crates/ty_server/tests/e2e/main.rs +++ b/crates/ty_server/tests/e2e/main.rs @@ -49,20 +49,23 @@ use lsp_types::notification::{ }; use lsp_types::request::{ DocumentDiagnosticRequest, Initialize, Request, Shutdown, WorkspaceConfiguration, + WorkspaceDiagnosticRequest, }; use lsp_types::{ ClientCapabilities, ConfigurationParams, DiagnosticClientCapabilities, DidChangeTextDocumentParams, DidChangeWatchedFilesClientCapabilities, DidChangeWatchedFilesParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams, DocumentDiagnosticParams, DocumentDiagnosticReportResult, FileEvent, InitializeParams, - InitializeResult, InitializedParams, PartialResultParams, PublishDiagnosticsClientCapabilities, - TextDocumentClientCapabilities, TextDocumentContentChangeEvent, TextDocumentIdentifier, - TextDocumentItem, Url, VersionedTextDocumentIdentifier, WorkDoneProgressParams, - WorkspaceClientCapabilities, WorkspaceFolder, + InitializeResult, InitializedParams, PartialResultParams, PreviousResultId, + PublishDiagnosticsClientCapabilities, TextDocumentClientCapabilities, + TextDocumentContentChangeEvent, TextDocumentIdentifier, TextDocumentItem, Url, + VersionedTextDocumentIdentifier, WorkDoneProgressParams, WorkspaceClientCapabilities, + WorkspaceDiagnosticParams, WorkspaceDiagnosticReportResult, WorkspaceFolder, }; use ruff_db::system::{OsSystem, SystemPath, SystemPathBuf, TestSystem}; use rustc_hash::FxHashMap; use serde::de::DeserializeOwned; +use serde_json::json; use tempfile::TempDir; use ty_server::{ClientOptions, LogLevel, Server, init_logging}; @@ -156,6 +159,7 @@ impl TestServer { workspaces: Vec<(WorkspaceFolder, ClientOptions)>, test_context: TestContext, capabilities: ClientCapabilities, + initialization_options: Option, ) -> Result { setup_tracing(); @@ -204,7 +208,7 @@ impl TestServer { workspace_configurations, registered_capabilities: Vec::new(), } - .initialize(workspace_folders, capabilities) + .initialize(workspace_folders, capabilities, initialization_options) } /// Perform LSP initialization handshake @@ -212,13 +216,15 @@ impl TestServer { mut self, workspace_folders: Vec, capabilities: ClientCapabilities, + initialization_options: Option, ) -> Result { let init_params = InitializeParams { capabilities, workspace_folders: Some(workspace_folders), // TODO: This should be configurable by the test server builder. This might not be // required after client settings are implemented in the server. - initialization_options: Some(serde_json::Value::Object(serde_json::Map::new())), + initialization_options: initialization_options + .map(|options| json!({ "settings": options})), ..Default::default() }; @@ -591,7 +597,6 @@ impl TestServer { } /// Send a `textDocument/didChange` notification with the given content changes - #[expect(dead_code)] pub(crate) fn change_text_document( &mut self, path: impl AsRef, @@ -630,19 +635,36 @@ impl TestServer { pub(crate) fn document_diagnostic_request( &mut self, path: impl AsRef, + previous_result_id: Option, ) -> Result { let params = DocumentDiagnosticParams { text_document: TextDocumentIdentifier { uri: self.file_uri(path), }, identifier: Some("ty".to_string()), - previous_result_id: None, + previous_result_id, work_done_progress_params: WorkDoneProgressParams::default(), partial_result_params: PartialResultParams::default(), }; let id = self.send_request::(params); self.await_response::(id) } + + /// Send a `workspace/diagnostic` request with optional previous result IDs. + pub(crate) fn workspace_diagnostic_request( + &mut self, + previous_result_ids: Option>, + ) -> Result { + let params = WorkspaceDiagnosticParams { + identifier: Some("ty".to_string()), + previous_result_ids: previous_result_ids.unwrap_or_default(), + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }; + + let id = self.send_request::(params); + self.await_response::(id) + } } impl fmt::Debug for TestServer { @@ -709,6 +731,7 @@ impl Drop for TestServer { pub(crate) struct TestServerBuilder { test_context: TestContext, workspaces: Vec<(WorkspaceFolder, ClientOptions)>, + initialization_options: Option, client_capabilities: ClientCapabilities, } @@ -735,10 +758,16 @@ impl TestServerBuilder { Ok(Self { workspaces: Vec::new(), test_context: TestContext::new()?, + initialization_options: None, client_capabilities, }) } + pub(crate) fn with_initialization_options(mut self, options: ClientOptions) -> Self { + self.initialization_options = Some(options); + self + } + /// Add a workspace to the test server with the given root path and options. /// /// This option will be used to respond to the `workspace/configuration` request that the @@ -837,7 +866,12 @@ impl TestServerBuilder { /// Build the test server pub(crate) fn build(self) -> Result { - TestServer::new(self.workspaces, self.test_context, self.client_capabilities) + TestServer::new( + self.workspaces, + self.test_context, + self.client_capabilities, + self.initialization_options, + ) } } diff --git a/crates/ty_server/tests/e2e/pull_diagnostics.rs b/crates/ty_server/tests/e2e/pull_diagnostics.rs index cfc803038f..73a1a727ae 100644 --- a/crates/ty_server/tests/e2e/pull_diagnostics.rs +++ b/crates/ty_server/tests/e2e/pull_diagnostics.rs @@ -1,11 +1,16 @@ use anyhow::Result; +use lsp_types::{ + PreviousResultId, WorkspaceDiagnosticReportResult, WorkspaceDocumentDiagnosticReport, +}; use ruff_db::system::SystemPath; -use ty_server::ClientOptions; +use ty_server::{ClientOptions, DiagnosticMode}; use crate::TestServerBuilder; #[test] fn on_did_open() -> Result<()> { + let _filter = filter_result_id(); + let workspace_root = SystemPath::new("src"); let foo = SystemPath::new("src/foo.py"); let foo_content = "\ @@ -21,9 +26,305 @@ def foo() -> str: .wait_until_workspaces_are_initialized()?; server.open_text_document(foo, &foo_content, 1); - let diagnostics = server.document_diagnostic_request(foo)?; + let diagnostics = server.document_diagnostic_request(foo, None)?; insta::assert_debug_snapshot!(diagnostics); Ok(()) } + +#[test] +fn document_diagnostic_caching_unchanged() -> Result<()> { + let _filter = filter_result_id(); + + let workspace_root = SystemPath::new("src"); + let foo = SystemPath::new("src/foo.py"); + let foo_content = "\ +def foo() -> str: + return 42 +"; + + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, ClientOptions::default())? + .with_file(foo, foo_content)? + .enable_pull_diagnostics(true) + .build()? + .wait_until_workspaces_are_initialized()?; + + server.open_text_document(foo, &foo_content, 1); + + // First request with no previous result ID + let first_response = server.document_diagnostic_request(foo, None)?; + + // Extract result ID from first response + let result_id = match &first_response { + lsp_types::DocumentDiagnosticReportResult::Report( + lsp_types::DocumentDiagnosticReport::Full(report), + ) => report + .full_document_diagnostic_report + .result_id + .as_ref() + .expect("First response should have a result ID") + .clone(), + _ => panic!("First response should be a full report"), + }; + + // Second request with the previous result ID - should return Unchanged + let second_response = server.document_diagnostic_request(foo, Some(result_id))?; + + // Verify it's an unchanged report + match second_response { + lsp_types::DocumentDiagnosticReportResult::Report( + lsp_types::DocumentDiagnosticReport::Unchanged(_), + ) => { + // Success - got unchanged report as expected + } + _ => panic!("Expected an unchanged report when diagnostics haven't changed"), + } + + Ok(()) +} + +#[test] +fn document_diagnostic_caching_changed() -> Result<()> { + let _filter = filter_result_id(); + + let workspace_root = SystemPath::new("src"); + let foo = SystemPath::new("src/foo.py"); + let foo_content_v1 = "\ +def foo() -> str: + return 42 +"; + let foo_content_v2 = "\ +def foo() -> str: + return \"fixed\" +"; + + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, ClientOptions::default())? + .with_file(foo, foo_content_v1)? + .enable_pull_diagnostics(true) + .build()? + .wait_until_workspaces_are_initialized()?; + + server.open_text_document(foo, &foo_content_v1, 1); + + // First request with no previous result ID + let first_response = server.document_diagnostic_request(foo, None)?; + + // Extract result ID from first response + let result_id = match &first_response { + lsp_types::DocumentDiagnosticReportResult::Report( + lsp_types::DocumentDiagnosticReport::Full(report), + ) => report + .full_document_diagnostic_report + .result_id + .as_ref() + .expect("First response should have a result ID") + .clone(), + _ => panic!("First response should be a full report"), + }; + + // Change the document to fix the error + server.change_text_document( + foo, + vec![lsp_types::TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: foo_content_v2.to_string(), + }], + 2, + ); + + // Second request with the previous result ID - should return a new full report + let second_response = server.document_diagnostic_request(foo, Some(result_id))?; + + // Verify it's a full report (not unchanged) + match second_response { + lsp_types::DocumentDiagnosticReportResult::Report( + lsp_types::DocumentDiagnosticReport::Full(report), + ) => { + // Should have no diagnostics now + assert_eq!(report.full_document_diagnostic_report.items.len(), 0); + } + _ => panic!("Expected a full report when diagnostics have changed"), + } + + Ok(()) +} + +#[test] +fn workspace_diagnostic_caching() -> Result<()> { + let _filter = filter_result_id(); + + let workspace_root = SystemPath::new("src"); + + // File A: Will have an unchanged diagnostic + let file_a = SystemPath::new("src/unchanged.py"); + let file_a_content = "\ +def foo() -> str: + return 42 # This error will remain the same +"; + + // File B: Initially no error, will get a new error (added diagnostic) + let file_b = SystemPath::new("src/new_error.py"); + let file_b_content_v1 = "\ +def foo() -> int: + return 42 # No error initially +"; + let file_b_content_v2 = "\ +def foo() -> str: + return 42 # Error appears +"; + + // File C: Initially has error, will be fixed (removed diagnostic) + let file_c = SystemPath::new("src/fixed_error.py"); + let file_c_content_v1 = "\ +def foo() -> str: + return 42 # Error initially +"; + let file_c_content_v2 = "\ +def foo() -> str: + return \"fixed\" # Error removed +"; + + // File D: Has error that changes content (changed diagnostic) + let file_d = SystemPath::new("src/changed_error.py"); + let file_d_content_v1 = "\ +def foo() -> str: + return 42 # First error: expected str, got int +"; + let file_d_content_v2 = "\ +def foo() -> int: + return \"hello\" # Different error: expected int, got str +"; + + // File E: Modified but same diagnostic (e.g., new function added but original error remains) + let file_e = SystemPath::new("src/modified_same_error.py"); + let file_e_content_v1 = "\ +def foo() -> str: + return 42 # Error: expected str, got int +"; + let file_e_content_v2 = "\ +def bar() -> int: + return 100 # New function added at the top + +def foo() -> str: + return 42 # Same error: expected str, got int +"; + + let global_options = ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace); + + let mut server = TestServerBuilder::new()? + .with_workspace( + workspace_root, + ClientOptions::default().with_diagnostic_mode(DiagnosticMode::Workspace), + )? + .with_initialization_options(global_options) + .with_file(file_a, file_a_content)? + .with_file(file_b, file_b_content_v1)? + .with_file(file_c, file_c_content_v1)? + .with_file(file_d, file_d_content_v1)? + .with_file(file_e, file_e_content_v1)? + .enable_pull_diagnostics(true) + .build()? + .wait_until_workspaces_are_initialized()?; + + server.open_text_document(file_a, &file_a_content, 1); + + // First request with no previous result IDs + let first_response = server.workspace_diagnostic_request(None)?; + insta::assert_debug_snapshot!("workspace_diagnostic_initial_state", first_response); + + // Extract result IDs from the first response + let previous_result_ids = match first_response { + WorkspaceDiagnosticReportResult::Report(report) => { + report.items.into_iter().filter_map(|item| match item { + WorkspaceDocumentDiagnosticReport::Full(full_report) => { + let result_id = full_report.full_document_diagnostic_report.result_id?; + Some(PreviousResultId { + uri: full_report.uri, + value: result_id, + }) + } + WorkspaceDocumentDiagnosticReport::Unchanged(_) => { + panic!("The first response must be a full report, not unchanged"); + } + }) + } + WorkspaceDiagnosticReportResult::Partial(_) => { + panic!("The first response must be a full report"); + } + } + .collect(); + + // Make changes to files B, C, D, and E (leave A unchanged) + // Need to open files before changing them + server.open_text_document(file_b, &file_b_content_v1, 1); + server.open_text_document(file_c, &file_c_content_v1, 1); + server.open_text_document(file_d, &file_d_content_v1, 1); + server.open_text_document(file_e, &file_e_content_v1, 1); + + // File B: Add a new error + server.change_text_document( + file_b, + vec![lsp_types::TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: file_b_content_v2.to_string(), + }], + 2, + ); + + // File C: Fix the error + server.change_text_document( + file_c, + vec![lsp_types::TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: file_c_content_v2.to_string(), + }], + 2, + ); + + // File D: Change the error + server.change_text_document( + file_d, + vec![lsp_types::TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: file_d_content_v2.to_string(), + }], + 2, + ); + + // File E: Modify the file but keep the same diagnostic + server.change_text_document( + file_e, + vec![lsp_types::TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: file_e_content_v2.to_string(), + }], + 2, + ); + + // Second request with previous result IDs + // Expected results: + // - File A: Unchanged report (diagnostic hasn't changed) + // - File B: Full report (new diagnostic appeared) + // - File C: Full report with empty diagnostics (diagnostic was removed) + // - File D: Full report (diagnostic content changed) + // - File E: Full report (the range changes) + let second_response = server.workspace_diagnostic_request(Some(previous_result_ids))?; + insta::assert_debug_snapshot!("workspace_diagnostic_after_changes", second_response); + + Ok(()) +} + +// Redact result_id values since they are hash-based and non-deterministic +fn filter_result_id() -> insta::internals::SettingsBindDropGuard { + let mut settings = insta::Settings::clone_current(); + settings.add_filter(r#""[a-f0-9]{16}""#, r#""[RESULT_ID]""#); + settings.bind_to_scope() +} diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__pull_diagnostics__on_did_open.snap b/crates/ty_server/tests/e2e/snapshots/e2e__pull_diagnostics__on_did_open.snap index 89c5daba5d..12ade6fda2 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__pull_diagnostics__on_did_open.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__pull_diagnostics__on_did_open.snap @@ -7,7 +7,9 @@ Report( RelatedFullDocumentDiagnosticReport { related_documents: None, full_document_diagnostic_report: FullDocumentDiagnosticReport { - result_id: None, + result_id: Some( + "[RESULT_ID]", + ), items: [ Diagnostic { range: Range { diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__pull_diagnostics__workspace_diagnostic_after_changes.snap b/crates/ty_server/tests/e2e/snapshots/e2e__pull_diagnostics__workspace_diagnostic_after_changes.snap new file mode 100644 index 0000000000..fe171a057c --- /dev/null +++ b/crates/ty_server/tests/e2e/snapshots/e2e__pull_diagnostics__workspace_diagnostic_after_changes.snap @@ -0,0 +1,359 @@ +--- +source: crates/ty_server/tests/e2e/pull_diagnostics.rs +expression: second_response +--- +Report( + WorkspaceDiagnosticReport { + items: [ + Full( + WorkspaceFullDocumentDiagnosticReport { + uri: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/src/changed_error.py", + query: None, + fragment: None, + }, + version: Some( + 2, + ), + full_document_diagnostic_report: FullDocumentDiagnosticReport { + result_id: Some( + "[RESULT_ID]", + ), + items: [ + Diagnostic { + range: Range { + start: Position { + line: 1, + character: 11, + }, + end: Position { + line: 1, + character: 18, + }, + }, + severity: Some( + Error, + ), + code: Some( + String( + "invalid-return-type", + ), + ), + code_description: Some( + CodeDescription { + href: Url { + scheme: "https", + cannot_be_a_base: false, + username: "", + password: None, + host: Some( + Domain( + "ty.dev", + ), + ), + port: None, + path: "/rules", + query: None, + fragment: Some( + "invalid-return-type", + ), + }, + }, + ), + source: Some( + "ty", + ), + message: "Return type does not match returned value: expected `int`, found `Literal[/"hello/"]`", + related_information: Some( + [ + DiagnosticRelatedInformation { + location: Location { + uri: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/src/changed_error.py", + query: None, + fragment: None, + }, + range: Range { + start: Position { + line: 0, + character: 13, + }, + end: Position { + line: 0, + character: 16, + }, + }, + }, + message: "Expected `int` because of return type", + }, + ], + ), + tags: None, + data: None, + }, + ], + }, + }, + ), + Full( + WorkspaceFullDocumentDiagnosticReport { + uri: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/src/modified_same_error.py", + query: None, + fragment: None, + }, + version: Some( + 2, + ), + full_document_diagnostic_report: FullDocumentDiagnosticReport { + result_id: Some( + "[RESULT_ID]", + ), + items: [ + Diagnostic { + range: Range { + start: Position { + line: 4, + character: 11, + }, + end: Position { + line: 4, + character: 13, + }, + }, + severity: Some( + Error, + ), + code: Some( + String( + "invalid-return-type", + ), + ), + code_description: Some( + CodeDescription { + href: Url { + scheme: "https", + cannot_be_a_base: false, + username: "", + password: None, + host: Some( + Domain( + "ty.dev", + ), + ), + port: None, + path: "/rules", + query: None, + fragment: Some( + "invalid-return-type", + ), + }, + }, + ), + source: Some( + "ty", + ), + message: "Return type does not match returned value: expected `str`, found `Literal[42]`", + related_information: Some( + [ + DiagnosticRelatedInformation { + location: Location { + uri: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/src/modified_same_error.py", + query: None, + fragment: None, + }, + range: Range { + start: Position { + line: 3, + character: 13, + }, + end: Position { + line: 3, + character: 16, + }, + }, + }, + message: "Expected `str` because of return type", + }, + ], + ), + tags: None, + data: None, + }, + ], + }, + }, + ), + Full( + WorkspaceFullDocumentDiagnosticReport { + uri: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/src/new_error.py", + query: None, + fragment: None, + }, + version: Some( + 2, + ), + full_document_diagnostic_report: FullDocumentDiagnosticReport { + result_id: Some( + "[RESULT_ID]", + ), + items: [ + Diagnostic { + range: Range { + start: Position { + line: 1, + character: 11, + }, + end: Position { + line: 1, + character: 13, + }, + }, + severity: Some( + Error, + ), + code: Some( + String( + "invalid-return-type", + ), + ), + code_description: Some( + CodeDescription { + href: Url { + scheme: "https", + cannot_be_a_base: false, + username: "", + password: None, + host: Some( + Domain( + "ty.dev", + ), + ), + port: None, + path: "/rules", + query: None, + fragment: Some( + "invalid-return-type", + ), + }, + }, + ), + source: Some( + "ty", + ), + message: "Return type does not match returned value: expected `str`, found `Literal[42]`", + related_information: Some( + [ + DiagnosticRelatedInformation { + location: Location { + uri: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/src/new_error.py", + query: None, + fragment: None, + }, + range: Range { + start: Position { + line: 0, + character: 13, + }, + end: Position { + line: 0, + character: 16, + }, + }, + }, + message: "Expected `str` because of return type", + }, + ], + ), + tags: None, + data: None, + }, + ], + }, + }, + ), + Unchanged( + WorkspaceUnchangedDocumentDiagnosticReport { + uri: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/src/unchanged.py", + query: None, + fragment: None, + }, + version: Some( + 1, + ), + unchanged_document_diagnostic_report: UnchangedDocumentDiagnosticReport { + result_id: "[RESULT_ID]", + }, + }, + ), + Full( + WorkspaceFullDocumentDiagnosticReport { + uri: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/src/fixed_error.py", + query: None, + fragment: None, + }, + version: Some( + 2, + ), + full_document_diagnostic_report: FullDocumentDiagnosticReport { + result_id: None, + items: [], + }, + }, + ), + ], + }, +) diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__pull_diagnostics__workspace_diagnostic_initial_state.snap b/crates/ty_server/tests/e2e/snapshots/e2e__pull_diagnostics__workspace_diagnostic_initial_state.snap new file mode 100644 index 0000000000..2b3bc9b579 --- /dev/null +++ b/crates/ty_server/tests/e2e/snapshots/e2e__pull_diagnostics__workspace_diagnostic_initial_state.snap @@ -0,0 +1,412 @@ +--- +source: crates/ty_server/tests/e2e/pull_diagnostics.rs +expression: first_response +--- +Report( + WorkspaceDiagnosticReport { + items: [ + Full( + WorkspaceFullDocumentDiagnosticReport { + uri: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/src/changed_error.py", + query: None, + fragment: None, + }, + version: None, + full_document_diagnostic_report: FullDocumentDiagnosticReport { + result_id: Some( + "[RESULT_ID]", + ), + items: [ + Diagnostic { + range: Range { + start: Position { + line: 1, + character: 11, + }, + end: Position { + line: 1, + character: 13, + }, + }, + severity: Some( + Error, + ), + code: Some( + String( + "invalid-return-type", + ), + ), + code_description: Some( + CodeDescription { + href: Url { + scheme: "https", + cannot_be_a_base: false, + username: "", + password: None, + host: Some( + Domain( + "ty.dev", + ), + ), + port: None, + path: "/rules", + query: None, + fragment: Some( + "invalid-return-type", + ), + }, + }, + ), + source: Some( + "ty", + ), + message: "Return type does not match returned value: expected `str`, found `Literal[42]`", + related_information: Some( + [ + DiagnosticRelatedInformation { + location: Location { + uri: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/src/changed_error.py", + query: None, + fragment: None, + }, + range: Range { + start: Position { + line: 0, + character: 13, + }, + end: Position { + line: 0, + character: 16, + }, + }, + }, + message: "Expected `str` because of return type", + }, + ], + ), + tags: None, + data: None, + }, + ], + }, + }, + ), + Full( + WorkspaceFullDocumentDiagnosticReport { + uri: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/src/fixed_error.py", + query: None, + fragment: None, + }, + version: None, + full_document_diagnostic_report: FullDocumentDiagnosticReport { + result_id: Some( + "[RESULT_ID]", + ), + items: [ + Diagnostic { + range: Range { + start: Position { + line: 1, + character: 11, + }, + end: Position { + line: 1, + character: 13, + }, + }, + severity: Some( + Error, + ), + code: Some( + String( + "invalid-return-type", + ), + ), + code_description: Some( + CodeDescription { + href: Url { + scheme: "https", + cannot_be_a_base: false, + username: "", + password: None, + host: Some( + Domain( + "ty.dev", + ), + ), + port: None, + path: "/rules", + query: None, + fragment: Some( + "invalid-return-type", + ), + }, + }, + ), + source: Some( + "ty", + ), + message: "Return type does not match returned value: expected `str`, found `Literal[42]`", + related_information: Some( + [ + DiagnosticRelatedInformation { + location: Location { + uri: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/src/fixed_error.py", + query: None, + fragment: None, + }, + range: Range { + start: Position { + line: 0, + character: 13, + }, + end: Position { + line: 0, + character: 16, + }, + }, + }, + message: "Expected `str` because of return type", + }, + ], + ), + tags: None, + data: None, + }, + ], + }, + }, + ), + Full( + WorkspaceFullDocumentDiagnosticReport { + uri: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/src/modified_same_error.py", + query: None, + fragment: None, + }, + version: None, + full_document_diagnostic_report: FullDocumentDiagnosticReport { + result_id: Some( + "[RESULT_ID]", + ), + items: [ + Diagnostic { + range: Range { + start: Position { + line: 1, + character: 11, + }, + end: Position { + line: 1, + character: 13, + }, + }, + severity: Some( + Error, + ), + code: Some( + String( + "invalid-return-type", + ), + ), + code_description: Some( + CodeDescription { + href: Url { + scheme: "https", + cannot_be_a_base: false, + username: "", + password: None, + host: Some( + Domain( + "ty.dev", + ), + ), + port: None, + path: "/rules", + query: None, + fragment: Some( + "invalid-return-type", + ), + }, + }, + ), + source: Some( + "ty", + ), + message: "Return type does not match returned value: expected `str`, found `Literal[42]`", + related_information: Some( + [ + DiagnosticRelatedInformation { + location: Location { + uri: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/src/modified_same_error.py", + query: None, + fragment: None, + }, + range: Range { + start: Position { + line: 0, + character: 13, + }, + end: Position { + line: 0, + character: 16, + }, + }, + }, + message: "Expected `str` because of return type", + }, + ], + ), + tags: None, + data: None, + }, + ], + }, + }, + ), + Full( + WorkspaceFullDocumentDiagnosticReport { + uri: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/src/unchanged.py", + query: None, + fragment: None, + }, + version: Some( + 1, + ), + full_document_diagnostic_report: FullDocumentDiagnosticReport { + result_id: Some( + "[RESULT_ID]", + ), + items: [ + Diagnostic { + range: Range { + start: Position { + line: 1, + character: 11, + }, + end: Position { + line: 1, + character: 13, + }, + }, + severity: Some( + Error, + ), + code: Some( + String( + "invalid-return-type", + ), + ), + code_description: Some( + CodeDescription { + href: Url { + scheme: "https", + cannot_be_a_base: false, + username: "", + password: None, + host: Some( + Domain( + "ty.dev", + ), + ), + port: None, + path: "/rules", + query: None, + fragment: Some( + "invalid-return-type", + ), + }, + }, + ), + source: Some( + "ty", + ), + message: "Return type does not match returned value: expected `str`, found `Literal[42]`", + related_information: Some( + [ + DiagnosticRelatedInformation { + location: Location { + uri: Url { + scheme: "file", + cannot_be_a_base: false, + username: "", + password: None, + host: None, + port: None, + path: "/src/unchanged.py", + query: None, + fragment: None, + }, + range: Range { + start: Position { + line: 0, + character: 13, + }, + end: Position { + line: 0, + character: 16, + }, + }, + }, + message: "Expected `str` because of return type", + }, + ], + ), + tags: None, + data: None, + }, + ], + }, + }, + ), + ], + }, +)