From dee5b2a983f6a334753be48730868e8dd651b505 Mon Sep 17 00:00:00 2001
From: Matthias Deiml <matthias@deiml.net>
Date: Wed, 9 Nov 2022 10:17:09 +0100
Subject: [PATCH] Add LSP workspace command picker (#3140)

* Add workspace command picker

* Make command typable

* Add optional argument to lsp-workspace-command
---
 book/src/generated/typable-cmd.md |  1 +
 helix-lsp/src/client.rs           |  3 ++
 helix-term/src/commands/lsp.rs    |  8 ++++
 helix-term/src/commands/typed.rs  | 78 +++++++++++++++++++++++++++++++
 helix-term/src/ui/mod.rs          | 39 ++++++++++++++++
 5 files changed, 129 insertions(+)

diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md
index f858ba72..b7496d33 100644
--- a/book/src/generated/typable-cmd.md
+++ b/book/src/generated/typable-cmd.md
@@ -45,6 +45,7 @@
 | `:encoding` | Set encoding. Based on `https://encoding.spec.whatwg.org`. |
 | `:reload` | Discard changes and reload from the source file. |
 | `:update` | Write changes only if the file has been modified. |
+| `:lsp-workspace-command` | Open workspace command picker |
 | `:lsp-restart` | Restarts the Language Server that is in use by the current doc |
 | `:tree-sitter-scopes` | Display tree sitter scopes, primarily for theming and development. |
 | `:debug-start`, `:dbg` | Start a debug session from a given template with given parameters. |
diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs
index 81b7d8ad..50c4b87f 100644
--- a/helix-lsp/src/client.rs
+++ b/helix-lsp/src/client.rs
@@ -298,6 +298,9 @@ impl Client {
                         dynamic_registration: Some(false),
                         ..Default::default()
                     }),
+                    execute_command: Some(lsp::DynamicRegistrationClientCapabilities {
+                        dynamic_registration: Some(false),
+                    }),
                     ..Default::default()
                 }),
                 text_document: Some(lsp::TextDocumentClientCapabilities {
diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs
index 5498fc83..c149e62b 100644
--- a/helix-term/src/commands/lsp.rs
+++ b/helix-term/src/commands/lsp.rs
@@ -603,6 +603,14 @@ pub fn code_action(cx: &mut Context) {
         },
     )
 }
+
+impl ui::menu::Item for lsp::Command {
+    type Data = ();
+    fn label(&self, _data: &Self::Data) -> Spans {
+        self.title.as_str().into()
+    }
+}
+
 pub fn execute_lsp_command(editor: &mut Editor, cmd: lsp::Command) {
     let doc = doc!(editor);
     let language_server = language_server!(editor, doc);
diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs
index 2f387bfd..c6810f05 100644
--- a/helix-term/src/commands/typed.rs
+++ b/helix-term/src/commands/typed.rs
@@ -1052,6 +1052,77 @@ fn update(
     }
 }
 
+fn lsp_workspace_command(
+    cx: &mut compositor::Context,
+    args: &[Cow<str>],
+    event: PromptEvent,
+) -> anyhow::Result<()> {
+    if event != PromptEvent::Validate {
+        return Ok(());
+    }
+
+    let (_, doc) = current!(cx.editor);
+
+    let language_server = match doc.language_server() {
+        Some(language_server) => language_server,
+        None => {
+            cx.editor
+                .set_status("Language server not active for current buffer");
+            return Ok(());
+        }
+    };
+
+    let options = match &language_server.capabilities().execute_command_provider {
+        Some(options) => options,
+        None => {
+            cx.editor
+                .set_status("Workspace commands are not supported for this language server");
+            return Ok(());
+        }
+    };
+    if args.is_empty() {
+        let commands = options
+            .commands
+            .iter()
+            .map(|command| helix_lsp::lsp::Command {
+                title: command.clone(),
+                command: command.clone(),
+                arguments: None,
+            })
+            .collect::<Vec<_>>();
+        let callback = async move {
+            let call: job::Callback = Callback::EditorCompositor(Box::new(
+                move |_editor: &mut Editor, compositor: &mut Compositor| {
+                    let picker = ui::Picker::new(commands, (), |cx, command, _action| {
+                        execute_lsp_command(cx.editor, command.clone());
+                    });
+                    compositor.push(Box::new(overlayed(picker)))
+                },
+            ));
+            Ok(call)
+        };
+        cx.jobs.callback(callback);
+    } else {
+        let command = args.join(" ");
+        if options.commands.iter().any(|c| c == &command) {
+            execute_lsp_command(
+                cx.editor,
+                helix_lsp::lsp::Command {
+                    title: command.clone(),
+                    arguments: None,
+                    command,
+                },
+            );
+        } else {
+            cx.editor.set_status(format!(
+                "`{command}` is not supported for this language server"
+            ));
+            return Ok(());
+        }
+    }
+    Ok(())
+}
+
 fn lsp_restart(
     cx: &mut compositor::Context,
     _args: &[Cow<str>],
@@ -1987,6 +2058,13 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[
             fun: update,
             completer: None,
         },
+        TypableCommand {
+            name: "lsp-workspace-command",
+            aliases: &[],
+            doc: "Open workspace command picker",
+            fun: lsp_workspace_command,
+            completer: Some(completers::lsp_workspace_command),
+        },
         TypableCommand {
             name: "lsp-restart",
             aliases: &[],
diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs
index f99dea0b..cca9e9bf 100644
--- a/helix-term/src/ui/mod.rs
+++ b/helix-term/src/ui/mod.rs
@@ -390,6 +390,45 @@ pub mod completers {
             .collect()
     }
 
+    pub fn lsp_workspace_command(editor: &Editor, input: &str) -> Vec<Completion> {
+        let matcher = Matcher::default();
+
+        let (_, doc) = current_ref!(editor);
+
+        let language_server = match doc.language_server() {
+            Some(language_server) => language_server,
+            None => {
+                return vec![];
+            }
+        };
+
+        let options = match &language_server.capabilities().execute_command_provider {
+            Some(options) => options,
+            None => {
+                return vec![];
+            }
+        };
+
+        let mut matches: Vec<_> = options
+            .commands
+            .iter()
+            .filter_map(|command| {
+                matcher
+                    .fuzzy_match(command, input)
+                    .map(|score| (command, score))
+            })
+            .collect();
+
+        matches.sort_unstable_by(|(command1, score1), (command2, score2)| {
+            (Reverse(*score1), command1).cmp(&(Reverse(*score2), command2))
+        });
+
+        matches
+            .into_iter()
+            .map(|(command, _score)| ((0..), command.clone().into()))
+            .collect()
+    }
+
     pub fn directory(editor: &Editor, input: &str) -> Vec<Completion> {
         filename_impl(editor, input, |entry| {
             let is_dir = entry.file_type().map_or(false, |entry| entry.is_dir());