Preserve group ids when updating diagnostics

Max Brunsfeld created

Change summary

crates/language/src/buffer.rs | 170 +++++++++++++++++++++++++-----------
crates/language/src/tests.rs  |  30 +++++-
2 files changed, 143 insertions(+), 57 deletions(-)

Detailed changes

crates/language/src/buffer.rs 🔗

@@ -7,6 +7,7 @@ pub use crate::{
 };
 use anyhow::{anyhow, Result};
 use clock::ReplicaId;
+use collections::hash_map;
 use futures::FutureExt as _;
 use gpui::{fonts::HighlightStyle, AppContext, Entity, ModelContext, MutableAppContext, Task};
 use lazy_static::lazy_static;
@@ -18,11 +19,11 @@ use smol::future::yield_now;
 use std::{
     any::Any,
     cell::RefCell,
-    cmp::{self, Reverse},
+    cmp::{self, Ordering},
     collections::{BTreeMap, HashMap, HashSet},
     ffi::OsString,
     future::Future,
-    iter::{self, Iterator, Peekable},
+    iter::{Iterator, Peekable},
     ops::{Deref, DerefMut, Range},
     path::{Path, PathBuf},
     str,
@@ -68,6 +69,7 @@ pub struct Buffer {
     remote_selections: TreeMap<ReplicaId, Arc<[Selection<Anchor>]>>,
     diagnostics: DiagnosticSet,
     diagnostics_update_count: usize,
+    next_diagnostic_group_id: usize,
     language_server: Option<LanguageServerState>,
     deferred_ops: OperationQueue<Operation>,
     #[cfg(test)]
@@ -360,6 +362,7 @@ impl Buffer {
             remote_selections: Default::default(),
             diagnostics: Default::default(),
             diagnostics_update_count: 0,
+            next_diagnostic_group_id: 0,
             language_server: None,
             deferred_ops: OperationQueue::new(),
             #[cfg(test)]
@@ -726,7 +729,20 @@ impl Buffer {
         mut diagnostics: Vec<DiagnosticEntry<PointUtf16>>,
         cx: &mut ModelContext<Self>,
     ) -> Result<Operation> {
-        diagnostics.sort_unstable_by_key(|d| (d.range.start, Reverse(d.range.end)));
+        fn compare_diagnostics(a: &Diagnostic, b: &Diagnostic) -> Ordering {
+            Ordering::Equal
+                .then_with(|| b.is_primary.cmp(&a.is_primary))
+                .then_with(|| a.source.cmp(&b.source))
+                .then_with(|| a.severity.cmp(&b.severity))
+                .then_with(|| a.message.cmp(&b.message))
+        }
+
+        diagnostics.sort_unstable_by(|a, b| {
+            Ordering::Equal
+                .then_with(|| a.range.start.cmp(&b.range.start))
+                .then_with(|| b.range.end.cmp(&a.range.end))
+                .then_with(|| compare_diagnostics(&a.diagnostic, &b.diagnostic))
+        });
 
         let version = version.map(|version| version as usize);
         let content = if let Some(version) = version {
@@ -803,65 +819,117 @@ impl Buffer {
             }
             ix += 1;
         }
-
         drop(edits_since_save);
 
-        let diagnostics = diagnostics.into_iter().map(|entry| DiagnosticEntry {
-            range: content.anchor_before(entry.range.start)..content.anchor_after(entry.range.end),
-            diagnostic: entry.diagnostic,
-        });
-
-        // Some diagnostic sources are reported on a less frequent basis than others.
-        // If those sources are absent from this message, then preserve the previous
-        // diagnostics for those sources, but mark them as stale, and set a time to
-        // clear them out.
-        let mut merged_old_disk_based_diagnostics = false;
-        self.diagnostics = if has_disk_based_diagnostics {
-            DiagnosticSet::from_sorted_entries(diagnostics, content)
-        } else {
-            let mut new_diagnostics = diagnostics.peekable();
-            let mut old_diagnostics = self
-                .diagnostics
-                .iter()
-                .filter_map(|entry| {
-                    let is_disk_based = entry
+        let mut merged_diagnostics = Vec::with_capacity(diagnostics.len());
+        let mut old_diagnostics = self
+            .diagnostics
+            .iter()
+            .map(|entry| {
+                (
+                    entry,
+                    entry
                         .diagnostic
                         .source
                         .as_ref()
-                        .map_or(false, |source| disk_based_sources.contains(source));
-                    if is_disk_based {
+                        .map_or(false, |source| disk_based_sources.contains(source)),
+                )
+            })
+            .peekable();
+        let mut new_diagnostics = diagnostics
+            .into_iter()
+            .map(|entry| DiagnosticEntry {
+                range: content.anchor_before(entry.range.start)
+                    ..content.anchor_after(entry.range.end),
+                diagnostic: entry.diagnostic,
+            })
+            .peekable();
+
+        // Compare the old and new diagnostics for two reasons.
+        // 1. Recycling group ids - diagnostic groups whose primary diagnostic has not
+        //    changed should use the same group id as before, so that downstream code
+        //    can determine which diagnostics are new.
+        // 2. Preserving disk-based diagnostics - These diagnostic sources are reported
+        //    on a less frequent basis than others. If these sources are absent from this
+        //    message, then preserve the previous diagnostics for those sources, but mark
+        //    them as invalid, and set a time to clear them out.
+        let mut group_id_replacements = HashMap::new();
+        let mut merged_old_disk_based_diagnostics = false;
+        loop {
+            match (old_diagnostics.peek(), new_diagnostics.peek()) {
+                (None, None) => break,
+                (None, Some(_)) => {
+                    merged_diagnostics.push(new_diagnostics.next().unwrap());
+                }
+                (Some(_), None) => {
+                    let (old_entry, is_disk_based) = old_diagnostics.next().unwrap();
+                    if is_disk_based && !has_disk_based_diagnostics {
+                        let mut old_entry = old_entry.clone();
+                        old_entry.diagnostic.is_valid = false;
                         merged_old_disk_based_diagnostics = true;
-                        let mut entry = entry.clone();
-                        entry.diagnostic.is_valid = false;
-                        Some(entry)
-                    } else {
-                        None
+                        merged_diagnostics.push(old_entry);
                     }
-                })
-                .peekable();
-            let merged_diagnostics =
-                iter::from_fn(|| match (old_diagnostics.peek(), new_diagnostics.peek()) {
-                    (None, None) => None,
-                    (Some(_), None) => old_diagnostics.next(),
-                    (None, Some(_)) => new_diagnostics.next(),
-                    (Some(old), Some(new)) => {
-                        let ordering = old
-                            .range
-                            .start
-                            .cmp(&new.range.start, content)
-                            .unwrap()
-                            .then_with(|| new.range.end.cmp(&old.range.end, content).unwrap());
-                        if ordering.is_lt() {
-                            old_diagnostics.next()
-                        } else {
-                            new_diagnostics.next()
+                }
+                (Some((old, _)), Some(new)) => {
+                    let ordering = Ordering::Equal
+                        .then_with(|| old.range.start.cmp(&new.range.start, content).unwrap())
+                        .then_with(|| new.range.end.cmp(&old.range.end, content).unwrap())
+                        .then_with(|| compare_diagnostics(&old.diagnostic, &new.diagnostic));
+                    match ordering {
+                        Ordering::Less => {
+                            let (old_entry, is_disk_based) = old_diagnostics.next().unwrap();
+                            if is_disk_based && !has_disk_based_diagnostics {
+                                let mut old_entry = old_entry.clone();
+                                old_entry.diagnostic.is_valid = false;
+                                merged_old_disk_based_diagnostics = true;
+                                merged_diagnostics.push(old_entry);
+                            }
+                        }
+                        Ordering::Equal => {
+                            let (old_entry, _) = old_diagnostics.next().unwrap();
+                            let new_entry = new_diagnostics.next().unwrap();
+                            if new_entry.diagnostic.is_primary {
+                                group_id_replacements.insert(
+                                    new_entry.diagnostic.group_id,
+                                    old_entry.diagnostic.group_id,
+                                );
+                            }
+                            merged_diagnostics.push(new_entry);
+                        }
+                        Ordering::Greater => {
+                            let new_entry = new_diagnostics.next().unwrap();
+                            merged_diagnostics.push(new_entry);
                         }
                     }
-                });
-            DiagnosticSet::from_sorted_entries(merged_diagnostics, content)
-        };
+                }
+            }
+        }
+        drop(old_diagnostics);
+
+        // Having determined which group ids should be recycled, renumber all of
+        // groups. Any new group that does not correspond to an old group receives
+        // a brand new group id.
+        let mut next_diagnostic_group_id = self.next_diagnostic_group_id;
+        for entry in &mut merged_diagnostics {
+            if entry.diagnostic.is_valid {
+                match group_id_replacements.entry(entry.diagnostic.group_id) {
+                    hash_map::Entry::Occupied(e) => entry.diagnostic.group_id = *e.get(),
+                    hash_map::Entry::Vacant(e) => {
+                        entry.diagnostic.group_id = post_inc(&mut next_diagnostic_group_id);
+                        e.insert(entry.diagnostic.group_id);
+                    }
+                }
+            }
+        }
 
+        self.diagnostics = DiagnosticSet::from_sorted_entries(merged_diagnostics, content);
         self.diagnostics_update_count += 1;
+        self.next_diagnostic_group_id = next_diagnostic_group_id;
+
+        if merged_old_disk_based_diagnostics {
+            // TODO - spawn a task to clear the old ones
+        }
+
         cx.notify();
         cx.emit(Event::DiagnosticsUpdated);
         Ok(Operation::UpdateDiagnostics {

crates/language/src/tests.rs 🔗

@@ -386,6 +386,7 @@ fn test_edit_with_autoindent(cx: &mut MutableAppContext) {
 //         buffer
 //     });
 // }
+
 #[gpui::test]
 fn test_autoindent_does_not_adjust_lines_with_unchanged_suggestion(cx: &mut MutableAppContext) {
     cx.add_model(|cx| {
@@ -518,6 +519,7 @@ async fn test_diagnostics(mut cx: gpui::TestAppContext) {
                         diagnostic: Diagnostic {
                             severity: DiagnosticSeverity::ERROR,
                             message: "undefined variable 'A'".to_string(),
+                            source: Some("disk".to_string()),
                             group_id: 0,
                             is_primary: true,
                             ..Default::default()
@@ -528,6 +530,7 @@ async fn test_diagnostics(mut cx: gpui::TestAppContext) {
                         diagnostic: Diagnostic {
                             severity: DiagnosticSeverity::ERROR,
                             message: "undefined variable 'BB'".to_string(),
+                            source: Some("disk".to_string()),
                             group_id: 1,
                             is_primary: true,
                             ..Default::default()
@@ -537,6 +540,7 @@ async fn test_diagnostics(mut cx: gpui::TestAppContext) {
                         range: PointUtf16::new(2, 9)..PointUtf16::new(2, 12),
                         diagnostic: Diagnostic {
                             severity: DiagnosticSeverity::ERROR,
+                            source: Some("disk".to_string()),
                             message: "undefined variable 'CCC'".to_string(),
                             group_id: 2,
                             is_primary: true,
@@ -560,6 +564,7 @@ async fn test_diagnostics(mut cx: gpui::TestAppContext) {
                     diagnostic: Diagnostic {
                         severity: DiagnosticSeverity::ERROR,
                         message: "undefined variable 'BB'".to_string(),
+                        source: Some("disk".to_string()),
                         group_id: 1,
                         is_primary: true,
                         ..Default::default()
@@ -570,6 +575,7 @@ async fn test_diagnostics(mut cx: gpui::TestAppContext) {
                     diagnostic: Diagnostic {
                         severity: DiagnosticSeverity::ERROR,
                         message: "undefined variable 'CCC'".to_string(),
+                        source: Some("disk".to_string()),
                         group_id: 2,
                         is_primary: true,
                         ..Default::default()
@@ -608,6 +614,7 @@ async fn test_diagnostics(mut cx: gpui::TestAppContext) {
                         diagnostic: Diagnostic {
                             severity: DiagnosticSeverity::ERROR,
                             message: "undefined variable 'A'".to_string(),
+                            source: Some("disk".to_string()),
                             group_id: 0,
                             is_primary: true,
                             ..Default::default()
@@ -638,7 +645,7 @@ async fn test_diagnostics(mut cx: gpui::TestAppContext) {
                     diagnostic: Diagnostic {
                         severity: DiagnosticSeverity::WARNING,
                         message: "unreachable statement".to_string(),
-                        group_id: 1,
+                        group_id: 3,
                         is_primary: true,
                         ..Default::default()
                     }
@@ -648,6 +655,7 @@ async fn test_diagnostics(mut cx: gpui::TestAppContext) {
                     diagnostic: Diagnostic {
                         severity: DiagnosticSeverity::ERROR,
                         message: "undefined variable 'A'".to_string(),
+                        source: Some("disk".to_string()),
                         group_id: 0,
                         is_primary: true,
                         ..Default::default()
@@ -740,7 +748,7 @@ async fn test_diagnostics(mut cx: gpui::TestAppContext) {
                         severity: DiagnosticSeverity::ERROR,
                         message: "undefined variable 'BB'".to_string(),
                         source: Some("disk".to_string()),
-                        group_id: 1,
+                        group_id: 4,
                         is_primary: true,
                         ..Default::default()
                     },
@@ -751,7 +759,7 @@ async fn test_diagnostics(mut cx: gpui::TestAppContext) {
 }
 
 #[gpui::test]
-async fn test_preserving_disk_based_diagnostics(mut cx: gpui::TestAppContext) {
+async fn test_preserving_old_group_ids_and_disk_based_diagnostics(mut cx: gpui::TestAppContext) {
     let buffer = cx.add_model(|cx| {
         let text = "
             use a::*;
@@ -822,10 +830,10 @@ async fn test_preserving_disk_based_diagnostics(mut cx: gpui::TestAppContext) {
         );
     });
 
-    // The diagnostics are updated, and the disk-based diagnostic is omitted from this message.
+    // The diagnostics are updated. The disk-based diagnostic is omitted, and one
+    // other diagnostic has changed its message.
     let mut new_diagnostics = vec![diagnostics[0].clone(), diagnostics[2].clone()];
     new_diagnostics[0].diagnostic.message = "another syntax error".to_string();
-    new_diagnostics[1].diagnostic.message = "yet another syntax error".to_string();
 
     buffer.update(&mut cx, |buffer, cx| {
         buffer
@@ -837,7 +845,16 @@ async fn test_preserving_disk_based_diagnostics(mut cx: gpui::TestAppContext) {
                 .diagnostics_in_range::<_, PointUtf16>(PointUtf16::new(0, 0)..PointUtf16::new(4, 0))
                 .collect::<Vec<_>>(),
             &[
-                new_diagnostics[0].clone(),
+                // The changed diagnostic is given a new group id.
+                DiagnosticEntry {
+                    range: new_diagnostics[0].range.clone(),
+                    diagnostic: Diagnostic {
+                        group_id: 3,
+                        ..new_diagnostics[0].diagnostic.clone()
+                    },
+                },
+                // The old disk-based diagnostic is marked as invalid, but keeps
+                // its original group id.
                 DiagnosticEntry {
                     range: diagnostics[1].range.clone(),
                     diagnostic: Diagnostic {
@@ -845,6 +862,7 @@ async fn test_preserving_disk_based_diagnostics(mut cx: gpui::TestAppContext) {
                         ..diagnostics[1].diagnostic.clone()
                     },
                 },
+                // The unchanged diagnostic keeps its original group id
                 new_diagnostics[1].clone(),
             ],
         );