diff --git a/cmake/Rust.cmake b/cmake/Rust.cmake
index 5d24905f9..45cf1c882 100644
--- a/cmake/Rust.cmake
+++ b/cmake/Rust.cmake
@@ -74,6 +74,7 @@ corrosion_set_env_vars(${fish_rust_target}
     "DATADIR=${CMAKE_INSTALL_FULL_DATADIR}"
     "SYSCONFDIR=${CMAKE_INSTALL_FULL_SYSCONFDIR}"
     "BINDIR=${CMAKE_INSTALL_FULL_BINDIR}"
+    "LOCALEDIR=${CMAKE_INSTALL_FULL_LOCALEDIR}"
 )
 
 # this needs an extra fish-rust due to the poor source placement
diff --git a/fish-rust/build.rs b/fish-rust/build.rs
index 7940d01fd..e16a17492 100644
--- a/fish-rust/build.rs
+++ b/fish-rust/build.rs
@@ -4,7 +4,7 @@ use std::error::Error;
 use std::process::Stdio;
 
 fn main() {
-    for key in ["DOCDIR", "DATADIR", "SYSCONFDIR", "BINDIR"] {
+    for key in ["DOCDIR", "DATADIR", "SYSCONFDIR", "BINDIR", "LOCALEDIR"] {
         if let Ok(val) = env::var(key) {
             // Forward some CMake config
             println!("cargo:rustc-env={key}={val}");
diff --git a/fish-rust/src/complete.rs b/fish-rust/src/complete.rs
index 712f49e84..4bc564477 100644
--- a/fish-rust/src/complete.rs
+++ b/fish-rust/src/complete.rs
@@ -1,4 +1,5 @@
 use std::{
+    borrow::Cow,
     cmp::Ordering,
     collections::{BTreeMap, HashMap, HashSet},
     mem,
@@ -80,11 +81,11 @@ fn C_(s: &wstr) -> &'static wstr {
     if s.is_empty() {
         L!("")
     } else {
-        wgettext_impl_do_not_use_directly(
+        wgettext_impl_do_not_use_directly(Cow::Owned(
             U32CString::from_ustr(s)
                 .expect("translation string without NUL bytes")
-                .as_slice_with_nul(),
-        )
+                .into_vec_with_nul(),
+        ))
     }
 }
 
diff --git a/fish-rust/src/ffi.rs b/fish-rust/src/ffi.rs
index 8f18b341f..11abd87c9 100644
--- a/fish-rust/src/ffi.rs
+++ b/fish-rust/src/ffi.rs
@@ -40,8 +40,6 @@ include_cpp! {
     generate_pod!("wcharz_t")
     generate!("wcstring_list_ffi_t")
 
-    generate!("wgettext_ptr")
-
     generate!("highlight_spec_t")
 
     generate!("rgb_color_t")
diff --git a/fish-rust/src/fish.rs b/fish-rust/src/fish.rs
index 07b0f6bfa..e11421f1d 100644
--- a/fish-rust/src/fish.rs
+++ b/fish-rust/src/fish.rs
@@ -68,7 +68,7 @@ use std::sync::Arc;
 // FIXME: when the crate is actually called fish and not fish-rust, read this from cargo
 // See: https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates
 // for reference
-const PACKAGE_NAME: &str = "fish"; // env!("CARGO_PKG_NAME");
+pub const PACKAGE_NAME: &str = "fish"; // env!("CARGO_PKG_NAME");
 
 // FIXME: the following should just use env!(), this is to make `cargo test` work without CMake for now
 const DOC_DIR: &str = {
diff --git a/fish-rust/src/wutil/gettext.rs b/fish-rust/src/wutil/gettext.rs
index 1ee33e23e..175690cd6 100644
--- a/fish-rust/src/wutil/gettext.rs
+++ b/fish-rust/src/wutil/gettext.rs
@@ -1,32 +1,115 @@
-use crate::ffi;
-use crate::wchar::wstr;
-use crate::wchar_ffi::{wchar_t, wcslen};
+use std::borrow::Cow;
+use std::collections::hash_map::Entry;
+use std::collections::HashMap;
+use std::ffi::CString;
+use std::pin::Pin;
+use std::sync::Mutex;
+
+use crate::common::{charptr2wcstring, wcs2zstring};
+use crate::fish::PACKAGE_NAME;
+use crate::wchar::prelude::*;
+use crate::wchar_ffi::wchar_t;
+use errno::{errno, set_errno};
+use once_cell::sync::{Lazy, OnceCell};
 use widestring::U32CString;
 
-/// Support for wgettext.
+#[cfg(feature = "gettext")]
+mod internal {
+    use libc::c_char;
+    use std::ffi::CStr;
+    extern "C" {
+        fn gettext(msgid: *const c_char) -> *mut c_char;
+        fn bindtextdomain(domainname: *const c_char, dirname: *const c_char) -> *mut c_char;
+        fn textdomain(domainname: *const c_char) -> *mut c_char;
+    }
+    pub fn fish_gettext(msgid: &CStr) -> *const c_char {
+        unsafe { gettext(msgid.as_ptr()) }
+    }
+    pub fn fish_bindtextdomain(domainname: &CStr, dirname: &CStr) -> *mut c_char {
+        unsafe { bindtextdomain(domainname.as_ptr(), dirname.as_ptr()) }
+    }
+    pub fn fish_textdomain(domainname: &CStr) -> *mut c_char {
+        unsafe { textdomain(domainname.as_ptr()) }
+    }
+}
+#[cfg(not(feature = "gettext"))]
+mod internal {
+    use libc::c_char;
+    use std::ffi::CStr;
+    pub fn fish_gettext(msgid: &CStr) -> *const c_char {
+        msgid.as_ptr()
+    }
+    pub fn fish_bindtextdomain(_domainname: &CStr, _dirname: &CStr) -> *mut c_char {
+        std::ptr::null_mut()
+    }
+    pub fn fish_textdomain(_domainname: &CStr) -> *mut c_char {
+        std::ptr::null_mut()
+    }
+}
+
+use internal::*;
+
+// Really init wgettext.
+fn wgettext_really_init() {
+    let package_name = CString::new(PACKAGE_NAME).unwrap();
+    let localedir = CString::new(option_env!("LOCALEDIR").unwrap_or("UNDEFINED")).unwrap();
+    fish_bindtextdomain(&package_name, &localedir);
+    fish_textdomain(&package_name);
+}
+
+fn wgettext_init_if_necessary() {
+    static INIT: OnceCell<()> = OnceCell::new();
+    INIT.get_or_init(wgettext_really_init);
+}
 
 /// Implementation detail for wgettext!.
-pub fn wgettext_impl_do_not_use_directly(text: &[wchar_t]) -> &'static wstr {
+/// Wide character wrapper around the gettext function. For historic reasons, unlike the real
+/// gettext function, wgettext takes care of setting the correct domain, etc. using the textdomain
+/// and bindtextdomain functions. This should probably be moved out of wgettext, so that wgettext
+/// will be nothing more than a wrapper around gettext, like all other functions in this file.
+pub fn wgettext_impl_do_not_use_directly(text: Cow<'static, [wchar_t]>) -> &'static wstr {
     assert_eq!(text.last(), Some(&0), "should be nul-terminated");
-    let res: *const wchar_t = ffi::wgettext_ptr(text.as_ptr());
-    #[allow(clippy::unnecessary_cast)]
-    let slice = unsafe { std::slice::from_raw_parts(res as *const u32, wcslen(res)) };
-    wstr::from_slice(slice).expect("Invalid UTF-32")
+    // Preserve errno across this since this is often used in printing error messages.
+    let err = errno();
+
+    wgettext_init_if_necessary();
+    #[allow(clippy::type_complexity)]
+    static WGETTEXT_MAP: Lazy<Mutex<HashMap<Cow<'static, [wchar_t]>, Pin<Box<WString>>>>> =
+        Lazy::new(|| Mutex::new(HashMap::new()));
+    let mut wmap = WGETTEXT_MAP.lock().unwrap();
+    let v = match wmap.entry(text) {
+        Entry::Occupied(v) => Pin::get_ref(Pin::as_ref(v.get())) as *const WString,
+        Entry::Vacant(v) => {
+            let key = wstr::from_slice(v.key()).unwrap();
+            let mbs_in = wcs2zstring(key);
+            let out = fish_gettext(&mbs_in);
+            let out = charptr2wcstring(out);
+            let res = Pin::new(Box::new(out));
+            let value = v.insert(res);
+            Pin::get_ref(Pin::as_ref(value)) as *const WString
+        }
+    };
+
+    set_errno(err);
+
+    // The returned string is stored in the map.
+    // TODO: If we want to shrink the map, this would be a problem.
+    unsafe { v.as_ref().unwrap() }.as_utfstr()
 }
 
 /// Get a (possibly translated) string from a non-literal.
 pub fn wgettext_str(s: &wstr) -> &'static wstr {
     let cstr: U32CString = U32CString::from_chars_truncate(s.as_char_slice());
-    wgettext_impl_do_not_use_directly(cstr.as_slice_with_nul())
+    wgettext_impl_do_not_use_directly(Cow::Owned(cstr.into_vec_with_nul()))
 }
 
 /// Get a (possibly translated) string from a string literal.
 /// This returns a &'static wstr.
 macro_rules! wgettext {
     ($string:expr) => {
-        crate::wutil::gettext::wgettext_impl_do_not_use_directly(
-            crate::wchar_ffi::u32cstr!($string).as_slice_with_nul(),
-        )
+        crate::wutil::gettext::wgettext_impl_do_not_use_directly(std::borrow::Cow::Borrowed(
+            widestring::u32cstr!($string).as_slice_with_nul(),
+        ))
     };
 }
 pub(crate) use wgettext;
@@ -34,9 +117,9 @@ pub(crate) use wgettext;
 /// Like wgettext, but for non-literals.
 macro_rules! wgettext_expr {
     ($string:expr) => {
-        crate::wutil::gettext::wgettext_impl_do_not_use_directly(
-            widestring::U32CString::from_ustr_truncate($string).as_slice_with_nul(),
-        )
+        crate::wutil::gettext::wgettext_impl_do_not_use_directly(std::borrow::Cow::Owned(
+            widestring::U32CString::from_ustr_truncate($string).into_vec_with_nul(),
+        ))
     };
 }
 pub(crate) use wgettext_expr;