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