Repository: autozimu/LanguageClient-neovim (language: rust)

Issue:
Add option to set the default initialization options for a language server
## Is your feature request related to a problem? Please describe.

Specifying initialization options is repetitive.
Each individual project needs a new `.vim/settings.json` file.
Changes to the initialization options for a language server need require many `.vim/settings.json` files.

## Describe the solution you'd like

An option in Vim script to set the default initialization options for a language server.

```vim
let g:LanguageClient_defaultInitializationOptions = {}
let g:LanguageClient_defaultInitializationOptions.ruby = {
      \ "supportsOperationNotifications": v:true,
      \ }
```

In general, initialization options are overwhelmingly used to say "the LSP client supports this functionality."
That is, initialization options are frequently a function of the client (global) not each project.

## Describe alternatives you've considered

Currently `LanguageClient-neovim` reads from a JSON file (`.vim/settings.json`) in the current directory.
An alternative would be to also search for this file globally. That is undesirable:

- Vim plugins that configure LanguageClient-neovim on behalf of a user would not be able to control these settings.
- Vim is configured in Vim script. Vim users expect to use Vim script (not JSON) to configure their plugins.
- Using a variable lets the option be configured in code. For example, I currently have code to set different server options if my Ruby language server was built in release or debug mode (e.g., add some extra debug mode options for logging that are slow). Using code for initialization options would make it easier to test, for example, experimental / debug-only features.




Discussion / hints:
To answer your "each individual project needs a new settings.json file" point, you can set the settings path to be used with `let g:LanguageClient_settingsPath = /your/path`, and it doesn't have to be within your project folder. So you could either set that to something like `let g:LanguageClient_settingsPath = ~/.vim/settings.json` or create a per-language config in your `ftplugin` folder and having a different setting for each language. That would essentially have the same effect as having a global config.

```vim
" contents of ~/.config/nvim/ftplugin/ruby.vim
let g:LanguageClient_settingsPath = ~/.vim/ruby/settings.json
```


```vim
" contents of ~/.config/nvim/ftplugin/rust.vim
let g:LanguageClient_settingsPath = ~/.vim/rust/settings.json
```

Your point about vimscript being preferable over JSON I think is valid, but I think ultimately most servers document the configuration with JSON examples so it's easier to use JSON.
Having said that, I'm not opposed to using vimscript for that, and I think there's value in adding a config value for this, but I think it would be weird to have this setting be a vimscript setting and the workspace local setting be a JSON one. Especially because we'd probably have to merge (or at least choose one of) the two settings.

So to summarise, I see the value in adding that, but it comes with a lot of added complexity as well, and the "global config" can already be configured so I'm not sure if it's worth going there.
Ok.

I find it frustrating whenever I find Vim plugins that invent their own config formats instead of using Vim script (coc.nvim is guilty of the same sin). But it's your decision.

I have implemented what I want to based on `LanguageClient_settingsPath` here:

https://github.com/jez/vim-sorbet/blob/bec2698771f4392c2d0f75a4bad537c41beae707/ftplugin/ruby.vim#L13-L19

I would still appreciate this feature, but I have a workaround so if you want to close I'm fine with that too.
I agree with that feeling, but I reckon that this case was a little different, since those settings could be workspace settings, so adding them to the vim configuration was not really an option (or at least I can't think now how that would look). But more importantly, I think that supporting default initialisation options as vimscript and workspace level settings as JSON would be a little too inconsistent, and dropping support for JSON would break the plugin for many people.

Having said all that, this isn't really my decision neither, it's really up to @autozimu , so maybe he has a different opinion.

Fix patch:
diff --git a/doc/LanguageClient.txt b/doc/LanguageClient.txt
index 3293996a..89e8b4a6 100644
--- a/doc/LanguageClient.txt
+++ b/doc/LanguageClient.txt
@@ -75,17 +75,49 @@ To use the language server with Vim's formatting operator |gq|, set 'formatexpr'
 
 2.1 g:LanguageClient_serverCommands          *g:LanguageClient_serverCommands*
 
-String to list map. Defines commands to start language server for specific
-filetype. For example: >
+Dictionary to specify the servers to be used for each filetype. The keys are
+strings representing the filetypes and the values are either
+  1 - a list of strings that form a command
+  2 - a dictionary with keys name, command and initializationOptions, where:
+    - name: is the name of the server, which should match the root node of the
+      configuration options specified in the settings.json file for this server
+    - command: is the same list of strings in option 1
+    - initializationOptions: is an optional dictionary with options to be passed
+      to the server. The values to be set here are highly dependent on each
+      server so you should see the documentation for each server to find out
+      what to configure in here (if anything).  The options set in
+      initializationOptions are merged with the default settings for each
+      language server and with the workspace settings defined by the user in
+      the files specified in the variable `LanguageClient_settingsPath'. The
+      order in which these settings are merged is first the defualt settings,
+      then the server settings configured in this section and lastly the
+      contents of the files in the `LanguageClient_settingsPath` variable in
+      the order in which they were listed.
+
+For example: >
 
     let g:LanguageClient_serverCommands = {
         \ 'rust': ['rustup', 'run', 'stable', 'rls'],
-        \ }
-
-In the example above, 'run', 'stable', and 'rls' are arguments to the
-'rustup' command line tool.
-
-Or tcp connection string to the server, >
+        \ 'go': {
+        \   'name': 'gopls',
+        \   'command': ['gopls'],
+        \   'initializationOptions': {
+        \     'usePlaceholders': v:true,
+        \     'codelens': {
+        \       'generate': v:true,
+        \       'test': v:true,
+        \     },
+        \   },
+        \ },
+        \}
+
+In the configuration for the rust filetype above, 'run', 'stable', and 'rls'
+are arguments to the 'rustup' command line tool. And in the configuration for
+the go filetype the server is configured to run the command `gopls` with no
+additional arguments, and with the initialization options set in the
+`initializationOptions` key.
+
+You can also use a tcp connection to the server, for example: >
     let g:LanguageClient_serverCommands = {
         \ 'javascript': ['tcp://127.0.0.1:2089'],
         \ }
@@ -93,7 +125,14 @@ Or tcp connection string to the server, >
 Note: environmental variables are not supported except home directory alias `~`.
 
 Default: {}
-Valid Option: Map<String, List<String> | String>
+
+
+
+Valid Option: Map<String, List<String> | {
+    name: String
+    command: List<String>
+    initializationOptions?: Map<String, Any>
+  }>
 
 2.2 g:LanguageClient_diagnosticsDisplay  *g:LanguageClient_diagnosticsDisplay*
 
@@ -231,9 +270,34 @@ path this is relative to the workspace directory. If several paths are
 provided, then the corresponding settings are merged with precedence going to
 the last file.
 
+The initialization options found in the files in this config are combined with
+the initialization options specified in the server command, if any. The former
+taking precedence over the latter.
+
+Note that the key under which the initialization options for each server lives
+matches the key under which the server expects it's configuration. This is
+important for servers that can request configuration via a
+`workspace/configuration` request.
+
+Previously, the initialization options lived under a `initializationOptions`
+key, which worked, but made answering that `workspace/configuration` request
+hard, since we couldn't really get the correct path to the setting the server
+requested. Since version 0.1.161 of this plugin, that key has been deprecated
+and you'll see a message saying that you should move off of it if your settings
+file includes an `initializationOptions` key.
+
 Example settings file content: >
     {
-        "rust.clippy_preference": "on"
+        "gopls": {
+          "usePlaceholders": true,
+          "local": "github.com/my/pkg",
+        },
+        "rust-analyzer": {
+          "inlayHints": {
+            "enable": true,
+            "chainingHints": true
+          },
+        },
     }
 
 Default: ".vim/settings.json"
diff --git a/src/config.rs b/src/config/mod.rs
similarity index 98%
rename from src/config.rs
rename to src/config/mod.rs
index 2204c47d..96998abc 100644
--- a/src/config.rs
+++ b/src/config/mod.rs
@@ -1,3 +1,7 @@
+mod server_command;
+
+pub use server_command::*;
+
 use crate::{
     types::{
         CodeLensDisplay, DiagnosticsDisplay, DiagnosticsList, DocumentHighlightDisplay,
@@ -14,7 +18,7 @@ use std::{path::PathBuf, str::FromStr, time::Duration};
 #[derive(Debug)]
 pub struct Config {
     pub auto_start: bool,
-    pub server_commands: HashMap<String, Vec<String>>,
+    pub server_commands: HashMap<String, ServerCommand>,
     pub selection_ui: SelectionUI,
     pub trace: TraceOption,
     pub settings_path: Vec<String>,
@@ -98,7 +102,7 @@ struct DeserializableConfig {
     logging_level: log::LevelFilter,
     server_stderr: Option<String>,
     auto_start: u8,
-    server_commands: HashMap<String, Vec<String>>,
+    server_commands: HashMap<String, ServerCommand>,
     selection_ui: Option<String>,
     trace: Option<String>,
     settings_path: Vec<String>,
diff --git a/src/config/server_command.rs b/src/config/server_command.rs
new file mode 100644
index 00000000..dfe5d488
--- /dev/null
+++ b/src/config/server_command.rs
@@ -0,0 +1,83 @@
+use crate::utils::expand_json_path;
+use jsonrpc_core::Value;
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ServerDetails {
+    pub command: Vec<String>,
+    pub name: String,
+    pub initialization_options: Option<Value>,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum ServerCommand {
+    Simple(Vec<String>),
+    Detailed(ServerDetails),
+}
+
+impl ServerCommand {
+    pub fn get_command(&self) -> &[String] {
+        match self {
+            ServerCommand::Simple(cmd) => &cmd,
+            ServerCommand::Detailed(cmd) => &cmd.command,
+        }
+    }
+
+    /// Returns the server name from a ServerCommand. For compatibility purposes, this
+    /// makes a rather wild assumption when the server name hasn't been explicitly
+    /// configured. The assumption is that the command for this server is an
+    /// executable and that the name of the executable is the name of the server.
+    /// This may not be true for many cases, but it's the best we can do to try and
+    /// guess the name of the server.
+    pub fn name(&self) -> String {
+        match self {
+            ServerCommand::Simple(cmd) => ServerCommand::name_from_command(&cmd),
+            ServerCommand::Detailed(cmd) => cmd.name.clone(),
+        }
+    }
+
+    pub fn initialization_options(&self) -> Value {
+        match self {
+            ServerCommand::Simple(_) => Value::Null,
+            ServerCommand::Detailed(cmd) => {
+                let options = cmd.initialization_options.clone();
+                expand_json_path(options.unwrap_or_default())
+            }
+        }
+    }
+
+    #[cfg(not(target_os = "windows"))]
+    fn name_from_command(cmd: &[String]) -> String {
+        // it's safe to assume there is at least one item in cmd, otherwise
+        // this would be an empty server command
+        let first = cmd.first().cloned().unwrap_or_default();
+        first.split('/').last().unwrap_or_default().to_string()
+    }
+
+    #[cfg(target_os = "windows")]
+    fn name_from_command(cmd: &[String]) -> String {
+        // it's safe to assume there is at least one item in cmd, otherwise
+        // this would be an empty server command
+        let first = cmd.first().cloned().unwrap_or_default();
+        first.split('\\').last().unwrap_or_default().to_string()
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+
+    #[test]
+    fn test_name_from_command_handles_binary_name() {
+        let name = ServerCommand::name_from_command(&vec!["gopls".into()]);
+        assert_eq!(name.as_str(), "gopls");
+    }
+
+    #[test]
+    fn test_name_from_command_handles_binary_path() {
+        let name = ServerCommand::name_from_command(&vec!["/path/to/gopls".into()]);
+        assert_eq!(name.as_str(), "gopls");
+    }
+}
diff --git a/src/extensions/rust_analyzer.rs b/src/extensions/rust_analyzer.rs
index ae7d60a9..f2bdfaab 100644
--- a/src/extensions/rust_analyzer.rs
+++ b/src/extensions/rust_analyzer.rs
@@ -94,8 +94,8 @@ pub mod request {
     }
 }
 
-const FILETYPE: &'static str = "rust";
-pub const SERVER_NAME: &'static str = "rust-analyzer";
+const FILETYPE: &str = "rust";
+pub const SERVER_NAME: &str = "rust-analyzer";
 
 impl LanguageClient {
     pub fn rust_analyzer_inlay_hints(&self, filename: &str) -> Result<Vec<types::InlayHint>> {
diff --git a/src/language_client.rs b/src/language_client.rs
index 209a64e4..3096f279 100644
--- a/src/language_client.rs
+++ b/src/language_client.rs
@@ -22,9 +22,9 @@ pub struct LanguageClient {
 }
 
 impl LanguageClient {
-    pub fn new(version: String, state: State) -> Self {
+    pub fn new(version: impl Into<String>, state: State) -> Self {
         LanguageClient {
-            version,
+            version: version.into(),
             state_mutex: Arc::new(Mutex::new(state)),
             clients_mutex: Arc::new(Mutex::new(HashMap::new())),
             config: Arc::new(RwLock::new(Config::default())),
diff --git a/src/language_server_protocol.rs b/src/language_server_protocol.rs
index 0560ebd9..57bb13ca 100644
--- a/src/language_server_protocol.rs
+++ b/src/language_server_protocol.rs
@@ -1,4 +1,4 @@
-use crate::config::Config;
+use crate::config::{Config, ServerCommand};
 use crate::extensions::java;
 use crate::language_client::LanguageClient;
 use crate::sign::Sign;
@@ -847,23 +847,25 @@ impl LanguageClient {
         let root =
             self.get_state(|state| state.roots.get(&language_id).cloned().unwrap_or_default())?;
 
-        let initialization_options = self
-            .get_workspace_settings(&root)
-            .map(|s| s["initializationOptions"].clone())
-            .unwrap_or_else(|err| {
-                warn!("Failed to get initializationOptions: {}", err);
-                json!(Value::Null)
-            });
-        let initialization_options =
-            get_default_initialization_options(&language_id).combine(&initialization_options);
-        let initialization_options = if initialization_options.is_null() {
-            None
-        } else {
-            Some(initialization_options)
-        };
-
         let trace = self.get_config(|c| c.trace)?;
         let preferred_markup_kind = self.get_config(|c| c.preferred_markup_kind.clone())?;
+        let command = self.get_config(|c| c.server_commands.get(&language_id).cloned())?;
+        if command.is_none() {
+            return Err(anyhow!(
+                "No server command found for language {}",
+                language_id
+            ));
+        }
+        let command = command.unwrap();
+
+        let settings = self.get_workspace_settings(&root).unwrap_or_default();
+        // warn the user that they are using a deprecated workspace settings
+        // file format and direct them to the documentation about the new one
+        if settings.pointer("/initializationOptions").is_some() {
+            let _ = self.vim()?.echoerr("You seem to be using an incorrect workspace settings format for LanguageClient-neovim, to learn more about this error see `:help g:LanguageClient_settingsPath`");
+        }
+
+        let initialization_options = merged_initialization_options(&command, &settings)?;
 
         let result: Value = self.get_client(&Some(language_id.clone()))?.call(
             lsp_types::request::Initialize::METHOD,
@@ -961,6 +963,7 @@ impl LanguageClient {
                     }),
                     workspace: Some(WorkspaceClientCapabilities {
                         apply_edit: Some(true),
+                        configuration: Some(true),
                         did_change_watched_files: Some(GenericCapability {
                             dynamic_registration: Some(true),
                         }),
@@ -981,7 +984,9 @@ impl LanguageClient {
                 .map(|info| info.name.clone());
             match (server_name, initialization_options) {
                 (Some(name), Some(options)) => {
-                    state.initialization_options.insert(name, options);
+                    state.initialization_options = state
+                        .initialization_options
+                        .combine(&json!({ name: options }));
                 }
                 _ => {}
             }
@@ -2636,7 +2641,7 @@ impl LanguageClient {
 
     #[tracing::instrument(level = "info", skip(self))]
     pub fn register_server_commands(&self, params: &Value) -> Result<Value> {
-        let commands = HashMap::<String, Vec<String>>::deserialize(params)?;
+        let commands = HashMap::<String, ServerCommand>::deserialize(params)?;
         self.update_config(|c| c.server_commands.extend(commands))?;
         let exp = format!(
             "let g:LanguageClient_serverCommands={}",
@@ -3582,6 +3587,7 @@ impl LanguageClient {
                 })
             })
         })??;
+        let command = command.get_command();
 
         let root_path: Option<String> = try_get("rootPath", &params)?;
         let root = if let Some(r) = root_path {
@@ -3663,7 +3669,7 @@ impl LanguageClient {
             };
 
         let lcn = self.clone();
-        let on_server_crash = move |language_id: &LanguageId| -> () {
+        let on_server_crash = move |language_id: &LanguageId| {
             if let Err(err) = lcn.on_server_crash(language_id) {
                 error!("Restart attempt failed: {}", err);
             }
@@ -3915,3 +3921,140 @@ impl LanguageClient {
         Ok(json!(msg))
     }
 }
+
+fn merged_initialization_options(
+    command: &ServerCommand,
+    settings: &Value,
+) -> Result<Option<Value>> {
+    let server_name = command.name();
+    let section = format!("/{}", server_name);
+    let default_initialization_options = get_default_initialization_options(&server_name);
+    let server_initialization_options = command.initialization_options();
+    let workspace_initialization_options =
+        settings.pointer(section.as_str()).unwrap_or(&Value::Null);
+    let initialization_options = default_initialization_options
+        .combine(&server_initialization_options)
+        .combine(workspace_initialization_options);
+
+    if initialization_options.is_null() {
+        Ok(None)
+    } else {
+        Ok(Some(initialization_options))
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+    use crate::config::{ServerCommand, ServerDetails};
+
+    #[test]
+    fn test_expands_initialization_options() {
+        let settings = json!({
+            "rust-analyzer": {
+                "rustfmt": {
+                    "overrideCommand": ["rustfmt"],
+                },
+                "checkOnSave": {
+                    "overrideCommand": ["cargo", "check"],
+                }
+            },
+        });
+        let command = ServerCommand::Detailed(ServerDetails {
+            name: "rust-analyzer".into(),
+            command: vec!["rust-analyzer".into()],
+            initialization_options: Some(json!({
+                "inlayHints.enable": true,
+            })),
+        });
+
+        let options = merged_initialization_options(&command, &settings)
+            .expect("could not get initialization options");
+        assert!(options.is_some());
+        assert_eq!(
+            json!({
+                "checkOnSave": {
+                    "overrideCommand": ["cargo", "check"],
+                },
+                "inlayHints": {
+                    "enable": true,
+                },
+                "rustfmt": {
+                    "overrideCommand": ["rustfmt"],
+                },
+            }),
+            options.unwrap()
+        );
+    }
+
+    #[test]
+    fn test_handles_empty_global_options() {
+        let settings = json!({
+            "gopls": {
+                "local": "github.com/import/path/to/package"
+            }
+        });
+        let command = ServerCommand::Detailed(ServerDetails {
+            name: "gopls".into(),
+            command: vec!["gopls".into()],
+            initialization_options: None,
+        });
+
+        let options = merged_initialization_options(&command, &settings)
+            .expect("could not get initialization options");
+        assert!(options.is_some());
+        assert_eq!(
+            json!({
+                "local": "github.com/import/path/to/package",
+            }),
+            options.unwrap()
+        );
+    }
+
+    #[test]
+    fn test_merges_global_and_workspace_local_options() {
+        let settings = json!({
+            "gopls": {
+                "local": "github.com/import/path/to/package"
+            }
+        });
+        let command = ServerCommand::Detailed(ServerDetails {
+            name: "gopls".into(),
+            command: vec!["gopls".into()],
+            initialization_options: Some(json!({
+                "usePlaceholders": true,
+            })),
+        });
+
+        let options = merged_initialization_options(&command, &settings)
+            .expect("could not get initialization options");
+        assert!(options.is_some());
+        assert_eq!(
+            json!({
+                "usePlaceholders": true,
+                "local": "github.com/import/path/to/package",
+            }),
+            options.unwrap()
+        );
+    }
+
+    #[test]
+    fn test_handles_options_for_simple_commands() {
+        let settings = json!({
+            "gopls": {
+                "local": "github.com/import/path/to/package"
+            }
+        });
+        let command = ServerCommand::Simple(vec!["gopls".into()]);
+
+        let options = merged_initialization_options(&command, &settings)
+            .expect("could not get initialization options");
+        assert!(options.is_some());
+        assert_eq!(
+            json!({
+                "local": "github.com/import/path/to/package",
+            }),
+            options.unwrap()
+        );
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index a3d37729..bf498b07 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -31,7 +31,7 @@ extern crate lazy_static;
 fn main() -> Result<()> {
     let _ = clap::app_from_crate!().get_matches();
 
-    let version = env!("CARGO_PKG_VERSION").into();
+    let version: String = env!("CARGO_PKG_VERSION").into();
     let logger = Logger::new()?;
     let (tx, rx) = crossbeam::channel::unbounded();
     let rpcclient = Arc::new(RpcClient::new(
diff --git a/src/rpcclient.rs b/src/rpcclient.rs
index 3f5104c0..dac81af6 100644
--- a/src/rpcclient.rs
+++ b/src/rpcclient.rs
@@ -43,7 +43,7 @@ impl RpcClient {
         writer: impl Write + Send + 'static,
         process_id: Option<u32>,
         sink: Sender<Call>,
-        on_crash: impl Fn(&LanguageId) -> () + Clone + Send + 'static,
+        on_crash: impl Fn(&LanguageId) + Clone + Send + 'static,
     ) -> Result<Self> {
         let (reader_tx, reader_rx): (Sender<(Id, Sender<jsonrpc_core::Output>)>, _) = unbounded();
 
diff --git a/src/types.rs b/src/types.rs
index 525c8b29..1eb39548 100644
--- a/src/types.rs
+++ b/src/types.rs
@@ -185,7 +185,22 @@ pub struct State {
     pub stashed_code_action_actions: Vec<CodeAction>,
 
     pub logger: Logger,
-    pub initialization_options: HashMap<String, Value>,
+    /// Stores a JSON with the initialization options for all servers started with this client, each
+    /// server will store its initialization options in an object in the root of this JSON, keyed
+    /// with the name of th server. So if you are running both gopls and rust-analyzer in the same
+    /// instance of LanguageClient-neovim, initialization_options will look something like this:
+    ///
+    /// ```json
+    /// {
+    ///  "gopls": {  }
+    ///  "rust-analyzer": {  }
+    /// }
+    /// ```
+    ///
+    /// This assumes that there are no conflicting options, but it should be a safe assumption, as
+    /// servers seem to use a root section with the name of the server to group its initialization
+    /// options.
+    pub initialization_options: Value,
 }
 
 impl State {
@@ -223,7 +238,7 @@ impl State {
             last_cursor_line: 0,
             last_line_diagnostic: " ".into(),
             stashed_code_action_actions: vec![],
-            initialization_options: HashMap::new(),
+            initialization_options: Value::Null,
             logger,
         }
     }
diff --git a/src/utils.rs b/src/utils.rs
index 5d5cfe78..bc0fe926 100644
--- a/src/utils.rs
+++ b/src/utils.rs
@@ -397,7 +397,7 @@ where
 
 pub fn get_default_initialization_options(language_id: &str) -> Value {
     match language_id {
-        "java" => json!({
+        "lsp4j" => json!({
             "extendedClientCapabilities": {
                 "classFileContentsSupport": true
             }
@@ -709,6 +709,24 @@ mod test {
                 }
             })
         );
+        assert_eq!(
+            expand_json_path(json!({
+                "rust-analyzer.inlayHints.enable": true,
+                "rust-analyzer.inlayHints.chainingHints": true,
+                "rust-analyzer.rustfmt.overrideCommand": ["rustfmt"],
+            })),
+            json!({
+                "rust-analyzer": {
+                    "rustfmt": {
+                        "overrideCommand": ["rustfmt"]
+                    },
+                    "inlayHints": {
+                        "enable": true,
+                        "chainingHints": true,
+                    }
+                }
+            })
+        );
     }
 
     #[test]


Test patch: