refactor: resolve cross-crate integration TODOs and resources discovery

Cleanup pass over items collected in docs/rewrite/INTEGRATION_TODO.md:
- OSC 133 printf-q / percent decoders live once in
  ghostty_vt::string_encoding; ghostty-os re-exports them.
- CursorStyle and clipboard codepoint map entries have one definition
  (ghostty-terminal); ghostty-config now depends on ghostty-terminal.
- ActionKey::docs() trailing blank lines fixed at the source; cli's
  workaround removed (+list-actions --docs still byte-identical).
- x11_color exposes names in original case; cli no longer re-parses
  rgb.txt.
- One cgroup scope wait loop (ghostty_os::cgroup::wait_for_scope).
- Flatpak passwd lookup implemented in ghostty-os via a local pty.
- +version shows the rustc version captured at build time.

Resources: dev builds find <workspace>/resources automatically (keeping
Ghostty's sentinel-file detection and GHOSTTY_RESOURCES_DIR);
scripts/gen-terminfo.sh (just terminfo) regenerates the compiled
terminfo; `just install [PREFIX]` installs binary, share/ghostty,
terminfo, desktop/D-Bus/systemd/metainfo files and icons.

Test Plan:
- cargo test for vt, os, config, input, cli
- diff `+list-keybinds`, `+list-actions --docs`, `+show-config --default`
  against /usr/bin/ghostty
This commit is contained in:
ddidderr committed 2026-10-12 02:08:22 +02:00
1 parent 062c643ea8
commit 7868e56273
24 files changed
+656 -513

No files matched your search

+1
View File
@@ -11,6 +11,7 @@ ghostty-config.workspace = true
ghostty-font.workspace = true
ghostty-input.workspace = true
ghostty-os.workspace = true
ghostty-vt.workspace = true
log = "0.4.34"
ratatui = "0.30.2"
thiserror = "2.0.21"
+18
View File
@@ -0,0 +1,18 @@
//! Captures the compiler version for `+version` (Zig: `builtin.zig_version_string`).
use std::process::Command;
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-env-changed=RUSTC");
let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into());
let version = Command::new(rustc)
.arg("--version")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
// "rustc 1.99.0 (b940084d7 2026-09-28)" -> "1.99.0"
.and_then(|s| s.split_whitespace().nth(1).map(str::to_owned))
.unwrap_or_else(|| "unknown".into());
println!("cargo:rustc-env=GHOSTTY_RUSTC_VERSION={version}");
}
+4 -48
View File
@@ -3,7 +3,6 @@
use std::io::Write;
use ghostty_config::config_struct;
use ghostty_input::binding::ActionKey;
use crate::error::Error;
@@ -35,44 +34,12 @@ pub fn run(args: &[String]) -> Result<u8, Error> {
}
/// `helpgen_actions.generate(.plaintext, show_docs)`.
///
/// TODO(integration): this is a local copy of
/// `ghostty_input::helpgen_actions::generate`. The embedded docs of a few
/// actions in ghostty-input (`adjust_selection`, ...) end with an extra blank
/// line compared to Ghostty's `help_strings`, which adds a stray `" "` line
/// to `--docs` output. Trailing newlines are normalised here; once the data
/// is fixed in ghostty-input this should call `helpgen_actions::generate`.
#[must_use]
pub fn generate_plaintext(show_docs: bool) -> String {
let mut writer = String::new();
let mut stream = String::new();
for &key in ActionKey::ALL {
let name = key.name();
if name.starts_with('_') {
continue;
}
// Write previously stored doc comment below all related actions
if show_docs {
writer.push_str(&stream);
writer.push('\n');
stream.clear();
writer.push_str(name);
writer.push_str(":\n");
for line in key.docs().trim_end_matches('\n').split('\n') {
stream.push_str(" ");
stream.push_str(line);
stream.push('\n');
}
} else {
writer.push_str(name);
writer.push('\n');
}
}
// Write any remaining buffered documentation
writer.push_str(&stream);
writer
ghostty_input::helpgen_actions::generate_string(
ghostty_input::helpgen_actions::Format::Plaintext,
show_docs,
)
}
#[cfg(test)]
@@ -94,15 +61,4 @@ mod tests {
assert!(s.contains(" respectively.\n\njump_to_prompt:\n"));
assert!(s.ends_with('\n'));
}
#[test]
fn matches_input_crate_when_docs_are_clean() {
// The first actions have well-formed docs.
let ours = generate_plaintext(true);
let theirs = ghostty_input::helpgen_actions::generate_string(
ghostty_input::helpgen_actions::Format::Plaintext,
true,
);
assert!(ours.lines().take(100).eq(theirs.lines().take(100)));
}
}
+4 -25
View File
@@ -16,35 +16,14 @@ config_struct! {
}
cli_options!(Options, help);
/// `terminal/res/rgb.txt`, the same file `x11_color` is built from.
///
/// TODO(integration): ghostty-vt's `x11_color` only exposes lookups (and
/// lower-cases the names); expose its key list and use it instead of
/// re-parsing the data here.
const RGB_DATA: &str = include_str!("../../ghostty-vt/src/res/rgb.txt");
/// All X11 color names (with their original spelling) and values. Like the
/// case-insensitive Zig map the first spelling of a name wins.
#[must_use]
pub fn x11_colors() -> Vec<(String, (u8, u8, u8))> {
let mut seen = std::collections::HashSet::new();
let mut out = vec![];
for line in RGB_DATA.split('\n') {
if line.len() < 12 {
continue;
}
let parse = |s: &str| s.trim_matches(' ').parse::<u8>().ok();
let (Some(r), Some(g), Some(b)) =
(parse(&line[0..3]), parse(&line[4..7]), parse(&line[8..11]))
else {
continue;
};
let name = line[12..].trim_matches([' ', '\t', '\n']);
if seen.insert(name.to_ascii_lowercase()) {
out.push((name.to_string(), (r, g, b)));
}
}
out
ghostty_vt::x11_color::names()
.iter()
.map(|(n, c)| ((*n).to_string(), (c.r, c.g, c.b)))
.collect()
}
/// `std.ascii.orderIgnoreCase`.
+1 -5
View File
@@ -41,11 +41,7 @@ pub fn write_version(out: &mut dyn Write, info: &BuildInfo, tty: bool) -> std::i
writeln!(out, "Build Config")?;
// The Zig build prints its compiler version here.
writeln!(
out,
" - Rust version : {}",
option_env!("CARGO_PKG_RUST_VERSION").unwrap_or("unknown")
)?;
writeln!(out, " - Rust version : {}", env!("GHOSTTY_RUSTC_VERSION"))?;
writeln!(out, " - build mode : {}", info.build_mode)?;
writeln!(out, " - app runtime : {}", info.app_runtime)?;
writeln!(out, " - font engine : {}", info.font_backend)?;
+1
View File
@@ -8,6 +8,7 @@ license.workspace = true
const_format = "0.2.36"
ghostty-input.workspace = true
ghostty-os.workspace = true
ghostty-terminal.workspace = true
ghostty-vt.workspace = true
log = "0.4.34"
thiserror = "2.0.21"
@@ -2,27 +2,10 @@
//! for clipboard operations. When copying text to clipboard, matching
//! codepoints will be replaced with their mapped values.
/// Replacement value for a codepoint range.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Replacement {
/// A single replacement codepoint.
Codepoint(u32),
/// A UTF-8 encoded string to replace with.
String(String),
}
/// One entry of the map.
///
/// TODO(integration): this mirrors `terminal/formatter.zig` `CodepointMap`;
/// replace with `ghostty_terminal::formatter::CodepointMap` once available.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
/// Unicode codepoint range to replace.
/// Asserts: range[0] <= range[1]
pub range: [u32; 2],
/// Replacement value for this range.
pub replacement: Replacement,
}
/// One entry of the map and its replacement value. The canonical
/// definitions live in `ghostty_terminal::formatter` (`CodepointMap`,
/// `Replacement`), which is what the terminal formatter consumes.
pub use ghostty_terminal::formatter::{CodepointMap as Entry, Replacement};
/// The list of entries.
///
+28 -16
View File
@@ -21,23 +21,35 @@ fn write_void(f: &mut EntryFormatter<'_>) -> fmt::Result {
}
// ---------------------------------------------------------------------
// Cursor style (ZIG/src/terminal/cursor.zig `Style`), duplicated here
// because config cannot depend on ghostty-terminal.
// Cursor style: the canonical type is `ghostty_terminal::cursor::Style`
// (ZIG/src/terminal/cursor.zig `Style`); config only adds the field impl.
config_enum! {
/// The visual style of the cursor. Whether or not it blinks
/// is determined by mode 12 (modes.zig). This mode is synchronized
/// with CSI q, the same as xterm.
pub enum CursorStyle {
/// DECSCUSR 5, 6
Bar => "bar",
/// DECSCUSR 1, 2
Block => "block",
/// DECSCUSR 3, 4
Underline => "underline",
/// Hollow block cursor. This is a block cursor with the center empty.
/// Reported as DECSCUSR 1 or 2 (block).
BlockHollow => "block_hollow",
pub use ghostty_terminal::cursor::Style as CursorStyle;
impl ConfigField for CursorStyle {
const VALID_VALUES: Option<&'static [&'static str]> =
Some(&["bar", "block", "underline", "block_hollow"]);
fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> {
let v = value.ok_or(ArgError::ValueRequired)?;
*self = match v {
"bar" => CursorStyle::Bar,
"block" => CursorStyle::Block,
"underline" => CursorStyle::Underline,
"block_hollow" => CursorStyle::BlockHollow,
_ => return Err(ArgError::InvalidValue),
};
Ok(())
}
fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result {
let name = match self {
CursorStyle::Bar => "bar",
CursorStyle::Block => "block",
CursorStyle::Underline => "underline",
CursorStyle::BlockHollow => "block_hollow",
};
writeln!(f.writer, "{} = {}", f.name, name)
}
}
@@ -1438,7 +1438,6 @@ Valid arguments are:
Adjust the selection to the beginning or the end of the line
respectively.
"##
}
ActionKey::JumpToPrompt => {
@@ -1469,7 +1468,6 @@ Valid actions are:
The default OS editor is determined by using `open` on macOS
and `xdg-open` on Linux.
"##
}
ActionKey::WriteScreenFile => {
@@ -1576,7 +1574,6 @@ Valid arguments:
Creates a new split along the larger direction.
For example, if the parent split is currently wider than it is tall,
then a left-right split would be created, and vice versa.
"##
}
ActionKey::GotoSplit => {
@@ -1943,7 +1940,6 @@ The value determines the crash location:
- `render`
Crash on the render thread for the focused surface.
"##
}
}
+3 -2
View File
@@ -9,8 +9,9 @@ use indexmap::IndexMap;
use super::{Action, Elem, Error, Flags, FoldedTrigger, Parser, Trigger, TriggerKey};
use crate::key::KeyEvent;
/// TODO(integration): stand-in for `config/formatter.zig` `EntryFormatter`.
/// The config crate implements this to write `keybind = <value>` lines.
/// Sink for `config/formatter.zig` `EntryFormatter`. The config crate
/// implements this (`ghostty_config::keybinds`) to write `keybind = <value>`
/// lines.
pub trait EntryFormatter {
/// Format a single `key = value` entry where `value` is a string.
fn format_entry(&mut self, value: &str);
+2 -1
View File
@@ -5,10 +5,11 @@ edition.workspace = true
license.workspace = true
[dependencies]
ghostty-vt.workspace = true
gettext-rs = { version = "0.8.0", features = ["gettext-system"] }
log = "0.4.34"
nix = { version = "0.31.3", features = ["user"] }
rustix = { version = "1.1.5", features = ["fs", "process", "system", "pipe", "net", "time", "rand"] }
rustix = { version = "1.1.5", features = ["fs", "process", "system", "pipe", "net", "time", "rand", "pty"] }
thiserror = "2.0.21"
zbus = "5.19.0"
+24
View File
@@ -24,6 +24,30 @@ pub fn current(pid: u32) -> Option<String> {
Some(String::from_utf8_lossy(&rest[..end]).into_owned())
}
/// True if the final path component of `pid`'s cgroup equals `expected`.
pub fn scope_matches(pid: u32, expected: &str) -> bool {
current(pid)
.as_deref()
.and_then(|raw| raw.rsplit_once('/'))
.is_some_and(|(_, last)| last == expected)
}
/// Poll (every 25ms, at most 250ms) until `pid`'s cgroup ends in `expected`.
/// Returns true if seen, false on timeout. This is the single wait loop
/// shared by the termio `Command` hooks and apprt-gtk's pre-exec/post-fork.
pub fn wait_for_scope(pid: u32, expected: &str) -> bool {
let start = std::time::Instant::now();
loop {
if start.elapsed() > std::time::Duration::from_millis(250) {
return false;
}
if scope_matches(pid, expected) {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(25));
}
}
#[cfg(test)]
mod tests {
use super::*;
+94 -9
View File
@@ -25,16 +25,12 @@ pub fn get() -> std::io::Result<Entry> {
}
};
// TODO(integration): Zig shells out to the host via
// FlatpakHostCommand + a pty (`getent passwd <name>`) when running in
// Flatpak, because the sandbox passwd entry only has a correct username.
// That needs the `ghostty-termio` Pty; until then we use the sandbox
// entry and warn.
// If we're in flatpak then our entry is always empty so we grab it
// by shelling out to the host. note that we do HAVE an entry in the
// sandbox but only the username is correct.
if is_flatpak() {
log::warn!(
"flatpak detected, but host passwd lookup is not implemented; \
using sandbox passwd entry"
);
log::info!("flatpak detected, will use host command to get our entry");
return host_entry(&user.name);
}
Ok(Entry {
@@ -44,10 +40,99 @@ pub fn get() -> std::io::Result<Entry> {
})
}
/// Flatpak: look up our entry on the host with `getent passwd <name>` over a
/// pty (Zig `passwd.get`, flatpak branch).
///
/// Dependency note: Zig uses `pty.zig`; `ghostty-termio` (which owns the Rust
/// `Pty`) depends on this crate, so a minimal openpt/ptsname pair is opened
/// here with rustix instead. Like Zig, only `shell` and `home` are filled in.
fn host_entry(name: &str) -> std::io::Result<Entry> {
use rustix::fs::{Mode, OFlags};
use rustix::pty::{OpenptFlags, grantpt, openpt, ptsname, unlockpt};
use std::io::Read;
let err = std::io::Error::from;
let master =
openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC).map_err(err)?;
grantpt(&master).map_err(err)?;
unlockpt(&master).map_err(err)?;
let slave_name = ptsname(&master, Vec::new()).map_err(err)?;
let slave = rustix::fs::open(
slave_name.as_c_str(),
OFlags::RDWR | OFlags::NOCTTY,
Mode::empty(),
)
.map_err(err)?;
// Note: we wrap our getent call in a /bin/sh login shell because
// some operating systems (NixOS tested) don't set the PATH for various
// utilities properly until we get a login shell.
let mut cmd = crate::flatpak::FlatpakHostCommand::new(
vec![
"/bin/sh".into(),
"-l".into(),
"-c".into(),
format!("getent passwd {name}"),
],
slave.try_clone()?,
slave.try_clone()?,
slave.try_clone()?,
);
cmd.spawn().map_err(std::io::Error::other)?;
cmd.wait().map_err(std::io::Error::other)?;
// Once started, we can close the child side. We do this after
// wait right now but that is fine too. This lets us read the
// parent and detect EOF.
drop(cmd);
drop(slave);
// Read all of our output (max total size is 1024 bytes, like Zig).
let mut file = std::fs::File::from(master);
let mut output = Vec::new();
let mut buf = [0u8; 1024];
loop {
let n = match file.read(&mut buf) {
Ok(0) => break,
Ok(n) => n,
// EIO is triggered at the end since we closed our child side.
// This is just EOF for this.
Err(e) if e.raw_os_error() == Some(rustix::io::Errno::IO.raw_os_error()) => break,
Err(e) => return Err(e),
};
output.extend_from_slice(&buf[..n]);
if output.len() > buf.len() {
break;
}
}
Ok(parse_getent(&String::from_utf8_lossy(&output)))
}
/// Shell and home are the last two `:`-separated fields of the output.
fn parse_getent(output: &str) -> Entry {
let mut it = output.trim_end_matches([' ', '\r', '\n']).rsplit(':');
let shell = it.next().map(str::to_owned);
let home = it.next().map(str::to_owned);
Entry {
shell,
home,
name: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_getent_output() {
let e = parse_getent("mitchell:x:1000:1000:Mitchell:/home/mitchell:/bin/zsh\r\n");
assert_eq!(e.shell.as_deref(), Some("/bin/zsh"));
assert_eq!(e.home.as_deref(), Some("/home/mitchell"));
assert_eq!(e.name, None);
}
#[test]
fn get_entry() {
// We should be able to get an entry
+46
View File
@@ -64,6 +64,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
let Some(mut exe) = exe else {
return ResourcesDir::default();
};
let exe_start = exe;
// We have an exe path! Climb the tree looking for the terminfo
// bundle as we expect it.
@@ -91,9 +92,41 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
};
}
// Rust-specific: development builds run out of `<workspace>/target`, where
// there is no installed `share/` tree. Fall back to the in-repo
// `<workspace>/resources` directory (it carries the same sentinel,
// `terminfo/x/xterm-ghostty`, see scripts/gen-terminfo.sh) if the binary
// lives inside this workspace.
if debug && let Some(dir) = dev_resources_dir(exe_start) {
return ResourcesDir {
app_path: Some(dir),
host_path: None,
};
}
ResourcesDir::default()
}
/// The workspace root this crate was compiled in (`crates/ghostty-os/../..`).
fn workspace_root() -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
}
/// Returns `<workspace>/resources` if `exe` is inside the workspace this
/// crate was built from and the directory contains our sentinel.
fn dev_resources_dir(exe: &Path) -> Option<String> {
let root = workspace_root().canonicalize().ok()?;
let exe = exe.canonicalize().unwrap_or_else(|_| exe.to_path_buf());
if !exe.starts_with(&root) {
return None;
}
let res = root.join("resources");
SENTINELS
.iter()
.any(|s| res.join(s).exists())
.then(|| res.to_string_lossy().into_owned())
}
/// Little helper to check if the "base/sub/suffix" directory exists and
/// if so return "base/sub". The "suffix" is just used as a way to verify a
/// directory seems roughly right.
@@ -168,6 +201,19 @@ mod tests {
);
}
#[test]
fn dev_fallback_finds_workspace_resources() {
let exe = workspace_root().join("target/debug/ghostty");
let r = resources_dir_impl(true, Some(&exe), None);
assert!(r.app().is_some_and(|d| d.ends_with("/resources")));
// Outside the workspace there is no fallback.
let r = resources_dir_impl(true, Some(Path::new("/nonexistent/bin/x")), None);
assert_eq!(r.app(), None);
// Release builds never use it.
let r = resources_dir_impl(false, Some(&exe), None);
assert_eq!(r.app(), None);
}
#[test]
fn maybe_dir_checks_suffix() {
let td = make_tree();
+7 -244
View File
@@ -1,245 +1,8 @@
use std::io::{self, Write};
//! String decoding/encoding helpers (ZIG/src/os/string_encoding.zig).
//!
//! The implementation lives in `ghostty-vt` (the OSC 133 parser needs it and
//! vt cannot depend on os); this module re-exports it.
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("decode error")]
DecodeError,
#[error(transparent)]
Io(#[from] io::Error),
}
impl PartialEq for Error {
fn eq(&self, other: &Self) -> bool {
matches!((self, other), (Error::DecodeError, Error::DecodeError))
}
}
/// Decode date from the buffer that has been encoded in the same way that
/// `bash`'s `printf %q` encodes a string and write it to the writer. If an
/// error is returned garbage may have been written to the buffer.
pub fn printf_q_decode<W: Write>(writer: &mut W, buf: &[u8]) -> Result<(), Error> {
let data: &[u8] = if buf.starts_with(b"$'") {
// Strip off `$''` quoting.
if buf.len() < 3 || !buf.ends_with(b"'") {
return Err(Error::DecodeError);
}
&buf[2..buf.len() - 1]
} else if buf.starts_with(b"'") {
// Strip off `''` quoting.
if buf.len() < 2 || !buf.ends_with(b"'") {
return Err(Error::DecodeError);
}
&buf[1..buf.len() - 1]
} else {
buf
};
let mut src = 0;
while src < data.len() {
match data[src] {
b'\\' => {
if src + 1 >= data.len() {
return Err(Error::DecodeError);
}
let out = match data[src + 1] {
c @ (b' ' | b'\\' | b'"' | b'\'' | b'$') => c,
b'e' => 0x1b,
b'n' => b'\n',
b'r' => b'\r',
b't' => b'\t',
b'v' => 0x0b,
_ => return Err(Error::DecodeError),
};
writer.write_all(&[out])?;
src += 2;
}
c => {
writer.write_all(&[c])?;
src += 1;
}
}
}
Ok(())
}
/// Decode data from the buffer that has been URL percent encoded and write
/// it to the given buffer. If an error is returned the garbage may have been
/// written to the writer.
pub fn url_percent_decode<W: Write>(writer: &mut W, buf: &[u8]) -> Result<(), Error> {
let mut src = 0;
while src < buf.len() {
match buf[src] {
b'%' => {
if src + 2 >= buf.len() {
return Err(Error::DecodeError);
}
let (Some(hi), Some(lo)) = (hex(buf[src + 1]), hex(buf[src + 2])) else {
return Err(Error::DecodeError);
};
writer.write_all(&[(hi << 4) | lo])?;
src += 3;
}
c => {
writer.write_all(&[c])?;
src += 1;
}
}
}
Ok(())
}
fn hex(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
}
/// Is the given character valid in URI percent encoding?
fn is_valid_char(c: u8) -> bool {
match c {
b' ' | b';' | b'=' => false,
_ => (0x20..=0x7e).contains(&c),
}
}
/// Write data to the writer after URI percent encoding.
pub fn url_percent_encode<W: Write>(writer: &mut W, data: &[u8]) -> io::Result<()> {
for &c in data {
if is_valid_char(c) {
writer.write_all(&[c])?;
} else {
write!(writer, "%{c:02X}")?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn pq(s: &[u8]) -> Result<Vec<u8>, Error> {
let mut w = Vec::new();
printf_q_decode(&mut w, s).map(|()| w)
}
fn pd(s: &[u8]) -> Result<Vec<u8>, Error> {
let mut w = Vec::new();
url_percent_decode(&mut w, s).map(|()| w)
}
#[test]
fn printf_q_1() {
assert_eq!(pq(b"bobr\\ kurwa").unwrap(), b"bobr kurwa");
}
#[test]
fn printf_q_2() {
assert_eq!(pq(b"bobr\\nkurwa").unwrap(), b"bobr\nkurwa");
}
#[test]
fn printf_q_3() {
assert_eq!(pq(b"bobr\\dkurwa"), Err(Error::DecodeError));
}
#[test]
fn printf_q_4() {
assert_eq!(pq(b"bobr kurwa\\"), Err(Error::DecodeError));
}
#[test]
fn printf_q_5() {
assert_eq!(pq(b"$'bobr kurwa'").unwrap(), b"bobr kurwa");
}
#[test]
fn printf_q_6() {
assert_eq!(pq(b"'bobr kurwa'").unwrap(), b"bobr kurwa");
}
#[test]
fn printf_q_7() {
assert_eq!(pq(b"$'bobr kurwa"), Err(Error::DecodeError));
}
#[test]
fn printf_q_8() {
assert_eq!(pq(b"$'"), Err(Error::DecodeError));
}
#[test]
fn printf_q_9() {
assert_eq!(pq(b"'bobr kurwa"), Err(Error::DecodeError));
}
#[test]
fn printf_q_10() {
assert_eq!(pq(b"'"), Err(Error::DecodeError));
}
#[test]
fn printf_q_escapes() {
assert_eq!(
pq(b"\\e\\r\\t\\v\\\\\\\"\\'\\$").unwrap(),
b"\x1b\r\t\x0b\\\"'$"
);
}
#[test]
fn singles_percent() {
for c in 0..255u32 {
let lower = format!("%{c:02x}");
let decoded = pd(lower.as_bytes()).unwrap();
assert_eq!(decoded, [c as u8]);
let upper = format!("%{c:02X}");
let decoded = pd(upper.as_bytes()).unwrap();
assert_eq!(decoded, [c as u8]);
}
}
#[test]
fn percent_1() {
assert_eq!(pd(b"bobr%20kurwa").unwrap(), b"bobr kurwa");
}
#[test]
fn percent_2() {
assert_eq!(pd(b"bobr%2kurwa"), Err(Error::DecodeError));
}
#[test]
fn percent_3() {
assert_eq!(pd(b"bobr%kurwa"), Err(Error::DecodeError));
}
#[test]
fn percent_4() {
assert_eq!(pd(b"bobr%%kurwa"), Err(Error::DecodeError));
}
#[test]
fn percent_5() {
assert_eq!(pd(b"bobr%20kurwa%20").unwrap(), b"bobr kurwa ");
}
#[test]
fn percent_6() {
assert_eq!(pd(b"bobr%20kurwa%2"), Err(Error::DecodeError));
}
#[test]
fn percent_7() {
assert_eq!(pd(b"bobr%20kurwa%"), Err(Error::DecodeError));
}
#[test]
fn percent_encode() {
let mut w = Vec::new();
url_percent_encode(&mut w, b"a b;c=d\n\xc3\xa9~").unwrap();
assert_eq!(String::from_utf8(w).unwrap(), "a%20b%3Bc%3Dd%0A%C3%A9~");
}
}
pub use ghostty_vt::string_encoding::{
Error, printf_q_decode, url_percent_decode, url_percent_encode,
};
@@ -0,0 +1,6 @@
//! Prints the xterm-ghostty terminfo source (Zig: `ghostty +terminfo` in
//! `main_build_data.zig`). Used by `scripts/gen-terminfo.sh`.
fn main() {
print!("{}", ghostty_termio::terminfo::ghostty.encode_to_string());
}
+1
View File
@@ -17,6 +17,7 @@ pub mod parser;
pub mod sgr;
pub mod simd;
pub mod stream;
pub mod string_encoding;
pub mod tmux;
pub mod utf8_decoder;
pub mod x11_color;
@@ -4,19 +4,11 @@
//! <https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md>
use crate::osc::{Command as OscCommand, Parser, State};
use crate::string_encoding::{printf_q_decode, url_percent_decode};
/// Error returned when decoding a command line fails (Zig
/// `error.DecodeError`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DecodeError;
impl std::fmt::Display for DecodeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("decode error")
}
}
impl std::error::Error for DecodeError {}
pub type DecodeError = crate::string_encoding::Error;
/// A single semantic prompt command.
///
@@ -121,11 +113,6 @@ impl Command {
/// Write the decoded command line (if any) to the writer. If an error
/// occurs garbage may have been written to the writer.
///
/// TODO(integration): uses local copies of `printfQDecode` /
/// `urlPercentDecode` (ZIG/src/os/string_encoding.zig); switch to
/// `ghostty-os` once available (that crate would need to be a dependency
/// of ghostty-vt, so these remain local for now).
pub fn write_command_line(&self, writer: &mut Vec<u8>) -> Result<(), DecodeError> {
if let Some(OptionValue::Bytes(command_line)) = self.read_option(OptionKey::Cmdline) {
return printf_q_decode(writer, command_line);
@@ -416,91 +403,6 @@ pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<OscComma
Some(OscCommand::SemanticPrompt(cmd))
}
// TODO(integration): local copies of ZIG/src/os/string_encoding.zig.
/// Decode data that has been encoded in the same way that `bash`'s
/// `printf %q` encodes a string and write it to the writer. If an error is
/// returned garbage may have been written to the buffer.
pub fn printf_q_decode(writer: &mut Vec<u8>, buf: &[u8]) -> Result<(), DecodeError> {
let data: &[u8] = if buf.starts_with(b"$'") {
// Strip off `$''` quoting.
if buf.len() < 3 || !buf.ends_with(b"'") {
return Err(DecodeError);
}
&buf[2..buf.len() - 1]
} else if buf.starts_with(b"'") {
// Strip off `''` quoting.
if buf.len() < 2 || !buf.ends_with(b"'") {
return Err(DecodeError);
}
&buf[1..buf.len() - 1]
} else {
buf
};
let mut src = 0;
while src < data.len() {
match data[src] {
b'\\' => {
if src + 1 >= data.len() {
return Err(DecodeError);
}
match data[src + 1] {
c @ (b' ' | b'\\' | b'"' | b'\'' | b'$') => writer.push(c),
b'e' => writer.push(0x1b),
b'n' => writer.push(b'\n'),
b'r' => writer.push(b'\r'),
b't' => writer.push(b'\t'),
b'v' => writer.push(0x0b),
_ => return Err(DecodeError),
}
src += 2;
}
c => {
writer.push(c);
src += 1;
}
}
}
Ok(())
}
/// Decode data that has been URL percent encoded and write it to the given
/// buffer. If an error is returned garbage may have been written.
pub fn url_percent_decode(writer: &mut Vec<u8>, buf: &[u8]) -> Result<(), DecodeError> {
fn hex(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
}
let mut src = 0;
while src < buf.len() {
match buf[src] {
b'%' => {
if src + 2 >= buf.len() {
return Err(DecodeError);
}
match (hex(buf[src + 1]), hex(buf[src + 2])) {
(Some(a), Some(b)) => {
writer.push((a << 4) | b);
src += 3;
}
_ => return Err(DecodeError),
}
}
c => {
writer.push(c);
src += 1;
}
}
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
@@ -531,7 +433,7 @@ mod tests {
}
fn cmdline_err(input: &str) {
assert_eq!(cmdline(input).1, Err(DecodeError));
assert_eq!(cmdline(input).1, Err(DecodeError::DecodeError));
}
#[test]
@@ -980,19 +882,4 @@ mod tests {
assert!(r("abc").is_none());
assert_eq!(r("127;aid=foo"), Some(OptionValue::Int(127)));
}
#[test]
fn string_encoding_extra_printf_q_and_percent() {
let mut w = Vec::new();
printf_q_decode(&mut w, b"bobr\\ kurwa").unwrap();
assert_eq!(w, b"bobr kurwa");
assert_eq!(
printf_q_decode(&mut Vec::new(), b"bobr\\dkurwa"),
Err(DecodeError)
);
assert_eq!(
printf_q_decode(&mut Vec::new(), b"bobr kurwa\\"),
Err(DecodeError)
);
}
}
+250
View File
@@ -0,0 +1,250 @@
//! String decoding/encoding helpers (ZIG/src/os/string_encoding.zig).
//!
//! Canonical implementation; `ghostty-os::string_encoding` re-exports it.
use std::io::{self, Write};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("decode error")]
DecodeError,
#[error(transparent)]
Io(#[from] io::Error),
}
impl PartialEq for Error {
fn eq(&self, other: &Self) -> bool {
matches!((self, other), (Error::DecodeError, Error::DecodeError))
}
}
/// Decode date from the buffer that has been encoded in the same way that
/// `bash`'s `printf %q` encodes a string and write it to the writer. If an
/// error is returned garbage may have been written to the buffer.
pub fn printf_q_decode<W: Write>(writer: &mut W, buf: &[u8]) -> Result<(), Error> {
let data: &[u8] = if buf.starts_with(b"$'") {
// Strip off `$''` quoting.
if buf.len() < 3 || !buf.ends_with(b"'") {
return Err(Error::DecodeError);
}
&buf[2..buf.len() - 1]
} else if buf.starts_with(b"'") {
// Strip off `''` quoting.
if buf.len() < 2 || !buf.ends_with(b"'") {
return Err(Error::DecodeError);
}
&buf[1..buf.len() - 1]
} else {
buf
};
let mut src = 0;
while src < data.len() {
match data[src] {
b'\\' => {
if src + 1 >= data.len() {
return Err(Error::DecodeError);
}
let out = match data[src + 1] {
c @ (b' ' | b'\\' | b'"' | b'\'' | b'$') => c,
b'e' => 0x1b,
b'n' => b'\n',
b'r' => b'\r',
b't' => b'\t',
b'v' => 0x0b,
_ => return Err(Error::DecodeError),
};
writer.write_all(&[out])?;
src += 2;
}
c => {
writer.write_all(&[c])?;
src += 1;
}
}
}
Ok(())
}
/// Decode data from the buffer that has been URL percent encoded and write
/// it to the given buffer. If an error is returned the garbage may have been
/// written to the writer.
pub fn url_percent_decode<W: Write>(writer: &mut W, buf: &[u8]) -> Result<(), Error> {
let mut src = 0;
while src < buf.len() {
match buf[src] {
b'%' => {
if src + 2 >= buf.len() {
return Err(Error::DecodeError);
}
let (Some(hi), Some(lo)) = (hex(buf[src + 1]), hex(buf[src + 2])) else {
return Err(Error::DecodeError);
};
writer.write_all(&[(hi << 4) | lo])?;
src += 3;
}
c => {
writer.write_all(&[c])?;
src += 1;
}
}
}
Ok(())
}
fn hex(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
}
/// Is the given character valid in URI percent encoding?
fn is_valid_char(c: u8) -> bool {
match c {
b' ' | b';' | b'=' => false,
_ => (0x20..=0x7e).contains(&c),
}
}
/// Write data to the writer after URI percent encoding.
pub fn url_percent_encode<W: Write>(writer: &mut W, data: &[u8]) -> io::Result<()> {
for &c in data {
if is_valid_char(c) {
writer.write_all(&[c])?;
} else {
write!(writer, "%{c:02X}")?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn pq(s: &[u8]) -> Result<Vec<u8>, Error> {
let mut w = Vec::new();
printf_q_decode(&mut w, s).map(|()| w)
}
fn pd(s: &[u8]) -> Result<Vec<u8>, Error> {
let mut w = Vec::new();
url_percent_decode(&mut w, s).map(|()| w)
}
#[test]
fn printf_q_1() {
assert_eq!(pq(b"bobr\\ kurwa").unwrap(), b"bobr kurwa");
}
#[test]
fn printf_q_2() {
assert_eq!(pq(b"bobr\\nkurwa").unwrap(), b"bobr\nkurwa");
}
#[test]
fn printf_q_3() {
assert_eq!(pq(b"bobr\\dkurwa"), Err(Error::DecodeError));
}
#[test]
fn printf_q_4() {
assert_eq!(pq(b"bobr kurwa\\"), Err(Error::DecodeError));
}
#[test]
fn printf_q_5() {
assert_eq!(pq(b"$'bobr kurwa'").unwrap(), b"bobr kurwa");
}
#[test]
fn printf_q_6() {
assert_eq!(pq(b"'bobr kurwa'").unwrap(), b"bobr kurwa");
}
#[test]
fn printf_q_7() {
assert_eq!(pq(b"$'bobr kurwa"), Err(Error::DecodeError));
}
#[test]
fn printf_q_8() {
assert_eq!(pq(b"$'"), Err(Error::DecodeError));
}
#[test]
fn printf_q_9() {
assert_eq!(pq(b"'bobr kurwa"), Err(Error::DecodeError));
}
#[test]
fn printf_q_10() {
assert_eq!(pq(b"'"), Err(Error::DecodeError));
}
#[test]
fn printf_q_escapes() {
assert_eq!(
pq(b"\\e\\r\\t\\v\\\\\\\"\\'\\$").unwrap(),
b"\x1b\r\t\x0b\\\"'$"
);
}
#[test]
fn singles_percent() {
for c in 0..255u32 {
let lower = format!("%{c:02x}");
let decoded = pd(lower.as_bytes()).unwrap();
assert_eq!(decoded, [c as u8]);
let upper = format!("%{c:02X}");
let decoded = pd(upper.as_bytes()).unwrap();
assert_eq!(decoded, [c as u8]);
}
}
#[test]
fn percent_1() {
assert_eq!(pd(b"bobr%20kurwa").unwrap(), b"bobr kurwa");
}
#[test]
fn percent_2() {
assert_eq!(pd(b"bobr%2kurwa"), Err(Error::DecodeError));
}
#[test]
fn percent_3() {
assert_eq!(pd(b"bobr%kurwa"), Err(Error::DecodeError));
}
#[test]
fn percent_4() {
assert_eq!(pd(b"bobr%%kurwa"), Err(Error::DecodeError));
}
#[test]
fn percent_5() {
assert_eq!(pd(b"bobr%20kurwa%20").unwrap(), b"bobr kurwa ");
}
#[test]
fn percent_6() {
assert_eq!(pd(b"bobr%20kurwa%2"), Err(Error::DecodeError));
}
#[test]
fn percent_7() {
assert_eq!(pd(b"bobr%20kurwa%"), Err(Error::DecodeError));
}
#[test]
fn percent_encode() {
let mut w = Vec::new();
url_percent_encode(&mut w, b"a b;c=d\n\xc3\xa9~").unwrap();
assert_eq!(String::from_utf8(w).unwrap(), "a%20b%3Bc%3Dd%0A%C3%A9~");
}
}
+36 -12
View File
@@ -10,29 +10,45 @@ use std::sync::OnceLock;
/// licensed under the same license as Ghostty.
const DATA: &str = include_str!("res/rgb.txt");
/// Parse rgb.txt into `(name with original case, value)` entries in file
/// order. This is not very robust parsing, because we expect a very exact
/// format for rgb.txt.
fn parse_entries() -> impl Iterator<Item = (&'static str, Rgb)> {
DATA.split('\n').filter(|l| !l.is_empty()).map(|line| {
let r: u8 = line[0..3].trim_matches(' ').parse().expect("bad rgb.txt");
let g: u8 = line[4..7].trim_matches(' ').parse().expect("bad rgb.txt");
let b: u8 = line[8..11].trim_matches(' ').parse().expect("bad rgb.txt");
let name = line[12..].trim_matches(|c| c == ' ' || c == '\t' || c == '\n');
(name, Rgb { r, g, b })
})
}
/// The map of all available X11 colors. Keys are lowercase ASCII; lookups
/// are ASCII case-insensitive (see [`get`]).
fn map() -> &'static HashMap<String, Rgb> {
static MAP: OnceLock<HashMap<String, Rgb>> = OnceLock::new();
MAP.get_or_init(|| {
let mut m = HashMap::new();
// Parse the line. This is not very robust parsing, because we expect
// a very exact format for rgb.txt.
for line in DATA.split('\n') {
if line.is_empty() {
continue;
}
let r: u8 = line[0..3].trim_matches(' ').parse().expect("bad rgb.txt");
let g: u8 = line[4..7].trim_matches(' ').parse().expect("bad rgb.txt");
let b: u8 = line[8..11].trim_matches(' ').parse().expect("bad rgb.txt");
let name = line[12..].trim_matches(|c| c == ' ' || c == '\t' || c == '\n');
m.entry(name.to_ascii_lowercase())
.or_insert(Rgb { r, g, b });
for (name, rgb) in parse_entries() {
m.entry(name.to_ascii_lowercase()).or_insert(rgb);
}
m
})
}
/// All X11 color names with their original spelling (e.g. `AliceBlue`,
/// `alice blue`) and values, in rgb.txt order. Like the case-insensitive
/// map, the first spelling of a name wins.
pub fn names() -> &'static [(&'static str, Rgb)] {
static NAMES: OnceLock<Vec<(&'static str, Rgb)>> = OnceLock::new();
NAMES.get_or_init(|| {
let mut seen = std::collections::HashSet::new();
parse_entries()
.filter(|(n, _)| seen.insert(n.to_ascii_lowercase()))
.collect()
})
}
/// Look up an X11 color by name (ASCII case-insensitive).
pub fn get(name: &str) -> Option<Rgb> {
map().get(&name.to_ascii_lowercase()).copied()
@@ -48,6 +64,14 @@ mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn names_keep_original_case() {
let n = names();
assert!(n.iter().any(|(name, _)| *name == "AliceBlue"));
assert!(n.iter().any(|(name, _)| *name == "alice blue"));
assert!(n.iter().all(|(name, rgb)| get(name) == Some(*rgb)));
}
#[test]
fn x11_color_map() {
assert_eq!(None, get("nosuchcolor"));