use std::hash::{DefaultHasher, Hash as _, Hasher as _}; use lsp_types::notification::PublishDiagnostics; use lsp_types::{ CodeDescription, Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, DiagnosticTag, NumberOrString, PublishDiagnosticsParams, Range, Url, }; use rustc_hash::FxHashMap; use ruff_db::diagnostic::{Annotation, Severity, SubDiagnostic}; use ruff_db::files::FileRange; use ruff_db::source::{line_index, source_text}; use ruff_db::system::SystemPathBuf; use ty_project::{Db, ProjectDatabase}; use crate::document::{DocumentKey, FileRangeExt, ToRangeExt}; use crate::session::DocumentSnapshot; use crate::session::client::Client; use crate::system::{AnySystemPath, file_to_url}; 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 LspDiagnostics { TextDocument(Vec), /// A map of cell URLs to the diagnostics for that cell. NotebookDocument(FxHashMap>), } impl LspDiagnostics { /// Returns the diagnostics for a text document. /// /// # Panics /// /// Panics if the diagnostics are for a notebook document. pub(super) fn expect_text_document(self) -> Vec { match self { LspDiagnostics::TextDocument(diagnostics) => diagnostics, LspDiagnostics::NotebookDocument(_) => { panic!("Expected a text document diagnostics, but got notebook diagnostics") } } } } /// Clears the diagnostics for the document identified by `key`. /// /// This is done by notifying the client with an empty list of diagnostics for the document. /// For notebook cells, this clears diagnostics for the specific cell. /// For other document types, this clears diagnostics for the main document. pub(super) fn clear_diagnostics(key: &DocumentKey, client: &Client) { let Some(uri) = key.to_url() else { // If we can't convert to URL, we can't clear diagnostics return; }; client.send_notification::(PublishDiagnosticsParams { uri, diagnostics: vec![], version: None, }); } /// Publishes the diagnostics for the given document snapshot using the [publish diagnostics /// notification]. /// /// This function is a no-op if the client supports pull diagnostics. /// /// [publish diagnostics notification]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_publishDiagnostics pub(super) fn publish_diagnostics(session: &Session, key: &DocumentKey, client: &Client) { if session.client_capabilities().supports_pull_diagnostics() { return; } let Some(url) = key.to_url() else { return; }; let snapshot = session.take_document_snapshot(url.clone()); let document = match snapshot.document() { Ok(document) => document, Err(err) => { tracing::debug!("Failed to resolve document for URL `{}`: {}", url, err); return; } }; let db = session.project_db(key.path()); let Some(diagnostics) = compute_diagnostics(db, &snapshot) else { return; }; // Sends a notification to the client with the diagnostics for the document. let publish_diagnostics_notification = |uri: Url, diagnostics: Vec| { client.send_notification::(PublishDiagnosticsParams { uri, diagnostics, version: Some(document.version()), }); }; match diagnostics.to_lsp_diagnostics(db) { LspDiagnostics::TextDocument(diagnostics) => { publish_diagnostics_notification(url, diagnostics); } LspDiagnostics::NotebookDocument(cell_diagnostics) => { for (cell_url, diagnostics) in cell_diagnostics { publish_diagnostics_notification(cell_url, diagnostics); } } } } /// Publishes settings diagnostics for all the project at the given path /// using the [publish diagnostics notification]. /// /// [publish diagnostics notification]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_publishDiagnostics pub(crate) fn publish_settings_diagnostics( session: &mut Session, client: &Client, path: SystemPathBuf, ) { // Don't publish settings diagnostics for workspace that are already doing full diagnostics. // // Note we DO NOT respect the fact that clients support pulls because these are // files they *specifically* won't pull diagnostics from us for, because we don't // claim to be an LSP for them. let has_workspace_diagnostics = session .workspaces() .for_path(&path) .map(|workspace| workspace.settings().diagnostic_mode().is_workspace()) .unwrap_or(false); if has_workspace_diagnostics { return; } let session_encoding = session.position_encoding(); let state = session.project_state_mut(&AnySystemPath::System(path)); let db = &state.db; let project = db.project(); let settings_diagnostics = project.check_settings(db); // We need to send diagnostics if we have non-empty ones, or we have ones to clear. // These will both almost always be empty so this function will almost always be a no-op. if settings_diagnostics.is_empty() && state.untracked_files_with_pushed_diagnostics.is_empty() { return; } // Group diagnostics by URL let mut diagnostics_by_url: FxHashMap> = FxHashMap::default(); for diagnostic in settings_diagnostics { if let Some(span) = diagnostic.primary_span() { let file = span.expect_ty_file(); let Some(url) = file_to_url(db, file) else { tracing::debug!("Failed to convert file to URL at {}", file.path(db)); continue; }; diagnostics_by_url.entry(url).or_default().push(diagnostic); } } // Record the URLs we're sending non-empty diagnostics for, so we know to clear them // the next time we publish settings diagnostics! let old_untracked = std::mem::replace( &mut state.untracked_files_with_pushed_diagnostics, diagnostics_by_url.keys().cloned().collect(), ); // Add empty diagnostics for any files that had diagnostics before but don't now. // This will clear them (either the file is no longer relevant to us or fixed!) for url in old_untracked { diagnostics_by_url.entry(url).or_default(); } // Send the settings diagnostics! for (url, file_diagnostics) in diagnostics_by_url { // Convert diagnostics to LSP format let lsp_diagnostics = file_diagnostics .into_iter() .map(|diagnostic| to_lsp_diagnostic(db, &diagnostic, session_encoding)) .collect::>(); client.send_notification::(PublishDiagnosticsParams { uri: url, diagnostics: lsp_diagnostics, version: None, }); } } pub(super) fn compute_diagnostics<'a>( db: &ProjectDatabase, snapshot: &'a DocumentSnapshot, ) -> Option> { let document = match snapshot.document() { Ok(document) => document, Err(err) => { tracing::info!("Failed to resolve document for snapshot: {}", err); return None; } }; let Some(file) = document.file(db) else { tracing::info!("No file found for snapshot for `{}`", document.file_path()); return None; }; let diagnostics = db.check_file(file); Some(Diagnostics { items: diagnostics, encoding: snapshot.encoding(), document, }) } /// Converts the tool specific [`Diagnostic`][ruff_db::diagnostic::Diagnostic] to an LSP /// [`Diagnostic`]. pub(super) fn to_lsp_diagnostic( db: &dyn Db, diagnostic: &ruff_db::diagnostic::Diagnostic, encoding: PositionEncoding, ) -> Diagnostic { let range = if let Some(span) = diagnostic.primary_span() { let file = span.expect_ty_file(); let index = line_index(db, file); let source = source_text(db, file); span.range() .map(|range| range.to_lsp_range(&source, &index, encoding)) .unwrap_or_default() } else { Range::default() }; let severity = match diagnostic.severity() { Severity::Info => DiagnosticSeverity::INFORMATION, Severity::Warning => DiagnosticSeverity::WARNING, Severity::Error | Severity::Fatal => DiagnosticSeverity::ERROR, }; let tags = diagnostic .primary_tags() .map(|tags| { tags.iter() .map(|tag| match tag { ruff_db::diagnostic::DiagnosticTag::Unnecessary => DiagnosticTag::UNNECESSARY, ruff_db::diagnostic::DiagnosticTag::Deprecated => DiagnosticTag::DEPRECATED, }) .collect::>() }) .filter(|mapped_tags| !mapped_tags.is_empty()); let code_description = diagnostic .id() .is_lint() .then(|| { Some(CodeDescription { href: Url::parse(&format!("https://ty.dev/rules#{}", diagnostic.id())).ok()?, }) }) .flatten(); let mut related_information = Vec::new(); related_information.extend( diagnostic .secondary_annotations() .filter_map(|annotation| annotation_to_related_information(db, annotation, encoding)), ); for sub_diagnostic in diagnostic.sub_diagnostics() { related_information.extend(sub_diagnostic_to_related_information( db, sub_diagnostic, encoding, )); related_information.extend( sub_diagnostic .annotations() .iter() .filter_map(|annotation| { annotation_to_related_information(db, annotation, encoding) }), ); } Diagnostic { range, severity: Some(severity), tags, code: Some(NumberOrString::String(diagnostic.id().to_string())), code_description, source: Some("ty".into()), message: diagnostic.concise_message().to_string(), related_information: Some(related_information), data: None, } } /// Converts an [`Annotation`] to a [`DiagnosticRelatedInformation`]. fn annotation_to_related_information( db: &dyn Db, annotation: &Annotation, encoding: PositionEncoding, ) -> Option { let span = annotation.get_span(); let annotation_message = annotation.get_message()?; let range = FileRange::try_from(span).ok()?; let location = range.to_location(db, encoding)?; Some(DiagnosticRelatedInformation { location, message: annotation_message.to_string(), }) } /// Converts a [`SubDiagnostic`] to a [`DiagnosticRelatedInformation`]. fn sub_diagnostic_to_related_information( db: &dyn Db, diagnostic: &SubDiagnostic, encoding: PositionEncoding, ) -> Option { let primary_annotation = diagnostic.primary_annotation()?; let span = primary_annotation.get_span(); let range = FileRange::try_from(span).ok()?; let location = range.to_location(db, encoding)?; Some(DiagnosticRelatedInformation { location, message: diagnostic.concise_message().to_string(), }) }