Skip to main content

open_gpui_util/
shell_env.rs

1use std::path::Path;
2
3use anyhow::{Context as _, Result};
4use open_gpui_collections::HashMap;
5use serde::Deserialize;
6
7use crate::shell::ShellKind;
8
9fn parse_env_map_from_noisy_output(
10    output: &str,
11) -> Result<open_gpui_collections::HashMap<String, String>> {
12    for (position, _) in output.match_indices('{') {
13        let candidate = &output[position..];
14        let mut deserializer = serde_json::Deserializer::from_str(candidate);
15        if let Ok(env_map) = HashMap::<String, String>::deserialize(&mut deserializer) {
16            return Ok(env_map);
17        }
18    }
19    anyhow::bail!("Failed to find JSON in shell output: {output}")
20}
21
22pub fn print_env() {
23    let env_vars: HashMap<String, String> = std::env::vars().collect();
24    let json = serde_json::to_string_pretty(&env_vars).unwrap_or_else(|err| {
25        eprintln!("Error serializing environment variables: {}", err);
26        std::process::exit(1);
27    });
28    println!("{}", json);
29}
30
31/// Capture all environment variables from the login shell in the given directory.
32pub async fn capture(
33    shell_path: impl AsRef<Path>,
34    args: &[String],
35    directory: impl AsRef<Path>,
36) -> Result<open_gpui_collections::HashMap<String, String>> {
37    #[cfg(windows)]
38    return capture_windows(shell_path.as_ref(), args, directory.as_ref()).await;
39    #[cfg(unix)]
40    return capture_unix(shell_path.as_ref(), args, directory.as_ref()).await;
41}
42
43/// Try to parse the environment output before checking the exit status.
44/// The user's shell rc files may contain commands that fail (e.g. editor
45/// integrations that call posix_spawnp outside a real PTY), causing a
46/// non-zero exit status even though `zed --printenv` ran successfully and
47/// produced valid output on its separate fd.
48fn parse_env_output(
49    env_output: &str,
50    status: &std::process::ExitStatus,
51    successful_capture_warning: impl FnOnce() -> String,
52    failed_capture_error: impl FnOnce() -> String,
53) -> Result<open_gpui_collections::HashMap<String, String>> {
54    match parse_env_map_from_noisy_output(env_output) {
55        Ok(env_map) => {
56            if !status.success() {
57                log::warn!("{}", successful_capture_warning());
58            }
59            Ok(env_map)
60        }
61        Err(parse_error) => {
62            if !status.success() {
63                anyhow::bail!(
64                    "{}. Failed to deserialize environment variables from json: {parse_error}. output: {env_output}",
65                    failed_capture_error(),
66                );
67            }
68
69            anyhow::bail!(
70                "Failed to deserialize environment variables from json: {parse_error}. output: {env_output}"
71            );
72        }
73    }
74}
75
76#[cfg(unix)]
77async fn capture_unix(
78    shell_path: &Path,
79    args: &[String],
80    directory: &Path,
81) -> Result<open_gpui_collections::HashMap<String, String>> {
82    use std::os::unix::process::CommandExt;
83
84    use crate::command::new_std_command;
85
86    let shell_kind = ShellKind::new(shell_path, false);
87    let quoted_zed_path = super::get_shell_safe_zed_path(shell_kind)?;
88
89    let mut command_string = String::new();
90    let mut command = new_std_command(shell_path);
91    command.args(args);
92    // In some shells, file descriptors greater than 2 cannot be used in interactive mode,
93    // so file descriptor 0 (stdin) is used instead. This impacts zsh, old bash; perhaps others.
94    // See: https://github.com/zed-industries/zed/pull/32136#issuecomment-2999645482
95    const FD_STDIN: std::os::fd::RawFd = 0;
96    const FD_STDOUT: std::os::fd::RawFd = 1;
97    const FD_STDERR: std::os::fd::RawFd = 2;
98
99    let (fd_num, redir) = match shell_kind {
100        ShellKind::Rc => (FD_STDIN, format!(">[1={}]", FD_STDIN)), // `[1=0]`
101        ShellKind::Nushell | ShellKind::Tcsh => (FD_STDOUT, "".to_string()),
102        // xonsh doesn't support redirecting to stdin, and control sequences are printed to
103        // stdout on startup
104        ShellKind::Xonsh => (FD_STDERR, "o>e".to_string()),
105        ShellKind::PowerShell => (FD_STDIN, format!(">{}", FD_STDIN)),
106        _ => (FD_STDIN, format!(">&{}", FD_STDIN)), // `>&0`
107    };
108
109    match shell_kind {
110        ShellKind::Csh | ShellKind::Tcsh => {
111            // For csh/tcsh, login shell requires passing `-` as 0th argument (instead of `-l`)
112            command.arg0("-");
113        }
114        ShellKind::Fish => {
115            // in fish, asdf, direnv attach to the `fish_prompt` event
116            command_string.push_str("emit fish_prompt;");
117            command.arg("-l");
118        }
119        _ => {
120            command.arg("-l");
121        }
122    }
123
124    match shell_kind {
125        // Nushell does not allow non-interactive login shells.
126        // Instead of doing "-l -i -c '<command>'"
127        // use "-l -e '<command>; exit'" instead
128        ShellKind::Nushell => command.arg("-e"),
129        _ => command.args(["-i", "-c"]),
130    };
131
132    // Prefix with "./" if the path starts with "-" to prevent cd from interpreting it as a flag
133    let dir_str = directory.to_string_lossy();
134    let dir_str = if dir_str.starts_with('-') {
135        format!("./{dir_str}").into()
136    } else {
137        dir_str
138    };
139    let quoted_dir = shell_kind
140        .try_quote(&dir_str)
141        .context("unexpected null in directory name")?;
142
143    // cd into the directory, triggering directory specific side-effects (asdf, direnv, etc)
144    command_string.push_str(&format!("cd {};", quoted_dir));
145    if let Some(prefix) = shell_kind.command_prefix() {
146        command_string.push(prefix);
147    }
148    command_string.push_str(&format!("{} --printenv {}", quoted_zed_path, redir));
149
150    if let ShellKind::Nushell = shell_kind {
151        command_string.push_str("; exit");
152    }
153
154    command.arg(&command_string);
155
156    super::set_pre_exec_to_start_new_session(&mut command);
157
158    let (env_output, process_output) = spawn_and_read_fd(command, fd_num).await?;
159    let env_output = String::from_utf8_lossy(&env_output);
160
161    parse_env_output(
162        &env_output,
163        &process_output.status,
164        || {
165            format!(
166                "login shell exited with {} but environment was captured successfully. stderr: {:?}",
167                process_output.status,
168                String::from_utf8_lossy(&process_output.stderr),
169            )
170        },
171        || {
172            format!(
173                "login shell exited with {}. stdout: {:?}, stderr: {:?}",
174                process_output.status,
175                String::from_utf8_lossy(&process_output.stdout),
176                String::from_utf8_lossy(&process_output.stderr),
177            )
178        },
179    )
180}
181
182#[cfg(unix)]
183async fn spawn_and_read_fd(
184    mut command: std::process::Command,
185    child_fd: std::os::fd::RawFd,
186) -> anyhow::Result<(Vec<u8>, std::process::Output)> {
187    use command_fds::{CommandFdExt, FdMapping};
188    use std::{io::Read, process::Stdio};
189
190    let (mut reader, writer) = std::io::pipe()?;
191
192    command.fd_mappings(vec![FdMapping {
193        parent_fd: writer.into(),
194        child_fd,
195    }])?;
196
197    let process = smol::process::Command::from(command)
198        .stdin(Stdio::null())
199        .stdout(Stdio::piped())
200        .stderr(Stdio::piped())
201        .spawn()?;
202
203    let mut buffer = Vec::new();
204    reader.read_to_end(&mut buffer)?;
205
206    Ok((buffer, process.output().await?))
207}
208
209#[cfg(windows)]
210async fn capture_windows(
211    shell_path: &Path,
212    args: &[String],
213    directory: &Path,
214) -> Result<open_gpui_collections::HashMap<String, String>> {
215    use std::process::Stdio;
216
217    let zed_path =
218        std::env::current_exe().context("Failed to determine current zed executable path.")?;
219
220    let shell_kind = ShellKind::new(shell_path, true);
221    // Prefix with "./" if the path starts with "-" to prevent cd from interpreting it as a flag
222    let directory_string = directory.display().to_string();
223    let directory_string = if directory_string.starts_with('-') {
224        format!("./{directory_string}")
225    } else {
226        directory_string
227    };
228    let zed_path_string = zed_path.display().to_string();
229    let quote_for_shell = |value: &str| {
230        shell_kind
231            .try_quote(value)
232            .map(|quoted| quoted.into_owned())
233            .context("unexpected null in directory name")
234    };
235    let mut cmd = crate::command::new_command(shell_path);
236    cmd.args(args);
237    let quoted_directory = quote_for_shell(&directory_string)?;
238    let quoted_zed_path = quote_for_shell(&zed_path_string)?;
239    let cmd = match shell_kind {
240        ShellKind::Csh
241        | ShellKind::Tcsh
242        | ShellKind::Rc
243        | ShellKind::Fish
244        | ShellKind::Xonsh
245        | ShellKind::Posix => cmd.args([
246            "-l",
247            "-i",
248            "-c",
249            &format!("cd {}; {} --printenv", quoted_directory, quoted_zed_path),
250        ]),
251        ShellKind::PowerShell | ShellKind::Pwsh => cmd.args([
252            "-NonInteractive",
253            "-NoProfile",
254            "-Command",
255            &format!(
256                "Set-Location {}; & {} --printenv",
257                quoted_directory, quoted_zed_path
258            ),
259        ]),
260        ShellKind::Elvish => cmd.args([
261            "-c",
262            &format!("cd {}; {} --printenv", quoted_directory, quoted_zed_path),
263        ]),
264        ShellKind::Nushell => {
265            let zed_command = shell_kind
266                .prepend_command_prefix(&quoted_zed_path)
267                .into_owned();
268            cmd.args([
269                "-c",
270                &format!("cd {}; {} --printenv", quoted_directory, zed_command),
271            ])
272        }
273        ShellKind::Cmd => {
274            let dir = directory_string.trim_end_matches('\\');
275            cmd.args(["/d", "/c", "cd", dir, "&&", &zed_path_string, "--printenv"])
276        }
277    }
278    .stdin(Stdio::null())
279    .stdout(Stdio::piped())
280    .stderr(Stdio::piped());
281    let output = cmd
282        .output()
283        .await
284        .with_context(|| format!("command {cmd:?}"))?;
285    let env_output = String::from_utf8_lossy(&output.stdout);
286
287    parse_env_output(
288        &env_output,
289        &output.status,
290        || {
291            format!(
292                "Command {cmd:?} exited with {} but environment was captured successfully. stderr: {:?}",
293                output.status,
294                String::from_utf8_lossy(&output.stderr),
295            )
296        },
297        || {
298            format!(
299                "Command {cmd:?} failed with {}. stdout: {:?}, stderr: {:?}",
300                output.status,
301                String::from_utf8_lossy(&output.stdout),
302                String::from_utf8_lossy(&output.stderr),
303            )
304        },
305    )
306}
307
308#[cfg(test)]
309mod tests {
310    use std::process::ExitStatus;
311
312    use super::*;
313    use crate::path;
314
315    #[cfg(unix)]
316    fn exit_status(code: i32) -> ExitStatus {
317        use std::os::unix::process::ExitStatusExt;
318
319        ExitStatus::from_raw(code << 8)
320    }
321
322    #[cfg(windows)]
323    fn exit_status(code: u32) -> ExitStatus {
324        use std::os::windows::process::ExitStatusExt;
325
326        ExitStatus::from_raw(code)
327    }
328
329    #[test]
330    fn parse_env_output_accepts_valid_env_when_shell_exits_nonzero() {
331        let env_json = serde_json::json!({
332            "PATH": path!("/usr/bin"),
333            "SHELL": path!("/bin/zsh"),
334        });
335        let env_output = format!("shell startup noise\n{env_json}\nshell shutdown noise");
336
337        let env_map = parse_env_output(
338            &env_output,
339            &exit_status(1),
340            || "shell exited with 1 but environment was captured successfully".to_string(),
341            || panic!("failed capture error should not be evaluated for valid environment output"),
342        )
343        .expect("valid environment output should be returned despite non-zero shell exit");
344        assert_eq!(
345            env_map.get("PATH").map(String::as_str),
346            Some(path!("/usr/bin"))
347        );
348        assert_eq!(
349            env_map.get("SHELL").map(String::as_str),
350            Some(path!("/bin/zsh"))
351        );
352    }
353}