environment.rs

  1use futures::{future::Shared, FutureExt};
  2use std::{path::Path, sync::Arc};
  3use util::ResultExt;
  4
  5use collections::HashMap;
  6use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Task};
  7use settings::Settings as _;
  8use worktree::WorktreeId;
  9
 10use crate::{
 11    project_settings::{DirenvSettings, ProjectSettings},
 12    worktree_store::{WorktreeStore, WorktreeStoreEvent},
 13};
 14
 15pub struct ProjectEnvironment {
 16    worktree_store: Entity<WorktreeStore>,
 17    cli_environment: Option<HashMap<String, String>>,
 18    environments: HashMap<WorktreeId, Shared<Task<Option<HashMap<String, String>>>>>,
 19    environment_error_messages: HashMap<WorktreeId, EnvironmentErrorMessage>,
 20}
 21
 22pub enum ProjectEnvironmentEvent {
 23    ErrorsUpdated,
 24}
 25
 26impl EventEmitter<ProjectEnvironmentEvent> for ProjectEnvironment {}
 27
 28impl ProjectEnvironment {
 29    pub fn new(
 30        worktree_store: &Entity<WorktreeStore>,
 31        cli_environment: Option<HashMap<String, String>>,
 32        cx: &mut App,
 33    ) -> Entity<Self> {
 34        cx.new(|cx| {
 35            cx.subscribe(worktree_store, |this: &mut Self, _, event, _| {
 36                if let WorktreeStoreEvent::WorktreeRemoved(_, id) = event {
 37                    this.remove_worktree_environment(*id);
 38                }
 39            })
 40            .detach();
 41
 42            Self {
 43                worktree_store: worktree_store.clone(),
 44                cli_environment,
 45                environments: Default::default(),
 46                environment_error_messages: Default::default(),
 47            }
 48        })
 49    }
 50
 51    pub(crate) fn remove_worktree_environment(&mut self, worktree_id: WorktreeId) {
 52        self.environment_error_messages.remove(&worktree_id);
 53        self.environments.remove(&worktree_id);
 54    }
 55
 56    /// Returns the inherited CLI environment, if this project was opened from the Zed CLI.
 57    pub(crate) fn get_cli_environment(&self) -> Option<HashMap<String, String>> {
 58        if let Some(mut env) = self.cli_environment.clone() {
 59            set_origin_marker(&mut env, EnvironmentOrigin::Cli);
 60            Some(env)
 61        } else {
 62            None
 63        }
 64    }
 65
 66    /// Returns an iterator over all pairs `(worktree_id, error_message)` of
 67    /// environment errors associated with this project environment.
 68    pub(crate) fn environment_errors(
 69        &self,
 70    ) -> impl Iterator<Item = (&WorktreeId, &EnvironmentErrorMessage)> {
 71        self.environment_error_messages.iter()
 72    }
 73
 74    pub(crate) fn remove_environment_error(
 75        &mut self,
 76        worktree_id: WorktreeId,
 77        cx: &mut Context<Self>,
 78    ) {
 79        self.environment_error_messages.remove(&worktree_id);
 80        cx.emit(ProjectEnvironmentEvent::ErrorsUpdated);
 81    }
 82
 83    /// Returns the project environment, if possible.
 84    /// If the project was opened from the CLI, then the inherited CLI environment is returned.
 85    /// If it wasn't opened from the CLI, and a worktree is given, then a shell is spawned in
 86    /// the worktree's path, to get environment variables as if the user has `cd`'d into
 87    /// the worktrees path.
 88    pub(crate) fn get_environment(
 89        &mut self,
 90        worktree_id: Option<WorktreeId>,
 91        worktree_abs_path: Option<Arc<Path>>,
 92        cx: &Context<Self>,
 93    ) -> Shared<Task<Option<HashMap<String, String>>>> {
 94        if cfg!(any(test, feature = "test-support")) {
 95            return Task::ready(Some(HashMap::default())).shared();
 96        }
 97
 98        if let Some(cli_environment) = self.get_cli_environment() {
 99            return cx
100                .spawn(async move |_, _| {
101                    let path = cli_environment
102                        .get("PATH")
103                        .map(|path| path.as_str())
104                        .unwrap_or_default();
105                    log::info!(
106                        "using project environment variables from CLI. PATH={:?}",
107                        path
108                    );
109                    Some(cli_environment)
110                })
111                .shared();
112        }
113
114        let Some((worktree_id, worktree_abs_path)) = worktree_id.zip(worktree_abs_path) else {
115            return Task::ready(None).shared();
116        };
117
118        if self
119            .worktree_store
120            .read(cx)
121            .worktree_for_id(worktree_id, cx)
122            .map(|w| !w.read(cx).is_local())
123            .unwrap_or(true)
124        {
125            return Task::ready(None).shared();
126        }
127
128        if let Some(task) = self.environments.get(&worktree_id) {
129            task.clone()
130        } else {
131            let task = self
132                .get_worktree_env(worktree_id, worktree_abs_path, cx)
133                .shared();
134            self.environments.insert(worktree_id, task.clone());
135            task
136        }
137    }
138
139    fn get_worktree_env(
140        &mut self,
141        worktree_id: WorktreeId,
142        worktree_abs_path: Arc<Path>,
143        cx: &Context<Self>,
144    ) -> Task<Option<HashMap<String, String>>> {
145        let load_direnv = ProjectSettings::get_global(cx).load_direnv.clone();
146
147        cx.spawn(async move |this, cx| {
148            let (mut shell_env, error_message) = cx
149                .background_spawn({
150                    let worktree_abs_path = worktree_abs_path.clone();
151                    async move {
152                        load_worktree_shell_environment(&worktree_abs_path, &load_direnv).await
153                    }
154                })
155                .await;
156
157            if let Some(shell_env) = shell_env.as_mut() {
158                let path = shell_env
159                    .get("PATH")
160                    .map(|path| path.as_str())
161                    .unwrap_or_default();
162                log::info!(
163                    "using project environment variables shell launched in {:?}. PATH={:?}",
164                    worktree_abs_path,
165                    path
166                );
167
168                set_origin_marker(shell_env, EnvironmentOrigin::WorktreeShell);
169            }
170
171            if let Some(error) = error_message {
172                this.update(cx, |this, cx| {
173                    this.environment_error_messages.insert(worktree_id, error);
174                    cx.emit(ProjectEnvironmentEvent::ErrorsUpdated)
175                })
176                .log_err();
177            }
178
179            shell_env
180        })
181    }
182}
183
184fn set_origin_marker(env: &mut HashMap<String, String>, origin: EnvironmentOrigin) {
185    env.insert(ZED_ENVIRONMENT_ORIGIN_MARKER.to_string(), origin.into());
186}
187
188const ZED_ENVIRONMENT_ORIGIN_MARKER: &str = "ZED_ENVIRONMENT";
189
190enum EnvironmentOrigin {
191    Cli,
192    WorktreeShell,
193}
194
195impl From<EnvironmentOrigin> for String {
196    fn from(val: EnvironmentOrigin) -> Self {
197        match val {
198            EnvironmentOrigin::Cli => "cli".into(),
199            EnvironmentOrigin::WorktreeShell => "worktree-shell".into(),
200        }
201    }
202}
203
204pub struct EnvironmentErrorMessage(pub String);
205
206impl EnvironmentErrorMessage {
207    #[allow(dead_code)]
208    fn from_str(s: &str) -> Self {
209        Self(String::from(s))
210    }
211}
212
213async fn load_worktree_shell_environment(
214    worktree_abs_path: &Path,
215    load_direnv: &DirenvSettings,
216) -> (
217    Option<HashMap<String, String>>,
218    Option<EnvironmentErrorMessage>,
219) {
220    match smol::fs::metadata(worktree_abs_path).await {
221        Ok(meta) => {
222            let dir = if meta.is_dir() {
223                worktree_abs_path
224            } else if let Some(parent) = worktree_abs_path.parent() {
225                parent
226            } else {
227                return (
228                    None,
229                    Some(EnvironmentErrorMessage(format!(
230                        "Failed to load shell environment in {}: not a directory",
231                        worktree_abs_path.display()
232                    ))),
233                );
234            };
235
236            load_shell_environment(&dir, load_direnv).await
237        }
238        Err(err) => (
239            None,
240            Some(EnvironmentErrorMessage(format!(
241                "Failed to load shell environment in {}: {}",
242                worktree_abs_path.display(),
243                err
244            ))),
245        ),
246    }
247}
248
249#[cfg(any(test, feature = "test-support"))]
250async fn load_shell_environment(
251    _dir: &Path,
252    _load_direnv: &DirenvSettings,
253) -> (
254    Option<HashMap<String, String>>,
255    Option<EnvironmentErrorMessage>,
256) {
257    let fake_env = [("ZED_FAKE_TEST_ENV".into(), "true".into())]
258        .into_iter()
259        .collect();
260    (Some(fake_env), None)
261}
262
263#[cfg(all(target_os = "windows", not(any(test, feature = "test-support"))))]
264async fn load_shell_environment(
265    _dir: &Path,
266    _load_direnv: &DirenvSettings,
267) -> (
268    Option<HashMap<String, String>>,
269    Option<EnvironmentErrorMessage>,
270) {
271    // TODO the current code works with Unix $SHELL only, implement environment loading on windows
272    (None, None)
273}
274
275#[cfg(not(any(target_os = "windows", test, feature = "test-support")))]
276async fn load_shell_environment(
277    dir: &Path,
278    load_direnv: &DirenvSettings,
279) -> (
280    Option<HashMap<String, String>>,
281    Option<EnvironmentErrorMessage>,
282) {
283    use crate::direnv::{load_direnv_environment, DirenvError};
284    use std::path::PathBuf;
285    use util::parse_env_output;
286
287    fn message<T>(with: &str) -> (Option<T>, Option<EnvironmentErrorMessage>) {
288        let message = EnvironmentErrorMessage::from_str(with);
289        (None, Some(message))
290    }
291
292    const MARKER: &str = "ZED_SHELL_START";
293    let Some(shell) = std::env::var("SHELL").log_err() else {
294        return message("Failed to get login environment. SHELL environment variable is not set");
295    };
296    let shell_path = PathBuf::from(&shell);
297    let shell_name = shell_path.file_name().and_then(|f| f.to_str());
298
299    // What we're doing here is to spawn a shell and then `cd` into
300    // the project directory to get the env in there as if the user
301    // `cd`'d into it. We do that because tools like direnv, asdf, ...
302    // hook into `cd` and only set up the env after that.
303    //
304    // If the user selects `Direct` for direnv, it would set an environment
305    // variable that later uses to know that it should not run the hook.
306    // We would include in `.envs` call so it is okay to run the hook
307    // even if direnv direct mode is enabled.
308    //
309    // In certain shells we need to execute additional_command in order to
310    // trigger the behavior of direnv, etc.
311    //
312    //
313    // The `exit 0` is the result of hours of debugging, trying to find out
314    // why running this command here, without `exit 0`, would mess
315    // up signal process for our process so that `ctrl-c` doesn't work
316    // anymore.
317    //
318    // We still don't know why `$SHELL -l -i -c '/usr/bin/env -0'`  would
319    // do that, but it does, and `exit 0` helps.
320
321    let command = match shell_name {
322        Some("fish") => format!(
323            "cd '{}'; emit fish_prompt; printf '%s' {MARKER}; /usr/bin/env; exit 0;",
324            dir.display()
325        ),
326        _ => format!(
327            "cd '{}'; printf '%s' {MARKER}; /usr/bin/env; exit 0;",
328            dir.display()
329        ),
330    };
331
332    // csh/tcsh only supports `-l` if it's the only flag. So this won't be a login shell.
333    // Users must rely on vars from `~/.tcshrc` or `~/.cshrc` and not `.login` as a result.
334    let args = match shell_name {
335        Some("tcsh") | Some("csh") => vec!["-i", "-c", &command],
336        _ => vec!["-l", "-i", "-c", &command],
337    };
338
339    let Some(output) = smol::process::Command::new(&shell)
340        .args(&args)
341        .output()
342        .await
343        .log_err()
344    else {
345        return message(
346            "Failed to spawn login shell to source login environment variables. See logs for details",
347        );
348    };
349
350    if !output.status.success() {
351        log::error!("login shell exited with {}", output.status);
352        return message("Login shell exited with nonzero exit code. See logs for details");
353    }
354
355    let stdout = String::from_utf8_lossy(&output.stdout);
356    let Some(env_output_start) = stdout.find(MARKER) else {
357        let stderr = String::from_utf8_lossy(&output.stderr);
358        log::error!(
359            "failed to parse output of `env` command in login shell. stdout: {:?}, stderr: {:?}",
360            stdout,
361            stderr
362        );
363        return message("Failed to parse stdout of env command. See logs for the output");
364    };
365
366    let mut parsed_env = HashMap::default();
367    let env_output = &stdout[env_output_start + MARKER.len()..];
368
369    parse_env_output(env_output, |key, value| {
370        parsed_env.insert(key, value);
371    });
372
373    let (direnv_environment, direnv_error) = match load_direnv {
374        DirenvSettings::ShellHook => (None, None),
375        DirenvSettings::Direct => match load_direnv_environment(&parsed_env, dir).await {
376            Ok(env) => (Some(env), None),
377            Err(err) => (
378                None,
379                <Option<EnvironmentErrorMessage> as From<DirenvError>>::from(err),
380            ),
381        },
382    };
383
384    for (key, value) in direnv_environment.unwrap_or(HashMap::default()) {
385        parsed_env.insert(key, value);
386    }
387
388    (Some(parsed_env), direnv_error)
389}