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 6792799fbe
commit 59bc84fde0
21 files changed
+7120 -28

No files matched your search

+872
View File
@@ -0,0 +1,872 @@
//! Command launches sub-processes. This is an alternate implementation to
//! `std::process::Command` (mirroring why the Zig one exists) since we need
//! precise control over what happens between fork and exec (e.g. attaching
//! a shell to a pty, cgroup transitions, best-effort chdir).
//!
//! Port of `Command.zig` (POSIX/Linux only) plus the Linux cgroup pre-exec /
//! post-fork logic from `apprt/gtk/{pre_exec,post_fork}.zig`.
//!
//! Safety: the child side of [`Command::start`] runs between `fork` and `exec`
//! in a possibly multithreaded process. All allocation is done before the
//! fork; the child only performs raw syscalls plus the user supplied
//! `os_pre_exec` / `rt_pre_exec` hooks (which must therefore be
//! async-signal-safe, like their Zig counterparts are expected to be).
use std::{
collections::HashMap,
ffi::{CString, NulError, c_char},
os::fd::OwnedFd,
panic::{AssertUnwindSafe, catch_unwind},
time::{Duration, Instant},
};
use ghostty_os::file::Rlimit;
use rustix::{
io::Errno,
process::{self, WaitOptions},
stdio,
};
/// Function executed /in the child process/ after the fork, but before
/// exec'ing the command. If the function returns `Some(code)`, the child
/// process will be exited with that exit code.
///
/// Must be async-signal-safe (see module docs).
pub type PreExecFn = Box<dyn Fn() -> Option<u8> + Send + Sync>;
/// Allowable set of errors that can be returned by a post fork function. Any
/// errors will result in the failure to create the surface.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("post fork hook failed")]
pub struct PostForkError;
/// Function executed /in the parent process/ after the fork. It receives the
/// command (the pid is already set).
pub type PostForkFn = Box<dyn Fn(&Command) -> Result<(), PostForkError> + Send + Sync>;
/// Errors from [`Command::start`] (all happen in the parent; the child never
/// returns from `start`, it either execs or exits like Zig's
/// `ExecFailedInChild` handling).
#[derive(Debug, thiserror::Error)]
pub enum StartError {
#[error("argument or environment contains an interior NUL byte")]
InvalidArgument(#[from] NulError),
#[error("fork failed: {0}")]
Fork(Errno),
#[error(transparent)]
PostFork(#[from] PostForkError),
}
/// The various methods a process may exit.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Exit {
/// Exited by normal exit call, value is exit status
Exited(u8),
/// Exited by a signal, value is the signal
Signal(u32),
/// Exited by a stop signal, value is signal
Stopped(u32),
/// Unknown exit reason, value is the status from waitpid
Unknown(u32),
}
impl Exit {
/// Decode a raw `waitpid` status (`W.IFEXITED` etc).
pub fn init(status: u32) -> Exit {
let low = status & 0x7f;
if low == 0 {
Exit::Exited(((status >> 8) & 0xff) as u8)
} else if (((low + 1) as u8 as i8) >> 1) > 0 {
Exit::Signal(low)
} else if (status & 0xff) == 0x7f {
Exit::Stopped((status >> 8) & 0xff)
} else {
Exit::Unknown(status)
}
}
}
/// Mirrors `config.Command`'s relevant enum `LinuxCgroup`.
/// TODO(integration): replace with the ghostty-config type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LinuxCgroup {
Never,
Always,
#[default]
SingleInstance,
}
/// TODO(integration): replace with the ghostty-config type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GtkSingleInstance {
False,
True,
#[default]
Detect,
}
/// Configuration information needed by the apprt pre exec function
/// (`apprt/gtk/pre_exec.zig` `PreExecInfo`). Trivially copyable.
/// TODO(integration): build from `ghostty-config::Config` (`init(cfg)`).
#[derive(Debug, Clone, Copy, Default)]
pub struct RtPreExecInfo {
pub gtk_single_instance: GtkSingleInstance,
pub linux_cgroup: LinuxCgroup,
pub linux_cgroup_hard_fail: bool,
}
/// Configuration information needed by the apprt post fork function
/// (`apprt/gtk/post_fork.zig` `PostForkInfo`).
/// TODO(integration): build from `ghostty-config::Config` (`init(cfg)`).
#[derive(Debug, Clone, Copy, Default)]
pub struct RtPostForkInfo {
pub gtk_single_instance: GtkSingleInstance,
pub linux_cgroup: LinuxCgroup,
pub linux_cgroup_hard_fail: bool,
pub linux_cgroup_memory_limit: Option<u64>,
pub linux_cgroup_processes_limit: Option<u64>,
}
/// A command to launch.
#[derive(Default)]
pub struct Command {
/// Path to the command to run. This doesn't have to be an absolute path,
/// because we use exec functions that search the PATH, if necessary.
pub path: String,
/// Command-line arguments. It is the responsibility of the caller to set
/// args[0] to the command. If args is empty then args[0] will
/// automatically be set to equal path.
pub args: Vec<String>,
/// Environment variables for the child process. If this is `None`,
/// inherits the environment variables from this process. These are the
/// exact environment variables to set; these are /not/ merged.
pub env: Option<HashMap<String, String>>,
/// Working directory to change to in the child process. If not set, the
/// working directory of the calling process is preserved. Failure to
/// change directory is ignored (as in Ghostty).
pub cwd: Option<String>,
/// The file handle to set for stdin/out/err. If this isn't set, we do
/// nothing explicitly so it is up to the behavior of the operating system.
/// (The fds are duplicated onto 0/1/2 in the child; the caller keeps its
/// own handles by passing `try_clone()`d fds.)
pub stdin: Option<OwnedFd>,
pub stdout: Option<OwnedFd>,
pub stderr: Option<OwnedFd>,
/// Rlimits to restore in the child (Ghostty raises RLIMIT_NOFILE at
/// startup; replaces `global_state.rlimits.restore()`).
/// TODO(integration): fill from the global state.
pub restore_rlimit: Option<Rlimit>,
/// Executed /in the child process/ after fork but before exec. Useful to
/// setup some state in the child before the exec process takes over, such
/// as signal handlers, setsid, setuid, etc.
pub os_pre_exec: Option<PreExecFn>,
/// Same as `os_pre_exec` but runs afterwards and is meant for the apprt
/// (e.g. [`linux_cgroup_pre_exec`]).
pub rt_pre_exec: Option<PreExecFn>,
/// Executed /in the parent process/ after the fork.
pub rt_post_fork: Option<PostForkFn>,
/// Process ID is set after start is called.
pub pid: Option<i32>,
}
impl std::fmt::Debug for Command {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Command")
.field("path", &self.path)
.field("args", &self.args)
.field("cwd", &self.cwd)
.field("pid", &self.pid)
.finish_non_exhaustive()
}
}
/// Everything that must be allocated before forking.
struct Prepared {
path: CString,
argv: Vec<CString>,
envp: Vec<CString>,
}
impl Command {
pub fn new(path: impl Into<String>, args: &[&str]) -> Self {
Self {
path: path.into(),
args: args.iter().map(|s| (*s).to_owned()).collect(),
..Self::default()
}
}
fn prepare(&self) -> Result<Prepared, NulError> {
let path = CString::new(self.path.as_str())?;
let argv = if self.args.is_empty() {
vec![path.clone()]
} else {
self.args
.iter()
.map(|a| CString::new(a.as_str()))
.collect::<Result<_, _>>()?
};
let envp = match &self.env {
Some(map) => map
.iter()
.map(|(k, v)| CString::new(format!("{k}={v}")))
.collect::<Result<_, _>>()?,
// Inherit: snapshot our environment before the fork (nothing may
// allocate in the child).
None => std::env::vars_os()
.map(|(k, v)| {
let mut b = k.into_encoded_bytes();
b.push(b'=');
b.extend_from_slice(v.as_encoded_bytes());
CString::new(b)
})
.collect::<Result<_, _>>()?,
};
Ok(Prepared { path, argv, envp })
}
/// Start the subprocess. This returns immediately once the child is
/// started.
///
/// After this is successful, `self.pid` is available.
pub fn start(&mut self) -> Result<(), StartError> {
// IMPORTANT: do all allocation prior to the fork().
let prep = self.prepare()?;
let cwd = self.cwd.as_deref().and_then(|c| CString::new(c).ok());
let mut argv_ptrs: Vec<*const c_char> = prep.argv.iter().map(|c| c.as_ptr()).collect();
argv_ptrs.push(std::ptr::null());
let mut envp_ptrs: Vec<*const c_char> = prep.envp.iter().map(|c| c.as_ptr()).collect();
envp_ptrs.push(std::ptr::null());
let pid = fork().map_err(StartError::Fork)?;
if pid != 0 {
// Parent, return immediately.
self.pid = Some(pid);
if let Some(f) = &self.rt_post_fork {
f(self)?;
}
return Ok(());
}
// We are the child. This never returns.
self.run_child(&prep, &argv_ptrs, &envp_ptrs, cwd.as_deref())
}
/// The child side of `start`: set up fds, chdir, rlimits, hooks, exec.
fn run_child(
&self,
prep: &Prepared,
argv: &[*const c_char],
envp: &[*const c_char],
cwd: Option<&std::ffi::CStr>,
) -> ! {
// Setup our file descriptors for std streams. Any failure here is
// equivalent to Zig's `ExecFailedInChild` -> the child exits 1.
if let Some(fd) = &self.stdin
&& setup_fd(|f| stdio::dup2_stdin(f), fd).is_err()
{
exit_immediately(1);
}
if let Some(fd) = &self.stdout
&& setup_fd(|f| stdio::dup2_stdout(f), fd).is_err()
{
exit_immediately(1);
}
if let Some(fd) = &self.stderr
&& setup_fd(|f| stdio::dup2_stderr(f), fd).is_err()
{
exit_immediately(1);
}
// Setup our working directory. This can fail if we don't have
// permission to go to this directory or if due to race conditions it
// doesn't exist or any various other reasons. We don't want to crash
// the entire process if this fails so we ignore it. We don't log
// because that'll show up in the output.
if let Some(cwd) = cwd {
let _ = process::chdir(cwd);
}
// Restore any rlimits that were set by Ghostty. This might fail but
// any failures are ignored (its best effort).
if let Some(lim) = self.restore_rlimit {
ghostty_os::restore_max_files(lim);
}
// If there are pre exec callbacks, call them now.
for hook in [&self.os_pre_exec, &self.rt_pre_exec].into_iter().flatten() {
match catch_unwind(AssertUnwindSafe(hook)) {
Ok(Some(code)) => exit_immediately(code),
Ok(None) => {}
Err(_) => exit_immediately(1),
}
}
// Finally, replace our process.
// Note: we must use the "p"-variant of exec here because we do not
// guarantee our command is looked up already in the path.
let err = execvpe(prep, argv, envp);
// If we are executing this code, the exec failed. We're in the child
// process so there isn't much we can do. We try to output something
// reasonable, then exit (Zig returns ExecFailedInChild which the
// caller turns into exit(1)).
if err == Errno::NOENT {
write_stderr(
b"Requested executable not found. Please verify the command is on\n\
the PATH and try again.\n",
);
} else {
let mut buf = [0u8; 96];
let mut n = 0;
let prefix = b"exec syscall failed with unexpected error: errno=";
buf[..prefix.len()].copy_from_slice(prefix);
n += prefix.len();
n += format_u32(err.raw_os_error() as u32, &mut buf[n..]);
buf[n] = b'\n';
n += 1;
write_stderr(&buf[..n]);
}
exit_immediately(1)
}
/// Wait for the command to exit and return information about how it
/// exited. With `block == false` this still loops until the child has a
/// status to report (see the comment in the body).
pub fn wait(&self, block: bool) -> Result<Exit, Errno> {
let raw = self.pid.expect("wait called before start");
// SAFETY of unwrap-free construction: pids are nonzero after fork.
let pid = process::Pid::from_raw(raw).ok_or(Errno::SRCH)?;
let opts = if block {
WaitOptions::empty()
} else {
// We specify NOHANG because its not our fault if the process we
// launch for the tty doesn't properly waitpid its children. We
// don't want to hang the terminal over it. When NOHANG is
// specified, waitpid returns "nothing" if the process doesn't
// have a status to report. When that happens, it is as though the
// wait call has not been performed, so we need to keep trying
// until we get a status, otherwise we end up with zombie
// processes.
WaitOptions::NOHANG
};
loop {
match process::waitpid(Some(pid), opts) {
Ok(Some((_pid, status))) => return Ok(Exit::init(status.as_raw() as u32)),
Ok(None) => std::thread::yield_now(),
Err(Errno::INTR) => {}
Err(e) => return Err(e),
}
}
}
}
fn setup_fd(dup: impl Fn(&OwnedFd) -> rustix::io::Result<()>, src: &OwnedFd) -> Result<(), Errno> {
// dup2 (like Zig's dup3 with flags=0) leaves the target without
// CLOEXEC: we do NOT want this file descriptor to be closed on exec.
loop {
match dup(src) {
Err(Errno::INTR) => {}
r => return r,
}
}
}
/// Exit the current (forked child) process immediately without running
/// destructors or atexit handlers (`_exit`). Use this from `os_pre_exec` /
/// `rt_pre_exec` hooks instead of `std::process::exit`.
#[allow(unsafe_code)]
pub fn exit_immediately(code: u8) -> ! {
// SAFETY: _exit is async-signal-safe and has no preconditions.
unsafe { libc::_exit(i32::from(code)) }
}
#[allow(unsafe_code)]
fn fork() -> Result<i32, Errno> {
// SAFETY: the child branch (in `Command::start`) only performs
// async-signal-safe operations on memory allocated before the fork and
// then execs or `_exit`s; it never returns into Rust code that could
// allocate or take locks.
let pid = unsafe { libc::fork() };
if pid < 0 {
Err(Errno::from_raw_os_error(
std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
))
} else {
Ok(pid)
}
}
#[allow(unsafe_code)]
fn execvpe(prep: &Prepared, argv: &[*const c_char], envp: &[*const c_char]) -> Errno {
debug_assert!(argv.last().is_some_and(|p| p.is_null()));
debug_assert!(envp.last().is_some_and(|p| p.is_null()));
// SAFETY: path is a valid NUL-terminated string; argv/envp are
// NULL-terminated arrays of pointers into CStrings (owned by `prep`) that
// outlive the call. execvpe only returns on failure.
unsafe {
libc::execvpe(prep.path.as_ptr(), argv.as_ptr(), envp.as_ptr());
}
Errno::from_raw_os_error(errno_now())
}
#[allow(unsafe_code)]
fn errno_now() -> i32 {
// SAFETY: __errno_location returns a valid thread-local pointer.
unsafe { *libc::__errno_location() }
}
fn write_stderr(msg: &[u8]) {
// Best effort, no allocation.
let _ = rustix::io::write(borrow_stderr(), msg);
}
#[allow(unsafe_code)]
fn borrow_stderr() -> rustix::fd::BorrowedFd<'static> {
// SAFETY: STDERR_FILENO is valid for the life of the process (or the
// write simply fails with EBADF).
unsafe { rustix::fd::BorrowedFd::borrow_raw(libc::STDERR_FILENO) }
}
/// Format `v` in decimal into `out`, returning the number of bytes written.
fn format_u32(mut v: u32, out: &mut [u8]) -> usize {
let mut tmp = [0u8; 10];
let mut i = tmp.len();
loop {
i -= 1;
tmp[i] = b'0' + (v % 10) as u8;
v /= 10;
if v == 0 {
break;
}
}
let n = tmp.len() - i;
out[..n].copy_from_slice(&tmp[i..]);
n
}
// ---------------------------------------------------------------------------
// Linux cgroup transitions (apprt/gtk/pre_exec.zig, post_fork.zig, cgroup.zig)
// ---------------------------------------------------------------------------
/// The name of the transient systemd scope for the given child pid
/// (`apprt/gtk/cgroup.zig` `fmtScope`).
pub fn fmt_scope(pid: u32) -> String {
format!("app-ghostty-surface-transient-{pid}.scope")
}
/// Wait for the current process' cgroup to end in `expected`. Returns true if
/// seen, false on timeout (250ms).
fn wait_for_cgroup(pid: u32, expected: &str) -> bool {
let start = Instant::now();
loop {
if start.elapsed() > Duration::from_millis(250) {
return false;
}
if let Some(raw) = ghostty_os::cgroup::current(pid)
&& let Some(idx) = raw.rfind('/')
&& &raw[idx + 1..] == expected
{
return true;
}
std::thread::sleep(Duration::from_millis(25));
}
}
/// If we are expecting to be moved to a transient systemd scope, wait to see
/// if that happens by checking for the correct name of the current cgroup.
/// Wait at most 250ms so that we don't overly delay the soft-fail scenario.
///
/// If we are configured to hard fail, log an error message and return an
/// exit code if we don't detect the move in time.
///
/// Intended as an `rt_pre_exec` hook (runs in the child; note that, like the
/// Zig code, it allocates and sleeps).
pub fn linux_cgroup_pre_exec(info: &RtPreExecInfo) -> Option<u8> {
match info.linux_cgroup {
LinuxCgroup::Always => {}
LinuxCgroup::Never => return None,
LinuxCgroup::SingleInstance => match info.gtk_single_instance {
GtkSingleInstance::True => {}
GtkSingleInstance::False => return None,
GtkSingleInstance::Detect => {
log::error!("gtk-single-instance is set to detect");
return Some(127);
}
},
}
let pid = process::getpid().as_raw_nonzero().get() as u32;
let expected = fmt_scope(pid);
if !wait_for_cgroup(pid, &expected) && info.linux_cgroup_hard_fail {
log::error!("transition to new transient systemd scope took too long");
return Some(127);
}
None
}
/// If we are configured to do so, tell `systemd` to move the new child PID
/// into a transient `systemd` scope with the configured resource limits.
///
/// `create_scope(pid, memory_high, tasks_max)` performs the D-Bus call
/// (`cgroup.createScope`); it returns `None` if there is no D-Bus connection
/// and `Some(Err(msg))` on failure.
/// TODO(integration): wire to the apprt-gtk D-Bus connection.
pub fn linux_cgroup_post_fork(
cmd: &Command,
info: &RtPostForkInfo,
create_scope: impl FnOnce(u32, Option<u64>, Option<u64>) -> Option<Result<(), String>>,
) -> Result<(), PostForkError> {
match info.linux_cgroup {
LinuxCgroup::Always => {}
LinuxCgroup::Never => return Ok(()),
LinuxCgroup::SingleInstance => match info.gtk_single_instance {
GtkSingleInstance::True => {}
GtkSingleInstance::False => return Ok(()),
GtkSingleInstance::Detect => {
log::error!("gtk-single-instance is set to detect which should be impossible!");
return Err(PostForkError);
}
},
}
let Some(pid) = cmd.pid else {
log::error!("PID of child not known!");
return Err(PostForkError);
};
let pid = pid as u32;
let expected = fmt_scope(pid);
log::debug!("beginning transition to transient systemd scope {expected}");
match create_scope(
pid,
info.linux_cgroup_memory_limit,
info.linux_cgroup_processes_limit,
) {
None => {
if info.linux_cgroup_hard_fail {
log::error!("dbus connection required for cgroup isolation, exiting");
return Err(PostForkError);
}
return Ok(());
}
Some(Err(err)) => {
if info.linux_cgroup_hard_fail {
log::error!("unable to create transient systemd scope {expected}: {err}");
return Err(PostForkError);
}
log::warn!("unable to create transient systemd scope {expected}: {err}");
return Ok(());
}
Some(Ok(())) => {}
}
if wait_for_cgroup(pid, &expected) {
log::debug!("transition to transient systemd scope {expected} complete");
} else {
if info.linux_cgroup_hard_fail {
log::error!("transition to new transient systemd scope {expected} took too long");
return Err(PostForkError);
}
log::warn!("transition to transient systemd scope {expected} took too long");
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::{
fs::File,
io::{Read, Seek, SeekFrom},
sync::Arc,
};
use std::os::fd::AsRawFd;
use ghostty_os::TempDir;
use super::*;
fn test_file(td: &TempDir, name: &str) -> File {
std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(td.path().join(name))
.unwrap()
}
fn read_all(mut f: File) -> String {
f.seek(SeekFrom::Start(0)).unwrap();
let mut s = String::new();
f.read_to_string(&mut s).unwrap();
s
}
fn sh(script: &str) -> Command {
Command::new("/bin/sh", &["/bin/sh", "-c", script])
}
#[test]
fn create_null_delimited_env_map() {
// The Zig test checks the null-delimited env block; here the
// equivalent is `prepare()`.
let mut cmd = Command::new("/bin/sh", &[]);
cmd.env = Some(
[
("HOME", "/home/ifreund"),
("WAYLAND_DISPLAY", "wayland-1"),
("DISPLAY", ":1"),
("DEBUGINFOD_URLS", " "),
("XCURSOR_SIZE", "24"),
]
.into_iter()
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.collect(),
);
let prep = cmd.prepare().unwrap();
assert_eq!(prep.envp.len(), 5);
for target in [
"HOME=/home/ifreund",
"WAYLAND_DISPLAY=wayland-1",
"DISPLAY=:1",
"DEBUGINFOD_URLS= ",
"XCURSOR_SIZE=24",
] {
assert!(prep.envp.iter().any(|v| v.to_str().unwrap() == target));
}
// Empty args fall back to path as args[0].
assert_eq!(prep.argv.len(), 1);
}
fn pre_exec_exit_test(cmd: &mut Command) {
cmd.start().unwrap();
assert!(cmd.pid.is_some());
assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(42));
}
#[test]
fn os_pre_exec_1() {
let mut cmd = Command::new("/bin/sh", &["/bin/sh", "-v"]);
// This runs in the child, so we can exit and it won't kill the test
// runner.
cmd.os_pre_exec = Some(Box::new(|| exit_immediately(42)));
pre_exec_exit_test(&mut cmd);
}
#[test]
fn os_pre_exec_2() {
let mut cmd = Command::new("/bin/sh", &["/bin/sh", "-v"]);
cmd.os_pre_exec = Some(Box::new(|| Some(42)));
pre_exec_exit_test(&mut cmd);
}
#[test]
fn rt_pre_exec_1() {
let mut cmd = Command::new("/bin/sh", &["/bin/sh", "-v"]);
cmd.rt_pre_exec = Some(Box::new(|| exit_immediately(42)));
pre_exec_exit_test(&mut cmd);
}
#[test]
fn rt_pre_exec_2() {
let mut cmd = Command::new("/bin/sh", &["/bin/sh", "-v"]);
cmd.rt_pre_exec = Some(Box::new(|| Some(42)));
pre_exec_exit_test(&mut cmd);
}
#[test]
fn rt_post_fork_1() {
let mut cmd = sh("sleep 1");
cmd.rt_post_fork = Some(Box::new(|_| Err(PostForkError)));
assert!(matches!(
cmd.start(),
Err(StartError::PostFork(PostForkError))
));
// Reap the child that was started anyway (like the Zig test leaks it).
if let Some(pid) = cmd.pid.and_then(process::Pid::from_raw) {
let _ = process::kill_process(pid, process::Signal::KILL);
}
let _ = cmd.wait(true);
}
#[test]
fn redirect_stdout_to_file() {
let td = TempDir::init().unwrap();
let stdout = test_file(&td, "stdout.txt");
let mut cmd = sh("echo hello");
cmd.stdout = Some(stdout.try_clone().unwrap().into());
cmd.start().unwrap();
assert!(cmd.pid.is_some());
assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(0));
assert!(!read_all(stdout).is_empty());
}
#[test]
fn custom_env_vars() {
let td = TempDir::init().unwrap();
let stdout = test_file(&td, "stdout.txt");
let mut cmd = sh("echo $VALUE");
cmd.stdout = Some(stdout.try_clone().unwrap().into());
cmd.env = Some(HashMap::from([("VALUE".to_owned(), "hello".to_owned())]));
cmd.start().unwrap();
assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(0));
assert_eq!("hello\n", read_all(stdout));
}
#[test]
fn custom_working_directory() {
let td = TempDir::init().unwrap();
let stdout = test_file(&td, "stdout.txt");
let mut cmd = sh("pwd");
cmd.stdout = Some(stdout.try_clone().unwrap().into());
cmd.cwd = Some("/tmp".to_owned());
cmd.start().unwrap();
assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(0));
assert_eq!("/tmp\n", read_all(stdout));
}
#[test]
fn invalid_cwd_is_ignored() {
let td = TempDir::init().unwrap();
let stdout = test_file(&td, "stdout.txt");
let mut cmd = sh("echo ok");
cmd.stdout = Some(stdout.try_clone().unwrap().into());
cmd.cwd = Some("/definitely/not/a/dir".to_owned());
cmd.start().unwrap();
assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(0));
assert_eq!("ok\n", read_all(stdout));
}
// Validate an exec failure correctly terminates the child (in Zig:
// error.ExecFailedInChild must be handled or a second copy of the process
// keeps running; here the child can never return into the caller).
#[test]
fn posix_fork_handles_execve_failure() {
let td = TempDir::init().unwrap();
let stdout = test_file(&td, "stdout.txt");
let stderr = test_file(&td, "stderr.txt");
let mut cmd = Command::new("/not/a/binary", &["/not/a/binary", ""]);
cmd.stdout = Some(stdout.try_clone().unwrap().into());
cmd.stderr = Some(stderr.try_clone().unwrap().into());
cmd.cwd = Some("/bin".to_owned());
cmd.start().unwrap();
assert!(cmd.pid.is_some());
assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(1));
assert!(read_all(stderr).starts_with("Requested executable not found."));
}
#[test]
fn wait_nonblocking_eventually_reports() {
let mut cmd = sh("exit 3");
cmd.start().unwrap();
assert_eq!(cmd.wait(false).unwrap(), Exit::Exited(3));
}
#[test]
fn exit_signal_and_decode() {
let mut cmd = sh("kill -TERM $$");
cmd.start().unwrap();
assert_eq!(cmd.wait(true).unwrap(), Exit::Signal(15));
assert_eq!(Exit::init(0), Exit::Exited(0));
assert_eq!(Exit::init(42 << 8), Exit::Exited(42));
assert_eq!(Exit::init(9), Exit::Signal(9));
assert_eq!(Exit::init((19 << 8) | 0x7f), Exit::Stopped(19));
}
#[test]
fn post_fork_sees_pid() {
let seen = Arc::new(std::sync::Mutex::new(None));
let seen2 = Arc::clone(&seen);
let mut cmd = sh("true");
cmd.rt_post_fork = Some(Box::new(move |c| {
*seen2.lock().unwrap() = c.pid;
Ok(())
}));
cmd.start().unwrap();
assert_eq!(*seen.lock().unwrap(), cmd.pid);
cmd.wait(true).unwrap();
}
#[test]
fn stdio_fds_are_not_cloexec_in_child_and_closed_others() {
// The master end of a pty is CLOEXEC so the child must not see it.
let pty = crate::pty::Pty::open(crate::pty::Winsize::default()).unwrap();
let master = pty.master.as_raw_fd();
let td = TempDir::init().unwrap();
let stdout = test_file(&td, "stdout.txt");
let mut cmd = sh(&format!(
"[ -e /proc/self/fd/{master} ] && echo leaked || echo ok"
));
cmd.stdout = Some(stdout.try_clone().unwrap().into());
cmd.start().unwrap();
cmd.wait(true).unwrap();
assert_eq!("ok\n", read_all(stdout));
}
#[test]
fn cgroup_pre_exec_modes() {
let never = RtPreExecInfo {
linux_cgroup: LinuxCgroup::Never,
..Default::default()
};
assert_eq!(linux_cgroup_pre_exec(&never), None);
let detect = RtPreExecInfo::default();
assert_eq!(linux_cgroup_pre_exec(&detect), Some(127));
let single_false = RtPreExecInfo {
gtk_single_instance: GtkSingleInstance::False,
..Default::default()
};
assert_eq!(linux_cgroup_pre_exec(&single_false), None);
assert_eq!(fmt_scope(1234), "app-ghostty-surface-transient-1234.scope");
}
#[test]
fn cgroup_post_fork_modes() {
let mut cmd = sh("true");
cmd.pid = Some(1);
let never = RtPostForkInfo {
linux_cgroup: LinuxCgroup::Never,
..Default::default()
};
assert!(linux_cgroup_post_fork(&cmd, &never, |_, _, _| unreachable!()).is_ok());
let detect = RtPostForkInfo::default();
assert!(linux_cgroup_post_fork(&cmd, &detect, |_, _, _| unreachable!()).is_err());
// No dbus: soft fail ok, hard fail err.
let always = RtPostForkInfo {
linux_cgroup: LinuxCgroup::Always,
..Default::default()
};
assert!(linux_cgroup_post_fork(&cmd, &always, |_, _, _| None).is_ok());
let hard = RtPostForkInfo {
linux_cgroup_hard_fail: true,
..always
};
assert!(linux_cgroup_post_fork(&cmd, &hard, |_, _, _| None).is_err());
assert!(linux_cgroup_post_fork(&cmd, &hard, |_, _, _| Some(Err("x".into()))).is_err());
}
}
+24
View File
@@ -1 +1,25 @@
//! ghostty-termio: see docs/rewrite/ARCHITECTURE.md for the module map.
//!
//! Ported so far (Linux only): `pty.zig`, `Command.zig`,
//! `termio/shell_integration.zig`, the `terminfo/*` source generator.
//! See `docs/rewrite/notes/ghostty-termio-IO1.md`.
//!
//! This crate may use limited `unsafe` (fork/exec in `command`, a few
//! async-signal-safe libc calls in `pty`); everything else is safe.
#![cfg_attr(
test,
allow(
clippy::unwrap_used,
clippy::assert_is_empty,
clippy::items_after_statements
)
)]
pub mod command;
pub mod pty;
pub mod shell_integration;
pub mod terminfo;
pub use command::Command;
pub use pty::{Pty, Winsize};
+303
View File
@@ -0,0 +1,303 @@
//! Linux PTY creation and management. This is just a thin layer on top
//! of Linux syscalls. Port of `pty.zig` (POSIX variant only; the Windows
//! and null variants are out of scope).
use std::os::fd::{AsRawFd, OwnedFd, RawFd};
use rustix::{fs, process, pty, termios};
/// Redeclare the winsize struct so we can just use a Rust struct. The
/// defaults on this are some reasonable screen size but you should probably
/// not use them.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Winsize {
pub ws_row: u16,
pub ws_col: u16,
pub ws_xpixel: u16,
pub ws_ypixel: u16,
}
impl Default for Winsize {
fn default() -> Self {
Self {
ws_row: 100,
ws_col: 80,
ws_xpixel: 800,
ws_ypixel: 600,
}
}
}
impl From<Winsize> for termios::Winsize {
fn from(w: Winsize) -> Self {
Self {
ws_row: w.ws_row,
ws_col: w.ws_col,
ws_xpixel: w.ws_xpixel,
ws_ypixel: w.ws_ypixel,
}
}
}
impl From<termios::Winsize> for Winsize {
fn from(w: termios::Winsize) -> Self {
Self {
ws_row: w.ws_row,
ws_col: w.ws_col,
ws_xpixel: w.ws_xpixel,
ws_ypixel: w.ws_ypixel,
}
}
}
/// The modes of a pty. Not all of these modes are supported on
/// all platforms but all platforms share the same mode struct.
///
/// The default values of fields in this struct are set to the
/// most typical values for a pty.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Mode {
/// ICANON on POSIX
pub canonical: bool,
/// ECHO on POSIX
pub echo: bool,
}
impl Default for Mode {
fn default() -> Self {
Self {
canonical: true,
echo: true,
}
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum Error {
#[error("openpty failed")]
OpenptyFailed,
#[error("get mode failed")]
GetModeFailed,
#[error("ioctl failed")]
IoctlFailed,
#[error("operation not supported")]
OperationNotSupported,
#[error("setsid failed")]
ProcessGroupFailed,
#[error("setting the controlling terminal failed")]
SetControllingTerminalFailed,
}
/// A pty master/slave pair.
///
/// Deviation from Zig: both fds are `OwnedFd` and are closed on drop (the Zig
/// struct never closes the slave automatically). To close the slave early
/// (as Exec does after the fork) use [`Pty::close_slave`] or destructure.
#[derive(Debug)]
pub struct Pty {
pub master: OwnedFd,
/// `None` once closed with [`Pty::close_slave`].
pub slave: Option<OwnedFd>,
}
impl Pty {
/// Open a new PTY with the given initial size.
pub fn open(size: Winsize) -> Result<Pty, Error> {
// Equivalent of glibc openpty(): the master is CLOEXEC (Ghostty sets
// this explicitly: only the slave fd should be inherited by the
// child), the slave is not.
let master = pty::openpt(
pty::OpenptFlags::RDWR | pty::OpenptFlags::NOCTTY | pty::OpenptFlags::CLOEXEC,
)
.map_err(|_| Error::OpenptyFailed)?;
pty::grantpt(&master).map_err(|_| Error::OpenptyFailed)?;
pty::unlockpt(&master).map_err(|_| Error::OpenptyFailed)?;
let name = pty::ptsname(&master, Vec::new()).map_err(|_| Error::OpenptyFailed)?;
let slave = fs::open(
name.as_c_str(),
fs::OFlags::RDWR | fs::OFlags::NOCTTY,
fs::Mode::empty(),
)
.map_err(|_| Error::OpenptyFailed)?;
termios::tcsetwinsize(&master, size.into()).map_err(|_| Error::OpenptyFailed)?;
// Enable UTF-8 mode. I think this is on by default on Linux but it
// is NOT on by default on macOS so we ensure that it is always set.
let mut attrs = termios::tcgetattr(&master).map_err(|_| Error::OpenptyFailed)?;
attrs.input_modes.insert(termios::InputModes::IUTF8);
termios::tcsetattr(&master, termios::OptionalActions::Now, &attrs)
.map_err(|_| Error::OpenptyFailed)?;
Ok(Pty {
master,
slave: Some(slave),
})
}
/// Close the slave side (the parent does this after forking the child).
pub fn close_slave(&mut self) {
self.slave = None;
}
/// Get the modes of the pty (read from the master).
pub fn get_mode(&self) -> Result<Mode, Error> {
let attrs = termios::tcgetattr(&self.master).map_err(|_| Error::GetModeFailed)?;
Ok(Mode {
canonical: attrs.local_modes.contains(termios::LocalModes::ICANON),
echo: attrs.local_modes.contains(termios::LocalModes::ECHO),
})
}
/// Return the size of the pty.
pub fn get_size(&self) -> Result<Winsize, Error> {
termios::tcgetwinsize(&self.master)
.map(Into::into)
.map_err(|_| Error::IoctlFailed)
}
/// Set the size of the pty.
pub fn set_size(&mut self, size: Winsize) -> Result<(), Error> {
termios::tcsetwinsize(&self.master, size.into()).map_err(|_| Error::IoctlFailed)
}
/// Raw fds for use in the child's pre-exec hook (see [`child_pre_exec`]).
pub fn raw_fds(&self) -> (RawFd, Option<RawFd>) {
(
self.master.as_raw_fd(),
self.slave.as_ref().map(AsRawFd::as_raw_fd),
)
}
/// This should be called prior to exec in the forked child process
/// in order to setup the tty properly. See [`child_pre_exec`].
pub fn child_pre_exec(&self) -> Result<(), Error> {
let (master, slave) = self.raw_fds();
child_pre_exec(master, slave.unwrap_or(-1))
}
}
/// Signals reset to their default disposition in the child.
const RESET_SIGNALS: [nix::sys::signal::Signal; 13] = {
use nix::sys::signal::Signal as S;
[
S::SIGABRT,
S::SIGALRM,
S::SIGBUS,
S::SIGCHLD,
S::SIGFPE,
S::SIGHUP,
S::SIGILL,
S::SIGINT,
S::SIGPIPE,
S::SIGSEGV,
S::SIGTRAP,
S::SIGTERM,
S::SIGQUIT,
]
};
/// Child-side setup of the tty: reset signals, `setsid`, `TIOCSCTTY` on the
/// slave and close the master/slave fds. Operates on raw fd numbers so it can
/// be used from a post-fork hook (`Command::os_pre_exec`) without touching
/// any `OwnedFd` destructors. Everything in here is async-signal-safe (no
/// allocation, no locks).
///
/// A negative `slave` skips the controlling-terminal step and the close.
pub fn child_pre_exec(master: RawFd, slave: RawFd) -> Result<(), Error> {
use nix::sys::signal::{SaFlags, SigAction, SigHandler, SigSet};
// Reset our signals
let sa = SigAction::new(SigHandler::SigDfl, SaFlags::empty(), SigSet::empty());
for sig in RESET_SIGNALS {
reset_signal(sig, &sa);
}
// Create a new process group
process::setsid().map_err(|_| Error::ProcessGroupFailed)?;
if slave >= 0 {
// Set controlling terminal
set_controlling_terminal(slave)?;
}
// Can close master/slave pair now
close_raw(slave);
close_raw(master);
Ok(())
}
#[allow(unsafe_code)]
fn reset_signal(sig: nix::sys::signal::Signal, sa: &nix::sys::signal::SigAction) {
// SAFETY: sigaction(2) is async-signal-safe and SIG_DFL installs no
// Rust handler code.
let _ = unsafe { nix::sys::signal::sigaction(sig, sa) };
}
#[allow(unsafe_code)]
fn set_controlling_terminal(slave: RawFd) -> Result<(), Error> {
use std::os::fd::BorrowedFd;
// SAFETY: the fd is owned by the caller and stays open for the duration of
// this call; we only borrow it for one ioctl.
let fd = unsafe { BorrowedFd::borrow_raw(slave) };
process::ioctl_tiocsctty(fd).map_err(|err| {
log::error!("error setting controlling terminal errno={err:?}");
Error::SetControllingTerminalFailed
})
}
#[allow(unsafe_code)]
fn close_raw(fd: RawFd) {
if fd < 0 {
return;
}
// SAFETY: closing a raw fd number we were told to close; in the child
// the `OwnedFd`s that nominally own these are never dropped (the child
// execs or `_exit`s), so there is no double close.
unsafe { rustix::io::close(fd) };
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pty_size() {
let mut ws = Winsize {
ws_row: 50,
ws_col: 80,
ws_xpixel: 1,
ws_ypixel: 1,
};
let mut pty = Pty::open(ws).unwrap();
// Initialize size should match what we gave it
assert_eq!(ws, pty.get_size().unwrap());
// Can set and read new sizes
ws.ws_row *= 2;
pty.set_size(ws).unwrap();
assert_eq!(ws, pty.get_size().unwrap());
}
#[test]
fn default_mode_and_utf8() {
let pty = Pty::open(Winsize::default()).unwrap();
let mode = pty.get_mode().unwrap();
assert_eq!(mode, Mode::default());
let attrs = termios::tcgetattr(&pty.master).unwrap();
assert!(attrs.input_modes.contains(termios::InputModes::IUTF8));
}
#[test]
fn master_is_cloexec_slave_is_not() {
let pty = Pty::open(Winsize::default()).unwrap();
let m = rustix::io::fcntl_getfd(&pty.master).unwrap();
assert!(m.contains(rustix::io::FdFlags::CLOEXEC));
let s = rustix::io::fcntl_getfd(pty.slave.as_ref().unwrap()).unwrap();
assert!(!s.contains(rustix::io::FdFlags::CLOEXEC));
}
}
@@ -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()
);
}
}
@@ -0,0 +1,1184 @@
//! Ghostty's terminfo entry. Port of `terminfo/ghostty.zig`.
use super::source::{Capability, Source, Value};
/// Ghostty's terminfo entry.
pub const GHOSTTY: Source = Source {
names: &[
// We support the "xterm-" prefix because some poorly behaved programs
// use this to detect if the terminal supports 256 colors and other
// features.
// HACK: This is a hack on a hack...we use "xterm-ghostty" to prevent
// vim from breaking, and when we do this as the default we break
// tcell-based applications (lazygit, aerc, etc). tcell has a bug where
// the primary terminfo name must be the value of TERM.
// https://github.com/gdamore/tcell/pull/639 fixes the issue but is not
// merged yet. Consider switching these in the future.
"xterm-ghostty",
// The preferred name
"ghostty",
// Our "formal" name
"Ghostty",
],
// NOTE: These capabilities are super underdocumented and I'm not 100%
// I've got the list or my understanding of any in this list fully correct.
// As we learn more, please update the comments to better explain what
// anything means.
//
// I've marked some capabilities as "???" if I don't understand what they
// mean but I just assume I support since other modern terminals do. In
// this case, I'd love if anyone could help explain what this means and
// verify that Ghostty does indeed support it and if not we can fix it.
capabilities: &[
// automatic right margin -- when reaching the end of a line, text is
// wrapped to the next line.
Capability {
name: "am",
value: Value::Boolean,
},
// background color erase -- screen is erased with the background color
Capability {
name: "bce",
value: Value::Boolean,
},
// terminal can change color definitions, i.e. we can change the color
// palette.
Capability {
name: "ccc",
value: Value::Boolean,
},
// supports changing the window title.
Capability {
name: "hs",
value: Value::Boolean,
},
// terminal has a meta key
Capability {
name: "km",
value: Value::Boolean,
},
// terminal will not echo input on the screen on its own
Capability {
name: "mc5i",
value: Value::Boolean,
},
// safe to move (move what?) while in insert/standout mode. (???)
Capability {
name: "mir",
value: Value::Boolean,
},
Capability {
name: "msgr",
value: Value::Boolean,
},
// no pad character (???)
Capability {
name: "npc",
value: Value::Boolean,
},
// newline ignored after 80 cols (???)
Capability {
name: "xenl",
value: Value::Boolean,
},
// Terminal supports default colors
Capability {
name: "AX",
value: Value::Boolean,
},
// Tmux "truecolor" mode. Other programs also use this to detect
// if the terminal supports "truecolor". This means that the terminal
// can display 24-bit RGB colors.
Capability {
name: "Tc",
value: Value::Boolean,
},
// Colored underlines. https://sw.kovidgoyal.net/kitty/underlines/
Capability {
name: "Su",
value: Value::Boolean,
},
// Terminal supports a number of xterm extensions
Capability {
name: "XT",
value: Value::Boolean,
},
// Full keyboard support using Kitty's keyboard protocol:
// https://sw.kovidgoyal.net/kitty/keyboard-protocol/
Capability {
name: "fullkbd",
value: Value::Boolean,
},
// Number of colors in the color palette.
Capability {
name: "colors",
value: Value::Numeric(256),
},
// Number of columns in a line. Our terminal is variable width on
// Window resize but this appears to just be the value set by most
// terminals.
Capability {
name: "cols",
value: Value::Numeric(80),
},
// Initial tabstop interval.
Capability {
name: "it",
value: Value::Numeric(8),
},
// Number of lines on a page. Similar to cols this is variable width
// but this appears to be the value set by most terminals.
Capability {
name: "lines",
value: Value::Numeric(24),
},
// Number of color pairs on the screen.
Capability {
name: "pairs",
value: Value::Numeric(32767),
},
// Alternate character set. This is the VT100 alternate character set.
// I don't know what the value means, I copied this from Kitty and
// verified with some other terminals (looks similar).
Capability {
name: "acsc",
value: Value::String(
"++\\,\\,--..00``aaffgghhiijjkkllmmnnooppqqrrssttuuvvwwxxyyzz{{||}}~~",
),
},
// Curly, dashed, etc underlines
Capability {
name: "Smulx",
value: Value::String("\\E[4:%p1%dm"),
},
// Colored underlines
Capability {
name: "Setulc",
value: Value::String(
"\\E[58:2::%p1%{65536}%/%d:%p1%{256}%/%{255}%&%d:%p1%{255}%&%d%;m",
),
},
// Cursor styles
Capability {
name: "Ss",
value: Value::String("\\E[%p1%d q"),
},
// Cursor style reset
Capability {
name: "Se",
value: Value::String("\\E[2 q"),
},
// OSC 52 Clipboard
Capability {
name: "Ms",
value: Value::String("\\E]52;%p1%s;%p2%s\\007"),
},
// Synchronized output
Capability {
name: "Sync",
value: Value::String("\\E[?2026%?%p1%{1}%-%tl%eh%;"),
},
// Bracketed paste mode
Capability {
name: "BD",
value: Value::String("\\E[?2004l"),
},
Capability {
name: "BE",
value: Value::String("\\E[?2004h"),
},
// Bracketed paste start/end
Capability {
name: "PS",
value: Value::String("\\E[200~"),
},
Capability {
name: "PE",
value: Value::String("\\E[201~"),
},
// Mouse
Capability {
name: "XM",
value: Value::String("\\E[?1006;1000%?%p1%{1}%=%th%el%;"),
},
Capability {
name: "xm",
value: Value::String("\\E[<%i%p3%d;%p1%d;%p2%d;%?%p4%tM%em%;"),
},
// Secondary device attributes request / response
Capability {
name: "RV",
value: Value::String("\\E[>c"),
},
Capability {
name: "rv",
value: Value::String("\\E\\\\[[0-9]+;[0-9]+;[0-9]+c"),
},
// XTVERSION
Capability {
name: "XR",
value: Value::String("\\E[>0q"),
},
Capability {
name: "xr",
value: Value::String("\\EP>\\\\|[ -~]+a\\E\\\\"),
},
// DECSLRM (Left/Right Margins)
Capability {
name: "Enmg",
value: Value::String("\\E[?69h"),
},
Capability {
name: "Dsmg",
value: Value::String("\\E[?69l"),
},
Capability {
name: "Clmg",
value: Value::String("\\E[s"),
},
Capability {
name: "Cmg",
value: Value::String("\\E[%i%p1%d;%p2%ds"),
},
// Clear screen. E3 is the extension to clear scrollback
Capability {
name: "clear",
value: Value::String("\\E[H\\E[2J"),
},
Capability {
name: "E3",
value: Value::String("\\E[3J"),
},
// Focus reporting. Introduced in ncurses 6.4-20231028
Capability {
name: "fe",
value: Value::String("\\E[?1004h"),
},
Capability {
name: "fd",
value: Value::String("\\E[?1004l"),
},
Capability {
name: "kxIN",
value: Value::String("\\E[I"),
},
Capability {
name: "kxOUT",
value: Value::String("\\E[O"),
},
// These are all capabilities that should be pretty straightforward
// and map to input sequences.
Capability {
name: "bel",
value: Value::String("^G"),
},
Capability {
name: "blink",
value: Value::String("\\E[5m"),
},
Capability {
name: "bold",
value: Value::String("\\E[1m"),
},
Capability {
name: "cbt",
value: Value::String("\\E[Z"),
},
Capability {
name: "civis",
value: Value::String("\\E[?25l"),
},
Capability {
name: "cnorm",
value: Value::String("\\E[?12l\\E[?25h"),
},
Capability {
name: "cr",
value: Value::String("\\r"),
},
Capability {
name: "csr",
value: Value::String("\\E[%i%p1%d;%p2%dr"),
},
Capability {
name: "cub",
value: Value::String("\\E[%p1%dD"),
},
Capability {
name: "cub1",
value: Value::String("^H"),
},
Capability {
name: "cud",
value: Value::String("\\E[%p1%dB"),
},
Capability {
name: "cud1",
value: Value::String("^J"),
},
Capability {
name: "cuf",
value: Value::String("\\E[%p1%dC"),
},
Capability {
name: "cuf1",
value: Value::String("\\E[C"),
},
Capability {
name: "cup",
value: Value::String("\\E[%i%p1%d;%p2%dH"),
},
Capability {
name: "cuu",
value: Value::String("\\E[%p1%dA"),
},
Capability {
name: "cuu1",
value: Value::String("\\E[A"),
},
Capability {
name: "cvvis",
value: Value::String("\\E[?12;25h"),
},
Capability {
name: "dch",
value: Value::String("\\E[%p1%dP"),
},
Capability {
name: "dch1",
value: Value::String("\\E[P"),
},
Capability {
name: "dim",
value: Value::String("\\E[2m"),
},
Capability {
name: "dl",
value: Value::String("\\E[%p1%dM"),
},
Capability {
name: "dl1",
value: Value::String("\\E[M"),
},
Capability {
name: "dsl",
value: Value::String("\\E]2;\\007"),
},
Capability {
name: "ech",
value: Value::String("\\E[%p1%dX"),
},
Capability {
name: "ed",
value: Value::String("\\E[J"),
},
Capability {
name: "el",
value: Value::String("\\E[K"),
},
Capability {
name: "el1",
value: Value::String("\\E[1K"),
},
Capability {
name: "flash",
value: Value::String("\\E[?5h$<100/>\\E[?5l"),
},
Capability {
name: "fsl",
value: Value::String("^G"),
},
Capability {
name: "home",
value: Value::String("\\E[H"),
},
Capability {
name: "hpa",
value: Value::String("\\E[%i%p1%dG"),
},
Capability {
name: "ht",
value: Value::String("^I"),
},
Capability {
name: "hts",
value: Value::String("\\EH"),
},
Capability {
name: "ich",
value: Value::String("\\E[%p1%d@"),
},
Capability {
name: "ich1",
value: Value::String("\\E[@"),
},
Capability {
name: "il",
value: Value::String("\\E[%p1%dL"),
},
Capability {
name: "il1",
value: Value::String("\\E[L"),
},
Capability {
name: "ind",
value: Value::String("\\n"),
},
Capability {
name: "indn",
value: Value::String("\\E[%p1%dS"),
},
Capability {
name: "initc",
value: Value::String(
"\\E]4;%p1%d;rgb\\:%p2%{255}%*%{1000}%/%2.2X/%p3%{255}%*%{1000}%/%2.2X/%p4%{255}%*%{1000}%/%2.2X\\E\\\\",
),
},
Capability {
name: "invis",
value: Value::String("\\E[8m"),
},
Capability {
name: "oc",
value: Value::String("\\E]104\\007"),
},
Capability {
name: "op",
value: Value::String("\\E[39;49m"),
},
Capability {
name: "rc",
value: Value::String("\\E8"),
},
Capability {
name: "rep",
value: Value::String("%p1%c\\E[%p2%{1}%-%db"),
},
Capability {
name: "rev",
value: Value::String("\\E[7m"),
},
Capability {
name: "ri",
value: Value::String("\\EM"),
},
Capability {
name: "rin",
value: Value::String("\\E[%p1%dT"),
},
Capability {
name: "ritm",
value: Value::String("\\E[23m"),
},
Capability {
name: "rmacs",
value: Value::String("\\E(B"),
},
Capability {
name: "rmam",
value: Value::String("\\E[?7l"),
},
Capability {
name: "rmcup",
value: Value::String("\\E[?1049l"),
},
Capability {
name: "rmir",
value: Value::String("\\E[4l"),
},
Capability {
name: "rmkx",
value: Value::String("\\E[?1l\\E>"),
},
Capability {
name: "rmso",
value: Value::String("\\E[27m"),
},
Capability {
name: "rmul",
value: Value::String("\\E[24m"),
},
Capability {
name: "rmxx",
value: Value::String("\\E[29m"),
},
Capability {
name: "setab",
value: Value::String(
"\\E[%?%p1%{8}%<%t4%p1%d%e%p1%{16}%<%t10%p1%{8}%-%d%e48;5;%p1%d%;m",
),
},
Capability {
name: "setaf",
value: Value::String(
"\\E[%?%p1%{8}%<%t3%p1%d%e%p1%{16}%<%t9%p1%{8}%-%d%e38;5;%p1%d%;m",
),
},
Capability {
name: "setrgbb",
value: Value::String("\\E[48:2:%p1%d:%p2%d:%p3%dm"),
},
Capability {
name: "setrgbf",
value: Value::String("\\E[38:2:%p1%d:%p2%d:%p3%dm"),
},
Capability {
name: "sgr",
value: Value::String(
"%?%p9%t\\E(0%e\\E(B%;\\E[0%?%p6%t;1%;%?%p5%t;2%;%?%p2%t;4%;%?%p1%p3%|%t;7%;%?%p4%t;5%;%?%p7%t;8%;m",
),
},
Capability {
name: "sgr0",
value: Value::String("\\E(B\\E[m"),
},
Capability {
name: "sitm",
value: Value::String("\\E[3m"),
},
Capability {
name: "smacs",
value: Value::String("\\E(0"),
},
Capability {
name: "smam",
value: Value::String("\\E[?7h"),
},
Capability {
name: "smcup",
value: Value::String("\\E[?1049h"),
},
Capability {
name: "smir",
value: Value::String("\\E[4h"),
},
Capability {
name: "smkx",
value: Value::String("\\E[?1h\\E="),
},
Capability {
name: "smso",
value: Value::String("\\E[7m"),
},
Capability {
name: "smul",
value: Value::String("\\E[4m"),
},
Capability {
name: "smxx",
value: Value::String("\\E[9m"),
},
Capability {
name: "tbc",
value: Value::String("\\E[3g"),
},
Capability {
name: "tsl",
value: Value::String("\\E]2;"),
},
Capability {
name: "u6",
value: Value::String("\\E[%i%d;%dR"),
},
Capability {
name: "u7",
value: Value::String("\\E[6n"),
},
Capability {
name: "u8",
value: Value::String("\\E[?%[;0123456789]c"),
},
Capability {
name: "u9",
value: Value::String("\\E[c"),
},
Capability {
name: "vpa",
value: Value::String("\\E[%i%p1%dd"),
},
//-----------------------------------------------------------
// Completely unvalidated entries that are blindly copied from
// other terminals (Kitty, Wezterm, Alacritty) and may or may not
// actually work with Ghostty. todo is to validate these!
Capability {
name: "kDC",
value: Value::String("\\E[3;2~"),
},
Capability {
name: "kDC3",
value: Value::String("\\E[3;3~"),
},
Capability {
name: "kDC4",
value: Value::String("\\E[3;4~"),
},
Capability {
name: "kDC5",
value: Value::String("\\E[3;5~"),
},
Capability {
name: "kDC6",
value: Value::String("\\E[3;6~"),
},
Capability {
name: "kDC7",
value: Value::String("\\E[3;7~"),
},
Capability {
name: "kDN",
value: Value::String("\\E[1;2B"),
},
Capability {
name: "kDN3",
value: Value::String("\\E[1;3B"),
},
Capability {
name: "kDN4",
value: Value::String("\\E[1;4B"),
},
Capability {
name: "kDN5",
value: Value::String("\\E[1;5B"),
},
Capability {
name: "kDN6",
value: Value::String("\\E[1;6B"),
},
Capability {
name: "kDN7",
value: Value::String("\\E[1;7B"),
},
Capability {
name: "kEND",
value: Value::String("\\E[1;2F"),
},
Capability {
name: "kEND3",
value: Value::String("\\E[1;3F"),
},
Capability {
name: "kEND4",
value: Value::String("\\E[1;4F"),
},
Capability {
name: "kEND5",
value: Value::String("\\E[1;5F"),
},
Capability {
name: "kEND6",
value: Value::String("\\E[1;6F"),
},
Capability {
name: "kEND7",
value: Value::String("\\E[1;7F"),
},
Capability {
name: "kHOM",
value: Value::String("\\E[1;2H"),
},
Capability {
name: "kHOM3",
value: Value::String("\\E[1;3H"),
},
Capability {
name: "kHOM4",
value: Value::String("\\E[1;4H"),
},
Capability {
name: "kHOM5",
value: Value::String("\\E[1;5H"),
},
Capability {
name: "kHOM6",
value: Value::String("\\E[1;6H"),
},
Capability {
name: "kHOM7",
value: Value::String("\\E[1;7H"),
},
Capability {
name: "kIC",
value: Value::String("\\E[2;2~"),
},
Capability {
name: "kIC3",
value: Value::String("\\E[2;3~"),
},
Capability {
name: "kIC4",
value: Value::String("\\E[2;4~"),
},
Capability {
name: "kIC5",
value: Value::String("\\E[2;5~"),
},
Capability {
name: "kIC6",
value: Value::String("\\E[2;6~"),
},
Capability {
name: "kIC7",
value: Value::String("\\E[2;7~"),
},
Capability {
name: "kLFT",
value: Value::String("\\E[1;2D"),
},
Capability {
name: "kLFT3",
value: Value::String("\\E[1;3D"),
},
Capability {
name: "kLFT4",
value: Value::String("\\E[1;4D"),
},
Capability {
name: "kLFT5",
value: Value::String("\\E[1;5D"),
},
Capability {
name: "kLFT6",
value: Value::String("\\E[1;6D"),
},
Capability {
name: "kLFT7",
value: Value::String("\\E[1;7D"),
},
Capability {
name: "kNXT",
value: Value::String("\\E[6;2~"),
},
Capability {
name: "kNXT3",
value: Value::String("\\E[6;3~"),
},
Capability {
name: "kNXT4",
value: Value::String("\\E[6;4~"),
},
Capability {
name: "kNXT5",
value: Value::String("\\E[6;5~"),
},
Capability {
name: "kNXT6",
value: Value::String("\\E[6;6~"),
},
Capability {
name: "kNXT7",
value: Value::String("\\E[6;7~"),
},
Capability {
name: "kPRV",
value: Value::String("\\E[5;2~"),
},
Capability {
name: "kPRV3",
value: Value::String("\\E[5;3~"),
},
Capability {
name: "kPRV4",
value: Value::String("\\E[5;4~"),
},
Capability {
name: "kPRV5",
value: Value::String("\\E[5;5~"),
},
Capability {
name: "kPRV6",
value: Value::String("\\E[5;6~"),
},
Capability {
name: "kPRV7",
value: Value::String("\\E[5;7~"),
},
Capability {
name: "kRIT",
value: Value::String("\\E[1;2C"),
},
Capability {
name: "kRIT3",
value: Value::String("\\E[1;3C"),
},
Capability {
name: "kRIT4",
value: Value::String("\\E[1;4C"),
},
Capability {
name: "kRIT5",
value: Value::String("\\E[1;5C"),
},
Capability {
name: "kRIT6",
value: Value::String("\\E[1;6C"),
},
Capability {
name: "kRIT7",
value: Value::String("\\E[1;7C"),
},
Capability {
name: "kUP",
value: Value::String("\\E[1;2A"),
},
Capability {
name: "kUP3",
value: Value::String("\\E[1;3A"),
},
Capability {
name: "kUP4",
value: Value::String("\\E[1;4A"),
},
Capability {
name: "kUP5",
value: Value::String("\\E[1;5A"),
},
Capability {
name: "kUP6",
value: Value::String("\\E[1;6A"),
},
Capability {
name: "kUP7",
value: Value::String("\\E[1;7A"),
},
Capability {
name: "kbs",
value: Value::String("^?"),
},
Capability {
name: "kcbt",
value: Value::String("\\E[Z"),
},
Capability {
name: "kcub1",
value: Value::String("\\EOD"),
},
Capability {
name: "kcud1",
value: Value::String("\\EOB"),
},
Capability {
name: "kcuf1",
value: Value::String("\\EOC"),
},
Capability {
name: "kcuu1",
value: Value::String("\\EOA"),
},
Capability {
name: "kdch1",
value: Value::String("\\E[3~"),
},
Capability {
name: "kend",
value: Value::String("\\EOF"),
},
Capability {
name: "kent",
value: Value::String("\\EOM"),
},
Capability {
name: "kf1",
value: Value::String("\\EOP"),
},
Capability {
name: "kf10",
value: Value::String("\\E[21~"),
},
Capability {
name: "kf11",
value: Value::String("\\E[23~"),
},
Capability {
name: "kf12",
value: Value::String("\\E[24~"),
},
Capability {
name: "kf13",
value: Value::String("\\E[1;2P"),
},
Capability {
name: "kf14",
value: Value::String("\\E[1;2Q"),
},
Capability {
name: "kf15",
value: Value::String("\\E[1;2R"),
},
Capability {
name: "kf16",
value: Value::String("\\E[1;2S"),
},
Capability {
name: "kf17",
value: Value::String("\\E[15;2~"),
},
Capability {
name: "kf18",
value: Value::String("\\E[17;2~"),
},
Capability {
name: "kf19",
value: Value::String("\\E[18;2~"),
},
Capability {
name: "kf2",
value: Value::String("\\EOQ"),
},
Capability {
name: "kf20",
value: Value::String("\\E[19;2~"),
},
Capability {
name: "kf21",
value: Value::String("\\E[20;2~"),
},
Capability {
name: "kf22",
value: Value::String("\\E[21;2~"),
},
Capability {
name: "kf23",
value: Value::String("\\E[23;2~"),
},
Capability {
name: "kf24",
value: Value::String("\\E[24;2~"),
},
Capability {
name: "kf25",
value: Value::String("\\E[1;5P"),
},
Capability {
name: "kf26",
value: Value::String("\\E[1;5Q"),
},
Capability {
name: "kf27",
value: Value::String("\\E[1;5R"),
},
Capability {
name: "kf28",
value: Value::String("\\E[1;5S"),
},
Capability {
name: "kf29",
value: Value::String("\\E[15;5~"),
},
Capability {
name: "kf3",
value: Value::String("\\EOR"),
},
Capability {
name: "kf30",
value: Value::String("\\E[17;5~"),
},
Capability {
name: "kf31",
value: Value::String("\\E[18;5~"),
},
Capability {
name: "kf32",
value: Value::String("\\E[19;5~"),
},
Capability {
name: "kf33",
value: Value::String("\\E[20;5~"),
},
Capability {
name: "kf34",
value: Value::String("\\E[21;5~"),
},
Capability {
name: "kf35",
value: Value::String("\\E[23;5~"),
},
Capability {
name: "kf36",
value: Value::String("\\E[24;5~"),
},
Capability {
name: "kf37",
value: Value::String("\\E[1;6P"),
},
Capability {
name: "kf38",
value: Value::String("\\E[1;6Q"),
},
Capability {
name: "kf39",
value: Value::String("\\E[1;6R"),
},
Capability {
name: "kf4",
value: Value::String("\\EOS"),
},
Capability {
name: "kf40",
value: Value::String("\\E[1;6S"),
},
Capability {
name: "kf41",
value: Value::String("\\E[15;6~"),
},
Capability {
name: "kf42",
value: Value::String("\\E[17;6~"),
},
Capability {
name: "kf43",
value: Value::String("\\E[18;6~"),
},
Capability {
name: "kf44",
value: Value::String("\\E[19;6~"),
},
Capability {
name: "kf45",
value: Value::String("\\E[20;6~"),
},
Capability {
name: "kf46",
value: Value::String("\\E[21;6~"),
},
Capability {
name: "kf47",
value: Value::String("\\E[23;6~"),
},
Capability {
name: "kf48",
value: Value::String("\\E[24;6~"),
},
Capability {
name: "kf49",
value: Value::String("\\E[1;3P"),
},
Capability {
name: "kf5",
value: Value::String("\\E[15~"),
},
Capability {
name: "kf50",
value: Value::String("\\E[1;3Q"),
},
Capability {
name: "kf51",
value: Value::String("\\E[1;3R"),
},
Capability {
name: "kf52",
value: Value::String("\\E[1;3S"),
},
Capability {
name: "kf53",
value: Value::String("\\E[15;3~"),
},
Capability {
name: "kf54",
value: Value::String("\\E[17;3~"),
},
Capability {
name: "kf55",
value: Value::String("\\E[18;3~"),
},
Capability {
name: "kf56",
value: Value::String("\\E[19;3~"),
},
Capability {
name: "kf57",
value: Value::String("\\E[20;3~"),
},
Capability {
name: "kf58",
value: Value::String("\\E[21;3~"),
},
Capability {
name: "kf59",
value: Value::String("\\E[23;3~"),
},
Capability {
name: "kf6",
value: Value::String("\\E[17~"),
},
Capability {
name: "kf60",
value: Value::String("\\E[24;3~"),
},
Capability {
name: "kf61",
value: Value::String("\\E[1;4P"),
},
Capability {
name: "kf62",
value: Value::String("\\E[1;4Q"),
},
Capability {
name: "kf63",
value: Value::String("\\E[1;4R"),
},
Capability {
name: "kf7",
value: Value::String("\\E[18~"),
},
Capability {
name: "kf8",
value: Value::String("\\E[19~"),
},
Capability {
name: "kf9",
value: Value::String("\\E[20~"),
},
Capability {
name: "khome",
value: Value::String("\\EOH"),
},
Capability {
name: "kich1",
value: Value::String("\\E[2~"),
},
Capability {
name: "kind",
value: Value::String("\\E[1;2B"),
},
Capability {
name: "kmous",
value: Value::String("\\E[<"),
},
Capability {
name: "knp",
value: Value::String("\\E[6~"),
},
Capability {
name: "kpp",
value: Value::String("\\E[5~"),
},
Capability {
name: "kri",
value: Value::String("\\E[1;2A"),
},
Capability {
name: "rs1",
value: Value::String("\\E]\\E\\\\\\Ec"),
},
Capability {
name: "sc",
value: Value::String("\\E7"),
},
],
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode() {
let mut buf = Vec::new();
GHOSTTY.encode(&mut buf).unwrap();
assert!(!buf.is_empty());
let s = String::from_utf8(buf).unwrap();
assert!(s.starts_with("xterm-ghostty|ghostty|Ghostty,\n\tam,\n\tbce,\n"));
assert!(s.ends_with("\tsc=\\E7,\n"));
}
}
+14
View File
@@ -0,0 +1,14 @@
//! Package terminfo provides functionality related to terminfo/termcap files.
//!
//! At the time of writing this comment, the focus is on generating terminfo
//! files so that we can maintain our terminfo in Rust instead of hand-writing
//! the archaic (imo) terminfo format by hand. But eventually we may want to
//! extract this into a more full-featured library on its own.
//!
//! Port of `terminfo/main.zig`.
pub mod ghostty;
pub mod source;
pub use ghostty::GHOSTTY as ghostty;
pub use source::Source;
@@ -0,0 +1,214 @@
//! Terminfo source format. This can be used to encode terminfo files.
//! This cannot parse terminfo source files yet because it isn't something
//! I need to do but this can be added later.
//!
//! Background: <https://invisible-island.net/ncurses/man/terminfo.5.html>
//!
//! Port of `terminfo/Source.zig`.
use std::collections::HashMap;
use std::io;
/// A terminfo source description.
#[derive(Debug, Clone, Copy)]
pub struct Source {
/// The set of names for the terminal. These match the TERM environment
/// variable and are used to look up this terminal. Historically, the final
/// name in the list was the most common name for the terminal and contains
/// spaces and other characters. See terminfo(5) for details.
pub names: &'static [&'static str],
/// The set of capabilities in this terminfo file.
pub capabilities: &'static [Capability],
}
/// A capability in a terminfo file. This also includes any "use" capabilities
/// since they behave just like other capabilities as documented in terminfo(5).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Capability {
/// The name of capability. This is the "Cap-name" value in terminfo(5).
pub name: &'static str,
pub value: Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Value {
/// Canceled value, i.e. suffixed with @
Canceled,
/// Boolean values are always true if they exist so there is no value.
Boolean,
/// Numeric values are always "unsigned decimal integers". The size
/// of the integer is unspecified in terminfo(5). 32-bits is a common
/// integer size but this may be wrong.
Numeric(u32),
String(&'static str),
}
impl Source {
/// Encode as a terminfo source file. The encoding is always done in a
/// human-readable format with whitespace. Fields are always written in the
/// order of the slices on this struct; this will not do any reordering.
pub fn encode<W: io::Write>(&self, writer: &mut W) -> io::Result<()> {
// Encode the names in the order specified
for (i, name) in self.names.iter().enumerate() {
if i != 0 {
writer.write_all(b"|")?;
}
writer.write_all(name.as_bytes())?;
}
writer.write_all(b",\n")?;
// Encode each of the capabilities in the order specified
for cap in self.capabilities {
writer.write_all(b"\t")?;
writer.write_all(cap.name.as_bytes())?;
match cap.value {
Value::Canceled => writer.write_all(b"@")?,
Value::Boolean => {}
Value::Numeric(v) => write!(writer, "#{v}")?,
Value::String(v) => write!(writer, "={v}")?,
}
writer.write_all(b",\n")?;
}
Ok(())
}
/// Encode to an owned `String`.
pub fn encode_to_string(&self) -> String {
let mut buf = Vec::new();
// Writing to a Vec cannot fail.
let _ = self.encode(&mut buf);
String::from_utf8_lossy(&buf).into_owned()
}
/// Returns a map for all of the capabilities in this terminfo. The key is
/// the hex-encoded (uppercase) capability name and the value is what
/// should be sent as a response to XTGETTCAP.
/// Important: the value is the FULL response including the escape
/// sequences.
///
/// Panics on canceled capabilities (unsupported, like the Zig
/// `@compileError`) and on `^` control characters that are not at the
/// start of a string.
pub fn xtgettcap_map(&self) -> HashMap<String, String> {
let mut kvs: Vec<(String, String)> = Vec::with_capacity(self.capabilities.len() + 3);
// We have all of our capabilities plus TN, Co, and RGB which aren't
// in the capabilities list but are query-able.
kvs.push(("TN".into(), self.names[0].into()));
kvs.push(("Co".into(), "256".into()));
kvs.push(("RGB".into(), "8".into()));
for cap in self.capabilities {
let value = match cap.value {
Value::Canceled => panic!("canceled not handled yet"),
Value::Boolean => String::new(),
Value::String(v) => {
// If a string contains parameters, then we do not escape
// anything within the string. I BELIEVE the history here is
// xterm initially only supported specific capabilities and
// none had parameters so it returned the tcap encoded form.
// Later, Kitty added support for more capabilities some of
// which have parameters. But Kitty returned them in
// terminfo source format. So we need to handle both cases.
if v.contains('%') {
v.to_string()
} else {
// No-parameters. Encode and return.
// First replace \E with the escape char (0x1B)
let mut result = v.replace("\\E", "\x1b");
// Replace '^' with the control char version of that char.
while let Some(idx) = result.find('^') {
assert!(idx == 0, "handle control-char in middle of string");
let c = result.as_bytes()[idx + 1];
let replacement = match c {
b'?' => 0x7F, // DEL, special cased from ncurses
c => c - 64,
};
let pat = result[idx..idx + 2].to_string();
result = result.replace(&pat, &char::from(replacement).to_string());
}
result
}
}
Value::Numeric(v) => v.to_string(),
};
kvs.push((cap.name.to_string(), value));
}
// Now go through and convert them all to hex-encoded strings.
kvs.into_iter()
.map(|(k, v)| {
let key = hexencode(k.as_bytes());
let out = if v.is_empty() {
format!("\x1bP1+r{key}\x1b\\")
} else {
format!("\x1bP1+r{key}={}\x1b\\", hexencode(v.as_bytes()))
};
(key, out)
})
.collect()
}
}
/// Uppercase hex encoding (`std.fmt.bytesToHex(.., .upper)`).
pub fn hexencode(input: &[u8]) -> String {
use std::fmt::Write;
let mut s = String::with_capacity(input.len() * 2);
for b in input {
let _ = write!(s, "{b:02X}");
}
s
}
#[cfg(test)]
mod tests {
use super::*;
const fn cap(name: &'static str, value: Value) -> Capability {
Capability { name, value }
}
#[test]
fn xtgettcap_map() {
static SRC: Source = Source {
names: &["ghostty", "xterm-ghostty", "Ghostty"],
capabilities: &[
cap("am", Value::Boolean),
cap("colors", Value::Numeric(256)),
cap("kx", Value::String("^?")),
cap("kbs", Value::String("^H")),
cap("kf1", Value::String("\\EOP")),
cap("Smulx", Value::String("\\E[4:%p1%dm")),
],
};
let map = SRC.xtgettcap_map();
assert_eq!("\x1bP1+r616D\x1b\\", map[&hexencode(b"am")]);
assert_eq!("\x1bP1+r6B78=7F\x1b\\", map[&hexencode(b"kx")]);
assert_eq!("\x1bP1+r6B6273=08\x1b\\", map[&hexencode(b"kbs")]);
assert_eq!("\x1bP1+r6B6631=1B4F50\x1b\\", map[&hexencode(b"kf1")]);
assert_eq!(
"\x1bP1+r536D756C78=5C455B343A25703125646D\x1b\\",
map[&hexencode(b"Smulx")]
);
}
#[test]
fn encode() {
static SRC: Source = Source {
names: &["ghostty", "xterm-ghostty", "Ghostty"],
capabilities: &[
cap("am", Value::Boolean),
cap("ccc", Value::Canceled),
cap("colors", Value::Numeric(256)),
cap("bel", Value::String("^G")),
],
};
let expected = "ghostty|xterm-ghostty|Ghostty,\n\tam,\n\tccc@,\n\tcolors#256,\n\tbel=^G,\n";
assert_eq!(expected, SRC.encode_to_string());
}
}