Detect and possibly use user-installed `gopls` / `zls` language servers (#8188)

Thorsten Ball and Antonio created

After a lot of back-and-forth, this is a small attempt to implement
solutions (1) and (3) in
https://github.com/zed-industries/zed/issues/7902. The goal is to have a
minimal change that helps users get started with Zed, until we have
extensions ready.

Release Notes:

- Added detection of user-installed `gopls` to Go language server
adapter. If a user has `gopls` in `$PATH` when opening a worktree, it
will be used.
- Added detection of user-installed `zls` to Zig language server
adapter. If a user has `zls` in `$PATH` when opening a worktree, it will
be used.

Example:

I don't have `go` installed globally, but I do have `gopls`:

```
~ $ which go
go not found
~ $ which gopls
/Users/thorstenball/code/go/bin/gopls
```

But I do have `go` in a project's directory:

```
~/tmp/go-testing φ which go
/Users/thorstenball/.local/share/mise/installs/go/1.21.5/go/bin/go
~/tmp/go-testing φ which gopls
/Users/thorstenball/code/go/bin/gopls
```

With current Zed when I run `zed ~/tmp/go-testing`, I'd get the dreaded
error:

![screenshot-2024-02-23-11 14
08@2x](https://github.com/zed-industries/zed/assets/1185253/822ea59b-c63e-4102-a50e-75501cc4e0e3)

But with the changes in this PR, it works:

```
[2024-02-23T11:14:42+01:00 INFO  language::language_registry] starting language server "gopls", path: "/Users/thorstenball/tmp/go-testing", id: 1
[2024-02-23T11:14:42+01:00 INFO  language::language_registry] found user-installed language server for Go. path: "/Users/thorstenball/code/go/bin/gopls", arguments: ["-mode=stdio"]
[2024-02-23T11:14:42+01:00 INFO  lsp] starting language server. binary path: "/Users/thorstenball/code/go/bin/gopls", working directory: "/Users/thorstenball/tmp/go-testing", args: ["-mode=stdio"]
```

---------

Co-authored-by: Antonio <antonio@zed.dev>

Change summary

Cargo.lock                               |  24 +++
Cargo.toml                               |   1 
crates/copilot/src/copilot.rs            |   2 
crates/language/src/language.rs          |  22 +++
crates/language/src/language_registry.rs | 147 +++++++++++++++++++------
crates/lsp/src/lsp.rs                    |   2 
crates/prettier/src/prettier.rs          |   1 
crates/project/Cargo.toml                |   1 
crates/project/src/project.rs            | 109 +++++++++++++++++-
crates/zed/src/languages/astro.rs        |   2 
crates/zed/src/languages/c.rs            |   2 
crates/zed/src/languages/clojure.rs      |   3 
crates/zed/src/languages/csharp.rs       |   2 
crates/zed/src/languages/css.rs          |   2 
crates/zed/src/languages/dart.rs         |   1 
crates/zed/src/languages/deno.rs         |   2 
crates/zed/src/languages/dockerfile.rs   |   2 
crates/zed/src/languages/elixir.rs       |   7 +
crates/zed/src/languages/elm.rs          |   2 
crates/zed/src/languages/erlang.rs       |   2 
crates/zed/src/languages/gleam.rs        |   2 
crates/zed/src/languages/go.rs           |  22 +++
crates/zed/src/languages/haskell.rs      |   1 
crates/zed/src/languages/html.rs         |   2 
crates/zed/src/languages/json.rs         |   2 
crates/zed/src/languages/lua.rs          |   2 
crates/zed/src/languages/nu.rs           |   1 
crates/zed/src/languages/ocaml.rs        |   1 
crates/zed/src/languages/php.rs          |   2 
crates/zed/src/languages/prisma.rs       |   2 
crates/zed/src/languages/purescript.rs   |   2 
crates/zed/src/languages/python.rs       |   2 
crates/zed/src/languages/ruby.rs         |   1 
crates/zed/src/languages/rust.rs         |   2 
crates/zed/src/languages/svelte.rs       |   2 
crates/zed/src/languages/tailwind.rs     |   2 
crates/zed/src/languages/toml.rs         |   2 
crates/zed/src/languages/typescript.rs   |   5 
crates/zed/src/languages/uiua.rs         |   1 
crates/zed/src/languages/vue.rs          |   2 
crates/zed/src/languages/yaml.rs         |   2 
crates/zed/src/languages/zig.rs          |  24 ++++
42 files changed, 371 insertions(+), 49 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1349,7 +1349,7 @@ dependencies = [
  "rustc-hash",
  "shlex",
  "syn 2.0.48",
- "which",
+ "which 4.4.2",
 ]
 
 [[package]]
@@ -4340,11 +4340,11 @@ dependencies = [
 
 [[package]]
 name = "home"
-version = "0.5.5"
+version = "0.5.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb"
+checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5"
 dependencies = [
- "windows-sys 0.48.0",
+ "windows-sys 0.52.0",
 ]
 
 [[package]]
@@ -6915,6 +6915,7 @@ dependencies = [
  "toml 0.8.10",
  "unindent",
  "util",
+ "which 6.0.0",
 ]
 
 [[package]]
@@ -7024,7 +7025,7 @@ dependencies = [
  "prost-types 0.9.0",
  "regex",
  "tempfile",
- "which",
+ "which 4.4.2",
 ]
 
 [[package]]
@@ -11396,6 +11397,19 @@ dependencies = [
  "rustix 0.38.30",
 ]
 
+[[package]]
+name = "which"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fa5e0c10bf77f44aac573e498d1a82d5fbd5e91f6fc0a99e7be4b38e85e101c"
+dependencies = [
+ "either",
+ "home",
+ "once_cell",
+ "rustix 0.38.30",
+ "windows-sys 0.52.0",
+]
+
 [[package]]
 name = "whoami"
 version = "1.4.1"

Cargo.toml 🔗

@@ -278,6 +278,7 @@ unindent = "0.1.7"
 url = "2.2"
 uuid = { version = "1.1.2", features = ["v4"] }
 wasmtime = "16"
+which = "6.0.0"
 sys-locale = "0.3.1"
 
 [patch.crates-io]

crates/copilot/src/copilot.rs 🔗

@@ -428,6 +428,8 @@ impl Copilot {
                 let binary = LanguageServerBinary {
                     path: node_path,
                     arguments,
+                    // TODO: We could set HTTP_PROXY etc here and fix the copilot issue.
+                    env: None,
                 };
 
                 let server = LanguageServer::new(

crates/language/src/language.rs 🔗

@@ -38,6 +38,7 @@ use serde_json::Value;
 use std::{
     any::Any,
     cell::RefCell,
+    ffi::OsString,
     fmt::Debug,
     hash::Hash,
     mem,
@@ -140,6 +141,14 @@ impl CachedLspAdapter {
         })
     }
 
+    pub fn check_if_user_installed(
+        &self,
+        delegate: &Arc<dyn LspAdapterDelegate>,
+        cx: &mut AsyncAppContext,
+    ) -> Option<Task<Option<LanguageServerBinary>>> {
+        self.adapter.check_if_user_installed(delegate, cx)
+    }
+
     pub async fn fetch_latest_server_version(
         &self,
         delegate: &dyn LspAdapterDelegate,
@@ -240,6 +249,11 @@ impl CachedLspAdapter {
 pub trait LspAdapterDelegate: Send + Sync {
     fn show_notification(&self, message: &str, cx: &mut AppContext);
     fn http_client(&self) -> Arc<dyn HttpClient>;
+    fn which_command(
+        &self,
+        command: OsString,
+        cx: &AppContext,
+    ) -> Task<Option<(PathBuf, HashMap<String, String>)>>;
 }
 
 #[async_trait]
@@ -248,6 +262,14 @@ pub trait LspAdapter: 'static + Send + Sync {
 
     fn short_name(&self) -> &'static str;
 
+    fn check_if_user_installed(
+        &self,
+        _: &Arc<dyn LspAdapterDelegate>,
+        _: &mut AsyncAppContext,
+    ) -> Option<Task<Option<LanguageServerBinary>>> {
+        None
+    }
+
     async fn fetch_latest_server_version(
         &self,
         delegate: &dyn LspAdapterDelegate,

crates/language/src/language_registry.rs 🔗

@@ -558,34 +558,41 @@ impl LanguageRegistry {
         let task = {
             let container_dir = container_dir.clone();
             cx.spawn(move |mut cx| async move {
-                login_shell_env_loaded.await;
-
-                let entry = this
-                    .lsp_binary_paths
-                    .lock()
-                    .entry(adapter.name.clone())
-                    .or_insert_with(|| {
-                        let adapter = adapter.clone();
-                        let language = language.clone();
-                        let delegate = delegate.clone();
-                        cx.spawn(|cx| {
-                            get_binary(
-                                adapter,
-                                language,
-                                delegate,
-                                container_dir,
-                                lsp_binary_statuses,
-                                cx,
-                            )
-                            .map_err(Arc::new)
-                        })
-                        .shared()
-                    })
-                    .clone();
-
-                let binary = match entry.await {
-                    Ok(binary) => binary,
-                    Err(err) => anyhow::bail!("{err}"),
+                // First we check whether the adapter can give us a user-installed binary.
+                // If so, we do *not* want to cache that, because each worktree might give us a different
+                // binary:
+                //
+                //      worktree 1: user-installed at `.bin/gopls`
+                //      worktree 2: user-installed at `~/bin/gopls`
+                //      worktree 3: no gopls found in PATH -> fallback to Zed installation
+                //
+                // We only want to cache when we fall back to the global one,
+                // because we don't want to download and overwrite our global one
+                // for each worktree we might have open.
+
+                let user_binary_task = check_user_installed_binary(
+                    adapter.clone(),
+                    language.clone(),
+                    delegate.clone(),
+                    &mut cx,
+                );
+                let binary = if let Some(user_binary) = user_binary_task.await {
+                    user_binary
+                } else {
+                    // If we want to install a binary globally, we need to wait for
+                    // the login shell to be set on our process.
+                    login_shell_env_loaded.await;
+
+                    get_or_install_binary(
+                        this,
+                        &adapter,
+                        language,
+                        &delegate,
+                        &cx,
+                        container_dir,
+                        lsp_binary_statuses,
+                    )
+                    .await?
                 };
 
                 if let Some(task) = adapter.will_start_server(&delegate, &mut cx) {
@@ -724,6 +731,62 @@ impl LspBinaryStatusSender {
     }
 }
 
+async fn check_user_installed_binary(
+    adapter: Arc<CachedLspAdapter>,
+    language: Arc<Language>,
+    delegate: Arc<dyn LspAdapterDelegate>,
+    cx: &mut AsyncAppContext,
+) -> Option<LanguageServerBinary> {
+    let Some(task) = adapter.check_if_user_installed(&delegate, cx) else {
+        return None;
+    };
+
+    task.await.and_then(|binary| {
+        log::info!(
+            "found user-installed language server for {}. path: {:?}, arguments: {:?}",
+            language.name(),
+            binary.path,
+            binary.arguments
+        );
+        Some(binary)
+    })
+}
+
+async fn get_or_install_binary(
+    registry: Arc<LanguageRegistry>,
+    adapter: &Arc<CachedLspAdapter>,
+    language: Arc<Language>,
+    delegate: &Arc<dyn LspAdapterDelegate>,
+    cx: &AsyncAppContext,
+    container_dir: Arc<Path>,
+    lsp_binary_statuses: LspBinaryStatusSender,
+) -> Result<LanguageServerBinary> {
+    let entry = registry
+        .lsp_binary_paths
+        .lock()
+        .entry(adapter.name.clone())
+        .or_insert_with(|| {
+            let adapter = adapter.clone();
+            let language = language.clone();
+            let delegate = delegate.clone();
+            cx.spawn(|cx| {
+                get_binary(
+                    adapter,
+                    language,
+                    delegate,
+                    container_dir,
+                    lsp_binary_statuses,
+                    cx,
+                )
+                .map_err(Arc::new)
+            })
+            .shared()
+        })
+        .clone();
+
+    entry.await.map_err(|err| anyhow!("{:?}", err))
+}
+
 async fn get_binary(
     adapter: Arc<CachedLspAdapter>,
     language: Arc<Language>,
@@ -757,15 +820,20 @@ async fn get_binary(
             .await
         {
             statuses.send(language.clone(), LanguageServerBinaryStatus::Cached);
-            return Ok(binary);
-        } else {
-            statuses.send(
-                language.clone(),
-                LanguageServerBinaryStatus::Failed {
-                    error: format!("{:?}", error),
-                },
+            log::info!(
+                "failed to fetch newest version of language server {:?}. falling back to using {:?}",
+                adapter.name,
+                binary.path.display()
             );
+            return Ok(binary);
         }
+
+        statuses.send(
+            language.clone(),
+            LanguageServerBinaryStatus::Failed {
+                error: format!("{:?}", error),
+            },
+        );
     }
 
     binary
@@ -779,14 +847,23 @@ async fn fetch_latest_binary(
     lsp_binary_statuses_tx: LspBinaryStatusSender,
 ) -> Result<LanguageServerBinary> {
     let container_dir: Arc<Path> = container_dir.into();
+
     lsp_binary_statuses_tx.send(
         language.clone(),
         LanguageServerBinaryStatus::CheckingForUpdate,
     );
 
+    log::info!(
+        "querying GitHub for latest version of language server {:?}",
+        adapter.name.0
+    );
     let version_info = adapter.fetch_latest_server_version(delegate).await?;
     lsp_binary_statuses_tx.send(language.clone(), LanguageServerBinaryStatus::Downloading);
 
+    log::info!(
+        "checking if Zed already installed or fetching version for language server {:?}",
+        adapter.name.0
+    );
     let binary = adapter
         .fetch_server_binary(version_info, container_dir.to_path_buf(), delegate)
         .await?;

crates/lsp/src/lsp.rs 🔗

@@ -55,6 +55,7 @@ pub enum IoKind {
 pub struct LanguageServerBinary {
     pub path: PathBuf,
     pub arguments: Vec<OsString>,
+    pub env: Option<HashMap<String, String>>,
 }
 
 /// A running language server process.
@@ -189,6 +190,7 @@ impl LanguageServer {
         let mut server = process::Command::new(&binary.path)
             .current_dir(working_dir)
             .args(binary.arguments)
+            .envs(binary.env.unwrap_or_default())
             .stdin(Stdio::piped())
             .stdout(Stdio::piped())
             .stderr(Stdio::piped())

crates/prettier/src/prettier.rs 🔗

@@ -192,6 +192,7 @@ impl Prettier {
             LanguageServerBinary {
                 path: node_path,
                 arguments: vec![prettier_server.into(), prettier_dir.as_path().into()],
+                env: None,
             },
             Path::new("/"),
             None,

crates/project/Cargo.toml 🔗

@@ -65,6 +65,7 @@ text.workspace = true
 thiserror.workspace = true
 toml.workspace = true
 util.workspace = true
+which.workspace = true
 
 [dev-dependencies]
 client = { workspace = true, features = ["test-support"] }

crates/project/src/project.rs 🔗

@@ -71,6 +71,8 @@ use smol::lock::Semaphore;
 use std::{
     cmp::{self, Ordering},
     convert::TryInto,
+    env,
+    ffi::OsString,
     hash::Hash,
     mem,
     num::NonZeroU32,
@@ -504,11 +506,6 @@ pub enum FormatTrigger {
     Manual,
 }
 
-struct ProjectLspAdapterDelegate {
-    project: Model<Project>,
-    http_client: Arc<dyn HttpClient>,
-}
-
 // Currently, formatting operations are represented differently depending on
 // whether they come from a language server or an external command.
 enum FormatOperation {
@@ -2800,7 +2797,7 @@ impl Project {
 
     fn start_language_server(
         &mut self,
-        worktree: &Model<Worktree>,
+        worktree_handle: &Model<Worktree>,
         adapter: Arc<CachedLspAdapter>,
         language: Arc<Language>,
         cx: &mut ModelContext<Self>,
@@ -2809,7 +2806,7 @@ impl Project {
             return;
         }
 
-        let worktree = worktree.read(cx);
+        let worktree = worktree_handle.read(cx);
         let worktree_id = worktree.id();
         let worktree_path = worktree.abs_path();
         let key = (worktree_id, adapter.name.clone());
@@ -2823,7 +2820,7 @@ impl Project {
             language.clone(),
             adapter.clone(),
             Arc::clone(&worktree_path),
-            ProjectLspAdapterDelegate::new(self, cx),
+            ProjectLspAdapterDelegate::new(self, worktree_handle, cx),
             cx,
         ) {
             Some(pending_server) => pending_server,
@@ -9271,10 +9268,17 @@ impl<P: AsRef<Path>> From<(WorktreeId, P)> for ProjectPath {
     }
 }
 
+struct ProjectLspAdapterDelegate {
+    project: Model<Project>,
+    worktree: Model<Worktree>,
+    http_client: Arc<dyn HttpClient>,
+}
+
 impl ProjectLspAdapterDelegate {
-    fn new(project: &Project, cx: &ModelContext<Project>) -> Arc<Self> {
+    fn new(project: &Project, worktree: &Model<Worktree>, cx: &ModelContext<Project>) -> Arc<Self> {
         Arc::new(Self {
             project: cx.handle(),
+            worktree: worktree.clone(),
             http_client: project.client.http_client(),
         })
     }
@@ -9289,6 +9293,41 @@ impl LspAdapterDelegate for ProjectLspAdapterDelegate {
     fn http_client(&self) -> Arc<dyn HttpClient> {
         self.http_client.clone()
     }
+
+    fn which_command(
+        &self,
+        command: OsString,
+        cx: &AppContext,
+    ) -> Task<Option<(PathBuf, HashMap<String, String>)>> {
+        let worktree_abs_path = self.worktree.read(cx).abs_path();
+        let command = command.to_owned();
+
+        cx.background_executor().spawn(async move {
+            let shell_env = load_shell_environment(&worktree_abs_path)
+                .await
+                .with_context(|| {
+                    format!(
+                        "failed to determine load login shell environment in {worktree_abs_path:?}"
+                    )
+                })
+                .log_err();
+
+            if let Some(shell_env) = shell_env {
+                let shell_path = shell_env.get("PATH");
+                match which::which_in(&command, shell_path, &worktree_abs_path) {
+                    Ok(command_path) => Some((command_path, shell_env)),
+                    Err(error) => {
+                        log::warn!(
+                            "failed to determine path for command {:?} in env {shell_env:?}: {error}", command.to_string_lossy()
+                        );
+                        None
+                    }
+                }
+            } else {
+                None
+            }
+        })
+    }
 }
 
 fn serialize_symbol(symbol: &Symbol) -> proto::Symbol {
@@ -9396,3 +9435,55 @@ fn include_text(server: &lsp::LanguageServer) -> bool {
         })
         .unwrap_or(false)
 }
+
+async fn load_shell_environment(dir: &Path) -> Result<HashMap<String, String>> {
+    let marker = "ZED_SHELL_START";
+    let shell = env::var("SHELL").context(
+        "SHELL environment variable is not assigned so we can't source login environment variables",
+    )?;
+    let output = smol::process::Command::new(&shell)
+        .args([
+            "-i",
+            "-c",
+            // What we're doing here is to spawn a shell and then `cd` into
+            // the project directory to get the env in there as if the user
+            // `cd`'d into it. We do that because tools like direnv, asdf, ...
+            // hook into `cd` and only set up the env after that.
+            //
+            // The `exit 0` is the result of hours of debugging, trying to find out
+            // why running this command here, without `exit 0`, would mess
+            // up signal process for our process so that `ctrl-c` doesn't work
+            // anymore.
+            // We still don't know why `$SHELL -l -i -c '/usr/bin/env -0'`  would
+            // do that, but it does, and `exit 0` helps.
+            &format!("cd {dir:?}; echo {marker}; /usr/bin/env -0; exit 0;"),
+        ])
+        .output()
+        .await
+        .context("failed to spawn login shell to source login environment variables")?;
+
+    anyhow::ensure!(
+        output.status.success(),
+        "login shell exited with error {:?}",
+        output.status
+    );
+
+    let stdout = String::from_utf8_lossy(&output.stdout);
+    let env_output_start = stdout.find(marker).ok_or_else(|| {
+        anyhow!(
+            "failed to parse output of `env` command in login shell: {}",
+            stdout
+        )
+    })?;
+
+    let mut parsed_env = HashMap::default();
+    let env_output = &stdout[env_output_start + marker.len()..];
+    for line in env_output.split_terminator('\0') {
+        if let Some(separator_index) = line.find('=') {
+            let key = line[..separator_index].to_string();
+            let value = line[separator_index + 1..].to_string();
+            parsed_env.insert(key, value);
+        }
+    }
+    Ok(parsed_env)
+}

crates/zed/src/languages/astro.rs 🔗

@@ -71,6 +71,7 @@ impl LspAdapter for AstroLspAdapter {
 
         Ok(LanguageServerBinary {
             path: self.node.binary_path().await?,
+            env: None,
             arguments: server_binary_arguments(&server_path),
         })
     }
@@ -122,6 +123,7 @@ async fn get_cached_server_binary(
         if server_path.exists() {
             Ok(LanguageServerBinary {
                 path: node.binary_path().await?,
+                env: None,
                 arguments: server_binary_arguments(&server_path),
             })
         } else {

crates/zed/src/languages/c.rs 🔗

@@ -84,6 +84,7 @@ impl super::LspAdapter for CLspAdapter {
 
         Ok(LanguageServerBinary {
             path: binary_path,
+            env: None,
             arguments: vec![],
         })
     }
@@ -260,6 +261,7 @@ async fn get_cached_server_binary(container_dir: PathBuf) -> Option<LanguageServ
         if clangd_bin.exists() {
             Ok(LanguageServerBinary {
                 path: clangd_bin,
+                env: None,
                 arguments: vec![],
             })
         } else {

crates/zed/src/languages/clojure.rs 🔗

@@ -105,6 +105,7 @@ impl super::LspAdapter for ClojureLspAdapter {
 
         Ok(LanguageServerBinary {
             path: binary_path,
+            env: None,
             arguments: vec![],
         })
     }
@@ -118,6 +119,7 @@ impl super::LspAdapter for ClojureLspAdapter {
         if binary_path.exists() {
             Some(LanguageServerBinary {
                 path: binary_path,
+                env: None,
                 arguments: vec![],
             })
         } else {
@@ -133,6 +135,7 @@ impl super::LspAdapter for ClojureLspAdapter {
         if binary_path.exists() {
             Some(LanguageServerBinary {
                 path: binary_path,
+                env: None,
                 arguments: vec!["--version".into()],
             })
         } else {

crates/zed/src/languages/csharp.rs 🔗

@@ -92,6 +92,7 @@ impl super::LspAdapter for OmniSharpAdapter {
         }
         Ok(LanguageServerBinary {
             path: binary_path,
+            env: None,
             arguments: server_binary_arguments(),
         })
     }
@@ -136,6 +137,7 @@ async fn get_cached_server_binary(container_dir: PathBuf) -> Option<LanguageServ
         if let Some(path) = last_binary_path {
             Ok(LanguageServerBinary {
                 path,
+                env: None,
                 arguments: server_binary_arguments(),
             })
         } else {

crates/zed/src/languages/css.rs 🔗

@@ -72,6 +72,7 @@ impl LspAdapter for CssLspAdapter {
 
         Ok(LanguageServerBinary {
             path: self.node.binary_path().await?,
+            env: None,
             arguments: server_binary_arguments(&server_path),
         })
     }
@@ -116,6 +117,7 @@ async fn get_cached_server_binary(
         if server_path.exists() {
             Ok(LanguageServerBinary {
                 path: node.binary_path().await?,
+                env: None,
                 arguments: server_binary_arguments(&server_path),
             })
         } else {

crates/zed/src/languages/dart.rs 🔗

@@ -39,6 +39,7 @@ impl LspAdapter for DartLanguageServer {
     ) -> Option<LanguageServerBinary> {
         Some(LanguageServerBinary {
             path: "dart".into(),
+            env: None,
             arguments: vec!["language-server".into(), "--protocol=lsp".into()],
         })
     }

crates/zed/src/languages/deno.rs 🔗

@@ -134,6 +134,7 @@ impl LspAdapter for DenoLspAdapter {
 
         Ok(LanguageServerBinary {
             path: binary_path,
+            env: None,
             arguments: deno_server_binary_arguments(),
         })
     }
@@ -220,6 +221,7 @@ async fn get_cached_server_binary(container_dir: PathBuf) -> Option<LanguageServ
                 if fs::metadata(&binary).await.is_ok() {
                     return Ok(LanguageServerBinary {
                         path: binary,
+                        env: None,
                         arguments: deno_server_binary_arguments(),
                     });
                 }

crates/zed/src/languages/dockerfile.rs 🔗

@@ -71,6 +71,7 @@ impl LspAdapter for DockerfileLspAdapter {
 
         Ok(LanguageServerBinary {
             path: self.node.binary_path().await?,
+            env: None,
             arguments: server_binary_arguments(&server_path),
         })
     }
@@ -110,6 +111,7 @@ async fn get_cached_server_binary(
         if server_path.exists() {
             Ok(LanguageServerBinary {
                 path: node.binary_path().await?,
+                env: None,
                 arguments: server_binary_arguments(&server_path),
             })
         } else {

crates/zed/src/languages/elixir.rs 🔗

@@ -174,6 +174,7 @@ impl LspAdapter for ElixirLspAdapter {
 
         Ok(LanguageServerBinary {
             path: binary_path,
+            env: None,
             arguments: vec![],
         })
     }
@@ -284,6 +285,7 @@ async fn get_cached_server_binary_elixir_ls(
     if server_path.exists() {
         Some(LanguageServerBinary {
             path: server_path,
+            env: None,
             arguments: vec![],
         })
     } else {
@@ -369,6 +371,7 @@ impl LspAdapter for NextLspAdapter {
 
         Ok(LanguageServerBinary {
             path: binary_path,
+            env: None,
             arguments: vec!["--stdio".into()],
         })
     }
@@ -435,6 +438,7 @@ async fn get_cached_server_binary_next(container_dir: PathBuf) -> Option<Languag
         if let Some(path) = last_binary_path {
             Ok(LanguageServerBinary {
                 path,
+                env: None,
                 arguments: Vec::new(),
             })
         } else {
@@ -476,6 +480,7 @@ impl LspAdapter for LocalLspAdapter {
         let path = shellexpand::full(&self.path)?;
         Ok(LanguageServerBinary {
             path: PathBuf::from(path.deref()),
+            env: None,
             arguments: self.arguments.iter().map(|arg| arg.into()).collect(),
         })
     }
@@ -488,6 +493,7 @@ impl LspAdapter for LocalLspAdapter {
         let path = shellexpand::full(&self.path).ok()?;
         Some(LanguageServerBinary {
             path: PathBuf::from(path.deref()),
+            env: None,
             arguments: self.arguments.iter().map(|arg| arg.into()).collect(),
         })
     }
@@ -496,6 +502,7 @@ impl LspAdapter for LocalLspAdapter {
         let path = shellexpand::full(&self.path).ok()?;
         Some(LanguageServerBinary {
             path: PathBuf::from(path.deref()),
+            env: None,
             arguments: self.arguments.iter().map(|arg| arg.into()).collect(),
         })
     }

crates/zed/src/languages/elm.rs 🔗

@@ -75,6 +75,7 @@ impl LspAdapter for ElmLspAdapter {
 
         Ok(LanguageServerBinary {
             path: self.node.binary_path().await?,
+            env: None,
             arguments: server_binary_arguments(&server_path),
         })
     }
@@ -134,6 +135,7 @@ async fn get_cached_server_binary(
         if server_path.exists() {
             Ok(LanguageServerBinary {
                 path: node.binary_path().await?,
+                env: None,
                 arguments: server_binary_arguments(&server_path),
             })
         } else {

crates/zed/src/languages/erlang.rs 🔗

@@ -41,6 +41,7 @@ impl LspAdapter for ErlangLspAdapter {
     ) -> Option<LanguageServerBinary> {
         Some(LanguageServerBinary {
             path: "erlang_ls".into(),
+            env: None,
             arguments: vec![],
         })
     }
@@ -52,6 +53,7 @@ impl LspAdapter for ErlangLspAdapter {
     async fn installation_test_binary(&self, _: PathBuf) -> Option<LanguageServerBinary> {
         Some(LanguageServerBinary {
             path: "erlang_ls".into(),
+            env: None,
             arguments: vec!["--version".into()],
         })
     }

crates/zed/src/languages/gleam.rs 🔗

@@ -81,6 +81,7 @@ impl LspAdapter for GleamLspAdapter {
 
         Ok(LanguageServerBinary {
             path: binary_path,
+            env: None,
             arguments: server_binary_arguments(),
         })
     }
@@ -116,6 +117,7 @@ async fn get_cached_server_binary(container_dir: PathBuf) -> Option<LanguageServ
 
         anyhow::Ok(LanguageServerBinary {
             path: last.ok_or_else(|| anyhow!("no cached binary"))?,
+            env: None,
             arguments: server_binary_arguments(),
         })
     })

crates/zed/src/languages/go.rs 🔗

@@ -58,6 +58,25 @@ impl super::LspAdapter for GoLspAdapter {
         Ok(Box::new(version) as Box<_>)
     }
 
+    fn check_if_user_installed(
+        &self,
+        delegate: &Arc<dyn LspAdapterDelegate>,
+        cx: &mut AsyncAppContext,
+    ) -> Option<Task<Option<LanguageServerBinary>>> {
+        let delegate = delegate.clone();
+
+        Some(cx.spawn(|cx| async move {
+            match cx.update(|cx| delegate.which_command(OsString::from("gopls"), cx)) {
+                Ok(task) => task.await.map(|(path, env)| LanguageServerBinary {
+                    path,
+                    arguments: server_binary_arguments(),
+                    env: Some(env),
+                }),
+                Err(_) => None,
+            }
+        }))
+    }
+
     fn will_fetch_server(
         &self,
         delegate: &Arc<dyn LspAdapterDelegate>,
@@ -107,6 +126,7 @@ impl super::LspAdapter for GoLspAdapter {
                     return Ok(LanguageServerBinary {
                         path: binary_path.to_path_buf(),
                         arguments: server_binary_arguments(),
+                        env: None,
                     });
                 }
             }
@@ -154,6 +174,7 @@ impl super::LspAdapter for GoLspAdapter {
         Ok(LanguageServerBinary {
             path: binary_path.to_path_buf(),
             arguments: server_binary_arguments(),
+            env: None,
         })
     }
 
@@ -372,6 +393,7 @@ async fn get_cached_server_binary(container_dir: PathBuf) -> Option<LanguageServ
             Ok(LanguageServerBinary {
                 path,
                 arguments: server_binary_arguments(),
+                env: None,
             })
         } else {
             Err(anyhow!("no cached binary"))

crates/zed/src/languages/haskell.rs 🔗

@@ -41,6 +41,7 @@ impl LspAdapter for HaskellLanguageServer {
     ) -> Option<LanguageServerBinary> {
         Some(LanguageServerBinary {
             path: "haskell-language-server-wrapper".into(),
+            env: None,
             arguments: vec!["lsp".into()],
         })
     }

crates/zed/src/languages/html.rs 🔗

@@ -72,6 +72,7 @@ impl LspAdapter for HtmlLspAdapter {
 
         Ok(LanguageServerBinary {
             path: self.node.binary_path().await?,
+            env: None,
             arguments: server_binary_arguments(&server_path),
         })
     }
@@ -116,6 +117,7 @@ async fn get_cached_server_binary(
         if server_path.exists() {
             Ok(LanguageServerBinary {
                 path: node.binary_path().await?,
+                env: None,
                 arguments: server_binary_arguments(&server_path),
             })
         } else {

crates/zed/src/languages/json.rs 🔗

@@ -122,6 +122,7 @@ impl LspAdapter for JsonLspAdapter {
 
         Ok(LanguageServerBinary {
             path: self.node.binary_path().await?,
+            env: None,
             arguments: server_binary_arguments(&server_path),
         })
     }
@@ -177,6 +178,7 @@ async fn get_cached_server_binary(
         if server_path.exists() {
             Ok(LanguageServerBinary {
                 path: node.binary_path().await?,
+                env: None,
                 arguments: server_binary_arguments(&server_path),
             })
         } else {

crates/zed/src/languages/lua.rs 🔗

@@ -94,6 +94,7 @@ impl super::LspAdapter for LuaLspAdapter {
         }
         Ok(LanguageServerBinary {
             path: binary_path,
+            env: None,
             arguments: Vec::new(),
         })
     }
@@ -138,6 +139,7 @@ async fn get_cached_server_binary(container_dir: PathBuf) -> Option<LanguageServ
         if let Some(path) = last_binary_path {
             Ok(LanguageServerBinary {
                 path,
+                env: None,
                 arguments: Vec::new(),
             })
         } else {

crates/zed/src/languages/nu.rs 🔗

@@ -41,6 +41,7 @@ impl LspAdapter for NuLanguageServer {
     ) -> Option<LanguageServerBinary> {
         Some(LanguageServerBinary {
             path: "nu".into(),
+            env: None,
             arguments: vec!["--lsp".into()],
         })
     }

crates/zed/src/languages/ocaml.rs 🔗

@@ -47,6 +47,7 @@ impl LspAdapter for OCamlLspAdapter {
     ) -> Option<LanguageServerBinary> {
         Some(LanguageServerBinary {
             path: "ocamllsp".into(),
+            env: None,
             arguments: vec![],
         })
     }

crates/zed/src/languages/php.rs 🔗

@@ -69,6 +69,7 @@ impl LspAdapter for IntelephenseLspAdapter {
         }
         Ok(LanguageServerBinary {
             path: self.node.binary_path().await?,
+            env: None,
             arguments: intelephense_server_binary_arguments(&server_path),
         })
     }
@@ -126,6 +127,7 @@ async fn get_cached_server_binary(
         if server_path.exists() {
             Ok(LanguageServerBinary {
                 path: node.binary_path().await?,
+                env: None,
                 arguments: intelephense_server_binary_arguments(&server_path),
             })
         } else {

crates/zed/src/languages/prisma.rs 🔗

@@ -70,6 +70,7 @@ impl LspAdapter for PrismaLspAdapter {
 
         Ok(LanguageServerBinary {
             path: self.node.binary_path().await?,
+            env: None,
             arguments: server_binary_arguments(&server_path),
         })
     }
@@ -112,6 +113,7 @@ async fn get_cached_server_binary(
         if server_path.exists() {
             Ok(LanguageServerBinary {
                 path: node.binary_path().await?,
+                env: None,
                 arguments: server_binary_arguments(&server_path),
             })
         } else {

crates/zed/src/languages/purescript.rs 🔗

@@ -74,6 +74,7 @@ impl LspAdapter for PurescriptLspAdapter {
 
         Ok(LanguageServerBinary {
             path: self.node.binary_path().await?,
+            env: None,
             arguments: server_binary_arguments(&server_path),
         })
     }
@@ -127,6 +128,7 @@ async fn get_cached_server_binary(
         if server_path.exists() {
             Ok(LanguageServerBinary {
                 path: node.binary_path().await?,
+                env: None,
                 arguments: server_binary_arguments(&server_path),
             })
         } else {

crates/zed/src/languages/python.rs 🔗

@@ -62,6 +62,7 @@ impl LspAdapter for PythonLspAdapter {
 
         Ok(LanguageServerBinary {
             path: self.node.binary_path().await?,
+            env: None,
             arguments: server_binary_arguments(&server_path),
         })
     }
@@ -167,6 +168,7 @@ async fn get_cached_server_binary(
     if server_path.exists() {
         Some(LanguageServerBinary {
             path: node.binary_path().await.log_err()?,
+            env: None,
             arguments: server_binary_arguments(&server_path),
         })
     } else {

crates/zed/src/languages/ruby.rs 🔗

@@ -39,6 +39,7 @@ impl LspAdapter for RubyLanguageServer {
     ) -> Option<LanguageServerBinary> {
         Some(LanguageServerBinary {
             path: "solargraph".into(),
+            env: None,
             arguments: vec!["stdio".into()],
         })
     }

crates/zed/src/languages/rust.rs 🔗

@@ -89,6 +89,7 @@ impl LspAdapter for RustLspAdapter {
 
         Ok(LanguageServerBinary {
             path: destination_path,
+            env: None,
             arguments: Default::default(),
         })
     }
@@ -296,6 +297,7 @@ async fn get_cached_server_binary(container_dir: PathBuf) -> Option<LanguageServ
 
         anyhow::Ok(LanguageServerBinary {
             path: last.ok_or_else(|| anyhow!("no cached binary"))?,
+            env: None,
             arguments: Default::default(),
         })
     })

crates/zed/src/languages/svelte.rs 🔗

@@ -71,6 +71,7 @@ impl LspAdapter for SvelteLspAdapter {
 
         Ok(LanguageServerBinary {
             path: self.node.binary_path().await?,
+            env: None,
             arguments: server_binary_arguments(&server_path),
         })
     }
@@ -148,6 +149,7 @@ async fn get_cached_server_binary(
         if server_path.exists() {
             Ok(LanguageServerBinary {
                 path: node.binary_path().await?,
+                env: None,
                 arguments: server_binary_arguments(&server_path),
             })
         } else {

crates/zed/src/languages/tailwind.rs 🔗

@@ -73,6 +73,7 @@ impl LspAdapter for TailwindLspAdapter {
 
         Ok(LanguageServerBinary {
             path: self.node.binary_path().await?,
+            env: None,
             arguments: server_binary_arguments(&server_path),
         })
     }
@@ -150,6 +151,7 @@ async fn get_cached_server_binary(
         if server_path.exists() {
             Ok(LanguageServerBinary {
                 path: node.binary_path().await?,
+                env: None,
                 arguments: server_binary_arguments(&server_path),
             })
         } else {

crates/zed/src/languages/toml.rs 🔗

@@ -85,6 +85,7 @@ impl LspAdapter for TaploLspAdapter {
 
         Ok(LanguageServerBinary {
             path: binary_path,
+            env: None,
             arguments: vec!["lsp".into(), "stdio".into()],
         })
     }
@@ -120,6 +121,7 @@ async fn get_cached_server_binary(container_dir: PathBuf) -> Option<LanguageServ
 
         anyhow::Ok(LanguageServerBinary {
             path: last.context("no cached binary")?,
+            env: None,
             arguments: Default::default(),
         })
     })

crates/zed/src/languages/typescript.rs 🔗

@@ -97,6 +97,7 @@ impl LspAdapter for TypeScriptLspAdapter {
 
         Ok(LanguageServerBinary {
             path: self.node.binary_path().await?,
+            env: None,
             arguments: typescript_server_binary_arguments(&server_path),
         })
     }
@@ -192,11 +193,13 @@ async fn get_cached_ts_server_binary(
         if new_server_path.exists() {
             Ok(LanguageServerBinary {
                 path: node.binary_path().await?,
+                env: None,
                 arguments: typescript_server_binary_arguments(&new_server_path),
             })
         } else if old_server_path.exists() {
             Ok(LanguageServerBinary {
                 path: node.binary_path().await?,
+                env: None,
                 arguments: typescript_server_binary_arguments(&old_server_path),
             })
         } else {
@@ -307,6 +310,7 @@ impl LspAdapter for EsLintLspAdapter {
 
         Ok(LanguageServerBinary {
             path: self.node.binary_path().await?,
+            env: None,
             arguments: eslint_server_binary_arguments(&server_path),
         })
     }
@@ -354,6 +358,7 @@ async fn get_cached_eslint_server_binary(
 
         Ok(LanguageServerBinary {
             path: node.binary_path().await?,
+            env: None,
             arguments: eslint_server_binary_arguments(&server_path),
         })
     })

crates/zed/src/languages/uiua.rs 🔗

@@ -41,6 +41,7 @@ impl LspAdapter for UiuaLanguageServer {
     ) -> Option<LanguageServerBinary> {
         Some(LanguageServerBinary {
             path: "uiua".into(),
+            env: None,
             arguments: vec!["lsp".into()],
         })
     }

crates/zed/src/languages/vue.rs 🔗

@@ -118,6 +118,7 @@ impl super::LspAdapter for VueLspAdapter {
         *self.typescript_install_path.lock() = Some(ts_path);
         Ok(LanguageServerBinary {
             path: self.node.binary_path().await?,
+            env: None,
             arguments: vue_server_binary_arguments(&server_path),
         })
     }
@@ -204,6 +205,7 @@ async fn get_cached_server_binary(
             Ok((
                 LanguageServerBinary {
                     path: node.binary_path().await?,
+                    env: None,
                     arguments: vue_server_binary_arguments(&server_path),
                 },
                 typescript_path,

crates/zed/src/languages/yaml.rs 🔗

@@ -74,6 +74,7 @@ impl LspAdapter for YamlLspAdapter {
 
         Ok(LanguageServerBinary {
             path: self.node.binary_path().await?,
+            env: None,
             arguments: server_binary_arguments(&server_path),
         })
     }
@@ -124,6 +125,7 @@ async fn get_cached_server_binary(
         if server_path.exists() {
             Ok(LanguageServerBinary {
                 path: node.binary_path().await?,
+                env: None,
                 arguments: server_binary_arguments(&server_path),
             })
         } else {

crates/zed/src/languages/zig.rs 🔗

@@ -3,10 +3,13 @@ use async_compression::futures::bufread::GzipDecoder;
 use async_tar::Archive;
 use async_trait::async_trait;
 use futures::{io::BufReader, StreamExt};
+use gpui::{AsyncAppContext, Task};
 use language::{LanguageServerName, LspAdapter, LspAdapterDelegate};
 use lsp::LanguageServerBinary;
 use smol::fs;
 use std::env::consts::{ARCH, OS};
+use std::ffi::OsString;
+use std::sync::Arc;
 use std::{any::Any, path::PathBuf};
 use util::async_maybe;
 use util::github::latest_github_release;
@@ -44,6 +47,25 @@ impl LspAdapter for ZlsAdapter {
         Ok(Box::new(version) as Box<_>)
     }
 
+    fn check_if_user_installed(
+        &self,
+        delegate: &Arc<dyn LspAdapterDelegate>,
+        cx: &mut AsyncAppContext,
+    ) -> Option<Task<Option<LanguageServerBinary>>> {
+        let delegate = delegate.clone();
+
+        Some(cx.spawn(|cx| async move {
+            match cx.update(|cx| delegate.which_command(OsString::from("zls"), cx)) {
+                Ok(task) => task.await.map(|(path, env)| LanguageServerBinary {
+                    path,
+                    arguments: vec![],
+                    env: Some(env),
+                }),
+                Err(_) => None,
+            }
+        }))
+    }
+
     async fn fetch_server_binary(
         &self,
         version: Box<dyn 'static + Send + Any>,
@@ -75,6 +97,7 @@ impl LspAdapter for ZlsAdapter {
         }
         Ok(LanguageServerBinary {
             path: binary_path,
+            env: None,
             arguments: vec![],
         })
     }
@@ -119,6 +142,7 @@ async fn get_cached_server_binary(container_dir: PathBuf) -> Option<LanguageServ
         if let Some(path) = last_binary_path {
             Ok(LanguageServerBinary {
                 path,
+                env: None,
                 arguments: Vec::new(),
             })
         } else {