editor: Fix LSP extension commands failing at end of file (#52651)

Florian TREHAUT , Florian Trehaut , and Kirill Bulatov created

## Context

Closes #51330

When the cursor is at the very end of a file,
`find_specific_language_server_in_selection` in
`crates/editor/src/lsp_ext.rs` silently skips the selection because the
anchor's `buffer_id` is `None`. This causes `editor: switch source
header` (clangd) and rust-analyzer extension commands (`expand macro`,
`open docs`, `open playground`) to do nothing.

The fix falls back to the singleton buffer's ID when the anchor has no
`buffer_id`.

## How to Review

Single file change in `crates/editor/src/lsp_ext.rs`. The diff is small
— pre-compute the singleton buffer ID, then use it as fallback in the
`filter_map` closure. An integration test verifies the fix.

## Self-Review Checklist

- [x] Reviewed own diff for quality, security, and reliability
- [x] No unsafe blocks
- [x] Tests pass (639 editor tests, 0 failures)
- [x] Manual testing: switch source header works at beginning, middle,
and end of C++ file

## Test Plan

- [x] New integration test: `test_find_language_server_at_end_of_file` —
verifies `find_specific_language_server_in_selection` returns `Some` at
both beginning and end of file
- [x] Confirmed the test fails without the fix (assertion on "should
find language server at end of file")
- [x] Manual: open a C++ file with clangd, place cursor at very end, run
`switch source header` — now correctly opens the header
- [x] Manual: verify it still works at beginning and middle of file
(non-regression)
- [x] `cargo test -p editor` — 639 passed, 0 failed

Release Notes:

- Fixed `editor: switch source header` and other LSP extension commands
not working when the cursor is at the very end of a file.

---------

Co-authored-by: Florian Trehaut <florian.trehaut@hillcode.fr>
Co-authored-by: Kirill Bulatov <kirill@zed.dev>

Change summary

crates/editor/src/lsp_ext.rs | 124 +++++++++++++++++++++++++++++++++++--
1 file changed, 116 insertions(+), 8 deletions(-)

Detailed changes

crates/editor/src/lsp_ext.rs 🔗

@@ -2,10 +2,9 @@ use std::sync::Arc;
 use std::time::Duration;
 
 use crate::Editor;
-use collections::HashMap;
+use collections::{HashMap, HashSet};
 use gpui::AsyncApp;
 use gpui::{App, Entity, Task};
-use itertools::Itertools;
 use language::Buffer;
 use language::Language;
 use lsp::LanguageServerId;
@@ -33,22 +32,34 @@ where
     F: Fn(&Language) -> bool,
 {
     let project = editor.project.clone()?;
+    let multi_buffer = editor.buffer();
+    let mut seen_buffer_ids = HashSet::default();
     editor
         .selections
         .disjoint_anchors_arc()
         .iter()
-        .filter_map(|selection| Some((selection.head(), selection.head().text_anchor.buffer_id?)))
-        .unique_by(|(_, buffer_id)| *buffer_id)
-        .find_map(|(trigger_anchor, buffer_id)| {
-            let buffer = editor.buffer().read(cx).buffer(buffer_id)?;
-            let language = buffer.read(cx).language_at(trigger_anchor.text_anchor)?;
+        .find_map(|selection| {
+            let multi_buffer = multi_buffer.read(cx);
+            let (position, buffer) = multi_buffer
+                .buffer_for_anchor(selection.head(), cx)
+                .map(|buffer| (selection.head(), buffer))
+                .or_else(|| {
+                    multi_buffer
+                        .buffer_for_anchor(selection.tail(), cx)
+                        .map(|buffer| (selection.tail(), buffer))
+                })?;
+            if !seen_buffer_ids.insert(buffer.read(cx).remote_id()) {
+                return None;
+            }
+
+            let language = buffer.read(cx).language_at(position.text_anchor)?;
             if filter_language(&language) {
                 let server_id = buffer.update(cx, |buffer, cx| {
                     project
                         .read(cx)
                         .language_server_id_for_name(buffer, &language_server_name, cx)
                 })?;
-                Some((trigger_anchor, language, server_id, buffer))
+                Some((position, language, server_id, buffer))
             } else {
                 None
             }
@@ -173,3 +184,100 @@ pub fn lsp_tasks(
         .await
     })
 }
+
+#[cfg(test)]
+mod tests {
+    use std::sync::Arc;
+
+    use futures::StreamExt as _;
+    use gpui::{AppContext as _, Entity, TestAppContext};
+    use language::{FakeLspAdapter, Language};
+    use languages::rust_lang;
+    use lsp::{LanguageServerId, LanguageServerName};
+    use multi_buffer::{Anchor, MultiBuffer};
+    use project::{FakeFs, Project};
+    use util::path;
+
+    use crate::{MoveToEnd, editor_tests::init_test, test::build_editor_with_project};
+
+    use super::find_specific_language_server_in_selection;
+
+    #[gpui::test]
+    async fn test_find_language_server_at_end_of_file(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_file(path!("/file.rs"), "fn main() {}".into())
+            .await;
+
+        let project = Project::test(fs, [path!("/file.rs").as_ref()], cx).await;
+        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+        language_registry.add(rust_lang());
+        let mut fake_servers =
+            language_registry.register_fake_lsp("Rust", FakeLspAdapter::default());
+
+        let underlying_buffer = project
+            .update(cx, |project, cx| {
+                project.open_local_buffer(path!("/file.rs"), cx)
+            })
+            .await
+            .unwrap();
+
+        let buffer = cx.new(|cx| MultiBuffer::singleton(underlying_buffer.clone(), cx));
+        let (editor, cx) = cx.add_window_view(|window, cx| {
+            build_editor_with_project(project.clone(), buffer, window, cx)
+        });
+
+        let fake_server = fake_servers.next().await.unwrap();
+        cx.executor().run_until_parked();
+
+        let expected_server_id = fake_server.server.server_id();
+        let language_server_name = LanguageServerName::new_static("the-fake-language-server");
+        let filter = |language: &Language| language.name().as_ref() == "Rust";
+
+        let assert_result = |result: Option<(
+            Anchor,
+            Arc<Language>,
+            LanguageServerId,
+            Entity<language::Buffer>,
+        )>,
+                             message: &str| {
+            let (_, language, server_id, buffer) = result.expect(message);
+            assert_eq!(
+                language.name().as_ref(),
+                "Rust",
+                "{message}: wrong language"
+            );
+            assert_eq!(server_id, expected_server_id, "{message}: wrong server ID");
+            assert_eq!(buffer, underlying_buffer, "{message}: wrong buffer");
+        };
+
+        editor.update(cx, |editor, cx| {
+            assert_result(
+                find_specific_language_server_in_selection(
+                    editor,
+                    cx,
+                    filter,
+                    language_server_name.clone(),
+                ),
+                "should find correct language server at beginning of file",
+            );
+        });
+
+        editor.update_in(cx, |editor, window, cx| {
+            editor.move_to_end(&MoveToEnd, window, cx);
+        });
+
+        editor.update(cx, |editor, cx| {
+            assert_result(
+                find_specific_language_server_in_selection(
+                    editor,
+                    cx,
+                    filter,
+                    language_server_name.clone(),
+                ),
+                "should find correct language server at end of file",
+            );
+        });
+    }
+}