feat(termio): port pty, child command spawning, shell integration

Ports the parts of Ghostty's IO layer that don't depend on the terminal:
- pty.zig: pty pair, window size, mode, child-side setsid/TIOCSCTTY;
  master is CLOEXEC with IUTF8 set
- Command.zig: fork/execvpe with Ghostty's exact child setup (stdio
  dup2, best-effort chdir, rlimit restore, os/runtime pre-exec hooks,
  cgroup handling) and exit status decoding
- shell_integration.zig for bash/zsh/fish/elvish/nushell plus feature
  flags; scripts copied verbatim to resources/shell-integration/
- terminfo generator for xterm-ghostty, byte-identical to Zig's encode
  output (golden file produced with the real Zig code)

This crate opts into unsafe_code = "deny" (fork/exec need it) with
allow + SAFETY comments on minimal items.

Integration left: config-derived option types are local stand-ins;
cgroup D-Bus scope creation is a closure hook; Exec/Termio/Thread/
stream_handler wait for ghostty-terminal. Pty now closes both fds on
drop (Zig never auto-closed the slave) - to re-check when wiring Exec.

Test Plan:
- cargo test -p ghostty-termio (52 tests, incl. real pty spawn test
  expecting "hi\r\n31 97\r\n" from `echo hi; stty size`)
This commit is contained in:
ddidderr committed 2026-10-11 22:06:51 +02:00
1 parent 668c236291
commit 09cbe3bc97
21 files changed
+7120 -28

No files matched your search

@@ -0,0 +1,893 @@
//! Shell integration setup. Port of `termio/shell_integration.zig`.
//!
//! The shell scripts live in `resources/shell-integration/` at the workspace
//! root (copied verbatim from `src/shell-integration/`).
use std::{collections::HashMap, path::Path};
use ghostty_os::{env::prepend_env, homedir, shell::ShellCommandBuilder};
/// The environment passed to the child (Zig `std.process.EnvMap`).
pub type EnvMap = HashMap<String, String>;
/// Shell types we support
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shell {
Bash,
Elvish,
Fish,
Nushell,
Zsh,
}
impl Shell {
pub const ALL: [Shell; 5] = [
Shell::Bash,
Shell::Elvish,
Shell::Fish,
Shell::Nushell,
Shell::Zsh,
];
/// The name of the shell (Zig `@tagName`), also the directory name inside
/// `shell-integration/`.
pub fn tag_name(self) -> &'static str {
match self {
Shell::Bash => "bash",
Shell::Elvish => "elvish",
Shell::Fish => "fish",
Shell::Nushell => "nushell",
Shell::Zsh => "zsh",
}
}
}
/// Local stand-in for `config.Command`.
/// TODO(integration): replace with `ghostty_config::Command`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
/// A shell command line, split with `ArgIteratorGeneral` rules.
Shell(String),
/// Directly executed argv.
Direct(Vec<String>),
}
impl Command {
/// The arguments of the command. For shell commands this is a best
/// effort parse equivalent to Zig's `std.process.ArgIteratorGeneral(.{})`
/// (whitespace separated, double quotes group, backslashes only special
/// before a quote).
pub fn arg_iterator(&self) -> std::vec::IntoIter<String> {
match self {
Command::Direct(v) => v.clone().into_iter(),
Command::Shell(s) => split_args_general(s).into_iter(),
}
}
}
/// `std.process.ArgIteratorGeneral(.{})` (Windows command-line rules).
fn split_args_general(cmd_line: &str) -> Vec<String> {
let b = cmd_line.as_bytes();
let mut i = 0;
let mut out = Vec::new();
loop {
// Skip whitespace
while i < b.len() && (b[i] == b' ' || b[i] == b'\t') {
i += 1;
}
if i >= b.len() {
return out;
}
let mut buf: Vec<u8> = Vec::new();
let mut backslashes = 0usize;
let mut in_quote = false;
loop {
let c = b.get(i).copied();
match c {
None => {
buf.extend(std::iter::repeat_n(b'\\', backslashes));
break;
}
Some(b' ' | b'\t') if !in_quote => {
buf.extend(std::iter::repeat_n(b'\\', backslashes));
i += 1;
break;
}
Some(b'\\') => {
backslashes += 1;
i += 1;
}
Some(b'"') => {
let real = backslashes.is_multiple_of(2);
buf.extend(std::iter::repeat_n(b'\\', backslashes / 2));
backslashes = 0;
if real {
in_quote = !in_quote;
} else {
buf.push(b'"');
}
i += 1;
}
Some(ch) => {
buf.extend(std::iter::repeat_n(b'\\', backslashes));
backslashes = 0;
buf.push(ch);
i += 1;
}
}
}
out.push(String::from_utf8_lossy(&buf).into_owned());
}
}
/// Local stand-in for `config.ShellIntegrationFeatures` (same defaults).
/// TODO(integration): replace with `ghostty_config::ShellIntegrationFeatures`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShellIntegrationFeatures {
pub cursor: bool,
pub sudo: bool,
pub title: bool,
pub ssh_env: bool,
pub ssh_terminfo: bool,
pub path: bool,
}
impl Default for ShellIntegrationFeatures {
fn default() -> Self {
Self {
cursor: true,
sudo: false,
title: true,
ssh_env: false,
ssh_terminfo: false,
path: true,
}
}
}
impl ShellIntegrationFeatures {
/// All features disabled (`std.mem.zeroes`).
pub const NONE: Self = Self {
cursor: false,
sudo: false,
title: false,
ssh_env: false,
ssh_terminfo: false,
path: false,
};
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("unable to determine home directory: {0}")]
Home(#[from] homedir::Error),
}
/// The result of setting up a shell integration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShellIntegration {
/// The successfully-integrated shell.
pub shell: Shell,
/// The command to use to start the shell with the integration.
/// In most cases this is identical to the command given but for
/// bash in particular it may be different.
pub command: Command,
}
/// Set up the command execution environment for automatic
/// integrated shell integration and return a ShellIntegration
/// struct describing the integration. If integration fails
/// (shell type couldn't be detected, etc.), this will return None.
pub fn setup(
resource_dir: &str,
command: &Command,
env: &mut EnvMap,
force_shell: Option<Shell>,
) -> Result<Option<ShellIntegration>, Error> {
let Some(shell) = force_shell.or_else(|| detect_shell(command)) else {
return Ok(None);
};
let new_command = match shell {
Shell::Bash => setup_bash(command, resource_dir, env)?,
Shell::Nushell => setup_nushell(command, resource_dir, env),
Shell::Zsh => setup_zsh(command, resource_dir, env),
Shell::Elvish | Shell::Fish => {
if !setup_xdg_data_dirs(resource_dir, env) {
return Ok(None);
}
Some(command.clone())
}
};
Ok(new_command.map(|command| ShellIntegration { shell, command }))
}
fn basename(path: &str) -> &str {
// std.fs.path.basename: strips trailing separators.
let trimmed = path.trim_end_matches('/');
match trimmed.rfind('/') {
Some(i) => &trimmed[i + 1..],
None => trimmed,
}
}
fn detect_shell(command: &Command) -> Option<Shell> {
let mut it = command.arg_iterator();
let arg0 = it.next()?;
let exe = basename(&arg0);
match exe {
// (The Darwin /bin/bash special case does not apply on Linux.)
"bash" => Some(Shell::Bash),
"elvish" => Some(Shell::Elvish),
"fish" => Some(Shell::Fish),
"nu" => Some(Shell::Nushell),
"zsh" => Some(Shell::Zsh),
_ => None,
}
}
/// Set up the shell integration features environment variable.
pub fn setup_features(env: &mut EnvMap, features: ShellIntegrationFeatures, cursor_blink: bool) {
// Sorted (case-insensitively) so that the output is deterministic.
let fields: [(&str, bool); 6] = [
("cursor", features.cursor),
("path", features.path),
("ssh-env", features.ssh_env),
("ssh-terminfo", features.ssh_terminfo),
("sudo", features.sudo),
("title", features.title),
];
let mut buf = String::new();
for (name, enabled) in fields {
if enabled {
if !buf.is_empty() {
buf.push(',');
}
buf.push_str(name);
if name == "cursor" {
buf.push_str(if cursor_blink { ":blink" } else { ":steady" });
}
}
}
if !buf.is_empty() {
env.insert("GHOSTTY_SHELL_FEATURES".to_owned(), buf);
}
}
/// Setup the bash automatic shell integration. This works by
/// starting bash in POSIX mode and using the ENV environment
/// variable to load our bash integration script. This prevents
/// bash from loading its normal startup files, which becomes
/// our script's responsibility (along with disabling POSIX
/// mode).
///
/// This returns a new shell command string that enables the integration
/// or None if integration failed.
fn setup_bash(
command: &Command,
resource_dir: &str,
env: &mut EnvMap,
) -> Result<Option<Command>, Error> {
let mut cmd = ShellCommandBuilder::new();
// Iterator that yields each argument in the original command line.
let mut iter = command.arg_iterator();
// Start accumulating arguments with the executable and initial flags.
match iter.next() {
Some(exe) => cmd.append_arg(&exe),
None => return Ok(None),
}
cmd.append_arg("--posix");
// Stores the list of intercepted command line flags that will be passed
// to our shell integration script: --norc --noprofile
// We always include at least "1" so the script can differentiate between
// being manually sourced or automatically injected (from here).
let mut inject = String::from("1");
// Walk through the rest of the given arguments. If we see an option that
// would require complex or unsupported integration behavior, we bail out
// and skip loading our shell integration. Users can still manually source
// the shell integration script.
//
// Unsupported options:
// -c -c is always non-interactive
// --posix POSIX mode (a la /bin/sh)
let mut rcfile: Option<String> = None;
while let Some(arg) = iter.next() {
if arg == "--posix" {
return Ok(None);
} else if arg == "--norc" {
inject.push_str(" --norc");
} else if arg == "--noprofile" {
inject.push_str(" --noprofile");
} else if arg == "--rcfile" || arg == "--init-file" {
rcfile = iter.next();
} else if arg.len() > 1 && arg.starts_with('-') && !arg[1..].starts_with('-') {
// '-c command' is always non-interactive
if arg.contains('c') {
return Ok(None);
}
cmd.append_arg(&arg);
} else if arg == "-" || arg == "--" {
// All remaining arguments should be passed directly to the shell
// command. We shouldn't perform any further option processing.
cmd.append_arg(&arg);
for remaining in iter.by_ref() {
cmd.append_arg(&remaining);
}
break;
} else {
cmd.append_arg(&arg);
}
}
// Preserve an existing ENV value. We're about to overwrite it.
if let Some(v) = env.get("ENV").cloned() {
env.insert("GHOSTTY_BASH_ENV".to_owned(), v);
}
// Set our new ENV to point to our integration script.
let script_path = format!("{resource_dir}/shell-integration/bash/ghostty.bash");
match std::fs::File::open(&script_path) {
Ok(_) => {
env.insert("ENV".to_owned(), script_path);
}
Err(err) => {
log::warn!("unable to open {script_path}: {err}");
env.remove("GHOSTTY_BASH_ENV");
return Ok(None);
}
}
env.insert("GHOSTTY_BASH_INJECT".to_owned(), inject);
if let Some(v) = rcfile {
env.insert("GHOSTTY_BASH_RCFILE".to_owned(), v);
}
// In POSIX mode, HISTFILE defaults to ~/.sh_history, so unless we're
// staying in POSIX mode (--posix), change it back to ~/.bash_history.
if !env.contains_key("HISTFILE")
&& let Some(home) = homedir::home()?
{
env.insert("HISTFILE".to_owned(), format!("{home}/.bash_history"));
env.insert("GHOSTTY_BASH_UNEXPORT_HISTFILE".to_owned(), "1".to_owned());
}
// Return a copy of our modified command line to use as the shell command.
Ok(Some(Command::Shell(cmd.written().to_owned())))
}
/// Setup automatic shell integration for shells that include
/// their modules from paths in `XDG_DATA_DIRS` env variable.
///
/// The shell-integration path is prepended to `XDG_DATA_DIRS`.
/// It is also saved in the `GHOSTTY_SHELL_INTEGRATION_XDG_DIR` variable
/// so that the shell can refer to it and safely remove this directory
/// from `XDG_DATA_DIRS` when integration is complete.
fn setup_xdg_data_dirs(resource_dir: &str, env: &mut EnvMap) -> bool {
// Get our path to the shell integration directory.
let integ_path = format!("{resource_dir}/shell-integration");
if let Err(err) = std::fs::read_dir(&integ_path) {
log::warn!("unable to open {integ_path}: {err}");
return false;
}
// Set an env var so we can remove this from XDG_DATA_DIRS later.
// This happens in the shell integration config itself. We do this
// so that our modifications don't interfere with other commands.
env.insert(
"GHOSTTY_SHELL_INTEGRATION_XDG_DIR".to_owned(),
integ_path.clone(),
);
// If no XDG_DATA_DIRS set use the default value as specified.
// This ensures that the default directories aren't lost by setting
// our desired integration dir directly. See #2711.
// <https://specifications.freedesktop.org/basedir-spec/0.6/#variables>
let key = "XDG_DATA_DIRS";
let current = env
.get(key)
.map_or("/usr/local/share:/usr/share", String::as_str);
let new = prepend_env(current, &integ_path);
env.insert(key.to_owned(), new);
true
}
/// Set up automatic Nushell shell integration. This works by adding our
/// shell resource directory to the `XDG_DATA_DIRS` environment variable,
/// which Nushell will use to load `nushell/vendor/autoload/ghostty.nu`.
///
/// We then add `--execute 'use ghostty ...'` to the nu command line to
/// automatically enable our shell features.
fn setup_nushell(command: &Command, resource_dir: &str, env: &mut EnvMap) -> Option<Command> {
// Add our XDG_DATA_DIRS entry (for nushell/vendor/autoload/). This
// makes our 'ghostty' module automatically available, even if any
// of the later checks abort the rest of our automatic integration.
if !setup_xdg_data_dirs(resource_dir, env) {
return None;
}
let mut cmd = ShellCommandBuilder::new();
let mut iter = command.arg_iterator();
// Start accumulating arguments with the executable and initial flags.
cmd.append_arg(&iter.next()?);
// Tell nu to immediately "use" all of the exported functions in our
// 'ghostty' module.
//
// We can consider making this more specific based on the set of
// enabled shell features (e.g. `use ghostty sudo`). At the moment,
// shell features are all runtime-guarded in the nushell script.
cmd.append_arg("--execute 'use ghostty *'");
// Walk through the rest of the given arguments. If we see an option that
// would require complex or unsupported integration behavior, we bail out
// and skip loading our shell integration. Users can still manually source
// the shell integration module.
//
// Unsupported options:
// -c / --command -c is always non-interactive
// --lsp --lsp starts the language server
while let Some(arg) = iter.next() {
if arg == "--command" || arg == "--lsp" {
return None;
} else if arg.len() > 1 && arg.starts_with('-') && !arg[1..].starts_with('-') {
if arg.contains('c') {
return None;
}
cmd.append_arg(&arg);
} else if arg == "-" || arg == "--" {
// All remaining arguments should be passed directly to the shell
// command. We shouldn't perform any further option processing.
cmd.append_arg(&arg);
for remaining in iter.by_ref() {
cmd.append_arg(&remaining);
}
break;
} else {
cmd.append_arg(&arg);
}
}
// Return a copy of our modified command line to use as the shell command.
Some(Command::Shell(cmd.written().to_owned()))
}
/// Setup the zsh automatic shell integration. This works by setting
/// ZDOTDIR to our resources dir so that zsh will load our config. This
/// config then loads the true user config.
fn setup_zsh(command: &Command, resource_dir: &str, env: &mut EnvMap) -> Option<Command> {
// Preserve an existing ZDOTDIR value. We're about to overwrite it.
if let Some(old) = env.get("ZDOTDIR").cloned() {
env.insert("GHOSTTY_ZSH_ZDOTDIR".to_owned(), old);
}
// Set our new ZDOTDIR to point to our shell resource directory.
let integ_path = format!("{resource_dir}/shell-integration/zsh");
if !Path::new(&integ_path).is_dir() {
// Mirrors openDirAbsolute failing (missing or not a directory).
log::warn!("unable to open {integ_path}");
return None;
}
env.insert("ZDOTDIR".to_owned(), integ_path);
Some(command.clone())
}
#[cfg(test)]
mod tests {
use ghostty_os::TempDir;
use super::*;
/// Test helper that creates a temporary resources directory with shell
/// integration paths.
struct TmpResourcesDir {
_tmp: TempDir,
path: String,
shell_path: String,
}
impl TmpResourcesDir {
fn new(shell: Shell) -> Self {
let tmp = TempDir::init().unwrap();
let path = std::fs::canonicalize(tmp.path())
.unwrap()
.to_string_lossy()
.into_owned();
let rel = format!("shell-integration/{}", shell.tag_name());
std::fs::create_dir_all(format!("{path}/{rel}")).unwrap();
let shell_path = format!("{path}/{rel}");
if shell == Shell::Bash {
std::fs::write(format!("{path}/shell-integration/bash/ghostty.bash"), "").unwrap();
}
Self {
_tmp: tmp,
path,
shell_path,
}
}
}
fn sh(s: &str) -> Command {
Command::Shell(s.to_owned())
}
fn shell_str(c: Option<&Command>) -> &str {
match c.unwrap() {
Command::Shell(s) => s,
Command::Direct(_) => panic!("expected shell"),
}
}
#[test]
fn force_shell() {
let mut env = EnvMap::new();
for shell in Shell::ALL {
let res = TmpResourcesDir::new(shell);
let result = setup(&res.path, &sh("sh"), &mut env, Some(shell)).unwrap();
assert_eq!(shell, result.unwrap().shell);
}
}
#[test]
fn shell_integration_failure() {
let mut env = EnvMap::new();
let result = setup("/nonexistent", &sh("sh"), &mut env, None).unwrap();
assert!(result.is_none());
assert_eq!(0, env.len());
}
#[test]
fn detect_shell_cases() {
assert_eq!(detect_shell(&sh("sh")), None);
assert_eq!(detect_shell(&sh("bash")), Some(Shell::Bash));
assert_eq!(detect_shell(&sh("elvish")), Some(Shell::Elvish));
assert_eq!(detect_shell(&sh("fish")), Some(Shell::Fish));
assert_eq!(detect_shell(&sh("nu")), Some(Shell::Nushell));
assert_eq!(detect_shell(&sh("zsh")), Some(Shell::Zsh));
assert_eq!(detect_shell(&sh("bash -c 'command'")), Some(Shell::Bash));
assert_eq!(detect_shell(&sh("\"/a b/bash\"")), Some(Shell::Bash));
assert_eq!(detect_shell(&sh("/usr/bin/zsh")), Some(Shell::Zsh));
assert_eq!(
detect_shell(&Command::Direct(vec!["/bin/fish".into()])),
Some(Shell::Fish)
);
assert_eq!(detect_shell(&sh("")), None);
}
#[test]
fn arg_iterator_shell_and_direct() {
let v: Vec<_> = sh("echo hello world").arg_iterator().collect();
assert_eq!(v, ["echo", "hello", "world"]);
let v: Vec<_> = Command::Direct(vec!["echo".into(), "hello world".into()])
.arg_iterator()
.collect();
assert_eq!(v, ["echo", "hello world"]);
let v: Vec<_> = sh("a \"b c\"\td\\\"e").arg_iterator().collect();
assert_eq!(v, ["a", "b c", "d\"e"]);
}
#[test]
fn setup_features_all_variants() {
// all features enabled
let mut env = EnvMap::new();
setup_features(
&mut env,
ShellIntegrationFeatures {
cursor: true,
sudo: true,
title: true,
ssh_env: true,
ssh_terminfo: true,
path: true,
},
true,
);
assert_eq!(
"cursor:blink,path,ssh-env,ssh-terminfo,sudo,title",
env["GHOSTTY_SHELL_FEATURES"]
);
// all features disabled
let mut env = EnvMap::new();
setup_features(&mut env, ShellIntegrationFeatures::NONE, true);
assert!(!env.contains_key("GHOSTTY_SHELL_FEATURES"));
// mixed features
let mut env = EnvMap::new();
setup_features(
&mut env,
ShellIntegrationFeatures {
sudo: true,
ssh_env: true,
..ShellIntegrationFeatures::NONE
},
true,
);
assert_eq!("ssh-env,sudo", env["GHOSTTY_SHELL_FEATURES"]);
// blinking cursor
let only_cursor = ShellIntegrationFeatures {
cursor: true,
..ShellIntegrationFeatures::NONE
};
let mut env = EnvMap::new();
setup_features(&mut env, only_cursor, true);
assert_eq!("cursor:blink", env["GHOSTTY_SHELL_FEATURES"]);
// steady cursor
let mut env = EnvMap::new();
setup_features(&mut env, only_cursor, false);
assert_eq!("cursor:steady", env["GHOSTTY_SHELL_FEATURES"]);
}
#[test]
fn default_features() {
let mut env = EnvMap::new();
setup_features(&mut env, ShellIntegrationFeatures::default(), true);
assert_eq!("cursor:blink,path,title", env["GHOSTTY_SHELL_FEATURES"]);
}
#[test]
fn bash() {
let res = TmpResourcesDir::new(Shell::Bash);
let mut env = EnvMap::new();
let command = setup_bash(&sh("bash"), &res.path, &mut env).unwrap();
assert_eq!("bash --posix", shell_str(command.as_ref()));
assert_eq!("1", env["GHOSTTY_BASH_INJECT"]);
assert_eq!(format!("{}/ghostty.bash", res.shell_path), env["ENV"]);
}
#[test]
fn bash_unsupported_options() {
let res = TmpResourcesDir::new(Shell::Bash);
for cmdline in [
"bash --posix",
"bash --rcfile script.sh --posix",
"bash --init-file script.sh --posix",
"bash -c script.sh",
"bash -ic script.sh",
] {
let mut env = EnvMap::new();
assert!(
setup_bash(&sh(cmdline), &res.path, &mut env)
.unwrap()
.is_none()
);
assert_eq!(0, env.len());
}
}
#[test]
fn bash_inject_flags() {
let res = TmpResourcesDir::new(Shell::Bash);
let mut env = EnvMap::new();
let command = setup_bash(&sh("bash --norc"), &res.path, &mut env).unwrap();
assert_eq!("bash --posix", shell_str(command.as_ref()));
assert_eq!("1 --norc", env["GHOSTTY_BASH_INJECT"]);
let mut env = EnvMap::new();
let command = setup_bash(&sh("bash --noprofile"), &res.path, &mut env).unwrap();
assert_eq!("bash --posix", shell_str(command.as_ref()));
assert_eq!("1 --noprofile", env["GHOSTTY_BASH_INJECT"]);
}
#[test]
fn bash_rcfile() {
let res = TmpResourcesDir::new(Shell::Bash);
let mut env = EnvMap::new();
let command = setup_bash(&sh("bash --rcfile profile.sh"), &res.path, &mut env).unwrap();
assert_eq!("bash --posix", shell_str(command.as_ref()));
assert_eq!("profile.sh", env["GHOSTTY_BASH_RCFILE"]);
let command = setup_bash(&sh("bash --init-file profile.sh"), &res.path, &mut env).unwrap();
assert_eq!("bash --posix", shell_str(command.as_ref()));
assert_eq!("profile.sh", env["GHOSTTY_BASH_RCFILE"]);
}
#[test]
fn bash_histfile() {
let res = TmpResourcesDir::new(Shell::Bash);
// HISTFILE unset (needs a home dir; set HOME so it is deterministic)
temp_env::with_var("HOME", Some("/home/test"), || {
let mut env = EnvMap::new();
setup_bash(&sh("bash"), &res.path, &mut env).unwrap();
assert!(env["HISTFILE"].ends_with(".bash_history"));
assert_eq!("1", env["GHOSTTY_BASH_UNEXPORT_HISTFILE"]);
});
// HISTFILE set
let mut env = EnvMap::new();
env.insert("HISTFILE".into(), "my_history".into());
setup_bash(&sh("bash"), &res.path, &mut env).unwrap();
assert_eq!("my_history", env["HISTFILE"]);
assert!(!env.contains_key("GHOSTTY_BASH_UNEXPORT_HISTFILE"));
}
#[test]
fn bash_env() {
let res = TmpResourcesDir::new(Shell::Bash);
let mut env = EnvMap::new();
env.insert("ENV".into(), "env.sh".into());
setup_bash(&sh("bash"), &res.path, &mut env).unwrap();
assert_eq!("env.sh", env["GHOSTTY_BASH_ENV"]);
assert_eq!(format!("{}/ghostty.bash", res.shell_path), env["ENV"]);
}
#[test]
fn bash_additional_arguments() {
let res = TmpResourcesDir::new(Shell::Bash);
let mut env = EnvMap::new();
let command = setup_bash(&sh("bash - --arg file1 file2"), &res.path, &mut env).unwrap();
assert_eq!(
"bash --posix - --arg file1 file2",
shell_str(command.as_ref())
);
let command = setup_bash(&sh("bash -- --arg file1 file2"), &res.path, &mut env).unwrap();
assert_eq!(
"bash --posix -- --arg file1 file2",
shell_str(command.as_ref())
);
}
#[test]
fn bash_missing_resources() {
let tmp = TempDir::init().unwrap();
let dir = std::fs::canonicalize(tmp.path()).unwrap();
let mut env = EnvMap::new();
assert!(
setup_bash(&sh("bash"), dir.to_str().unwrap(), &mut env)
.unwrap()
.is_none()
);
assert_eq!(0, env.len());
}
#[test]
fn xdg_empty_xdg_data_dirs() {
let res = TmpResourcesDir::new(Shell::Fish);
let mut env = EnvMap::new();
assert!(setup_xdg_data_dirs(&res.path, &mut env));
assert_eq!(
format!("{}/shell-integration", res.path),
env["GHOSTTY_SHELL_INTEGRATION_XDG_DIR"]
);
assert_eq!(
format!("{}/shell-integration:/usr/local/share:/usr/share", res.path),
env["XDG_DATA_DIRS"]
);
}
#[test]
fn xdg_existing_xdg_data_dirs() {
let res = TmpResourcesDir::new(Shell::Fish);
let mut env = EnvMap::new();
env.insert("XDG_DATA_DIRS".into(), "/opt/share".into());
assert!(setup_xdg_data_dirs(&res.path, &mut env));
assert_eq!(
format!("{}/shell-integration", res.path),
env["GHOSTTY_SHELL_INTEGRATION_XDG_DIR"]
);
assert_eq!(
format!("{}/shell-integration:/opt/share", res.path),
env["XDG_DATA_DIRS"]
);
}
#[test]
fn xdg_missing_resources() {
let tmp = TempDir::init().unwrap();
let dir = std::fs::canonicalize(tmp.path()).unwrap();
let mut env = EnvMap::new();
assert!(!setup_xdg_data_dirs(dir.to_str().unwrap(), &mut env));
assert_eq!(0, env.len());
}
#[test]
fn nushell() {
let res = TmpResourcesDir::new(Shell::Nushell);
let mut env = EnvMap::new();
let command = setup_nushell(&sh("nu"), &res.path, &mut env);
assert_eq!("nu --execute 'use ghostty *'", shell_str(command.as_ref()));
assert_eq!(
format!("{}/shell-integration", res.path),
env["GHOSTTY_SHELL_INTEGRATION_XDG_DIR"]
);
assert!(env["XDG_DATA_DIRS"].starts_with(&format!("{}/shell-integration", res.path)));
}
#[test]
fn nushell_unsupported_options() {
let res = TmpResourcesDir::new(Shell::Nushell);
for cmdline in [
"nu --command exit",
"nu --lsp",
"nu -c script.sh",
"nu -ic script.sh",
] {
let mut env = EnvMap::new();
assert!(setup_nushell(&sh(cmdline), &res.path, &mut env).is_none());
assert!(env.contains_key("XDG_DATA_DIRS"));
assert!(env.contains_key("GHOSTTY_SHELL_INTEGRATION_XDG_DIR"));
}
}
#[test]
fn nushell_missing_resources() {
let tmp = TempDir::init().unwrap();
let dir = std::fs::canonicalize(tmp.path()).unwrap();
let mut env = EnvMap::new();
assert!(setup_nushell(&sh("nu"), dir.to_str().unwrap(), &mut env).is_none());
assert_eq!(0, env.len());
}
#[test]
fn zsh() {
let res = TmpResourcesDir::new(Shell::Zsh);
let mut env = EnvMap::new();
let command = setup_zsh(&sh("zsh"), &res.path, &mut env);
assert_eq!("zsh", shell_str(command.as_ref()));
assert_eq!(res.shell_path, env["ZDOTDIR"]);
assert!(!env.contains_key("GHOSTTY_ZSH_ZDOTDIR"));
}
#[test]
fn zsh_zdotdir() {
let res = TmpResourcesDir::new(Shell::Zsh);
let mut env = EnvMap::new();
env.insert("ZDOTDIR".into(), "$HOME/.config/zsh".into());
let command = setup_zsh(&sh("zsh"), &res.path, &mut env);
assert_eq!("zsh", shell_str(command.as_ref()));
assert_eq!(res.shell_path, env["ZDOTDIR"]);
assert_eq!("$HOME/.config/zsh", env["GHOSTTY_ZSH_ZDOTDIR"]);
}
#[test]
fn zsh_missing_resources() {
let tmp = TempDir::init().unwrap();
let dir = std::fs::canonicalize(tmp.path()).unwrap();
let mut env = EnvMap::new();
assert!(setup_zsh(&sh("zsh"), dir.to_str().unwrap(), &mut env).is_none());
assert_eq!(0, env.len());
}
#[test]
fn bundled_resources_exist() {
// The shipped scripts that setup() points shells at.
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../resources");
let root = root.to_str().unwrap();
let mut env = EnvMap::new();
let r = setup(root, &sh("bash"), &mut env, None).unwrap().unwrap();
assert_eq!(r.shell, Shell::Bash);
assert!(env["ENV"].ends_with("shell-integration/bash/ghostty.bash"));
assert!(
Path::new(root)
.join("shell-integration/nushell/vendor/autoload/ghostty.nu")
.is_file()
);
}
}