Skip to main content

util/
shell_env.rs

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