Skip to main content

sley_core/
precompose.rs

1//! macOS HFS+/APFS Unicode path precomposition (`core.precomposeunicode`).
2//!
3//! On Apple filesystems, directory entries are typically stored in NFD while
4//! Git wants NFC path names in the index and trees. When
5//! `core.precomposeunicode` is true, Git:
6//!
7//! 1. Converts NFD command-line paths to NFC (`precompose_string_if_needed`)
8//! 2. Converts NFD names from `readdir` to NFC (`precompose_utf8_readdir`)
9//!
10//! This module mirrors that behavior for Sley. Conversion is gated by a
11//! process-wide flag set from the effective repository config (same role as
12//! git's `precomposed_unicode` cache). The flag is shared across threads so
13//! parallel worktree/status workers see the same value as the main thread.
14
15use std::borrow::Cow;
16use std::ffi::OsStr;
17use std::path::{Path, PathBuf};
18use std::sync::atomic::{AtomicBool, Ordering};
19
20use unicode_normalization::UnicodeNormalization;
21
22/// Whether the active repository has `core.precomposeunicode=true`.
23/// Matches git's cached `repo_config_values::precomposed_unicode == 1`.
24/// Process-wide so status/worktree worker threads share the repo-open value.
25static PRECOMPOSE_UNICODE: AtomicBool = AtomicBool::new(false);
26
27/// Enable or disable NFD→NFC path conversion for this process.
28pub fn set_precompose_unicode(enabled: bool) {
29    PRECOMPOSE_UNICODE.store(enabled, Ordering::Relaxed);
30}
31
32/// Whether path precomposition is currently active.
33pub fn precompose_unicode_enabled() -> bool {
34    PRECOMPOSE_UNICODE.load(Ordering::Relaxed)
35}
36
37/// Activate precomposition from a config bool (or `None` → disabled).
38pub fn activate_precompose_unicode(value: Option<bool>) {
39    set_precompose_unicode(value.unwrap_or(false));
40}
41
42/// True if `s` contains any non-ASCII byte (git's `has_non_ascii` path check).
43pub fn has_non_ascii(s: &str) -> bool {
44    s.bytes().any(|b| b >= 0x80)
45}
46
47/// True if `bytes` contains any non-ASCII byte.
48pub fn has_non_ascii_bytes(bytes: &[u8]) -> bool {
49    bytes.iter().any(|&b| b >= 0x80)
50}
51
52/// Convert a UTF-8 string from NFD to NFC when precomposition is active.
53///
54/// Returns the input unchanged when precomposition is off, the string is
55/// ASCII-only, invalid UTF-8 would be required, or NFC equals the input.
56pub fn precompose_string_if_needed(input: &str) -> Cow<'_, str> {
57    if !precompose_unicode_enabled() || !has_non_ascii(input) {
58        return Cow::Borrowed(input);
59    }
60    let nfc: String = input.nfc().collect();
61    if nfc == input {
62        Cow::Borrowed(input)
63    } else {
64        Cow::Owned(nfc)
65    }
66}
67
68/// Convert UTF-8 bytes from NFD to NFC when precomposition is active.
69///
70/// Non-UTF-8 byte sequences are returned unchanged (git leaves illegal
71/// sequences alone rather than dying).
72pub fn precompose_bytes_if_needed(input: &[u8]) -> Cow<'_, [u8]> {
73    if !precompose_unicode_enabled() || !has_non_ascii_bytes(input) {
74        return Cow::Borrowed(input);
75    }
76    let Ok(text) = std::str::from_utf8(input) else {
77        return Cow::Borrowed(input);
78    };
79    match precompose_string_if_needed(text) {
80        Cow::Borrowed(_) => Cow::Borrowed(input),
81        Cow::Owned(nfc) => Cow::Owned(nfc.into_bytes()),
82    }
83}
84
85/// Precompose an owned [`String`] in place when needed.
86pub fn precompose_owned_string_if_needed(input: String) -> String {
87    match precompose_string_if_needed(&input) {
88        Cow::Borrowed(_) => input,
89        Cow::Owned(nfc) => nfc,
90    }
91}
92
93/// Precompose each component of a path when needed (for index / pathspec paths).
94pub fn precompose_path_if_needed(path: &Path) -> Cow<'_, Path> {
95    if !precompose_unicode_enabled() {
96        return Cow::Borrowed(path);
97    }
98    let lossy = path.to_string_lossy();
99    if !has_non_ascii(&lossy) {
100        return Cow::Borrowed(path);
101    }
102    // Normalize each path component independently so separators stay platform-native
103    // in the returned PathBuf while git-path `/` joins still get NFC components.
104    let mut changed = false;
105    let mut out = PathBuf::new();
106    for component in path.components() {
107        match component {
108            std::path::Component::Normal(name) => {
109                let name_str = name.to_string_lossy();
110                match precompose_string_if_needed(&name_str) {
111                    Cow::Borrowed(_) => out.push(name),
112                    Cow::Owned(nfc) => {
113                        changed = true;
114                        out.push(nfc);
115                    }
116                }
117            }
118            other => out.push(other.as_os_str()),
119        }
120    }
121    if changed {
122        Cow::Owned(out)
123    } else {
124        Cow::Borrowed(path)
125    }
126}
127
128/// Precompose an [`OsStr`] component for directory entries / git paths.
129pub fn precompose_os_str_bytes_if_needed(name: &OsStr) -> Cow<'_, [u8]> {
130    #[cfg(unix)]
131    {
132        use std::os::unix::ffi::OsStrExt;
133        precompose_bytes_if_needed(name.as_bytes())
134    }
135    #[cfg(not(unix))]
136    {
137        let owned = name.to_string_lossy().into_owned();
138        match precompose_string_if_needed(&owned) {
139            Cow::Borrowed(_) => Cow::Owned(owned.into_bytes()),
140            Cow::Owned(nfc) => Cow::Owned(nfc.into_bytes()),
141        }
142    }
143}
144
145/// Precompose every string in `args` in place (git's `precompose_argv_prefix`).
146pub fn precompose_argv_if_needed(args: &mut [String]) {
147    if !precompose_unicode_enabled() {
148        return;
149    }
150    for arg in args.iter_mut() {
151        if has_non_ascii(arg) {
152            *arg = precompose_owned_string_if_needed(std::mem::take(arg));
153        }
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use std::sync::{Mutex, OnceLock};
161
162    /// Serialize tests that mutate the process-wide precompose flag so they
163    /// do not race when cargo runs the suite with multiple threads.
164    fn lock_precompose_for_test() -> std::sync::MutexGuard<'static, ()> {
165        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
166        LOCK.get_or_init(|| Mutex::new(()))
167            .lock()
168            .unwrap_or_else(|poisoned| poisoned.into_inner())
169    }
170
171    #[test]
172    fn nfc_conversion_only_when_enabled() {
173        let _guard = lock_precompose_for_test();
174        let nfd = "A\u{0308}"; // A + combining diaeresis
175        let nfc = "\u{00C4}"; // Ä
176
177        set_precompose_unicode(false);
178        assert_eq!(precompose_string_if_needed(nfd).as_ref(), nfd);
179
180        set_precompose_unicode(true);
181        assert_eq!(precompose_string_if_needed(nfd).as_ref(), nfc);
182        assert_eq!(precompose_string_if_needed(nfc).as_ref(), nfc);
183        assert_eq!(precompose_string_if_needed("ascii").as_ref(), "ascii");
184
185        set_precompose_unicode(false);
186    }
187
188    #[test]
189    fn path_components_are_precomposed() {
190        let _guard = lock_precompose_for_test();
191        set_precompose_unicode(true);
192        let nfd = PathBuf::from("d.A\u{0308}/f.A\u{0308}".to_string());
193        let precomposed = precompose_path_if_needed(&nfd);
194        assert_eq!(
195            precomposed.to_string_lossy().as_ref(),
196            "d.\u{00C4}/f.\u{00C4}"
197        );
198        set_precompose_unicode(false);
199    }
200
201    /// Parallel status workers must observe the same flag the parent set at
202    /// repo open (no per-thread re-activation required).
203    #[test]
204    fn worker_thread_sees_parent_set_value() {
205        let _guard = lock_precompose_for_test();
206        set_precompose_unicode(true);
207        let nfd = "A\u{0308}";
208        let nfc = "\u{00C4}";
209
210        let enabled = std::thread::scope(|s| {
211            s.spawn(|| {
212                assert!(precompose_unicode_enabled());
213                assert_eq!(precompose_string_if_needed(nfd).as_ref(), nfc);
214                precompose_unicode_enabled()
215            })
216            .join()
217            .expect("worker join")
218        });
219        assert!(enabled);
220
221        set_precompose_unicode(false);
222        let disabled = std::thread::scope(|s| {
223            s.spawn(|| {
224                assert!(!precompose_unicode_enabled());
225                assert_eq!(precompose_string_if_needed(nfd).as_ref(), nfd);
226                precompose_unicode_enabled()
227            })
228            .join()
229            .expect("worker join")
230        });
231        assert!(!disabled);
232    }
233}