Escaped config strings (ghostty_os::osbytes) kept reaching OS boundaries undecoded, and decoding was occasionally applied to plain text. The root cause was that both were plain `String`s. Now: - `EncodedOs` is a newtype (no Display/AsRef<Path>) that decodes only via to_os/to_path/to_bytes/to_wide; plain text enters via from_plain_text. OS helpers (env, home, xdg, passwd, resources dir, path expansion), config::Path, WorkingDirectory, theme/file_load/edit paths, terminal pwd, OpenUrl/Pwd actions and termio cwd use it, so the compiler forces decoding at every boundary. os::open takes &OsStr. Config output is unchanged. Fixes all rows of docs/rewrite/notes/review-rv5-codex.md (final-Z7.md): screen-file copy; regex/OSC 8 URLs no longer decoded (regression); lossless Windows WTF-8/WTF-16 for args, env and CreateProcessW; Windows resources dir/TERMINFO; color OSC request lists and X11 color lookup no longer allocate globally (C zero-allocation test extended); macOS Mach handler resumes all threads before KERN_FAILURE (hang regression) and SIGABRT minidumps go through the exception thread; plus open-config, background images, crash dir, ssh cache, exe dir/PATH, fontconfig filenames, file links, kitty TMPDIR, .hushlogin and benchmark paths. The typing also surfaced and fixed gtk-custom-css, bell audio, bash HISTFILE, OSC 52 writes and the C config getters. Test Plan: - cargo test --workspace (3376 passed) - just check-macos (both), just check-windows, just check-wasm - diff <(ghostty-rs +show-config --default --docs) \ <(ghostty +show-config --default --docs)
264 lines
9.1 KiB
Rust
264 lines
9.1 KiB
Rust
use crate::i18n_locales::LOCALES;
|
|
|
|
/// Application bundle ID, used as the gettext domain
|
|
/// (`build_config.bundle_id`).
|
|
// TODO(integration): take from a shared build-config crate.
|
|
pub const BUNDLE_ID: &str = "com.mitchellh.ghostty";
|
|
|
|
/// Membership test for supported locales (Zig: `locales_map`).
|
|
pub fn is_supported_locale(locale: &str) -> bool {
|
|
LOCALES.contains(&locale)
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum InitError {
|
|
#[error("invalid resources dir")]
|
|
InvalidResourcesDir,
|
|
#[error("out of memory")]
|
|
OutOfMemory,
|
|
}
|
|
|
|
/// Initialize i18n support for the application. This should be
|
|
/// called automatically by the global state initialization.
|
|
///
|
|
/// This calls `bindtextdomain` for gettext with the proper directory
|
|
/// of translations. This does NOT call `textdomain` as we don't
|
|
/// want to set the domain for the entire application since this is also
|
|
/// used by libghostty.
|
|
#[cfg(windows)]
|
|
#[allow(clippy::unnecessary_wraps)]
|
|
pub fn init(_resources_dir: &std::path::Path) -> Result<(), InitError> {
|
|
// i18n is unsupported on Windows
|
|
Ok(())
|
|
}
|
|
|
|
/// See the Windows variant above.
|
|
#[cfg(not(windows))]
|
|
pub fn init(resources_dir: &std::path::Path) -> Result<(), InitError> {
|
|
// Our resources dir is always nested below the share dir that
|
|
// is standard for translations.
|
|
// (`std.fs.path.dirname` returns null for a bare relative name, where
|
|
// `Path::parent` returns an empty path.)
|
|
let share_dir = resources_dir
|
|
.parent()
|
|
.filter(|p| !p.as_os_str().is_empty())
|
|
.ok_or(InitError::InvalidResourcesDir)?;
|
|
|
|
// Build our locale path
|
|
let path = share_dir.join("locale");
|
|
|
|
// Bind our bundle ID to the given locale path (gettextrs takes a
|
|
// `Into<PathBuf>`, so non-UTF-8 bytes are preserved).
|
|
log::debug!("binding domain={BUNDLE_ID} path={}", path.display());
|
|
gettextrs::bindtextdomain(BUNDLE_ID, path).map_err(|_| InitError::OutOfMemory)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Set the global gettext domain to our bundle ID, allowing unqualified
|
|
/// `gettext` calls to look up translations for our application.
|
|
///
|
|
/// This should only be called for apprts that are fully owning the
|
|
/// Ghostty application. This should not be called for libghostty users.
|
|
pub fn init_global_domain() -> Result<(), InitError> {
|
|
#[cfg(not(windows))]
|
|
gettextrs::textdomain(BUNDLE_ID).map_err(|_| InitError::OutOfMemory)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Translate a message for the Ghostty domain (Zig: `_`).
|
|
pub fn tr(msgid: &str) -> String {
|
|
#[cfg(windows)]
|
|
return msgid.to_owned();
|
|
#[cfg(not(windows))]
|
|
gettextrs::dgettext(BUNDLE_ID, msgid)
|
|
}
|
|
|
|
/// Canonicalize a locale name from a platform-specific value to
|
|
/// a POSIX-compliant value.
|
|
///
|
|
/// The Zig version is a thin layer over gnulib's
|
|
/// `_libintl_locale_name_canonicalize` (the Mac OS X flavour of
|
|
/// `gl_locale_name_canonicalize`), after fixing up some `zh` locales. This
|
|
/// ports that function exactly, including its legacy-name, language-tag and
|
|
/// script tables ([`crate::i18n_canon_table`]). Encodings are NOT parsed out
|
|
/// (like the original, `en_US.UTF-8` becomes `en_US.UTF_8`).
|
|
pub fn canonicalize_locale(locale: &str) -> String {
|
|
// Fix zh locales
|
|
if let Some(fixed) = fix_zh_locale(locale) {
|
|
return fixed.to_owned();
|
|
}
|
|
|
|
libintl_locale_name_canonicalize(locale)
|
|
}
|
|
|
|
/// `gl_locale_name_canonicalize` (gnulib `localename-unsafe.c`).
|
|
fn libintl_locale_name_canonicalize(name: &str) -> String {
|
|
use crate::i18n_canon_table::{LANGTAG_TABLE, LEGACY_TABLE, SCRIPT_TABLE};
|
|
|
|
// Step 1: Convert using legacy_table.
|
|
if name.as_bytes().first().is_some_and(u8::is_ascii_uppercase)
|
|
&& let Some((_, unixy)) = LEGACY_TABLE.iter().find(|(l, _)| *l == name)
|
|
{
|
|
return (*unixy).to_owned();
|
|
}
|
|
|
|
// Step 2: Convert using langtag_table and script_table.
|
|
if name.len() == 7 && name.as_bytes()[2] == b'-' {
|
|
if let Some((_, unixy)) = LANGTAG_TABLE.iter().find(|(l, _)| *l == name) {
|
|
return (*unixy).to_owned();
|
|
}
|
|
|
|
// `name.get(3..)` is None only when byte 3 is not a char boundary,
|
|
// in which case no script entry can match.
|
|
if let Some(script) = name.get(3..)
|
|
&& let Some((_, unixy)) = SCRIPT_TABLE.iter().find(|(s, _)| *s == script)
|
|
{
|
|
// name[2] = '@'; strcpy (name + 3, unixy)
|
|
return format!("{}@{unixy}", &name[..2]);
|
|
}
|
|
}
|
|
|
|
// Step 3: Convert new-style dash to Unix underscore.
|
|
name.replace('-', "_")
|
|
}
|
|
|
|
/// Handles some zh locales canonicalization because internal libintl
|
|
/// canonicalization function doesn't handle correctly in these cases.
|
|
fn fix_zh_locale(locale: &str) -> Option<&'static str> {
|
|
let mut it = locale.split('-');
|
|
let name = it.next()?;
|
|
if name != "zh" {
|
|
return None;
|
|
}
|
|
|
|
let script = it.next()?;
|
|
let region = it.next()?;
|
|
|
|
if script == "Hans" {
|
|
if region == "SG" {
|
|
return Some("zh_SG");
|
|
}
|
|
return Some("zh_CN");
|
|
}
|
|
|
|
if script == "Hant" {
|
|
if region == "MO" {
|
|
return Some("zh_MO");
|
|
}
|
|
if region == "HK" {
|
|
return Some("zh_HK");
|
|
}
|
|
return Some("zh_TW");
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// This can be called at any point a compile-time-known locale is
|
|
/// available. In a `const` context this verifies the locale is supported
|
|
/// (panics at compile time otherwise).
|
|
pub const fn static_locale(v: &'static str) -> &'static str {
|
|
let vb = v.as_bytes();
|
|
let mut i = 0;
|
|
while i < LOCALES.len() {
|
|
let lb = LOCALES[i].as_bytes();
|
|
if lb.len() == vb.len() {
|
|
let mut j = 0;
|
|
let mut eq = true;
|
|
while j < lb.len() {
|
|
if lb[j] != vb[j] {
|
|
eq = false;
|
|
break;
|
|
}
|
|
j += 1;
|
|
}
|
|
if eq {
|
|
return LOCALES[i];
|
|
}
|
|
}
|
|
i += 1;
|
|
}
|
|
panic!("unsupported locale");
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// The Zig test is darwin-only; the canonicalization here is portable.
|
|
#[test]
|
|
fn canonicalize_locale_darwin() {
|
|
assert_eq!("en_US", canonicalize_locale("en_US"));
|
|
assert_eq!("zh_CN", canonicalize_locale("zh-Hans"));
|
|
assert_eq!("zh_TW", canonicalize_locale("zh-Hant"));
|
|
|
|
assert_eq!("zh_CN", canonicalize_locale("zh-Hans-CN"));
|
|
assert_eq!("zh_SG", canonicalize_locale("zh-Hans-SG"));
|
|
assert_eq!("zh_TW", canonicalize_locale("zh-Hant-TW"));
|
|
assert_eq!("zh_HK", canonicalize_locale("zh-Hant-HK"));
|
|
assert_eq!("zh_MO", canonicalize_locale("zh-Hant-MO"));
|
|
|
|
// This is just an edge case I want to make sure we're aware of:
|
|
// canonicalizeLocale does not handle encodings and will turn them into
|
|
// underscores. We should parse them out before calling this function.
|
|
assert_eq!("en_US.UTF_8", canonicalize_locale("en_US.UTF-8"));
|
|
}
|
|
|
|
#[test]
|
|
fn canonicalize_locale_libintl_tables() {
|
|
// langtag_table
|
|
assert_eq!("pa_IN", canonicalize_locale("pa-Guru"));
|
|
assert_eq!("pa_PK", canonicalize_locale("pa-Arab"));
|
|
assert_eq!("az", canonicalize_locale("az-Latn"));
|
|
assert_eq!("sr", canonicalize_locale("sr-Cyrl"));
|
|
assert_eq!("kk", canonicalize_locale("kk-Cyrl"));
|
|
assert_eq!("ga", canonicalize_locale("ga-dots"));
|
|
// 8 bytes long: the `strlen == 7` guard skips the tables (upstream quirk).
|
|
assert_eq!("yue_Hans", canonicalize_locale("yue-Hans"));
|
|
// script_table (name[2] = '@')
|
|
assert_eq!("sr@latin", canonicalize_locale("sr-Latn"));
|
|
assert_eq!("uz@arabic", canonicalize_locale("uz-Arab"));
|
|
assert_eq!("mn@mongolian", canonicalize_locale("mn-Mong"));
|
|
// legacy_table (only for names starting uppercase)
|
|
assert_eq!("pt_BR", canonicalize_locale("Brazilian Portuguese"));
|
|
assert_eq!("zh_TW", canonicalize_locale("Traditional Chinese"));
|
|
assert_eq!("de", canonicalize_locale("German"));
|
|
assert_eq!("yi", canonicalize_locale("Yiddish"));
|
|
assert_eq!("english", canonicalize_locale("english"));
|
|
// dash fallback
|
|
assert_eq!("pt_BR", canonicalize_locale("pt-BR"));
|
|
assert_eq!("en_US", canonicalize_locale("en-US"));
|
|
// non-ASCII input of length 7 must not panic
|
|
assert_eq!(
|
|
"\u{e9}\u{e9}\u{e9}x",
|
|
canonicalize_locale("\u{e9}\u{e9}\u{e9}x")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn locales_map_and_static_locale() {
|
|
assert!(is_supported_locale("zh_CN"));
|
|
assert!(is_supported_locale("kk"));
|
|
assert!(!is_supported_locale("xx"));
|
|
const L: &str = static_locale("de");
|
|
assert_eq!(L, "de");
|
|
}
|
|
|
|
#[test]
|
|
fn init_rejects_dirless_resources_dir() {
|
|
assert!(matches!(
|
|
init(std::path::Path::new("ghostty")),
|
|
Err(InitError::InvalidResourcesDir)
|
|
));
|
|
assert!(matches!(
|
|
init(std::path::Path::new("/")),
|
|
Err(InitError::InvalidResourcesDir)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn tr_falls_back_to_msgid() {
|
|
assert_eq!(tr("some untranslated message"), "some untranslated message");
|
|
}
|
|
}
|