Skip to content

Commit e06f274

Browse files
smart-string: true try_ APIs + truncated/expect_capacity editing
1 parent c94ba2c commit e06f274

File tree

4 files changed

+325
-9
lines changed

4 files changed

+325
-9
lines changed

API-PARITY.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,8 @@ show up hot in profiling, we should document the cost model and optimize where p
5858
- [x] `push`, `push_str`, `pop`, `truncate`, `clear`
5959
- [x] `reserve`, `reserve_exact`, `try_reserve*`, `shrink_to_fit`, `shrink_to`
6060
- [x] `len`, `is_empty` (explicit wrappers for std parity + rustdoc discoverability)
61-
- [x] `insert`, `insert_str` (currently promotes to heap and delegates)
62-
- [x] `remove`, `retain`, `drain`, `replace_range` (currently promotes to heap and delegates)
61+
- [x] `insert`, `insert_str` (operates on stack when it fits; promotes to heap on overflow)
62+
- [x] `remove`, `retain`, `drain`, `replace_range` (note: some operations currently promote for simplicity)
6363
- [x] `split_off` (promotes to heap and delegates; returned value may be stored on stack if it fits)
6464
- [x] `into_bytes`, `into_string` (consuming conversions)
6565
- [x] `into_boxed_str`, `leak`, `from_utf8_lossy`

src/pascal_string/error.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,26 @@ pub enum TryFromStrError {
88
TooLong,
99
}
1010

11+
/// An error returned by insertion operations.
12+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13+
pub enum InsertError {
14+
/// The index is outside the string bounds.
15+
OutOfBounds { idx: usize, len: usize },
16+
/// The index is not on a UTF-8 character boundary.
17+
NotCharBoundary { idx: usize },
18+
/// The result would exceed the fixed capacity.
19+
TooLong,
20+
}
21+
22+
/// An error returned by removal operations.
23+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24+
pub enum RemoveError {
25+
/// The index is outside the string bounds.
26+
OutOfBounds { idx: usize, len: usize },
27+
/// The index is not on a UTF-8 character boundary.
28+
NotCharBoundary { idx: usize },
29+
}
30+
1131
/// An error returned when a conversion from a `&[u8]` to a `PascalString` fails.
1232
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1333
pub enum TryFromBytesError {
@@ -39,6 +59,29 @@ impl fmt::Display for TryFromStrError {
3959
}
4060
}
4161

62+
impl fmt::Display for InsertError {
63+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
64+
match self {
65+
InsertError::OutOfBounds { idx, len } => {
66+
write!(f, "index out of bounds: idx={idx}, len={len}")
67+
}
68+
InsertError::NotCharBoundary { idx } => write!(f, "index is not a char boundary: idx={idx}"),
69+
InsertError::TooLong => f.write_str("string too long"),
70+
}
71+
}
72+
}
73+
74+
impl fmt::Display for RemoveError {
75+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
76+
match self {
77+
RemoveError::OutOfBounds { idx, len } => {
78+
write!(f, "index out of bounds: idx={idx}, len={len}")
79+
}
80+
RemoveError::NotCharBoundary { idx } => write!(f, "index is not a char boundary: idx={idx}"),
81+
}
82+
}
83+
}
84+
4285
impl fmt::Display for TryFromBytesError {
4386
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
4487
match self {

src/pascal_string/mod.rs

Lines changed: 195 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ mod error;
1818
mod with_serde;
1919

2020
pub use error::TryFromBytesError;
21+
pub use error::InsertError;
22+
pub use error::RemoveError;
2123
pub use error::TryFromStrError;
2224

2325
#[derive(Clone, Copy)]
@@ -150,19 +152,190 @@ impl<const CAPACITY: usize> PascalString<CAPACITY> {
150152
///
151153
/// This mirrors `String::push_str`’s “cannot fail” ergonomics; use `try_push_str` if you want a recoverable error.
152154
#[inline]
155+
#[deprecated(note = "PascalString is fixed-capacity; prefer `try_push_str`, `push_str_truncated`, or `push_str_expect_capacity`.")]
153156
pub fn push_str(&mut self, string: &str) {
154-
self.try_push_str(string)
155-
.expect("PascalString capacity exceeded");
157+
self.push_str_expect_capacity(string);
156158
}
157159

158160
/// Appends a character, panicking if the capacity would be exceeded.
159161
///
160162
/// This mirrors `String::push`’s “cannot fail” ergonomics; use `try_push` if you want a recoverable error.
161163
#[inline]
164+
#[deprecated(note = "PascalString is fixed-capacity; prefer `try_push`, `push_str_truncated`, or `push_expect_capacity`.")]
162165
pub fn push(&mut self, ch: char) {
166+
self.push_expect_capacity(ch);
167+
}
168+
169+
/// Appends a string slice, panicking if the capacity would be exceeded.
170+
#[inline]
171+
pub fn push_str_expect_capacity(&mut self, string: &str) {
172+
self.try_push_str(string)
173+
.expect("PascalString capacity exceeded");
174+
}
175+
176+
/// Appends a character, panicking if the capacity would be exceeded.
177+
#[inline]
178+
pub fn push_expect_capacity(&mut self, ch: char) {
163179
self.try_push(ch).expect("PascalString capacity exceeded");
164180
}
165181

182+
/// Inserts a string slice at the given byte index.
183+
///
184+
/// This is a true `try_` API: it **never panics**. All failure modes are returned as `InsertError`.
185+
#[inline]
186+
pub fn try_insert_str(&mut self, idx: usize, string: &str) -> Result<(), InsertError> {
187+
let len = self.len();
188+
if idx > len {
189+
return Err(InsertError::OutOfBounds { idx, len });
190+
}
191+
if !self.is_char_boundary(idx) {
192+
return Err(InsertError::NotCharBoundary { idx });
193+
}
194+
195+
let insert_len = string.len();
196+
let new_len = len + insert_len;
197+
if new_len > CAPACITY {
198+
return Err(InsertError::TooLong);
199+
}
200+
201+
// Shift tail to make room.
202+
self.data.copy_within(idx..len, idx + insert_len);
203+
// Copy inserted bytes.
204+
self.data[idx..idx + insert_len].copy_from_slice(string.as_bytes());
205+
self.len = new_len as u8;
206+
Ok(())
207+
}
208+
209+
/// Inserts a string slice at the given byte index, truncating the inserted string to available capacity.
210+
///
211+
/// Returns the remainder that did not fit.
212+
///
213+
/// This is a true `try_` API: it **never panics**. Index/boundary errors are returned as `InsertError`.
214+
#[inline]
215+
pub fn try_insert_str_truncated<'s>(
216+
&mut self,
217+
idx: usize,
218+
string: &'s str,
219+
) -> Result<&'s str, InsertError> {
220+
let len = self.len();
221+
if idx > len {
222+
return Err(InsertError::OutOfBounds { idx, len });
223+
}
224+
if !self.is_char_boundary(idx) {
225+
return Err(InsertError::NotCharBoundary { idx });
226+
}
227+
228+
let available = CAPACITY.saturating_sub(len);
229+
if available >= string.len() {
230+
self.try_insert_str(idx, string)?;
231+
return Ok("");
232+
}
233+
234+
let mut prefix_len = 0;
235+
for c in string.chars() {
236+
let l = c.len_utf8();
237+
if prefix_len + l > available {
238+
break;
239+
}
240+
prefix_len += l;
241+
}
242+
243+
let (prefix, remainder) = string.split_at(prefix_len);
244+
// Prefix is constructed from `chars()` boundaries, so it is valid UTF-8 and fits by construction.
245+
self.try_insert_str(idx, prefix)?;
246+
Ok(remainder)
247+
}
248+
249+
/// Inserts a string slice at the given byte index, truncating to capacity, panicking on invalid index/boundary.
250+
///
251+
/// Returns the remainder that did not fit.
252+
#[inline]
253+
pub fn insert_str_truncated<'s>(&mut self, idx: usize, string: &'s str) -> &'s str {
254+
self.try_insert_str_truncated(idx, string)
255+
.expect("invalid index or char boundary")
256+
}
257+
258+
/// Inserts a string slice at the given byte index, panicking if the capacity would be exceeded.
259+
///
260+
/// This is an explicit opt-in panicking API for fixed-capacity strings.
261+
#[inline]
262+
pub fn insert_str_expect_capacity(&mut self, idx: usize, string: &str) {
263+
self.try_insert_str(idx, string)
264+
.expect("PascalString insert failed");
265+
}
266+
267+
/// Inserts a string slice at the given byte index, panicking if the capacity would be exceeded.
268+
#[inline]
269+
#[deprecated(note = "PascalString is fixed-capacity; prefer `try_insert_str`, `try_insert_str_truncated`, or `insert_str_expect_capacity`.")]
270+
pub fn insert_str(&mut self, idx: usize, string: &str) {
271+
self.insert_str_expect_capacity(idx, string);
272+
}
273+
274+
/// Inserts a character at the given byte index.
275+
///
276+
/// This is a true `try_` API: it **never panics**. All failure modes are returned as `InsertError`.
277+
#[inline]
278+
pub fn try_insert(&mut self, idx: usize, ch: char) -> Result<(), InsertError> {
279+
let mut buf = [0_u8; 4];
280+
let s = ch.encode_utf8(&mut buf);
281+
self.try_insert_str(idx, s)
282+
}
283+
284+
/// Inserts a character at the given byte index, panicking if the capacity would be exceeded.
285+
#[inline]
286+
pub fn insert_expect_capacity(&mut self, idx: usize, ch: char) {
287+
self.try_insert(idx, ch)
288+
.expect("PascalString insert failed");
289+
}
290+
291+
/// Inserts a character at the given byte index, panicking if the capacity would be exceeded.
292+
#[inline]
293+
#[deprecated(note = "PascalString is fixed-capacity; prefer `try_insert`, `try_insert_str_truncated`, or `insert_expect_capacity`.")]
294+
pub fn insert(&mut self, idx: usize, ch: char) {
295+
self.insert_expect_capacity(idx, ch);
296+
}
297+
298+
/// Removes and returns the `char` at the given byte index.
299+
///
300+
/// # Panics
301+
///
302+
/// - If `idx >= self.len()`
303+
/// - If `idx` is not on a UTF-8 character boundary
304+
#[inline]
305+
pub fn remove(&mut self, idx: usize) -> char {
306+
let len = self.len();
307+
assert!(idx < len, "index out of bounds");
308+
assert!(self.is_char_boundary(idx), "index is not a char boundary");
309+
310+
let ch = self.as_str()[idx..].chars().next().expect("idx < len");
311+
let ch_len = ch.len_utf8();
312+
313+
// Shift tail left to close the gap.
314+
self.data.copy_within(idx + ch_len..len, idx);
315+
let new_len = len - ch_len;
316+
self.len = new_len as u8;
317+
318+
// Keep deterministic contents beyond len (not required for soundness, but helps debugging/tests).
319+
self.data[new_len..len].fill(0);
320+
321+
ch
322+
}
323+
324+
/// Removes and returns the `char` at the given byte index.
325+
///
326+
/// This is a true `try_` API: it **never panics**. All failure modes are returned as `RemoveError`.
327+
#[inline]
328+
pub fn try_remove(&mut self, idx: usize) -> Result<char, RemoveError> {
329+
let len = self.len();
330+
if idx >= len {
331+
return Err(RemoveError::OutOfBounds { idx, len });
332+
}
333+
if !self.is_char_boundary(idx) {
334+
return Err(RemoveError::NotCharBoundary { idx });
335+
}
336+
Ok(self.remove(idx))
337+
}
338+
166339
/// Returns the remainder of the string that was not pushed.
167340
#[inline]
168341
pub fn push_str_truncated<'s>(&mut self, string: &'s str) -> &'s str {
@@ -632,11 +805,30 @@ mod tests {
632805
fn test_push_str_panics_on_overflow() {
633806
let result = std::panic::catch_unwind(|| {
634807
let mut ps = PascalString::<4>::new();
635-
ps.push_str("abcde");
808+
ps.push_str_expect_capacity("abcde");
636809
});
637810
assert!(result.is_err());
638811
}
639812

813+
#[test]
814+
fn test_insert_str_and_remove_unicode_boundaries() {
815+
let mut ps = PascalString::<8>::try_from("ab").unwrap();
816+
ps.insert_str_expect_capacity(1, "€"); // 3 bytes
817+
assert_eq!(ps.as_str(), "a€b");
818+
819+
let removed = ps.remove(1);
820+
assert_eq!(removed, '€');
821+
assert_eq!(ps.as_str(), "ab");
822+
}
823+
824+
#[test]
825+
fn test_try_insert_str_too_long_does_not_modify() {
826+
let mut ps = PascalString::<4>::try_from("ab").unwrap();
827+
let err = ps.try_insert_str(1, "€").unwrap_err(); // would become 5 bytes
828+
assert_eq!(err, InsertError::TooLong);
829+
assert_eq!(ps.as_str(), "ab");
830+
}
831+
640832
#[test]
641833
fn test_try_from_str_const() {
642834
const PS: Option<PascalString<4>> = PascalString::<4>::try_from_str_const("ab");

0 commit comments

Comments
 (0)