tuwunel_core/utils/string.rs
1//! String conversion, formatting, parsing, serialization, and slicing
2//! utilities.
3//!
4//! The module includes borrowed unquoted views, Serde adapters, chunking, case
5//! conversion, deterministic prefix selection, and UTF-8 conversion.
6//! Specialized display wrappers avoid allocation. String traits and helpers
7//! are re-exported from child modules.
8
9mod between;
10mod chunk;
11
12pub mod de;
13
14mod split;
15mod tests;
16mod unquote;
17mod unquoted;
18
19use std::{
20 borrow::Cow,
21 fmt::{self, write},
22 io,
23 mem::replace,
24 ops::Range,
25 str::from_utf8,
26};
27
28pub use self::{
29 between::Between, chunk::chunk, split::SplitInfallible, unquote::Unquote, unquoted::Unquoted,
30};
31use crate::{Result, arrayvec::ArrayString, smallstr::SmallString};
32
33/// Provides a shared empty string slice.
34///
35/// The value has static lifetime and is suitable for default or fallback
36/// references. It is identical to the empty string literal.
37pub const EMPTY: &str = "";
38
39/// Formats arguments into a small string with an inline byte capacity.
40///
41/// Arguments are forwarded to [`format_small_string`] without an intermediate
42/// `String` allocation. Inline capacity is inferred from the expected type at
43/// the call site, and output beyond it spills to the heap.
44#[macro_export]
45#[collapse_debuginfo(yes)]
46macro_rules! format_small_string {
47 ($($args:tt)+) => {
48 $crate::utils::string::format_small_string(std::format_args!($($args)+))
49 };
50}
51
52/// Formats arguments into an array string with constant byte capacity.
53///
54/// Arguments are forwarded to [`format_array_string`] without any allocation.
55/// Capacity is inferred from the expected type at the call site, and output
56/// beyond it panics.
57#[macro_export]
58#[collapse_debuginfo(yes)]
59macro_rules! format_array_string {
60 ($($args:tt)+) => {
61 $crate::utils::string::format_array_string(std::format_args!($($args)+))
62 };
63}
64
65/// Formats a literal only when placeholders appear to be present.
66///
67/// With one argument, a literal containing both `{` and `}` is formatted; any
68/// other literal is converted directly through `Into`. With additional
69/// arguments, the first literal is always treated as a format string.
70#[macro_export]
71#[collapse_debuginfo(yes)]
72macro_rules! format_maybe {
73 ($s:literal $(,)?) => {
74 if $crate::is_format!($s) { std::format!($s).into() } else { $s.into() }
75 };
76
77 ($s:literal, $($args:tt)+) => {
78 std::format!($s, $($args)+).into()
79 };
80}
81
82/// Tests whether a string literal appears to contain a formatting placeholder.
83///
84/// A literal returns `true` only when it contains at least one `{` and at least
85/// one `}`. Every other token pattern returns `false`, so the result is a
86/// heuristic rather than syntax validation.
87#[macro_export]
88#[collapse_debuginfo(yes)]
89macro_rules! is_format {
90 ($s:literal) => {
91 ::const_str::contains!($s, "{") && ::const_str::contains!($s, "}")
92 };
93
94 ($($s:tt)+) => {
95 false
96 };
97}
98
99/// Collects text emitted by a callback into an owned string.
100///
101/// The callback receives a formatting writer backed by a new `String`. Its
102/// error is propagated, and the buffer is returned only after the callback
103/// succeeds.
104#[inline]
105pub fn collect_stream<F>(func: F) -> Result<String>
106where
107 F: FnOnce(&mut dyn fmt::Write) -> Result,
108{
109 let mut out = String::new();
110 func(&mut out)?;
111
112 Ok(out)
113}
114
115/// Converts ASCII camel-case text into an owned lowercase snake-case string.
116///
117/// An underscore is inserted before an uppercase byte only when the preceding
118/// byte is not uppercase; leading and consecutive capitals remain joined. Input
119/// is processed bytewise, so non-ASCII UTF-8 text is not preserved.
120#[inline]
121#[must_use]
122pub fn camel_to_snake_string(s: &str) -> String {
123 let est_len = s
124 .chars()
125 .fold(s.len(), |est, c| est.saturating_add(usize::from(c.is_ascii_uppercase())));
126
127 let mut ret = String::with_capacity(est_len);
128 camel_to_snake_case(&mut ret, s.as_bytes()).expect("string-to-string stream error");
129 ret
130}
131
132/// Streams ASCII camel-case bytes into a writer as lowercase snake case.
133///
134/// The underscore rule matches [`camel_to_snake_string`]. The first input read
135/// error stops processing and is not returned, while output formatting errors
136/// are propagated. Bytes outside ASCII are written as individual Unicode code
137/// points rather than decoded as UTF-8.
138#[inline]
139#[expect(clippy::unbuffered_bytes)] // these are allocated string utilities, not file I/O utils
140pub fn camel_to_snake_case<I, O>(output: &mut O, input: I) -> Result
141where
142 I: io::Read,
143 O: fmt::Write,
144{
145 let mut state = false;
146 input
147 .bytes()
148 .take_while(Result::is_ok)
149 .map(Result::unwrap)
150 .map(char::from)
151 .try_for_each(|ch| {
152 let m = ch.is_ascii_uppercase();
153 let s = replace(&mut state, !m);
154 if m && s {
155 output.write_char('_')?;
156 }
157
158 output.write_char(ch.to_ascii_lowercase())?;
159
160 Result::<()>::Ok(())
161 })
162}
163
164/// Returns the longest common ASCII prefix of a collection of strings.
165///
166/// The result borrows from the first entry and is empty when the collection is
167/// empty or shares no prefix. Inputs are expected to be ASCII because some
168/// non-ASCII prefixes can produce an invalid byte boundary and panic.
169#[must_use]
170#[expect(clippy::string_slice)]
171pub fn common_prefix<T: AsRef<str>>(choice: &[T]) -> &str {
172 choice.first().map_or(EMPTY, move |best| {
173 choice
174 .iter()
175 .skip(1)
176 .fold(best.as_ref(), |best, choice| {
177 &best[0..choice
178 .as_ref()
179 .char_indices()
180 .zip(best.char_indices())
181 .take_while(|&(a, b)| a == b)
182 .count()]
183 })
184 })
185}
186
187/// Returns a deterministic prefix selected from the string contents.
188///
189/// The candidate index is the wrapping sum of input bytes modulo the byte
190/// length, with empty input using a modulus of one. When a range is supplied,
191/// the index is clamped inclusively between `range.start` and `range.end`;
192/// reversed endpoints panic. The index is then interpreted as a
193/// character position, or the full string is returned when that position does
194/// not exist. Because selection uses byte length but truncation uses character
195/// count, non-ASCII input more often falls back to the full string.
196#[inline]
197#[must_use]
198#[expect(clippy::arithmetic_side_effects)]
199pub fn truncate_deterministic(str: &str, range: Option<Range<usize>>) -> &str {
200 let range = range.unwrap_or(0..str.len());
201 let len = str
202 .as_bytes()
203 .iter()
204 .copied()
205 .map(Into::into)
206 .fold(0_usize, usize::wrapping_add)
207 .wrapping_rem(str.len().max(1))
208 .clamp(range.start, range.end);
209
210 str.char_indices()
211 .nth(len)
212 .map(|(i, _)| str.split_at(i).0)
213 .unwrap_or(str)
214}
215
216/// Displays a value into a small string with an inline byte capacity.
217///
218/// Output beyond `CAP` bytes spills to the heap. Panics if the value's
219/// `Display` implementation returns a formatting error.
220#[inline]
221#[must_use]
222pub fn to_small_string<const CAP: usize, T>(t: T) -> SmallString<[u8; CAP]>
223where
224 T: fmt::Display,
225{
226 format_small_string(format_args!("{t}"))
227}
228
229/// Formats arguments into a small string with an inline byte capacity.
230///
231/// Output beyond `CAP` bytes spills to the heap. Panics if formatting the
232/// arguments fails; [`try_format_small_string`] returns that error instead.
233#[inline]
234#[must_use]
235pub fn format_small_string<const CAP: usize>(args: fmt::Arguments<'_>) -> SmallString<[u8; CAP]> {
236 try_format_small_string(args).expect("Failed to format into SmallString")
237}
238
239/// Formats arguments into a small string, propagating any formatting error.
240///
241/// Output beyond `CAP` bytes spills to the heap, so the buffer itself never
242/// overflows. Only a `Display` implementation among the arguments can fail.
243#[inline]
244pub fn try_format_small_string<const CAP: usize>(
245 args: fmt::Arguments<'_>,
246) -> Result<SmallString<[u8; CAP]>> {
247 let mut ret = SmallString::<[u8; CAP]>::new();
248 write(&mut ret, args)?;
249
250 Ok(ret)
251}
252
253/// Displays a value into an array string with constant byte capacity.
254///
255/// Output is confined to the stack, so `CAP` must bound the formatted length.
256/// Panics when the output exceeds it, or when the value's `Display`
257/// implementation returns a formatting error.
258#[inline]
259#[must_use]
260pub fn to_array_string<const CAP: usize, T>(t: T) -> ArrayString<CAP>
261where
262 T: fmt::Display,
263{
264 format_array_string(format_args!("{t}"))
265}
266
267/// Formats arguments into an array string with constant byte capacity.
268///
269/// Output is confined to the stack, so `CAP` must bound the formatted length.
270/// Panics when the output exceeds it or the arguments fail to format;
271/// [`try_format_array_string`] returns both as an error.
272#[inline]
273#[must_use]
274pub fn format_array_string<const CAP: usize>(args: fmt::Arguments<'_>) -> ArrayString<CAP> {
275 try_format_array_string(args).expect("Failed to format into ArrayString")
276}
277
278/// Formats arguments into an array string, propagating any formatting error.
279///
280/// Output is confined to the stack. Exceeding `CAP` bytes yields a formatting
281/// error, indistinguishable from a failure in an argument's `Display`
282/// implementation.
283#[inline]
284pub fn try_format_array_string<const CAP: usize>(
285 args: fmt::Arguments<'_>,
286) -> Result<ArrayString<CAP>> {
287 let mut ret = ArrayString::<CAP>::new();
288 write(&mut ret, args)?;
289
290 Ok(ret)
291}
292
293/// Converts UTF-8 bytes into an owned string.
294///
295/// Valid input is copied into a new `String`. Invalid UTF-8 is returned as an
296/// error.
297pub fn string_from_bytes(bytes: &[u8]) -> Result<String> {
298 let str: &str = str_from_bytes(bytes)?;
299
300 Ok(str.to_owned())
301}
302
303/// Borrows a byte slice as UTF-8 text.
304///
305/// Valid input is returned without allocation. Invalid UTF-8 is returned as an
306/// error.
307#[inline]
308pub fn str_from_bytes(bytes: &[u8]) -> Result<&str> { Ok(from_utf8(bytes)?) }
309
310/// Escapes a value for one markdown table cell.
311///
312/// Backslashes and pipes are escaped, and control characters and Unicode line
313/// or paragraph separators become spaces, so the value cannot end its cell or
314/// row. A value needing no change is borrowed.
315#[must_use]
316pub fn markdown_cell(value: &str) -> Cow<'_, str> {
317 let breaks_cell = |character: char| {
318 character.is_control() || matches!(character, '\\' | '|' | '\u{2028}' | '\u{2029}')
319 };
320
321 let escape = |mut escaped: String, character: char| {
322 match character {
323 | '\\' => escaped.push_str("\\\\"),
324 | '|' => escaped.push_str("\\|"),
325 | _ if breaks_cell(character) => escaped.push(' '),
326 | _ => escaped.push(character),
327 }
328
329 escaped
330 };
331
332 value
333 .contains(breaks_cell)
334 .then(|| {
335 value
336 .chars()
337 .fold(String::with_capacity(value.len()), escape)
338 })
339 .map_or(Cow::Borrowed(value), Cow::Owned)
340}
341
342/// Picks the singular or plural form of a noun for a count.
343///
344/// Only a count of exactly one takes the singular, so zero reads as plural.
345#[inline]
346#[must_use]
347pub fn plural<'a>(count: usize, one: &'a str, many: &'a str) -> &'a str {
348 match count {
349 | 1 => one,
350 | _ => many,
351 }
352}