Skip to content

Commit f718eca

Browse files
committed
fix(lsp): drop a pending edit when its file is deleted
A file change is debounced and a deletion is not, so an edit made shortly before a deletion is still queued when the deletion arrives. Flushing it afterwards put the file back in the workspace index, and the cross-file rules then kept answering questions about a file that was no longer there: a link into the deleted file still reported a missing fragment. Renaming reaches this on any edit-then-rename, since an editor reports a rename as a deletion of the old path.
1 parent 4c4cbed commit f718eca

2 files changed

Lines changed: 85 additions & 3 deletions

File tree

src/lsp/index_worker.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -258,9 +258,12 @@ impl IndexWorker {
258258
}
259259

260260
/// Handle a file deletion
261-
async fn handle_file_deleted(&self, path: &Path) {
262-
// Remove pending update for this file
263-
// (self.pending is not accessible here directly, but FileDeleted is handled immediately)
261+
async fn handle_file_deleted(&mut self, path: &Path) {
262+
// A change is debounced and a deletion is not, so an edit made shortly
263+
// before the delete is still waiting here. Flushing it afterwards would
264+
// put the file back in the index, and a rule reading that entry then
265+
// answers questions about a file that no longer exists.
266+
self.pending.remove(path);
264267

265268
// Get dependents before removing
266269
let dependents = {

src/lsp/relint_tests.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,3 +562,82 @@ async fn test_dropping_the_server_stops_its_background_tasks() {
562562
"background tasks outlived the server they belong to"
563563
);
564564
}
565+
566+
/// A deletion is handled immediately and a change is debounced, so an edit made
567+
/// shortly before a file is deleted is still queued when the deletion arrives.
568+
/// Flushing it afterwards puts the file back in the index, and the cross-file
569+
/// rules then keep answering questions about a file that is no longer there.
570+
///
571+
/// Renaming reaches this on every keystroke-then-rename: the editor reports a
572+
/// rename as a deletion of the old path.
573+
#[tokio::test]
574+
async fn test_a_deleted_file_is_not_resurrected_by_a_pending_edit() {
575+
let temp = tempfile::tempdir().unwrap();
576+
let root = write_workspace(
577+
&temp,
578+
&[
579+
(".rumdl.toml", ENABLE_MD051),
580+
(
581+
"a.md",
582+
"# A\n\nSee [gone](./b.md#missing) and [kept](./c.md#missing).\n",
583+
),
584+
("b.md", "# B\n"),
585+
("c.md", "# C\n"),
586+
],
587+
);
588+
let (a, b) = (root.join("a.md"), root.join("b.md"));
589+
let a_text = "# A\n\nSee [gone](./b.md#missing) and [kept](./c.md#missing).\n";
590+
591+
let client = LspTestClient::start(&root.join(".rumdl.toml"));
592+
client.initialize(&root, push_capabilities()).await;
593+
client.notify("initialized", json!({})).await;
594+
client.wait_for_index_ready().await;
595+
596+
client.did_open(&a, a_text).await;
597+
let before = client.wait_for_publishes(&a, 1).await;
598+
assert!(
599+
before[0].iter().any(|d| d.contains("./b.md")),
600+
"control: b.md is indexed and lacks the anchor, so its fragment is reported \
601+
before the deletion, got {before:?}"
602+
);
603+
604+
// The edit queues a debounced update for b.md; the deletion arrives while
605+
// that update is still waiting.
606+
client.did_open(&b, "# B\n").await;
607+
client.did_change(&b, 2, "# B\n\nEdited.\n").await;
608+
std::fs::remove_file(&b).unwrap();
609+
client
610+
.notify(
611+
"workspace/didChangeWatchedFiles",
612+
json!({"changes": [{"uri": Url::from_file_path(&b).unwrap(), "type": 3}]}),
613+
)
614+
.await;
615+
616+
// Long enough for the pending edit to have been flushed if it survived the
617+
// deletion, which is the whole failure mode.
618+
tokio::time::sleep(Duration::from_millis(600)).await;
619+
let resurrected = {
620+
let index = client.server.workspace_index.read().await;
621+
index.get_file(&b).is_some()
622+
};
623+
assert!(!resurrected, "a deleted file must not be back in the workspace index");
624+
625+
// What the editor shows: an edit of a.md re-lints it, and the link into the
626+
// deleted file no longer has an index entry to be judged against. Count the
627+
// publishes first, so this reads the lint that ran after the deletion rather
628+
// than the one from did_open.
629+
let seen = client.wait_for_publishes(&a, 1).await.len();
630+
let a_edited = "# A\n\nSee [gone](./b.md#missing) and [kept](./c.md#missing). Edited.\n";
631+
client.did_change(&a, 2, a_edited).await;
632+
let after = client.wait_for_publishes(&a, seen + 1).await;
633+
let latest = after.last().unwrap();
634+
assert!(
635+
latest.iter().any(|d| d.contains("./c.md")),
636+
"control: the surviving file is still judged, so a lint that simply went \
637+
quiet cannot pass for a fixed one, got {latest:?}"
638+
);
639+
assert!(
640+
!latest.iter().any(|d| d.contains("./b.md")),
641+
"the deleted file must stop answering fragment questions, got {latest:?}"
642+
);
643+
}

0 commit comments

Comments
 (0)