Fix nushell local env detection by using direnv export (#13902)

Stanislav Alekseev and Thorsten Ball created

I don't intend fully on getting this merged, this is just an experiment
on using `direnv` directly without relying on shell-specific behaviours.
It works though, so this finally closes #8633
Release Notes:

- Fixed nushell not picking up `direnv` environments by directly
interfacing with it using `direnv export`

---------

Co-authored-by: Thorsten Ball <mrnugget@gmail.com>

Change summary

assets/settings/default.json           |  8 ++++
crates/project/src/project.rs          | 52 ++++++++++++++++++++++++++-
crates/project/src/project_settings.rs | 17 +++++++++
docs/src/configuring-zed.md            | 16 ++++++++
4 files changed, 90 insertions(+), 3 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -546,6 +546,14 @@
       // "delay_ms": 600
     }
   },
+  // Configuration for how direnv configuration should be loaded. May take 2 values:
+  // 1. Load direnv configuration through the shell hook, works for POSIX shells and fish.
+  //      "direnv": "shell_hook"
+  // 2. Load direnv configuration using `direnv export json` directly.
+  //    This can help with some shells that otherwise would not detect
+  //    the direnv environment, such as nushell or elvish.
+  //      "direnv": "direct"
+  "direnv": "shell_hook",
   "inline_completions": {
     // A list of globs representing files that inline completions should be disabled for.
     "disabled_globs": [".env"]

crates/project/src/project.rs 🔗

@@ -73,7 +73,7 @@ use paths::{
 };
 use postage::watch;
 use prettier_support::{DefaultPrettier, PrettierInstance};
-use project_settings::{LspSettings, ProjectSettings};
+use project_settings::{DirenvSettings, LspSettings, ProjectSettings};
 use rand::prelude::*;
 use rpc::{ErrorCode, ErrorExt as _};
 use search::SearchQuery;
@@ -11640,6 +11640,7 @@ pub struct ProjectLspAdapterDelegate {
     http_client: Arc<dyn HttpClient>,
     language_registry: Arc<LanguageRegistry>,
     shell_env: Mutex<Option<HashMap<String, String>>>,
+    load_direnv: DirenvSettings,
 }
 
 impl ProjectLspAdapterDelegate {
@@ -11648,6 +11649,7 @@ impl ProjectLspAdapterDelegate {
         worktree: &Model<Worktree>,
         cx: &ModelContext<Project>,
     ) -> Arc<Self> {
+        let load_direnv = ProjectSettings::get_global(cx).load_direnv.clone();
         Arc::new(Self {
             project: cx.weak_model(),
             worktree: worktree.read(cx).snapshot(),
@@ -11655,12 +11657,13 @@ impl ProjectLspAdapterDelegate {
             http_client: project.client.http_client(),
             language_registry: project.languages.clone(),
             shell_env: Default::default(),
+            load_direnv,
         })
     }
 
     async fn load_shell_env(&self) {
         let worktree_abs_path = self.worktree.abs_path();
-        let shell_env = load_shell_environment(&worktree_abs_path)
+        let shell_env = load_shell_environment(&worktree_abs_path, &self.load_direnv)
             .await
             .with_context(|| {
                 format!("failed to determine load login shell environment in {worktree_abs_path:?}")
@@ -11874,7 +11877,44 @@ fn include_text(server: &lsp::LanguageServer) -> bool {
         .unwrap_or(false)
 }
 
-async fn load_shell_environment(dir: &Path) -> Result<HashMap<String, String>> {
+async fn load_direnv_environment(dir: &Path) -> Result<Option<HashMap<String, String>>> {
+    let Ok(direnv_path) = which::which("direnv") else {
+        return Ok(None);
+    };
+
+    let direnv_output = smol::process::Command::new(direnv_path)
+        .args(["export", "json"])
+        .current_dir(dir)
+        .output()
+        .await
+        .context("failed to spawn direnv to get local environment variables")?;
+
+    anyhow::ensure!(
+        direnv_output.status.success(),
+        "direnv exited with error {:?}",
+        direnv_output.status
+    );
+
+    let output = String::from_utf8_lossy(&direnv_output.stdout);
+    if output.is_empty() {
+        return Ok(None);
+    }
+
+    Ok(Some(
+        serde_json::from_str(&output).context("failed to parse direnv output")?,
+    ))
+}
+
+async fn load_shell_environment(
+    dir: &Path,
+    load_direnv: &DirenvSettings,
+) -> Result<HashMap<String, String>> {
+    let direnv_environment = match load_direnv {
+        DirenvSettings::ShellHook => None,
+        DirenvSettings::Direct => load_direnv_environment(dir).await?,
+    }
+    .unwrap_or(HashMap::default());
+
     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",
@@ -11885,6 +11925,11 @@ async fn load_shell_environment(dir: &Path) -> Result<HashMap<String, String>> {
     // `cd`'d into it. We do that because tools like direnv, asdf, ...
     // hook into `cd` and only set up the env after that.
     //
+    // If the user selects `Direct` for direnv, it would set an environment
+    // variable that later uses to know that it should not run the hook.
+    // We would include in `.envs` call so it is okay to run the hook
+    // even if direnv direct mode is enabled.
+    //
     // In certain shells we need to execute additional_command in order to
     // trigger the behavior of direnv, etc.
     //
@@ -11912,6 +11957,7 @@ async fn load_shell_environment(dir: &Path) -> Result<HashMap<String, String>> {
 
     let output = smol::process::Command::new(&shell)
         .args(["-i", "-c", &command])
+        .envs(direnv_environment)
         .output()
         .await
         .context("failed to spawn login shell to source login environment variables")?;

crates/project/src/project_settings.rs 🔗

@@ -20,6 +20,23 @@ pub struct ProjectSettings {
     /// Configuration for Git-related features
     #[serde(default)]
     pub git: GitSettings,
+
+    /// Configuration for how direnv configuration should be loaded
+    #[serde(default)]
+    pub load_direnv: DirenvSettings,
+}
+
+#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum DirenvSettings {
+    /// Load direnv configuration through a shell hook
+    #[default]
+    ShellHook,
+    /// Load direnv configuration directly using `direnv export json`
+    ///
+    /// Warning: This option is experimental and might cause some inconsistent behaviour compared to using the shell hook.
+    /// If it does, please report it to GitHub
+    Direct,
 }
 
 #[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]

docs/src/configuring-zed.md 🔗

@@ -190,6 +190,22 @@ You can also set other OpenType features, like setting `cv01` to `7`:
 The `left_padding` and `right_padding` options define the relative width of the
 left and right padding of the central pane from the workspace when the centered layout mode is activated. Valid values range is from `0` to `0.4`.
 
+## Direnv Integration
+
+- Description: Settings for [direnv](https://direnv.net/) integration. Requires `direnv` to be installed. `direnv` integration currently only means that the environment variables set by a `direnv` configuration can be used to detect some language servers in `$PATH` instead of installing them.
+- Setting: `direnv`
+- Default:
+
+```json
+"direnv": "shell_hook"
+```
+
+**Options**
+There are two options to choose from:
+
+1. `shell_hook`: Use the shell hook to load direnv. This relies on direnv to activate upon entering the directory. Supports POSIX shells and fish.
+2. `direct`: Use `direnv export json` to load direnv. This will load direnv directly without relying on the shell hook and might cause some inconsistencies. This allows direnv to work with any shell.
+
 ## Inline Completions
 
 - Description: Settings for inline completions.