[ty] Implement diagnostic caching (#19605)

This commit is contained in:
Micha Reiser 2025-07-30 12:04:34 +02:00 committed by GitHub
parent 4ecf1d205a
commit 2a5ace6e55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1322 additions and 100 deletions

View file

@ -21,7 +21,7 @@ mod stylesheet;
/// characteristics in the inputs given to the tool. Typically, but not always, /// characteristics in the inputs given to the tool. Typically, but not always,
/// a characteristic is a deficiency. An example of a characteristic that is /// a characteristic is a deficiency. An example of a characteristic that is
/// _not_ a deficiency is the `reveal_type` diagnostic for our type checker. /// _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 { pub struct Diagnostic {
/// The actual 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 { struct DiagnosticInner {
id: DiagnosticId, id: DiagnosticId,
severity: Severity, severity: Severity,
@ -555,7 +555,7 @@ impl Eq for RenderingSortKey<'_> {}
/// Currently, the order in which sub-diagnostics are rendered relative to one /// 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 /// another (for a single parent diagnostic) is the order in which they were
/// attached to the diagnostic. /// attached to the diagnostic.
#[derive(Debug, Clone, Eq, PartialEq, get_size2::GetSize)] #[derive(Debug, Clone, Eq, PartialEq, Hash, get_size2::GetSize)]
pub struct SubDiagnostic { pub struct SubDiagnostic {
/// Like with `Diagnostic`, we box the `SubDiagnostic` to make it /// Like with `Diagnostic`, we box the `SubDiagnostic` to make it
/// pointer-sized. /// 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 { struct SubDiagnosticInner {
severity: SubDiagnosticSeverity, severity: SubDiagnosticSeverity,
message: DiagnosticMessage, message: DiagnosticMessage,
@ -687,7 +687,7 @@ struct SubDiagnosticInner {
/// ///
/// Messages attached to annotations should also be as brief and specific as /// Messages attached to annotations should also be as brief and specific as
/// possible. Long messages could negative impact the quality of rendering. /// 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 { pub struct Annotation {
/// The span of this annotation, corresponding to some subsequence of the /// The span of this annotation, corresponding to some subsequence of the
/// user's input that we want to highlight. /// 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. /// These tags are used to provide additional information about the annotation.
/// and are passed through to the language server protocol. /// 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 { pub enum DiagnosticTag {
/// Unused or unnecessary code. Used for unused parameters, unreachable code, etc. /// Unused or unnecessary code. Used for unused parameters, unreachable code, etc.
Unnecessary, 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 /// 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. /// 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 { pub enum UnifiedFile {
Ty(File), Ty(File),
Ruff(SourceFile), Ruff(SourceFile),
@ -1080,7 +1080,7 @@ impl DiagnosticSource {
/// It consists of a `File` and an optional range into that file. When the /// 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 /// 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. /// 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 { pub struct Span {
file: UnifiedFile, file: UnifiedFile,
range: Option<TextRange>, range: Option<TextRange>,
@ -1158,7 +1158,7 @@ impl From<crate::files::FileRange> 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 { pub enum Severity {
Info, Info,
Warning, Warning,
@ -1193,7 +1193,7 @@ impl Severity {
/// This type only exists to add an additional `Help` severity that isn't present in `Severity` or /// 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 /// used for main diagnostics. If we want to add `Severity::Help` in the future, this type could be
/// deleted and the two combined again. /// 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 { pub enum SubDiagnosticSeverity {
Help, Help,
Info, Info,
@ -1428,7 +1428,7 @@ impl std::fmt::Display for ConciseMessage<'_> {
/// In most cases, callers shouldn't need to use this. Instead, there is /// In most cases, callers shouldn't need to use this. Instead, there is
/// a blanket trait implementation for `IntoDiagnosticMessage` for /// a blanket trait implementation for `IntoDiagnosticMessage` for
/// anything that implements `std::fmt::Display`. /// 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<str>); pub struct DiagnosticMessage(Box<str>);
impl DiagnosticMessage { impl DiagnosticMessage {

View file

@ -43,7 +43,7 @@ pub enum IsolationLevel {
} }
/// A collection of [`Edit`] elements to be applied to a source file. /// 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))] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Fix { pub struct Fix {
/// The [`Edit`] elements to be applied, sorted by [`Edit::start`] in ascending order. /// The [`Edit`] elements to be applied, sorted by [`Edit::start`] in ascending order.

View file

@ -1,5 +1,6 @@
use std::cmp::Ordering; use std::cmp::Ordering;
use std::fmt::{Debug, Display, Formatter}; use std::fmt::{Debug, Display, Formatter};
use std::hash::Hash;
use std::sync::{Arc, OnceLock}; use std::sync::{Arc, OnceLock};
#[cfg(feature = "serde")] #[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`]. /// 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. /// 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))] #[cfg_attr(feature = "get-size", derive(get_size2::GetSize))]
pub struct SourceFile { pub struct SourceFile {
inner: Arc<SourceFileInner>, inner: Arc<SourceFileInner>,
@ -241,6 +242,13 @@ impl PartialEq for SourceFileInner {
impl Eq for SourceFileInner {} impl Eq for SourceFileInner {}
impl Hash for SourceFileInner {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.name.hash(state);
self.code.hash(state);
}
}
/// The line and column of an offset in a source file. /// The line and column of an offset in a source file.
/// ///
/// See [`LineIndex::line_column`] for more information. /// See [`LineIndex::line_column`] for more information.

View file

@ -6,7 +6,7 @@ use ruff_db::system::{OsSystem, SystemPathBuf};
pub use crate::logging::{LogLevel, init_logging}; pub use crate::logging::{LogLevel, init_logging};
pub use crate::server::Server; pub use crate::server::Server;
pub use crate::session::ClientOptions; pub use crate::session::{ClientOptions, DiagnosticMode};
pub use document::{NotebookDocument, PositionEncoding, TextDocument}; pub use document::{NotebookDocument, PositionEncoding, TextDocument};
pub(crate) use session::{DocumentQuery, Session}; pub(crate) use session::{DocumentQuery, Session};

View file

@ -98,7 +98,10 @@ impl<S> tracing_subscriber::layer::Filter<S> for LogLevelFilter {
meta: &tracing::Metadata<'_>, meta: &tracing::Metadata<'_>,
_: &tracing_subscriber::layer::Context<'_, S>, _: &tracing_subscriber::layer::Context<'_, S>,
) -> bool { ) -> 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() self.filter.trace_level()
} else { } else {
tracing::Level::WARN tracing::Level::WARN

View file

@ -1,3 +1,5 @@
use std::hash::{DefaultHasher, Hash as _, Hasher as _};
use lsp_types::notification::PublishDiagnostics; use lsp_types::notification::PublishDiagnostics;
use lsp_types::{ use lsp_types::{
CodeDescription, Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, DiagnosticTag, CodeDescription, Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, DiagnosticTag,
@ -15,17 +17,77 @@ use crate::document::{DocumentKey, FileRangeExt, ToRangeExt};
use crate::session::DocumentSnapshot; use crate::session::DocumentSnapshot;
use crate::session::client::Client; use crate::session::client::Client;
use crate::system::{AnySystemPath, file_to_url}; use crate::system::{AnySystemPath, file_to_url};
use crate::{PositionEncoding, Session}; use crate::{DocumentQuery, PositionEncoding, Session};
pub(super) struct Diagnostics<'a> {
items: Vec<ruff_db::diagnostic::Diagnostic>,
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<Url, Vec<Diagnostic>> = 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. /// Represents the diagnostics for a text document or a notebook document.
pub(super) enum Diagnostics { pub(super) enum LspDiagnostics {
TextDocument(Vec<Diagnostic>), TextDocument(Vec<Diagnostic>),
/// A map of cell URLs to the diagnostics for that cell. /// A map of cell URLs to the diagnostics for that cell.
NotebookDocument(FxHashMap<Url, Vec<Diagnostic>>), NotebookDocument(FxHashMap<Url, Vec<Diagnostic>>),
} }
impl Diagnostics { impl LspDiagnostics {
/// Returns the diagnostics for a text document. /// Returns the diagnostics for a text document.
/// ///
/// # Panics /// # Panics
@ -33,8 +95,8 @@ impl Diagnostics {
/// Panics if the diagnostics are for a notebook document. /// Panics if the diagnostics are for a notebook document.
pub(super) fn expect_text_document(self) -> Vec<Diagnostic> { pub(super) fn expect_text_document(self) -> Vec<Diagnostic> {
match self { match self {
Diagnostics::TextDocument(diagnostics) => diagnostics, LspDiagnostics::TextDocument(diagnostics) => diagnostics,
Diagnostics::NotebookDocument(_) => { LspDiagnostics::NotebookDocument(_) => {
panic!("Expected a text document diagnostics, but got notebook diagnostics") 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 { match diagnostics.to_lsp_diagnostics(db) {
Diagnostics::TextDocument(diagnostics) => { LspDiagnostics::TextDocument(diagnostics) => {
publish_diagnostics_notification(url, diagnostics); publish_diagnostics_notification(url, diagnostics);
} }
Diagnostics::NotebookDocument(cell_diagnostics) => { LspDiagnostics::NotebookDocument(cell_diagnostics) => {
for (cell_url, diagnostics) in cell_diagnostics { for (cell_url, diagnostics) in cell_diagnostics {
publish_diagnostics_notification(cell_url, 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, db: &ProjectDatabase,
snapshot: &DocumentSnapshot, snapshot: &'a DocumentSnapshot,
) -> Option<Diagnostics> { ) -> Option<Diagnostics<'a>> {
let document = match snapshot.document() { let document = match snapshot.document() {
Ok(document) => document, Ok(document) => document,
Err(err) => { Err(err) => {
@ -206,41 +268,11 @@ pub(super) fn compute_diagnostics(
let diagnostics = db.check_file(file); let diagnostics = db.check_file(file);
if let Some(notebook) = document.as_notebook() { Some(Diagnostics {
let mut cell_diagnostics: FxHashMap<Url, Vec<Diagnostic>> = FxHashMap::default(); items: diagnostics,
encoding: snapshot.encoding(),
// Populates all relevant URLs with an empty diagnostic list. This ensures that documents document,
// 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(),
))
}
} }
/// Converts the tool specific [`Diagnostic`][ruff_db::diagnostic::Diagnostic] to an LSP /// Converts the tool specific [`Diagnostic`][ruff_db::diagnostic::Diagnostic] to an LSP

View file

@ -3,11 +3,12 @@ use std::borrow::Cow;
use lsp_types::request::DocumentDiagnosticRequest; use lsp_types::request::DocumentDiagnosticRequest;
use lsp_types::{ use lsp_types::{
DocumentDiagnosticParams, DocumentDiagnosticReport, DocumentDiagnosticReportResult, DocumentDiagnosticParams, DocumentDiagnosticReport, DocumentDiagnosticReportResult,
FullDocumentDiagnosticReport, RelatedFullDocumentDiagnosticReport, Url, FullDocumentDiagnosticReport, RelatedFullDocumentDiagnosticReport,
RelatedUnchangedDocumentDiagnosticReport, UnchangedDocumentDiagnosticReport, Url,
}; };
use crate::server::Result; use crate::server::Result;
use crate::server::api::diagnostics::{Diagnostics, compute_diagnostics}; use crate::server::api::diagnostics::compute_diagnostics;
use crate::server::api::traits::{ use crate::server::api::traits::{
BackgroundDocumentRequestHandler, RequestHandler, RetriableRequestHandler, BackgroundDocumentRequestHandler, RequestHandler, RetriableRequestHandler,
}; };
@ -30,20 +31,38 @@ impl BackgroundDocumentRequestHandler for DocumentDiagnosticRequestHandler {
db: &ProjectDatabase, db: &ProjectDatabase,
snapshot: DocumentSnapshot, snapshot: DocumentSnapshot,
_client: &Client, _client: &Client,
_params: DocumentDiagnosticParams, params: DocumentDiagnosticParams,
) -> Result<DocumentDiagnosticReportResult> { ) -> Result<DocumentDiagnosticReportResult> {
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 { DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
related_documents: None, related_documents: None,
full_document_diagnostic_report: FullDocumentDiagnosticReport { 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 // SAFETY: Pull diagnostic requests are only called for text documents, not for
// notebook documents. // notebook documents.
items: compute_diagnostics(db, &snapshot) items: diagnostics.to_lsp_diagnostics(db).expect_text_document(),
.map_or_else(Vec::new, Diagnostics::expect_text_document),
}, },
}), })
)) };
Ok(DocumentDiagnosticReportResult::Report(report))
} }
} }

View file

@ -1,19 +1,20 @@
use lsp_types::request::WorkspaceDiagnosticRequest; use std::collections::BTreeMap;
use lsp_types::{
FullDocumentDiagnosticReport, Url, WorkspaceDiagnosticParams, WorkspaceDiagnosticReport,
WorkspaceDiagnosticReportResult, WorkspaceDocumentDiagnosticReport,
WorkspaceFullDocumentDiagnosticReport,
};
use rustc_hash::FxHashMap;
use crate::server::Result; 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::{ use crate::server::api::traits::{
BackgroundRequestHandler, RequestHandler, RetriableRequestHandler, BackgroundRequestHandler, RequestHandler, RetriableRequestHandler,
}; };
use crate::session::SessionSnapshot; use crate::session::SessionSnapshot;
use crate::session::client::Client; use crate::session::client::Client;
use crate::system::file_to_url; 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; pub(crate) struct WorkspaceDiagnosticRequestHandler;
@ -25,26 +26,31 @@ impl BackgroundRequestHandler for WorkspaceDiagnosticRequestHandler {
fn run( fn run(
snapshot: SessionSnapshot, snapshot: SessionSnapshot,
_client: &Client, _client: &Client,
_params: WorkspaceDiagnosticParams, params: WorkspaceDiagnosticParams,
) -> Result<WorkspaceDiagnosticReportResult> { ) -> Result<WorkspaceDiagnosticReportResult> {
let index = snapshot.index(); let index = snapshot.index();
if !index.global_settings().diagnostic_mode().is_workspace() { if !index.global_settings().diagnostic_mode().is_workspace() {
// VS Code sends us the workspace diagnostic request every 2 seconds, so these logs can tracing::debug!("Workspace diagnostics is disabled; returning empty report");
// be quite verbose.
tracing::trace!("Workspace diagnostics is disabled; returning empty report");
return Ok(WorkspaceDiagnosticReportResult::Report( return Ok(WorkspaceDiagnosticReportResult::Report(
WorkspaceDiagnosticReport { items: vec![] }, 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(); let mut items = Vec::new();
for db in snapshot.projects() { for db in snapshot.projects() {
let diagnostics = db.check(); let diagnostics = db.check();
// Group diagnostics by URL // Group diagnostics by URL
let mut diagnostics_by_url: FxHashMap<Url, Vec<_>> = FxHashMap::default(); let mut diagnostics_by_url: BTreeMap<Url, Vec<_>> = BTreeMap::default();
for diagnostic in diagnostics { for diagnostic in diagnostics {
if let Some(span) = diagnostic.primary_span() { if let Some(span) = diagnostic.primary_span() {
@ -66,6 +72,23 @@ impl BackgroundRequestHandler for WorkspaceDiagnosticRequestHandler {
.ok() .ok()
.and_then(|key| index.make_document_ref(key).ok()) .and_then(|key| index.make_document_ref(key).ok())
.map(|doc| i64::from(doc.version())); .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 // Convert diagnostics to LSP format
let lsp_diagnostics = file_diagnostics let lsp_diagnostics = file_diagnostics
@ -75,13 +98,13 @@ impl BackgroundRequestHandler for WorkspaceDiagnosticRequestHandler {
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
// Diagnostics have changed or this is the first request, return full report
items.push(WorkspaceDocumentDiagnosticReport::Full( items.push(WorkspaceDocumentDiagnosticReport::Full(
WorkspaceFullDocumentDiagnosticReport { WorkspaceFullDocumentDiagnosticReport {
uri: url, uri: url,
version, version,
full_document_diagnostic_report: FullDocumentDiagnosticReport { full_document_diagnostic_report: FullDocumentDiagnosticReport {
// TODO: We don't implement result ID caching yet result_id: Some(result_id),
result_id: None,
items: lsp_diagnostics, 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( Ok(WorkspaceDiagnosticReportResult::Report(
WorkspaceDiagnosticReport { items }, WorkspaceDiagnosticReport { items },
)) ))

View file

@ -21,8 +21,8 @@ use ty_project::{ChangeResult, Db as _, ProjectDatabase, ProjectMetadata};
pub(crate) use self::capabilities::ResolvedClientCapabilities; pub(crate) use self::capabilities::ResolvedClientCapabilities;
pub(crate) use self::index::DocumentQuery; pub(crate) use self::index::DocumentQuery;
pub use self::options::ClientOptions; pub(crate) use self::options::AllOptions;
pub(crate) use self::options::{AllOptions, DiagnosticMode}; pub use self::options::{ClientOptions, DiagnosticMode};
pub(crate) use self::settings::ClientSettings; pub(crate) use self::settings::ClientSettings;
use crate::document::{DocumentKey, DocumentVersion, NotebookDocument}; use crate::document::{DocumentKey, DocumentVersion, NotebookDocument};
use crate::server::publish_settings_diagnostics; use crate::server::publish_settings_diagnostics;

View file

@ -65,7 +65,7 @@ pub struct ClientOptions {
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
#[cfg_attr(test, derive(PartialEq, Eq))] #[cfg_attr(test, derive(PartialEq, Eq))]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub(crate) enum DiagnosticMode { pub enum DiagnosticMode {
/// Check only currently open files. /// Check only currently open files.
#[default] #[default]
OpenFilesOnly, OpenFilesOnly,
@ -140,6 +140,13 @@ impl ClientOptions {
overrides, 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 // TODO(dhruvmanila): We need to mirror the "python.*" namespace on the server side but ideally it

View file

@ -49,20 +49,23 @@ use lsp_types::notification::{
}; };
use lsp_types::request::{ use lsp_types::request::{
DocumentDiagnosticRequest, Initialize, Request, Shutdown, WorkspaceConfiguration, DocumentDiagnosticRequest, Initialize, Request, Shutdown, WorkspaceConfiguration,
WorkspaceDiagnosticRequest,
}; };
use lsp_types::{ use lsp_types::{
ClientCapabilities, ConfigurationParams, DiagnosticClientCapabilities, ClientCapabilities, ConfigurationParams, DiagnosticClientCapabilities,
DidChangeTextDocumentParams, DidChangeWatchedFilesClientCapabilities, DidChangeTextDocumentParams, DidChangeWatchedFilesClientCapabilities,
DidChangeWatchedFilesParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams, DidChangeWatchedFilesParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams,
DocumentDiagnosticParams, DocumentDiagnosticReportResult, FileEvent, InitializeParams, DocumentDiagnosticParams, DocumentDiagnosticReportResult, FileEvent, InitializeParams,
InitializeResult, InitializedParams, PartialResultParams, PublishDiagnosticsClientCapabilities, InitializeResult, InitializedParams, PartialResultParams, PreviousResultId,
TextDocumentClientCapabilities, TextDocumentContentChangeEvent, TextDocumentIdentifier, PublishDiagnosticsClientCapabilities, TextDocumentClientCapabilities,
TextDocumentItem, Url, VersionedTextDocumentIdentifier, WorkDoneProgressParams, TextDocumentContentChangeEvent, TextDocumentIdentifier, TextDocumentItem, Url,
WorkspaceClientCapabilities, WorkspaceFolder, VersionedTextDocumentIdentifier, WorkDoneProgressParams, WorkspaceClientCapabilities,
WorkspaceDiagnosticParams, WorkspaceDiagnosticReportResult, WorkspaceFolder,
}; };
use ruff_db::system::{OsSystem, SystemPath, SystemPathBuf, TestSystem}; use ruff_db::system::{OsSystem, SystemPath, SystemPathBuf, TestSystem};
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde_json::json;
use tempfile::TempDir; use tempfile::TempDir;
use ty_server::{ClientOptions, LogLevel, Server, init_logging}; use ty_server::{ClientOptions, LogLevel, Server, init_logging};
@ -156,6 +159,7 @@ impl TestServer {
workspaces: Vec<(WorkspaceFolder, ClientOptions)>, workspaces: Vec<(WorkspaceFolder, ClientOptions)>,
test_context: TestContext, test_context: TestContext,
capabilities: ClientCapabilities, capabilities: ClientCapabilities,
initialization_options: Option<ClientOptions>,
) -> Result<Self> { ) -> Result<Self> {
setup_tracing(); setup_tracing();
@ -204,7 +208,7 @@ impl TestServer {
workspace_configurations, workspace_configurations,
registered_capabilities: Vec::new(), registered_capabilities: Vec::new(),
} }
.initialize(workspace_folders, capabilities) .initialize(workspace_folders, capabilities, initialization_options)
} }
/// Perform LSP initialization handshake /// Perform LSP initialization handshake
@ -212,13 +216,15 @@ impl TestServer {
mut self, mut self,
workspace_folders: Vec<WorkspaceFolder>, workspace_folders: Vec<WorkspaceFolder>,
capabilities: ClientCapabilities, capabilities: ClientCapabilities,
initialization_options: Option<ClientOptions>,
) -> Result<Self> { ) -> Result<Self> {
let init_params = InitializeParams { let init_params = InitializeParams {
capabilities, capabilities,
workspace_folders: Some(workspace_folders), workspace_folders: Some(workspace_folders),
// TODO: This should be configurable by the test server builder. This might not be // TODO: This should be configurable by the test server builder. This might not be
// required after client settings are implemented in the server. // 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() ..Default::default()
}; };
@ -591,7 +597,6 @@ impl TestServer {
} }
/// Send a `textDocument/didChange` notification with the given content changes /// Send a `textDocument/didChange` notification with the given content changes
#[expect(dead_code)]
pub(crate) fn change_text_document( pub(crate) fn change_text_document(
&mut self, &mut self,
path: impl AsRef<SystemPath>, path: impl AsRef<SystemPath>,
@ -630,19 +635,36 @@ impl TestServer {
pub(crate) fn document_diagnostic_request( pub(crate) fn document_diagnostic_request(
&mut self, &mut self,
path: impl AsRef<SystemPath>, path: impl AsRef<SystemPath>,
previous_result_id: Option<String>,
) -> Result<DocumentDiagnosticReportResult> { ) -> Result<DocumentDiagnosticReportResult> {
let params = DocumentDiagnosticParams { let params = DocumentDiagnosticParams {
text_document: TextDocumentIdentifier { text_document: TextDocumentIdentifier {
uri: self.file_uri(path), uri: self.file_uri(path),
}, },
identifier: Some("ty".to_string()), identifier: Some("ty".to_string()),
previous_result_id: None, previous_result_id,
work_done_progress_params: WorkDoneProgressParams::default(), work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(), partial_result_params: PartialResultParams::default(),
}; };
let id = self.send_request::<DocumentDiagnosticRequest>(params); let id = self.send_request::<DocumentDiagnosticRequest>(params);
self.await_response::<DocumentDiagnosticReportResult>(id) self.await_response::<DocumentDiagnosticReportResult>(id)
} }
/// Send a `workspace/diagnostic` request with optional previous result IDs.
pub(crate) fn workspace_diagnostic_request(
&mut self,
previous_result_ids: Option<Vec<PreviousResultId>>,
) -> Result<WorkspaceDiagnosticReportResult> {
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::<WorkspaceDiagnosticRequest>(params);
self.await_response::<WorkspaceDiagnosticReportResult>(id)
}
} }
impl fmt::Debug for TestServer { impl fmt::Debug for TestServer {
@ -709,6 +731,7 @@ impl Drop for TestServer {
pub(crate) struct TestServerBuilder { pub(crate) struct TestServerBuilder {
test_context: TestContext, test_context: TestContext,
workspaces: Vec<(WorkspaceFolder, ClientOptions)>, workspaces: Vec<(WorkspaceFolder, ClientOptions)>,
initialization_options: Option<ClientOptions>,
client_capabilities: ClientCapabilities, client_capabilities: ClientCapabilities,
} }
@ -735,10 +758,16 @@ impl TestServerBuilder {
Ok(Self { Ok(Self {
workspaces: Vec::new(), workspaces: Vec::new(),
test_context: TestContext::new()?, test_context: TestContext::new()?,
initialization_options: None,
client_capabilities, 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. /// 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 /// This option will be used to respond to the `workspace/configuration` request that the
@ -837,7 +866,12 @@ impl TestServerBuilder {
/// Build the test server /// Build the test server
pub(crate) fn build(self) -> Result<TestServer> { pub(crate) fn build(self) -> Result<TestServer> {
TestServer::new(self.workspaces, self.test_context, self.client_capabilities) TestServer::new(
self.workspaces,
self.test_context,
self.client_capabilities,
self.initialization_options,
)
} }
} }

View file

@ -1,11 +1,16 @@
use anyhow::Result; use anyhow::Result;
use lsp_types::{
PreviousResultId, WorkspaceDiagnosticReportResult, WorkspaceDocumentDiagnosticReport,
};
use ruff_db::system::SystemPath; use ruff_db::system::SystemPath;
use ty_server::ClientOptions; use ty_server::{ClientOptions, DiagnosticMode};
use crate::TestServerBuilder; use crate::TestServerBuilder;
#[test] #[test]
fn on_did_open() -> Result<()> { fn on_did_open() -> Result<()> {
let _filter = filter_result_id();
let workspace_root = SystemPath::new("src"); let workspace_root = SystemPath::new("src");
let foo = SystemPath::new("src/foo.py"); let foo = SystemPath::new("src/foo.py");
let foo_content = "\ let foo_content = "\
@ -21,9 +26,305 @@ def foo() -> str:
.wait_until_workspaces_are_initialized()?; .wait_until_workspaces_are_initialized()?;
server.open_text_document(foo, &foo_content, 1); 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); insta::assert_debug_snapshot!(diagnostics);
Ok(()) 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()
}

View file

@ -7,7 +7,9 @@ Report(
RelatedFullDocumentDiagnosticReport { RelatedFullDocumentDiagnosticReport {
related_documents: None, related_documents: None,
full_document_diagnostic_report: FullDocumentDiagnosticReport { full_document_diagnostic_report: FullDocumentDiagnosticReport {
result_id: None, result_id: Some(
"[RESULT_ID]",
),
items: [ items: [
Diagnostic { Diagnostic {
range: Range { range: Range {

View file

@ -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: "<temp_dir>/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: "<temp_dir>/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: "<temp_dir>/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: "<temp_dir>/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: "<temp_dir>/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: "<temp_dir>/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: "<temp_dir>/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: "<temp_dir>/src/fixed_error.py",
query: None,
fragment: None,
},
version: Some(
2,
),
full_document_diagnostic_report: FullDocumentDiagnosticReport {
result_id: None,
items: [],
},
},
),
],
},
)

View file

@ -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: "<temp_dir>/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: "<temp_dir>/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: "<temp_dir>/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: "<temp_dir>/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: "<temp_dir>/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: "<temp_dir>/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: "<temp_dir>/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: "<temp_dir>/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,
},
],
},
},
),
],
},
)