1use futures::{FutureExt, future::Shared};
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::{DirenvError, load_direnv_environment};
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}