Skip to main content

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}