Skip to main content

atuin_common/string/
mod.rs

1//! String-related utilities and extension traits.
2
3use std::borrow::Cow;
4use std::fmt::{self, Write as _};
5
6#[cfg(feature = "unicode")]
7use unicode_width::UnicodeWidthStr;
8use url::{Position, Url};
9
10#[cfg(feature = "unicode")]
11pub mod align;
12pub mod bounded_buffer;
13#[cfg(feature = "unicode")]
14pub mod ellipsis;
15pub mod highlighted;
16pub mod trim;
17
18mod escape_non_printable_posix_ext;
19mod non_blank;
20mod non_nul_str;
21
22#[allow(clippy::manual_range_contains, clippy::must_use_candidate, reason = "vendored file")]
23mod normalize;
24
25#[cfg(feature = "unicode")]
26pub use align::{AlignExt, Alignment};
27pub use bounded_buffer::BoundedBuffer;
28#[cfg(feature = "unicode")]
29pub use ellipsis::EllipsizeExt;
30pub use escape_non_printable_posix_ext::EscapeNonPrintablePosixExt;
31pub use non_blank::{Blank, NonBlank, NonBlankString};
32pub use non_nul_str::{ContainsNul, NonNulStr};
33pub use normalize::normalize;
34pub use trim::TrimExt;
35
36pub trait TruncateCharsExt: AsRef<str> {
37    fn truncate_chars(&self, max_chars: usize) -> &str {
38        let s = self.as_ref();
39        if s.len() <= max_chars {
40            return s;
41        }
42
43        match s.char_indices().nth(max_chars) {
44            Some((end, _)) => &s[..end],
45            None => s,
46        }
47    }
48}
49
50impl<T: AsRef<str> + ?Sized> TruncateCharsExt for T {}
51
52/// Extension trait adding diacritic normalization to string slices.
53pub trait NormalizeDiacriticsExt: AsRef<str> {
54    /// Normalize Latin diacritics to their ASCII equivalents (`é` -> `e`).
55    fn normalize_diacritics(&self) -> Cow<'_, str> {
56        let s = self.as_ref();
57        if s.is_ascii() || !s.chars().any(|c| normalize(c) != c) {
58            return Cow::Borrowed(s);
59        }
60        Cow::Owned(s.chars().map(normalize).collect())
61    }
62}
63
64impl<T: AsRef<str> + ?Sized> NormalizeDiacriticsExt for T {}
65
66/// Extension trait for owned strings providing an empty-string fallback.
67pub trait NonEmptyOrExt: Sized {
68    /// Return the string if it is non-empty, otherwise `value`.
69    #[must_use]
70    fn nonempty_or(self, value: Self) -> Self {
71        self.nonempty_or_else(|| value)
72    }
73
74    /// Same as [`Self::nonempty_or`] but takes a factory function.
75    #[must_use]
76    fn nonempty_or_else(self, default: impl FnOnce() -> Self) -> Self;
77}
78
79impl NonEmptyOrExt for String {
80    fn nonempty_or_else(self, default: impl FnOnce() -> Self) -> Self {
81        if self.is_empty() {
82            default()
83        } else {
84            self
85        }
86    }
87}
88
89impl NonEmptyOrExt for &str {
90    fn nonempty_or_else(self, default: impl FnOnce() -> Self) -> Self {
91        if self.is_empty() {
92            default()
93        } else {
94            self
95        }
96    }
97}
98
99impl<T> NonEmptyOrExt for &[T] {
100    fn nonempty_or_else(self, default: impl FnOnce() -> Self) -> Self {
101        if self.is_empty() {
102            default()
103        } else {
104            self
105        }
106    }
107}
108
109impl<T> NonEmptyOrExt for Vec<T> {
110    fn nonempty_or_else(self, default: impl FnOnce() -> Self) -> Self {
111        if self.is_empty() {
112            default()
113        } else {
114            self
115        }
116    }
117}
118
119/// Extension trait for [`Url`] to render a `Debug` representation with any
120/// password redacted.
121pub trait FormatSafeUrlExt {
122    /// Debug-format the URL with its password replaced by `****`.
123    fn format_safe(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result;
124}
125
126impl FormatSafeUrlExt for Url {
127    fn format_safe(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        if self.password().is_none() {
129            return fmt::Debug::fmt(self.as_str(), f);
130        }
131
132        f.write_char('"')?;
133        for c in self[..Position::BeforePassword].escape_debug() {
134            f.write_char(c)?;
135        }
136        f.write_str("****")?;
137        for c in self[Position::AfterPassword..].escape_debug() {
138            f.write_char(c)?;
139        }
140        f.write_char('"')
141    }
142}
143
144/// How much room to truncate or pad into, and the unit it is measured in.
145#[cfg(feature = "unicode")]
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum Measure {
148    /// A UTF-8 byte budget.
149    Bytes(usize),
150    /// A display-column budget via `unicode-width` - a double-width glyph such
151    /// as `世` or `🦀` counts as two. Use for presentation.
152    Columns(usize),
153}
154
155#[cfg(feature = "unicode")]
156impl Measure {
157    /// The numeric limit, in this budget's own unit.
158    pub(crate) fn amount(self) -> usize {
159        match self {
160            Self::Bytes(n) | Self::Columns(n) => n,
161        }
162    }
163
164    /// Total cost of `s` in this budget's unit.
165    pub(crate) fn cost(self, s: &str) -> usize {
166        match self {
167            Self::Bytes(_) => s.len(),
168            Self::Columns(_) => s.width(),
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use rstest::rstest;
176    use url::Url;
177
178    use super::{FormatSafeUrlExt, NonEmptyOrExt, NormalizeDiacriticsExt, TruncateCharsExt};
179
180    #[rstest]
181    #[case::empty("", "")]
182    #[case::ascii_unchanged("hello world", "hello world")]
183    #[case::accented("café", "cafe")]
184    #[case::keeps_unmappable("naïve Æ", "naive Æ")] // ï -> i, but Æ has no single-ASCII mapping
185    #[case::position_preserved("élève", "eleve")]
186    fn normalizes_diacritics(#[case] input: &str, #[case] expected: &str) {
187        assert_eq!(input.normalize_diacritics(), expected);
188    }
189
190    #[rstest]
191    #[case::empty("")]
192    #[case::plain_ascii("just ascii text")]
193    #[case::unmappable("日本語")]
194    fn normalize_diacritics_borrows_when_unchanged(#[case] input: &str) {
195        assert!(matches!(input.normalize_diacritics(), std::borrow::Cow::Borrowed(_)));
196    }
197
198    #[rstest]
199    #[case::under_budget("hello", 10, "hello")]
200    #[case::exact_budget("hello", 5, "hello")]
201    #[case::over_budget("hello", 3, "hel")] // codespell:ignore hel
202    #[case::zero("hello", 0, "")]
203    #[case::empty("", 5, "")]
204    #[case::multibyte_cut("café", 3, "caf")] // never splits a multibyte char; codespell:ignore caf
205    #[case::multibyte_kept("café", 4, "café")]
206    fn truncates_by_char_count(#[case] input: &str, #[case] max: usize, #[case] expected: &str) {
207        let out = input.truncate_chars(max);
208        assert_eq!(out, expected);
209        assert!(out.chars().count() <= max);
210    }
211
212    #[rstest]
213    #[case::empty("", "fallback")]
214    #[case::non_empty("value", "value")]
215    fn nonempty_or_falls_back_only_when_empty(#[case] input: &str, #[case] expected: &str) {
216        assert_eq!(input.to_string().nonempty_or_else(|| "fallback".into()), expected);
217    }
218
219    #[rstest]
220    fn truncate_chars_returns_the_original_slice_when_it_fits() {
221        let s = "borrow me";
222        assert!(std::ptr::eq(s.truncate_chars(100), s));
223    }
224
225    struct Safe<'a>(&'a Url);
226
227    impl std::fmt::Debug for Safe<'_> {
228        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229            self.0.format_safe(f)
230        }
231    }
232
233    fn safe(url: &str) -> String {
234        format!("{:?}", Safe(&Url::parse(url).unwrap()))
235    }
236
237    #[rstest]
238    #[case::redacts_password(
239        "postgres://user:hunter2@localhost/db",
240        r#""postgres://user:****@localhost/db""#
241    )]
242    #[case::passwordless_unchanged(
243        "postgres://user@localhost/db",
244        r#""postgres://user@localhost/db""#
245    )]
246    #[case::empty_password_dropped("mysql://user:@localhost/db", r#""mysql://user@localhost/db""#)]
247    fn format_safe_redacts(#[case] url: &str, #[case] expected: &str) {
248        assert_eq!(safe(url), expected);
249    }
250}