Skip to main content

writeable/
replace.rs

1// This file is part of ICU4X. For terms of use, please see the file
2// called LICENSE at the top level of the ICU4X source tree
3// (online at: https://github.com/unicode-org/icu4x/blob/main/LICENSE ).
4
5use crate::{impl_display_with_writeable, LengthHint, Writeable};
6use core::fmt;
7
8/// A [`Writeable`] adapter that replaces occurrences of a needle with a replacement.
9///
10/// This adapter performs the replacement in a streaming fashion during `write_to`,
11/// requiring zero allocations.
12///
13/// # Examples
14///
15/// ```
16/// use writeable::adapters::Replace;
17/// use writeable::assert_writeable_eq;
18/// use writeable::concat_writeable;
19///
20/// let source = concat_writeable!("I 💖 🦀", " and 🦀 loves me!");
21/// let replace = Replace {
22///     source,
23///     needle: "🦀",
24///     replacement: "Rust",
25/// };
26///
27/// assert_writeable_eq!(replace, "I 💖 Rust and Rust loves me!");
28/// ```
29#[derive(#[automatically_derived]
#[allow(clippy::exhaustive_structs)]
impl<A: ::core::fmt::Debug, B: ::core::fmt::Debug, C: ::core::fmt::Debug>
    ::core::fmt::Debug for Replace<A, B, C> {
    #[inline]
    fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
        ::core::fmt::Formatter::debug_struct_field3_finish(f, "Replace",
            "source", &self.source, "needle", &self.needle, "replacement",
            &&self.replacement)
    }
}Debug)]
30#[allow(clippy::exhaustive_structs)] // designed for nesting
31pub struct Replace<A, B, C> {
32    /// The source writeable.
33    pub source: A,
34    /// The needle to search for.
35    pub needle: B,
36    /// The replacement writeable.
37    pub replacement: C,
38}
39
40// Computes the Knuth-Morris-Pratt (KMP) prefix function (failure function) value
41// for the character prefix ending at byte index `matched_bytes` in `needle`.
42//
43// Returns the byte length of the longest proper prefix of `needle[0..matched_bytes]`
44// that is also a suffix of `needle[0..matched_bytes]`.
45//
46// This is computed on the fly without allocation by iterating over char boundaries.
47fn get_pi_bytes(needle: &str, matched_bytes: usize) -> usize {
48    let s = match needle.get(0..matched_bytes) {
49        Some(s) => s,
50        None => return 0,
51    };
52    // char_indices() gives us the byte offsets of character starts.
53    // These offsets correspond to the byte lengths of all possible prefixes.
54    // We want to iterate them in reverse order, excluding the first one (0)
55    // because we want proper prefixes.
56    for k in s
57        .char_indices()
58        .map(|(idx, _)| idx)
59        .rev()
60        .filter(|&idx| idx > 0)
61    {
62        // Compare the prefix of length `k` with the suffix of length `k`.
63        if let Some(suffix) = s.as_bytes().get(s.len() - k..) {
64            if s.as_bytes().starts_with(suffix) {
65                return k;
66            }
67        }
68    }
69    0
70}
71
72// A writer wrapper that performs streaming replacement.
73// It intercepts characters written to it, matches them against `needle` using KMP
74// (tracking progress by storing the remaining unmatched suffix of the needle),
75// and writes `replacement` when a full match is found, or the original characters otherwise.
76struct ReplaceWriter<'a, W: ?Sized, C> {
77    // The underlying sink to write to.
78    sink: &'a mut W,
79    // The needle we are searching for.
80    needle: &'a str,
81    // The replacement to write when the needle is matched.
82    replacement: &'a C,
83    // The remaining unmatched suffix of the needle.
84    // This is always a suffix of `needle` starting at a character boundary.
85    remaining_needle: &'a str,
86}
87
88impl<'a, W, C> ReplaceWriter<'a, W, C>
89where
90    W: fmt::Write + ?Sized,
91    C: Writeable,
92{
93    fn new(sink: &'a mut W, needle: &'a str, replacement: &'a C) -> Self {
94        Self {
95            sink,
96            needle,
97            replacement,
98            remaining_needle: needle,
99        }
100    }
101
102    // Helper to get the length of the prefix matched so far.
103    fn matched_len(&self) -> usize {
104        self.needle.len() - self.remaining_needle.len()
105    }
106
107    // Finalizes the writer, flushing any partially matched prefix to the sink.
108    fn finalize(&mut self) -> fmt::Result {
109        let matched = self.matched_len();
110        if matched > 0 {
111            let slice = self.needle.get(0..matched).ok_or(fmt::Error)?;
112            self.sink.write_str(slice)?;
113            self.remaining_needle = self.needle;
114        }
115        Ok(())
116    }
117}
118
119impl<'a, W, C> fmt::Write for ReplaceWriter<'a, W, C>
120where
121    W: fmt::Write + ?Sized,
122    C: Writeable,
123{
124    fn write_str(&mut self, s: &str) -> fmt::Result {
125        for c in s.chars() {
126            self.write_char(c)?;
127        }
128        Ok(())
129    }
130
131    fn write_char(&mut self, c: char) -> fmt::Result {
132        // If the needle is empty, we just pass through the characters.
133        if self.needle.is_empty() {
134            return self.sink.write_char(c);
135        }
136
137        let mut matched = self.matched_len();
138        // KMP State Transition:
139        // While we have a mismatch and we are not at the start of the needle,
140        // backtrack using the prefix function.
141        while matched > 0 && !self.remaining_needle.starts_with(c) {
142            let old_j = matched;
143            matched = get_pi_bytes(self.needle, old_j);
144            // Since we backtracked, the prefix of length `old_j - j` is no longer
145            // part of the potential match. We write it to the sink as a single slice.
146            let slice = self.needle.get(0..(old_j - matched)).ok_or(fmt::Error)?;
147            self.sink.write_str(slice)?;
148            // Update remaining_needle to reflect the new matched length.
149            self.remaining_needle = self.needle.get(matched..).ok_or(fmt::Error)?;
150        }
151
152        // If the character matches the next character in the needle, advance the match state.
153        if self.remaining_needle.starts_with(c) {
154            // Advance remaining_needle by the matched character.
155            self.remaining_needle = self
156                .remaining_needle
157                .get(c.len_utf8()..)
158                .ok_or(fmt::Error)?;
159            if self.remaining_needle.is_empty() {
160                // Full match found! Write the replacement instead of the needle.
161                self.replacement.write_to(self.sink)?;
162                // Reset match state.
163                self.remaining_needle = self.needle;
164            }
165        } else {
166            // Mismatch at the very beginning of the needle. Write the character as is.
167            self.sink.write_char(c)?;
168        }
169        Ok(())
170    }
171}
172
173impl<A, C> Writeable for Replace<A, &str, C>
174where
175    A: Writeable,
176    C: Writeable,
177{
178    // We do not implement writeable_borrow because it is meant to be a constant-time O(1)
179    // operation, but determining if a replacement occurred would require O(N) scanning.
180    fn write_to<W: fmt::Write + ?Sized>(&self, sink: &mut W) -> fmt::Result {
181        let mut writer = ReplaceWriter::new(sink, self.needle, &self.replacement);
182        self.source.write_to(&mut writer)?;
183        writer.finalize()
184    }
185
186    fn writeable_length_hint(&self) -> LengthHint {
187        let source_hint = self.source.writeable_length_hint();
188        let needle_len = self.needle.len();
189        let replacement_hint = self.replacement.writeable_length_hint();
190
191        // If needle and replacement have same exact length, length is unchanged.
192        if let Some(r_upper) = replacement_hint.1 {
193            if replacement_hint.0 == r_upper && needle_len == r_upper {
194                return source_hint;
195            }
196        }
197
198        let mut lower = 0;
199        let mut upper = None;
200
201        // If replacement is always larger than or equal to needle:
202        // New length is at least the source length.
203        if replacement_hint.0 >= needle_len {
204            lower = source_hint.0;
205        }
206
207        // If replacement is always smaller than or equal to needle:
208        // New length is at most the source length.
209        if let Some(r_upper) = replacement_hint.1 {
210            if r_upper <= needle_len {
211                upper = source_hint.1;
212            }
213        }
214
215        LengthHint(lower, upper)
216    }
217}
218
219/// This trait is implemented for compatibility with [`fmt!`](core::fmt).
/// To create a string, [`Writeable::write_to_string`] is usually more efficient.
impl<'a, A: Writeable, C: Writeable> core::fmt::Display for
    Replace<A, &'a str, C> {
    #[inline]
    fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
        crate::Writeable::write_to(&self, f)
    }
}impl_display_with_writeable!(Replace<A, &'a str, C>, #[cfg(feature = "alloc")], where 'a, A: Writeable, C: Writeable);
220
221#[test]
222fn test_replace() {
223    use crate::assert_writeable_eq;
224    use crate::concat::Concat;
225
226    // Basic replacement
227    let replace1 = Replace {
228        source: Concat("Hello", " 10 22 1101 33"),
229        needle: "10",
230        replacement: Concat("4", "4"),
231    };
232    assert_writeable_eq!(replace1, "Hello 44 22 1441 33");
233
234    // Empty needle (should just write source)
235    let replace2 = Replace {
236        source: "Hello World",
237        needle: "",
238        replacement: "X",
239    };
240    assert_writeable_eq!(replace2, "Hello World");
241
242    // Empty replacement
243    let replace3 = Replace {
244        source: "Hello 10 World 10",
245        needle: "10",
246        replacement: "",
247    };
248    assert_writeable_eq!(replace3, "Hello  World ");
249
250    // Needle not found
251    let replace4 = Replace {
252        source: "Hello World",
253        needle: "10",
254        replacement: "X",
255    };
256    assert_writeable_eq!(replace4, "Hello World");
257
258    // Needle at the beginning
259    let replace5 = Replace {
260        source: "10 Hello World",
261        needle: "10",
262        replacement: "X",
263    };
264    assert_writeable_eq!(replace5, "X Hello World");
265
266    // Needle at the end
267    let replace6 = Replace {
268        source: "Hello World 10",
269        needle: "10",
270        replacement: "X",
271    };
272    assert_writeable_eq!(replace6, "Hello World X");
273
274    // Overlapping needles (should consume and not match again)
275    let replace7 = Replace {
276        source: "ababa",
277        needle: "aba",
278        replacement: "X",
279    };
280    assert_writeable_eq!(replace7, "Xba");
281
282    // Self-overlap but no match
283    let replace8 = Replace {
284        source: "aab",
285        needle: "aac",
286        replacement: "X",
287    };
288    assert_writeable_eq!(replace8, "aab");
289
290    // Multi-byte UTF-8
291    let replace9 = Replace {
292        source: "🚀 🛸 🚀🚀 🚁",
293        needle: "🚀",
294        replacement: "星",
295    };
296    assert_writeable_eq!(replace9, "星 🛸 星星 🚁");
297
298    // Multi-byte UTF-8 with partial match
299    let replace10 = Replace {
300        source: "🚀🚁",
301        needle: "🚀🛸",
302        replacement: "星",
303    };
304    assert_writeable_eq!(replace10, "🚀🚁");
305
306    // Multi-byte UTF-8 with backtracking (no match)
307    let replace11 = Replace {
308        source: "🚀🚀🚁",
309        needle: "🚀🚀🛸",
310        replacement: "星",
311    };
312    assert_writeable_eq!(replace11, "🚀🚀🚁");
313
314    // Multi-byte UTF-8 with backtracking (match)
315    let replace12 = Replace {
316        source: "🚀🚀🚀🛸",
317        needle: "🚀🚀🛸",
318        replacement: "星",
319    };
320    assert_writeable_eq!(replace12, "🚀星");
321}