Skip to content

Commit 219b66e

Browse files
committed
harper-ls: debounce diagnostics after edits
Keep delayed diagnostics simple and bug-focused by debouncing didChange publishes, cleaning up pending task state, and removing the race-prone post-code-action refresh path. before getting rid of the generation now generation pending_diagnostic_task move publish_immediately get rid of the test
1 parent 374e528 commit 219b66e

3 files changed

Lines changed: 111 additions & 22 deletions

File tree

harper-ls/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ harper-html = { path = "../harper-html", version = "2.0.0" }
2121
harper-python = { path = "../harper-python", version = "2.0.0" }
2222
harper-asciidoc = { path = "../harper-asciidoc", version = "2.0.0" }
2323
tower-lsp-server = "0.22.1"
24-
tokio = { version = "1.52.1", default-features = false, features = ["fs", "io-std", "io-util", "macros", "net", "rt-multi-thread", "sync"] }
24+
tokio = { version = "1.52.1", default-features = false, features = ["fs", "io-std", "io-util", "macros", "net", "rt-multi-thread", "sync", "test-util", "time"] }
2525
clap = { version = "4.6.0", default-features = false, features = ["derive", "std"] }
2626
dirs = "6.0.0"
2727
anyhow = "1.0.102"

harper-ls/src/backend.rs

Lines changed: 80 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use std::io::{BufWriter, Write};
44
use std::path::{Path, PathBuf};
55
use std::sync::Arc;
66
use std::sync::atomic::{AtomicU64, Ordering};
7+
use std::time::Duration;
78

89
use crate::config::Config;
910
use crate::document_state::DocumentState;
@@ -36,7 +37,7 @@ use tower_lsp_server::lsp_types::notification::{Progress, PublishDiagnostics};
3637
use tower_lsp_server::lsp_types::request::WorkDoneProgressCreate;
3738
use tower_lsp_server::lsp_types::{
3839
CodeActionOrCommand, CodeActionParams, CodeActionProviderCapability, CodeActionResponse,
39-
Diagnostic, DidChangeConfigurationParams, DidChangeTextDocumentParams,
40+
ConfigurationItem, Diagnostic, DidChangeConfigurationParams, DidChangeTextDocumentParams,
4041
DidChangeWatchedFilesParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams,
4142
DidSaveTextDocumentParams, ExecuteCommandOptions, ExecuteCommandParams, FileChangeType,
4243
InitializeParams, InitializeResult, InitializedParams, MessageType, NumberOrString,
@@ -53,14 +54,16 @@ pub fn ls_version() -> &'static str {
5354
env!("CARGO_PKG_VERSION")
5455
}
5556

57+
#[derive(Clone)]
5658
pub struct Backend {
5759
client: Client,
58-
root: RwLock<PathBuf>,
59-
config: RwLock<Config>,
60-
stats: RwLock<Stats>,
61-
doc_state: Mutex<HashMap<Uri, DocumentState>>,
62-
progress_counter: AtomicU64,
63-
lang_detect: LanguageDetectionRegistry,
60+
root: Arc<RwLock<PathBuf>>,
61+
config: Arc<RwLock<Config>>,
62+
stats: Arc<RwLock<Stats>>,
63+
doc_state: Arc<Mutex<HashMap<Uri, DocumentState>>>,
64+
progress_counter: Arc<AtomicU64>,
65+
lang_detect: Arc<LanguageDetectionRegistry>,
66+
pending_diagnostic_tasks: Arc<Mutex<HashMap<Uri, tokio::task::JoinHandle<()>>>>,
6467
}
6568

6669
const MIN_WORDS_FOR_LANGUAGE_DETECTION: usize = 10;
@@ -69,12 +72,13 @@ impl Backend {
6972
pub fn new(client: Client, config: Config) -> Self {
7073
Self {
7174
client,
72-
root: RwLock::new(".".into()),
73-
stats: RwLock::new(Stats::new()),
74-
config: RwLock::new(config),
75-
doc_state: Mutex::new(HashMap::new()),
76-
progress_counter: AtomicU64::new(1),
77-
lang_detect: LanguageDetectionRegistry::new(),
75+
root: Arc::new(RwLock::new(".".into())),
76+
stats: Arc::new(RwLock::new(Stats::new())),
77+
config: Arc::new(RwLock::new(config)),
78+
doc_state: Arc::new(Mutex::new(HashMap::new())),
79+
progress_counter: Arc::new(AtomicU64::new(1)),
80+
lang_detect: Arc::new(LanguageDetectionRegistry::new()),
81+
pending_diagnostic_tasks: Arc::new(Mutex::new(HashMap::new())),
7882
}
7983
}
8084

@@ -759,6 +763,13 @@ impl Backend {
759763
}
760764
}
761765

766+
/// The async pending diagnostics are discarded and diagnostics are created
767+
/// and published immediately.
768+
async fn publish_diagnostics_immediately(&self, uri: &Uri) {
769+
self.abort_pending_diagnostics(uri).await;
770+
self.publish_diagnostics(uri).await;
771+
}
772+
762773
/// Update the configuration of the server and publish document updates that
763774
/// match it.
764775
async fn update_config_from_obj(&self, json_obj: Value) {
@@ -795,6 +806,41 @@ impl Backend {
795806
*config = new_config;
796807
}
797808
}
809+
async fn pull_config(&self) {
810+
let mut new_config = self
811+
.client
812+
.configuration(vec![ConfigurationItem {
813+
scope_uri: None,
814+
section: None,
815+
}])
816+
.await
817+
.unwrap_or(vec![json!({ "harper-ls": {} })]);
818+
819+
if let Some(first) = new_config.pop() {
820+
self.update_config_from_obj(first).await;
821+
}
822+
}
823+
824+
/// Aborts a pending diagnostic run, e.g. because the document was changed.
825+
async fn abort_pending_diagnostics(&self, uri: &Uri) {
826+
let mut tasks = self.pending_diagnostic_tasks.lock().await;
827+
if let Some(task) = tasks.remove(uri) {
828+
task.abort();
829+
}
830+
}
831+
832+
/// Schedule diagnostics in `delay_ms` milliseconds
833+
pub async fn schedule_diagnostics(&self, uri: &Uri, delay_ms: u64) {
834+
let task_uri = uri.clone();
835+
let backend = self.clone();
836+
let handle = tokio::spawn(async move {
837+
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
838+
backend.publish_diagnostics(&task_uri).await;
839+
});
840+
841+
let mut tasks = self.pending_diagnostic_tasks.lock().await;
842+
tasks.insert(uri.clone(), handle);
843+
}
798844
}
799845

800846
impl LanguageServer for Backend {
@@ -875,7 +921,8 @@ impl LanguageServer for Backend {
875921
error!("{err}");
876922
}
877923

878-
self.publish_diagnostics(&params.text_document.uri).await;
924+
self.publish_diagnostics_immediately(&params.text_document.uri)
925+
.await;
879926
self.end_progress(progress, "Analysis complete").await;
880927
}
881928

@@ -895,15 +942,25 @@ impl LanguageServer for Backend {
895942
error!("{err}")
896943
}
897944

898-
self.publish_diagnostics(&params.text_document.uri).await;
945+
let delay_ms = self.config.read().await.diagnostic_delay_ms;
946+
if delay_ms > 0 {
947+
// Diagnostics delay is configured, abort pending diagnostic task
948+
// and schedule a new diagnostic run in delay_ms milliseconds.
949+
self.abort_pending_diagnostics(&params.text_document.uri)
950+
.await;
951+
self.schedule_diagnostics(&params.text_document.uri, delay_ms)
952+
.await;
953+
} else {
954+
self.publish_diagnostics(&params.text_document.uri).await;
955+
}
899956
self.end_progress(progress, "Diagnostics updated").await;
900957
}
901958

902959
async fn did_close(&self, _params: DidCloseTextDocumentParams) {
903960
let uri = _params.text_document.uri;
904961
let mut doc_lock = self.doc_state.lock().await;
905962
doc_lock.remove(&uri);
906-
963+
self.abort_pending_diagnostics(&uri).await;
907964
self.client
908965
.send_notification::<PublishDiagnostics>(PublishDiagnosticsParams {
909966
uri: uri.clone(),
@@ -935,6 +992,7 @@ impl LanguageServer for Backend {
935992
}
936993

937994
for uri in &uris_to_clear {
995+
self.abort_pending_diagnostics(uri).await;
938996
self.client
939997
.send_notification::<PublishDiagnostics>(PublishDiagnosticsParams {
940998
uri: uri.clone(),
@@ -989,7 +1047,7 @@ impl LanguageServer for Backend {
9891047
.await
9901048
.map_err(|err| error!("{err}"))
9911049
.err();
992-
self.publish_diagnostics(&file_uri).await;
1050+
self.publish_diagnostics_immediately(&file_uri).await;
9931051
}
9941052
"HarperAddToWSDict" => {
9951053
let word = &first.chars().collect::<Vec<_>>();
@@ -1011,7 +1069,7 @@ impl LanguageServer for Backend {
10111069
.await
10121070
.map_err(|err| error!("{err}"))
10131071
.err();
1014-
self.publish_diagnostics(&file_uri).await;
1072+
self.publish_diagnostics_immediately(&file_uri).await;
10151073
}
10161074
"HarperAddToFileDict" => {
10171075
let word = &first.chars().collect::<Vec<_>>();
@@ -1043,7 +1101,7 @@ impl LanguageServer for Backend {
10431101
.await
10441102
.map_err(|err| error!("{err}"))
10451103
.err();
1046-
self.publish_diagnostics(&file_uri).await;
1104+
self.publish_diagnostics_immediately(&file_uri).await;
10471105
}
10481106
"HarperOpen" => match open::that(&first) {
10491107
Ok(()) => {
@@ -1093,7 +1151,7 @@ impl LanguageServer for Backend {
10931151

10941152
drop(doc_lock);
10951153

1096-
self.publish_diagnostics(&uri).await;
1154+
self.publish_diagnostics_immediately(&uri).await;
10971155
}
10981156
_ => (),
10991157
}
@@ -1128,7 +1186,7 @@ impl LanguageServer for Backend {
11281186
.await
11291187
.map_err(|err| error!("{err}"))
11301188
.err();
1131-
self.publish_diagnostics(&uri).await;
1189+
self.publish_diagnostics_immediately(&uri).await;
11321190
}
11331191
}
11341192

@@ -1155,6 +1213,7 @@ impl LanguageServer for Backend {
11551213

11561214
// Clears the diagnostics for open buffers (without holding lock)
11571215
for uri in &uris {
1216+
self.abort_pending_diagnostics(uri).await;
11581217
let result = PublishDiagnosticsParams {
11591218
uri: uri.clone(),
11601219
diagnostics: vec![],

harper-ls/src/config.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ pub struct Config {
8383
/// Above this limit, the file will not be linted.
8484
pub max_file_length: usize,
8585
pub exclude_patterns: GlobSet,
86+
/// Delay in milliseconds after typing stops before diagnostics are published.
87+
/// Set to 0 to publish diagnostics immediately.
88+
pub diagnostic_delay_ms: u64,
8689
}
8790

8891
impl Config {
@@ -276,6 +279,10 @@ impl Config {
276279
}
277280
}
278281

282+
if let Some(v) = value.get("diagnosticDelayMs") {
283+
base.diagnostic_delay_ms = serde_json::from_value(v.clone())?;
284+
}
285+
279286
Ok(base)
280287
}
281288
}
@@ -298,6 +305,7 @@ impl Default for Config {
298305
language: Language::default(),
299306
max_file_length: 120_000,
300307
exclude_patterns: GlobSet::empty(),
308+
diagnostic_delay_ms: 0,
301309
}
302310
}
303311
}
@@ -308,6 +316,28 @@ mod tests {
308316
use serde_json::json;
309317
use tempfile::TempDir;
310318

319+
#[test]
320+
fn parses_diagnostic_delay() {
321+
let config = Config::from_lsp_config(
322+
std::path::Path::new("."),
323+
json!({
324+
"harper-ls": {
325+
"diagnosticDelayMs": 750
326+
}
327+
}),
328+
)
329+
.unwrap();
330+
331+
assert_eq!(config.diagnostic_delay_ms, 750);
332+
}
333+
334+
#[test]
335+
fn defaults_diagnostic_delay_to_zero() {
336+
let config = Config::default();
337+
338+
assert_eq!(config.diagnostic_delay_ms, 0);
339+
}
340+
311341
#[test]
312342
fn stats_path_config_does_not_override_file_dict_path() {
313343
let tempdir = TempDir::new().unwrap();

0 commit comments

Comments
 (0)