Skip to main content

atuin_common/string/
trim.rs

1mod sealed {
2    pub trait Sealed {}
3}
4
5/// A pattern that, unlike [`std::str::pattern::Pattern`], does not consume the pattern when used.
6// Because `std::str::pattern::Pattern` is unstable, we cannot implement `PatternRef` in terms of
7// it. Ideally this trait would be very simple -- an associated type `Self::Pattern<'a>` that
8// implements `std::str::pattern::Pattern`, and a method to go from `&'a mut Self` to
9// `Self::Pattern<'a>`. But because the standard library trait is unstable, we need to implement
10// every `Pattern`-accepting `str` method we want to use here.
11pub trait PatternRef: sealed::Sealed {
12    fn trim_start_matches<'a>(&mut self, s: &'a str) -> &'a str;
13    fn trim_end_matches<'a>(&mut self, s: &'a str) -> &'a str;
14}
15
16macro_rules! impl_pattern_ref {
17    ([$($gen:tt)*], $ty:ty, $to_pattern:expr) => {
18        impl<$($gen)*> sealed::Sealed for $ty {}
19
20        impl<$($gen)*> PatternRef for $ty {
21            fn trim_start_matches<'a>(&mut self, s: &'a str) -> &'a str {
22                s.trim_start_matches(($to_pattern)(self))
23            }
24
25            fn trim_end_matches<'a>(&mut self, s: &'a str) -> &'a str {
26                s.trim_end_matches(($to_pattern)(self))
27            }
28        }
29    };
30}
31
32// Implement `PatternRef` for all of the types that implement `Pattern`.
33impl_pattern_ref!([], char, Clone::clone);
34impl_pattern_ref!([const N: usize], [char; N], Clone::clone);
35impl_pattern_ref!([const N: usize], &[char; N], Clone::clone);
36impl_pattern_ref!([], &[char], Clone::clone);
37impl_pattern_ref!([], &str, Clone::clone);
38impl_pattern_ref!([], &&str, Clone::clone);
39impl_pattern_ref!([F: FnMut(char) -> bool], F, std::convert::identity);
40
41pub trait TrimExt {
42    /// Like [`str::trim_matches`], but modifies the [`String`] in-place instead of returning a
43    /// substring.
44    fn trim_matches_in_place<P: PatternRef>(&mut self, pattern: P);
45
46    /// Like [`str::trim_start_matches`], but modifies the [`String`] in-place instead of returning
47    /// a substring.
48    fn trim_start_matches_in_place<P: PatternRef>(&mut self, pattern: P);
49
50    /// Like [`str::trim_end_matches`], but modifies the [`String`] in-place instead of returning a
51    /// substring.
52    fn trim_end_matches_in_place<P: PatternRef>(&mut self, pattern: P);
53
54    /// Like [`str::trim`], but modifies the [`String`] in-place instead of returning a substring.
55    fn trim_in_place(&mut self) {
56        self.trim_matches_in_place(char::is_whitespace);
57    }
58
59    /// Like [`str::trim_start`], but modifies the [`String`] in-place instead of returning a
60    /// substring.
61    fn trim_start_in_place(&mut self) {
62        self.trim_start_matches_in_place(char::is_whitespace);
63    }
64
65    /// Like [`str::trim_end`], but modifies the [`String`] in-place instead of returning a
66    /// substring.
67    fn trim_end_in_place(&mut self) {
68        self.trim_end_matches_in_place(char::is_whitespace);
69    }
70}
71
72fn trim_start_matches_in_place<P: PatternRef>(s: &mut String, pattern: &mut P) {
73    s.drain(..s.len() - pattern.trim_start_matches(s).len());
74}
75
76fn trim_end_matches_in_place<P: PatternRef>(s: &mut String, pattern: &mut P) {
77    s.truncate(pattern.trim_end_matches(s).len());
78}
79
80impl TrimExt for String {
81    fn trim_start_matches_in_place<P: PatternRef>(&mut self, mut pattern: P) {
82        trim_start_matches_in_place(self, &mut pattern);
83    }
84
85    fn trim_end_matches_in_place<P: PatternRef>(&mut self, mut pattern: P) {
86        trim_end_matches_in_place(self, &mut pattern);
87    }
88
89    fn trim_matches_in_place<P: PatternRef>(&mut self, mut pattern: P) {
90        trim_start_matches_in_place(self, &mut pattern);
91        trim_end_matches_in_place(self, &mut pattern);
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use proptest::prelude::*;
98    use rstest::rstest;
99
100    use super::TrimExt;
101
102    /// Run `trim_matches_in_place` over an owned copy of `input`.
103    fn trimmed(input: &str, pattern: impl super::PatternRef) -> String {
104        let mut string = input.to_string();
105        string.trim_matches_in_place(pattern);
106        string
107    }
108
109    #[rstest]
110    #[case::both_ends("xxhixx", "hi")]
111    #[case::leading_only("xxhi", "hi")]
112    #[case::trailing_only("hixx", "hi")]
113    #[case::interior_kept("xhixhix", "hixhi")]
114    #[case::no_match("hi", "hi")]
115    #[case::all_pattern("xxxx", "")]
116    #[case::empty("", "")]
117    #[case::single_char("x", "")]
118    fn trims_a_char_pattern(#[case] input: &str, #[case] expected: &str) {
119        assert_eq!(trimmed(input, 'x'), expected);
120    }
121
122    #[rstest]
123    #[case::blank_lines_and_spaces("\n\n  hi there  \n\n", "hi there")]
124    #[case::mixed_run(" \n \n hi", "hi")]
125    #[case::interior_newline_kept("\none\ntwo\n", "one\ntwo")]
126    fn trims_a_char_array_pattern(#[case] input: &str, #[case] expected: &str) {
127        assert_eq!(trimmed(input, ['\n', ' ']), expected);
128        assert_eq!(trimmed(input, &['\n', ' ']), expected);
129        // A slice, too: `str::trim_matches` accepts one, so `PatternRef` has to as well.
130        assert_eq!(trimmed(input, &['\n', ' '][..]), expected);
131    }
132
133    #[rstest]
134    #[case::str_pattern("abcXabc", "abc", "X")]
135    #[case::repeated_str_pattern("abcabcXabcabc", "abc", "X")]
136    #[case::partial_match_kept("abXab", "abc", "abXab")]
137    fn trims_a_str_pattern(#[case] input: &str, #[case] pattern: &str, #[case] expected: &str) {
138        assert_eq!(trimmed(input, pattern), expected);
139        assert_eq!(trimmed(input, &pattern), expected);
140    }
141
142    #[rstest]
143    #[case::digits("123hi456", "hi")]
144    #[case::only_digits("123", "")]
145    #[case::interior_digits_kept("1h2i3", "h2i")]
146    #[case::nothing_to_trim("hi", "hi")]
147    fn trims_a_closure_pattern(#[case] input: &str, #[case] expected: &str) {
148        assert_eq!(trimmed(input, |c: char| c.is_ascii_digit()), expected);
149    }
150
151    #[rstest]
152    #[case::multibyte_pattern("——hi——", '—', "hi")]
153    #[case::multibyte_content_preserved("xx🦀 世界xx", 'x', "🦀 世界")]
154    #[case::multibyte_content_all_trimmed("🦀🦀", '🦀', "")]
155    fn handles_multibyte_characters(
156        #[case] input: &str,
157        #[case] pattern: char,
158        #[case] expected: &str,
159    ) {
160        assert_eq!(trimmed(input, pattern), expected);
161    }
162
163    #[rstest]
164    #[case::ascii_whitespace(" \t\r\nhi \t\r\n", "hi")]
165    #[case::unicode_whitespace("\u{3000}hi\u{3000}", "hi")]
166    #[case::interior_kept("  a b  ", "a b")]
167    #[case::nothing_to_trim("hi", "hi")]
168    fn trim_in_place_matches_str_trim(#[case] input: &str, #[case] expected: &str) {
169        let mut string = input.to_string();
170        string.trim_in_place();
171        assert_eq!(string, expected);
172        assert_eq!(string, input.trim());
173    }
174
175    // -- One end at a time ----------------------------------------------------
176    //
177    // A split capture trims its two halves differently: leading blank lines come off the start
178    // chunk and trailing ones off the end chunk, but neither may lose the newlines facing the
179    // discarded middle, since those are real output.
180
181    /// Run each one-sided trim over an owned copy of `input`.
182    fn trimmed_start(input: &str, pattern: impl super::PatternRef) -> String {
183        let mut string = input.to_string();
184        string.trim_start_matches_in_place(pattern);
185        string
186    }
187
188    fn trimmed_end(input: &str, pattern: impl super::PatternRef) -> String {
189        let mut string = input.to_string();
190        string.trim_end_matches_in_place(pattern);
191        string
192    }
193
194    #[rstest]
195    #[case::both_ends("\n\nhi\n\n", "hi\n\n", "\n\nhi")]
196    #[case::leading_only("\n\nhi", "hi", "\n\nhi")]
197    #[case::trailing_only("hi\n\n", "hi\n\n", "hi")]
198    #[case::interior_kept("\none\ntwo\n", "one\ntwo\n", "\none\ntwo")]
199    #[case::all_pattern("\n\n", "", "")]
200    #[case::empty("", "", "")]
201    #[case::nothing_to_trim("hi", "hi", "hi")]
202    fn trims_only_the_requested_end(
203        #[case] input: &str,
204        #[case] start_trimmed: &str,
205        #[case] end_trimmed: &str,
206    ) {
207        assert_eq!(trimmed_start(input, '\n'), start_trimmed);
208        assert_eq!(trimmed_end(input, '\n'), end_trimmed);
209    }
210
211    #[rstest]
212    #[case::multibyte_pattern("——hi——", '—', "hi——", "——hi")]
213    #[case::multibyte_content_preserved("xx🦀 世界xx", 'x', "🦀 世界xx", "xx🦀 世界")]
214    fn one_sided_trims_handle_multibyte_characters(
215        #[case] input: &str,
216        #[case] pattern: char,
217        #[case] start_trimmed: &str,
218        #[case] end_trimmed: &str,
219    ) {
220        assert_eq!(trimmed_start(input, pattern), start_trimmed);
221        assert_eq!(trimmed_end(input, pattern), end_trimmed);
222    }
223
224    #[rstest]
225    #[case::ascii_whitespace(" \t\r\nhi \t\r\n", "hi \t\r\n", " \t\r\nhi")]
226    #[case::unicode_whitespace("\u{3000}hi\u{3000}", "hi\u{3000}", "\u{3000}hi")]
227    #[case::nothing_to_trim("hi", "hi", "hi")]
228    fn one_sided_whitespace_trims_match_str(
229        #[case] input: &str,
230        #[case] start_trimmed: &str,
231        #[case] end_trimmed: &str,
232    ) {
233        let mut start = input.to_string();
234        start.trim_start_in_place();
235        assert_eq!(start, start_trimmed);
236        assert_eq!(start, input.trim_start());
237
238        let mut end = input.to_string();
239        end.trim_end_in_place();
240        assert_eq!(end, end_trimmed);
241        assert_eq!(end, input.trim_end());
242    }
243
244    #[rstest]
245    fn a_stateful_pattern_is_reused_rather_than_consumed() {
246        // The point of `PatternRef`: a single `FnMut` drives both ends.
247        let mut string = "abhixy".to_string();
248        let mut chars: std::collections::HashSet<char> = string.chars().collect();
249        string.trim_matches_in_place(|c| {
250            assert!(chars.remove(&c), "matcher called on nonexistent char, or same char twice");
251            !c.is_ascii_alphabetic() || "abxy".contains(c)
252        });
253        assert_eq!(string, "hi");
254        assert!(chars.is_empty(), "matcher not called on every char");
255    }
256
257    proptest! {
258        /// However the pattern and haystack are chosen, trimming in place must agree with
259        /// `str::trim_matches` and never leave the string on a non-char boundary.
260        #[rstest]
261        fn agrees_with_str_trim_matches(input in ".{0,64}", pattern in prop::char::range('a', 'e')) {
262            let mut string = input.clone();
263            string.trim_matches_in_place(pattern);
264            prop_assert_eq!(string, input.trim_matches(pattern));
265        }
266
267        #[rstest]
268        fn agrees_with_str_trim(input in ".{0,64}") {
269            let mut string = input.clone();
270            string.trim_in_place();
271            prop_assert_eq!(string, input.trim());
272        }
273
274        #[rstest]
275        fn agrees_with_str_trim_start_matches(
276            input in ".{0,64}",
277            pattern in prop::char::range('a', 'e'),
278        ) {
279            let mut string = input.clone();
280            string.trim_start_matches_in_place(pattern);
281            prop_assert_eq!(string, input.trim_start_matches(pattern));
282        }
283
284        #[rstest]
285        fn agrees_with_str_trim_end_matches(
286            input in ".{0,64}",
287            pattern in prop::char::range('a', 'e'),
288        ) {
289            let mut string = input.clone();
290            string.trim_end_matches_in_place(pattern);
291            prop_assert_eq!(string, input.trim_end_matches(pattern));
292        }
293
294        #[rstest]
295        fn agrees_with_str_trim_start(input in ".{0,64}") {
296            let mut string = input.clone();
297            string.trim_start_in_place();
298            prop_assert_eq!(string, input.trim_start());
299        }
300
301        #[rstest]
302        fn agrees_with_str_trim_end(input in ".{0,64}") {
303            let mut string = input.clone();
304            string.trim_end_in_place();
305            prop_assert_eq!(string, input.trim_end());
306        }
307
308        /// Trimming both ends is exactly trimming each end in turn, however they are ordered.
309        #[rstest]
310        fn both_ends_is_the_two_one_sided_trims(
311            input in ".{0,64}",
312            pattern in prop::char::range('a', 'e'),
313        ) {
314            let mut both = input.clone();
315            both.trim_matches_in_place(pattern);
316
317            let mut start_then_end = input.clone();
318            start_then_end.trim_start_matches_in_place(pattern);
319            start_then_end.trim_end_matches_in_place(pattern);
320
321            let mut end_then_start = input;
322            end_then_start.trim_end_matches_in_place(pattern);
323            end_then_start.trim_start_matches_in_place(pattern);
324
325            prop_assert_eq!(&both, &start_then_end);
326            prop_assert_eq!(&both, &end_then_start);
327        }
328    }
329}