Files
ghostty-rs/crates/ghostty-os/src/i18n.rs
T
ddidderr f8c5ce06d6 feat(os): port Ghostty's Linux OS helpers
Ports ZIG/src/os for Linux into ghostty-os: XDG dirs, home dir, hostname,
env/PATH helpers, passwd lookup (via nix), shell detection, resources
dir discovery, string encodings (printf-q, URL percent), systemd
sd_notify, i18n via gettextrs, xdg-open, desktop/cgroup/kernel info,
temp dirs, URI parsing (port of std.Uri incl. Ghostty's extra options),
and a zbus-based Flatpak HostCommand implementation.

The crate stays free of unsafe. Consequences, all tracked as
TODO(integration) in docs/rewrite/notes/ghostty-os.md:
- locale::ensure_locale is a no-op (setlocale/setenv are unsafe; GTK
  calls setlocale itself); setenv/unsetenv are not exposed.
- passwd's Flatpak branch needs the termio pty and falls back to the
  sandbox entry for now.
- FlatpakHostCommand's D-Bus path is untested (no portal here).
Intentional fix: a leading '@' in NOTIFY_SOCKET is treated as an
abstract socket.

macOS/Windows/WASM modules are out of scope.

Test Plan:
- cargo test -p ghostty-os (88 tests)
- cargo clippy -p ghostty-os --all-targets -- -D warnings
2026-10-11 21:54:11 +02:00

195 lines
6.2 KiB
Rust

use std::path::Path;
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.
pub fn init(resources_dir: &str) -> 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 = Path::new(resources_dir)
.parent()
.filter(|p| !p.as_os_str().is_empty())
.ok_or(InitError::InvalidResourcesDir)?;
// Build our locale path
let path = format!("{}/locale", share_dir.display());
// Bind our bundle ID to the given locale path
log::debug!("binding domain={BUNDLE_ID} path={path}");
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> {
gettextrs::textdomain(BUNDLE_ID).map_err(|_| InitError::OutOfMemory)?;
Ok(())
}
/// Translate a message for the Ghostty domain (Zig: `_`).
pub fn tr(msgid: &str) -> String {
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` (only linked when building libintl
/// from source, i.e. macOS). That function isn't available here, so this is
/// a pure-Rust approximation of the cases that matter: BCP-47 style tags
/// have `-` replaced with `_` and the Chinese script tags are mapped to
/// their POSIX region equivalents. 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();
}
// Emulate gnulib's mapping of bare zh script tags.
match locale {
"zh-Hans" => return "zh_CN".to_owned(),
"zh-Hant" => return "zh_TW".to_owned(),
_ => {}
}
locale.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 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("ghostty"),
Err(InitError::InvalidResourcesDir)
));
assert!(matches!(init("/"), Err(InitError::InvalidResourcesDir)));
}
#[test]
fn tr_falls_back_to_msgid() {
assert_eq!(tr("some untranslated message"), "some untranslated message");
}
}