clangd: Implement switch source/header extension (#14646)

Thorben KrΓΆger and Kirill Bulatov created

Release Notes:

- Added switch source/header action for clangd language server (fixes
[#12801](https://github.com/zed-industries/zed/issues/12801)).

Note: I'm new to both rust and this codebase. I started my
implementation by copying how rust analyzer's "expand macro" LSP
extension is implemented. I don't yet understand some of the code I
copied (mostly the way to get the `server_to_query` in `clangd_ext.rs`
and the whole proto implementation).

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>

Change summary

crates/editor/src/actions.rs           |  1 
crates/editor/src/clangd_ext.rs        | 93 +++++++++++++++++++++++++++
crates/editor/src/editor.rs            |  2 
crates/editor/src/element.rs           |  1 
crates/editor/src/lsp_ext.rs           | 54 ++++++++++++++++
crates/editor/src/rust_analyzer_ext.rs | 76 ++++++---------------
crates/project/src/lsp_ext_command.rs  | 94 ++++++++++++++++++++++++++++
crates/proto/proto/zed.proto           | 16 ++++
crates/proto/src/proto.rs              |  4 +
9 files changed, 286 insertions(+), 55 deletions(-)

Detailed changes

crates/editor/src/actions.rs πŸ”—

@@ -306,6 +306,7 @@ gpui::actions!(
         SortLinesCaseInsensitive,
         SortLinesCaseSensitive,
         SplitSelectionIntoLines,
+        SwitchSourceHeader,
         Tab,
         TabPrev,
         ToggleAutoSignatureHelp,

crates/editor/src/clangd_ext.rs πŸ”—

@@ -0,0 +1,93 @@
+use std::path::PathBuf;
+
+use anyhow::Context as _;
+use gpui::{View, ViewContext, WindowContext};
+use language::Language;
+use url::Url;
+
+use crate::lsp_ext::find_specific_language_server_in_selection;
+
+use crate::{element::register_action, Editor, SwitchSourceHeader};
+
+static CLANGD_SERVER_NAME: &str = "clangd";
+
+fn is_c_language(language: &Language) -> bool {
+    return language.name().as_ref() == "C++" || language.name().as_ref() == "C";
+}
+
+pub fn switch_source_header(
+    editor: &mut Editor,
+    _: &SwitchSourceHeader,
+    cx: &mut ViewContext<'_, Editor>,
+) {
+    let Some(project) = &editor.project else {
+        return;
+    };
+    let Some(workspace) = editor.workspace() else {
+        return;
+    };
+
+    let Some((_, _, server_to_query, buffer)) =
+        find_specific_language_server_in_selection(&editor, cx, &is_c_language, CLANGD_SERVER_NAME)
+    else {
+        return;
+    };
+
+    let project = project.clone();
+    let buffer_snapshot = buffer.read(cx).snapshot();
+    let source_file = buffer_snapshot
+        .file()
+        .unwrap()
+        .file_name(cx)
+        .to_str()
+        .unwrap()
+        .to_owned();
+
+    let switch_source_header_task = project.update(cx, |project, cx| {
+        project.request_lsp(
+            buffer,
+            project::LanguageServerToQuery::Other(server_to_query),
+            project::lsp_ext_command::SwitchSourceHeader,
+            cx,
+        )
+    });
+    cx.spawn(|_editor, mut cx| async move {
+        let switch_source_header = switch_source_header_task
+            .await
+            .with_context(|| format!("Switch source/header LSP request for path \"{}\" failed", source_file))?;
+        if switch_source_header.0.is_empty() {
+            log::info!("Clangd returned an empty string when requesting to switch source/header from \"{}\"", source_file);
+            return Ok(());
+        }
+
+        let goto = Url::parse(&switch_source_header.0).with_context(|| {
+            format!(
+                "Parsing URL \"{}\" returned from switch source/header failed",
+                switch_source_header.0
+            )
+        })?;
+
+        workspace
+            .update(&mut cx, |workspace, view_cx| {
+                workspace.open_abs_path(PathBuf::from(goto.path()), false, view_cx)
+            })
+            .with_context(|| {
+                format!(
+                    "Switch source/header could not open \"{}\" in workspace",
+                    goto.path()
+                )
+            })?
+            .await
+            .map(|_| ())
+    })
+    .detach_and_log_err(cx);
+}
+
+pub fn apply_related_actions(editor: &View<Editor>, cx: &mut WindowContext) {
+    if editor.update(cx, |e, cx| {
+        find_specific_language_server_in_selection(e, cx, &is_c_language, CLANGD_SERVER_NAME)
+            .is_some()
+    }) {
+        register_action(editor, cx, switch_source_header);
+    }
+}

crates/editor/src/editor.rs πŸ”—

@@ -15,6 +15,7 @@
 pub mod actions;
 mod blame_entry_tooltip;
 mod blink_manager;
+mod clangd_ext;
 mod debounced_delay;
 pub mod display_map;
 mod editor_settings;
@@ -30,6 +31,7 @@ mod inlay_hint_cache;
 mod inline_completion_provider;
 pub mod items;
 mod linked_editing_ranges;
+mod lsp_ext;
 mod mouse_context_menu;
 pub mod movement;
 mod persistence;

crates/editor/src/element.rs πŸ”—

@@ -165,6 +165,7 @@ impl EditorElement {
         });
 
         crate::rust_analyzer_ext::apply_related_actions(view, cx);
+        crate::clangd_ext::apply_related_actions(view, cx);
         register_action(view, cx, Editor::move_left);
         register_action(view, cx, Editor::move_right);
         register_action(view, cx, Editor::move_down);

crates/editor/src/lsp_ext.rs πŸ”—

@@ -0,0 +1,54 @@
+use std::sync::Arc;
+
+use crate::Editor;
+use gpui::{Model, WindowContext};
+use language::Buffer;
+use language::Language;
+use lsp::LanguageServerId;
+use multi_buffer::Anchor;
+
+pub(crate) fn find_specific_language_server_in_selection<F>(
+    editor: &Editor,
+    cx: &WindowContext,
+    filter_language: F,
+    language_server_name: &str,
+) -> Option<(Anchor, Arc<Language>, LanguageServerId, Model<Buffer>)>
+where
+    F: Fn(&Language) -> bool,
+{
+    let Some(project) = &editor.project else {
+        return None;
+    };
+    let multibuffer = editor.buffer().read(cx);
+    editor
+        .selections
+        .disjoint_anchors()
+        .into_iter()
+        .filter(|selection| selection.start == selection.end)
+        .filter_map(|selection| Some((selection.start.buffer_id?, selection.start)))
+        .filter_map(|(buffer_id, trigger_anchor)| {
+            let buffer = multibuffer.buffer(buffer_id)?;
+            let language = buffer.read(cx).language_at(trigger_anchor.text_anchor)?;
+            if !filter_language(&language) {
+                return None;
+            }
+            Some((trigger_anchor, language, buffer))
+        })
+        .find_map(|(trigger_anchor, language, buffer)| {
+            project
+                .read(cx)
+                .language_servers_for_buffer(buffer.read(cx), cx)
+                .find_map(|(adapter, server)| {
+                    if adapter.name.0.as_ref() == language_server_name {
+                        Some((
+                            trigger_anchor,
+                            Arc::clone(&language),
+                            server.server_id(),
+                            buffer.clone(),
+                        ))
+                    } else {
+                        None
+                    }
+                })
+        })
+}

crates/editor/src/rust_analyzer_ext.rs πŸ”—

@@ -1,5 +1,3 @@
-use std::sync::Arc;
-
 use anyhow::Context as _;
 use gpui::{Context, View, ViewContext, VisualContext, WindowContext};
 use language::Language;
@@ -7,22 +5,24 @@ use multi_buffer::MultiBuffer;
 use project::lsp_ext_command::ExpandMacro;
 use text::ToPointUtf16;
 
-use crate::{element::register_action, Editor, ExpandMacroRecursively};
+use crate::{
+    element::register_action, lsp_ext::find_specific_language_server_in_selection, Editor,
+    ExpandMacroRecursively,
+};
 
-pub fn apply_related_actions(editor: &View<Editor>, cx: &mut WindowContext) {
-    let is_rust_related = editor.update(cx, |editor, cx| {
-        editor
-            .buffer()
-            .read(cx)
-            .all_buffers()
-            .iter()
-            .any(|b| match b.read(cx).language() {
-                Some(l) => is_rust_language(l),
-                None => false,
-            })
-    });
+static RUST_ANALYZER_NAME: &str = "rust-analyzer";
 
-    if is_rust_related {
+fn is_rust_language(language: &Language) -> bool {
+    language.name().as_ref() == "Rust"
+}
+
+pub fn apply_related_actions(editor: &View<Editor>, cx: &mut WindowContext) {
+    if editor
+        .update(cx, |e, cx| {
+            find_specific_language_server_in_selection(e, cx, &is_rust_language, RUST_ANALYZER_NAME)
+        })
+        .is_some()
+    {
         register_action(editor, cx, expand_macro_recursively);
     }
 }
@@ -42,39 +42,13 @@ pub fn expand_macro_recursively(
         return;
     };
 
-    let multibuffer = editor.buffer().read(cx);
-
-    let Some((trigger_anchor, rust_language, server_to_query, buffer)) = editor
-        .selections
-        .disjoint_anchors()
-        .into_iter()
-        .filter(|selection| selection.start == selection.end)
-        .filter_map(|selection| Some((selection.start.buffer_id?, selection.start)))
-        .filter_map(|(buffer_id, trigger_anchor)| {
-            let buffer = multibuffer.buffer(buffer_id)?;
-            let rust_language = buffer.read(cx).language_at(trigger_anchor.text_anchor)?;
-            if !is_rust_language(&rust_language) {
-                return None;
-            }
-            Some((trigger_anchor, rust_language, buffer))
-        })
-        .find_map(|(trigger_anchor, rust_language, buffer)| {
-            project
-                .read(cx)
-                .language_servers_for_buffer(buffer.read(cx), cx)
-                .find_map(|(adapter, server)| {
-                    if adapter.name.0.as_ref() == "rust-analyzer" {
-                        Some((
-                            trigger_anchor,
-                            Arc::clone(&rust_language),
-                            server.server_id(),
-                            buffer.clone(),
-                        ))
-                    } else {
-                        None
-                    }
-                })
-        })
+    let Some((trigger_anchor, rust_language, server_to_query, buffer)) =
+        find_specific_language_server_in_selection(
+            &editor,
+            cx,
+            &is_rust_language,
+            RUST_ANALYZER_NAME,
+        )
     else {
         return;
     };
@@ -120,7 +94,3 @@ pub fn expand_macro_recursively(
     })
     .detach_and_log_err(cx);
 }
-
-fn is_rust_language(language: &Language) -> bool {
-    language.name().as_ref() == "Rust"
-}

crates/project/src/lsp_ext_command.rs πŸ”—

@@ -135,3 +135,97 @@ impl LspCommand for ExpandMacro {
         BufferId::new(message.buffer_id)
     }
 }
+
+pub enum LspSwitchSourceHeader {}
+
+impl lsp::request::Request for LspSwitchSourceHeader {
+    type Params = SwitchSourceHeaderParams;
+    type Result = Option<SwitchSourceHeaderResult>;
+    const METHOD: &'static str = "textDocument/switchSourceHeader";
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+pub struct SwitchSourceHeaderParams(lsp::TextDocumentIdentifier);
+
+#[derive(Serialize, Deserialize, Debug, Default)]
+#[serde(rename_all = "camelCase")]
+pub struct SwitchSourceHeaderResult(pub String);
+
+#[derive(Default, Deserialize, Serialize, Debug)]
+#[serde(rename_all = "camelCase")]
+pub struct SwitchSourceHeader;
+
+#[async_trait(?Send)]
+impl LspCommand for SwitchSourceHeader {
+    type Response = SwitchSourceHeaderResult;
+    type LspRequest = LspSwitchSourceHeader;
+    type ProtoRequest = proto::LspExtSwitchSourceHeader;
+
+    fn to_lsp(
+        &self,
+        path: &Path,
+        _: &Buffer,
+        _: &Arc<LanguageServer>,
+        _: &AppContext,
+    ) -> SwitchSourceHeaderParams {
+        SwitchSourceHeaderParams(lsp::TextDocumentIdentifier {
+            uri: lsp::Url::from_file_path(path).unwrap(),
+        })
+    }
+
+    async fn response_from_lsp(
+        self,
+        message: Option<SwitchSourceHeaderResult>,
+        _: Model<Project>,
+        _: Model<Buffer>,
+        _: LanguageServerId,
+        _: AsyncAppContext,
+    ) -> anyhow::Result<SwitchSourceHeaderResult> {
+        Ok(message
+            .map(|message| SwitchSourceHeaderResult(message.0))
+            .unwrap_or_default())
+    }
+
+    fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::LspExtSwitchSourceHeader {
+        proto::LspExtSwitchSourceHeader {
+            project_id,
+            buffer_id: buffer.remote_id().into(),
+        }
+    }
+
+    async fn from_proto(
+        _: Self::ProtoRequest,
+        _: Model<Project>,
+        _: Model<Buffer>,
+        _: AsyncAppContext,
+    ) -> anyhow::Result<Self> {
+        Ok(Self {})
+    }
+
+    fn response_to_proto(
+        response: SwitchSourceHeaderResult,
+        _: &mut Project,
+        _: PeerId,
+        _: &clock::Global,
+        _: &mut AppContext,
+    ) -> proto::LspExtSwitchSourceHeaderResponse {
+        proto::LspExtSwitchSourceHeaderResponse {
+            target_file: response.0,
+        }
+    }
+
+    async fn response_from_proto(
+        self,
+        message: proto::LspExtSwitchSourceHeaderResponse,
+        _: Model<Project>,
+        _: Model<Buffer>,
+        _: AsyncAppContext,
+    ) -> anyhow::Result<SwitchSourceHeaderResult> {
+        Ok(SwitchSourceHeaderResult(message.target_file))
+    }
+
+    fn buffer_id_from_proto(message: &proto::LspExtSwitchSourceHeader) -> Result<BufferId> {
+        BufferId::new(message.buffer_id)
+    }
+}

crates/proto/proto/zed.proto πŸ”—

@@ -131,7 +131,7 @@ message Envelope {
         UpdateUserPlan update_user_plan = 234;
         UpdateDiffBase update_diff_base = 104;
         AcceptTermsOfService accept_terms_of_service = 239;
-        AcceptTermsOfServiceResponse accept_terms_of_service_response = 240; // current max
+        AcceptTermsOfServiceResponse accept_terms_of_service_response = 240;
 
         OnTypeFormatting on_type_formatting = 105;
         OnTypeFormattingResponse on_type_formatting_response = 106;
@@ -264,15 +264,18 @@ message Envelope {
 
         GetSignatureHelp get_signature_help = 217;
         GetSignatureHelpResponse get_signature_help_response = 218;
+
         ListRemoteDirectory list_remote_directory = 219;
         ListRemoteDirectoryResponse list_remote_directory_response = 220;
         UpdateDevServerProject update_dev_server_project = 221;
-
         AddWorktree add_worktree = 222;
         AddWorktreeResponse add_worktree_response = 223;
 
         GetLlmToken get_llm_token = 235;
         GetLlmTokenResponse get_llm_token_response = 236;
+
+        LspExtSwitchSourceHeader lsp_ext_switch_source_header = 241;
+        LspExtSwitchSourceHeaderResponse lsp_ext_switch_source_header_response = 242; // current max
     }
 
     reserved 158 to 161;
@@ -2076,6 +2079,15 @@ message LspExtExpandMacroResponse {
     string expansion = 2;
 }
 
+message LspExtSwitchSourceHeader {
+    uint64 project_id = 1;
+    uint64 buffer_id = 2;
+}
+
+message LspExtSwitchSourceHeaderResponse {
+    string target_file = 1;
+}
+
 message SetRoomParticipantRole {
     uint64 room_id = 1;
     uint64 user_id = 2;

crates/proto/src/proto.rs πŸ”—

@@ -406,6 +406,8 @@ messages!(
     (UpdateContext, Foreground),
     (SynchronizeContexts, Foreground),
     (SynchronizeContextsResponse, Foreground),
+    (LspExtSwitchSourceHeader, Background),
+    (LspExtSwitchSourceHeaderResponse, Background),
     (AddWorktree, Foreground),
     (AddWorktreeResponse, Foreground),
 );
@@ -528,6 +530,7 @@ request_messages!(
     (OpenContext, OpenContextResponse),
     (CreateContext, CreateContextResponse),
     (SynchronizeContexts, SynchronizeContextsResponse),
+    (LspExtSwitchSourceHeader, LspExtSwitchSourceHeaderResponse),
     (AddWorktree, AddWorktreeResponse),
 );
 
@@ -597,6 +600,7 @@ entity_messages!(
     CreateContext,
     UpdateContext,
     SynchronizeContexts,
+    LspExtSwitchSourceHeader
 );
 
 entity_messages!(