convert_case/boundary.rs
1use unicode_segmentation::UnicodeSegmentation;
2
3use alloc::vec::Vec;
4
5fn grapheme_is_digit(c: &&str) -> bool {
6 c.chars().all(|c| c.is_ascii_digit())
7}
8
9fn grapheme_is_uppercase(c: &&str) -> bool {
10 c.to_uppercase() != c.to_lowercase() && *c == c.to_uppercase()
11}
12
13fn grapheme_is_lowercase(c: &&str) -> bool {
14 c.to_uppercase() != c.to_lowercase() && *c == c.to_lowercase()
15}
16
17/// Conditions for splitting an identifier into words.
18///
19/// Some boundaries, [`Hyphen`](Boundary::Hyphen), [`Underscore`](Boundary::Underscore), and [`Space`](Boundary::Space),
20/// consume the character they split on, whereas the other boundaries do not.
21///
22/// `Boundary` includes methods that return useful groups of boundaries. It also
23/// contains the [`defaults_from`](Boundary::defaults_from) method which will generate a subset
24/// of default boundaries based on the boundaries present in a string.
25///
26/// You can also create custom delimiter boundaries using the [`delim_boundary`](crate::delim_boundary)
27/// macro or directly instantiate `Boundary` for complex boundary conditions.
28/// ```
29/// use convert_case::{Boundary, Case, Casing, Converter};
30///
31/// assert_eq!(
32/// "TransformationsIn3D"
33/// .from_case(Case::Camel)
34/// .remove_boundaries(&Boundary::digit_letter())
35/// .to_case(Case::Snake),
36/// "transformations_in_3d",
37/// );
38///
39/// let conv = Converter::new()
40/// .set_boundaries(&Boundary::defaults_from("aA "))
41/// .to_case(Case::Title);
42/// assert_eq!("7empest By Tool", conv.convert("7empest byTool"));
43/// ```
44///
45/// ## Example
46///
47/// For more complex boundaries, such as splitting based on the first character being a certain
48/// symbol and the second is lowercase, you can instantiate a boundary directly.
49///
50/// ```
51/// # use convert_case::{Boundary, Case, Casing};
52/// let at_then_letter = Boundary::Custom {
53/// condition: |s| {
54/// s.get(0).map(|c| *c == "@") == Some(true)
55/// && s.get(1).map(|c| *c == c.to_lowercase()) == Some(true)
56/// },
57/// start: 1,
58/// len: 0,
59/// };
60/// assert_eq!(
61/// "name@domain"
62/// .set_boundaries(&[at_then_letter])
63/// .to_case(Case::Title),
64/// "Name@ Domain",
65/// )
66/// ```
67
68#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
69pub enum Boundary {
70 Custom {
71 /// A function that determines if this boundary is present at the start
72 /// of the string. Second argument is the `arg` field.
73 condition: fn(&[&str]) -> bool,
74 /// Where the beginning of the boundary is.
75 start: usize,
76 /// The length of the boundary. This is the number of graphemes that
77 /// are removed when splitting.
78 len: usize,
79 },
80
81 /// Splits on `-`, consuming the character on segmentation.
82 /// ```
83 /// # use convert_case::Boundary;
84 /// assert_eq!(
85 /// Boundary::defaults_from("-"),
86 /// vec![Boundary::Hyphen],
87 /// );
88 /// ```
89 Hyphen,
90
91 /// Splits on `_`, consuming the character on segmentation.
92 /// ```
93 /// # use convert_case::Boundary;
94 /// assert_eq!(
95 /// Boundary::defaults_from("_"),
96 /// vec![Boundary::Underscore],
97 /// );
98 /// ```
99 Underscore,
100
101 /// Splits on space, consuming the character on segmentation.
102 /// ```
103 /// # use convert_case::Boundary;
104 /// assert_eq!(
105 /// Boundary::defaults_from(" "),
106 /// vec![Boundary::Space],
107 /// );
108 /// ```
109 Space,
110
111 /// Splits where an uppercase letter is followed by a lowercase letter. This is seldom used,
112 /// and is **not** included in the [defaults](Boundary::defaults).
113 /// ```
114 /// # use convert_case::Boundary;
115 /// assert!(
116 /// Boundary::defaults_from("Aa").len() == 0
117 /// );
118 UpperLower,
119
120 /// Splits where a lowercase letter is followed by an uppercase letter.
121 /// ```
122 /// # use convert_case::Boundary;
123 /// assert_eq!(
124 /// Boundary::defaults_from("aA"),
125 /// vec![Boundary::LowerUpper],
126 /// );
127 /// ```
128 LowerUpper,
129
130 /// Splits where digit is followed by an uppercase letter.
131 /// ```
132 /// # use convert_case::Boundary;
133 /// assert_eq!(
134 /// Boundary::defaults_from("1A"),
135 /// vec![Boundary::DigitUpper],
136 /// );
137 /// ```
138 DigitUpper,
139
140 /// Splits where an uppercase letter is followed by a digit.
141 /// ```
142 /// # use convert_case::Boundary;
143 /// assert_eq!(
144 /// Boundary::defaults_from("A1"),
145 /// vec![Boundary::UpperDigit],
146 /// );
147 /// ```
148 UpperDigit,
149
150 /// Splits where digit is followed by a lowercase letter.
151 /// ```
152 /// # use convert_case::Boundary;
153 /// assert_eq!(
154 /// Boundary::defaults_from("1a"),
155 /// vec![Boundary::DigitLower],
156 /// );
157 /// ```
158 DigitLower,
159
160 /// Splits where a lowercase letter is followed by a digit.
161 /// ```
162 /// # use convert_case::Boundary;
163 /// assert_eq!(
164 /// Boundary::defaults_from("a1"),
165 /// vec![Boundary::LowerDigit],
166 /// );
167 /// ```
168 LowerDigit,
169
170 /// Acronyms are identified by two uppercase letters followed by a lowercase letter.
171 /// The word boundary is between the two uppercase letters. For example, "HTTPRequest"
172 /// would have an acronym boundary identified at "PRe" and split into "HTTP" and "Request".
173 /// ```
174 /// # use convert_case::Boundary;
175 /// assert_eq!(
176 /// Boundary::defaults_from("AAa"),
177 /// vec![Boundary::Acronym],
178 /// );
179 /// ```
180 Acronym,
181}
182
183impl Boundary {
184 pub fn matches(self, s: &[&str]) -> bool {
185 use Boundary::*;
186 match self {
187 Underscore => s.first() == Some(&"_"),
188 Hyphen => s.first() == Some(&"-"),
189 Space => s.first() == Some(&" "),
190 Acronym => {
191 s.first().map(grapheme_is_uppercase) == Some(true)
192 && s.get(1).map(grapheme_is_uppercase) == Some(true)
193 && s.get(2).map(grapheme_is_lowercase) == Some(true)
194 }
195 LowerUpper => {
196 s.first().map(grapheme_is_lowercase) == Some(true)
197 && s.get(1).map(grapheme_is_uppercase) == Some(true)
198 }
199 UpperLower => {
200 s.first().map(grapheme_is_uppercase) == Some(true)
201 && s.get(1).map(grapheme_is_lowercase) == Some(true)
202 }
203 LowerDigit => {
204 s.first().map(grapheme_is_lowercase) == Some(true)
205 && s.get(1).map(grapheme_is_digit) == Some(true)
206 }
207 UpperDigit => {
208 s.first().map(grapheme_is_uppercase) == Some(true)
209 && s.get(1).map(grapheme_is_digit) == Some(true)
210 }
211 DigitLower => {
212 s.first().map(grapheme_is_digit) == Some(true)
213 && s.get(1).map(grapheme_is_lowercase) == Some(true)
214 }
215 DigitUpper => {
216 s.first().map(grapheme_is_digit) == Some(true)
217 && s.get(1).map(grapheme_is_uppercase) == Some(true)
218 }
219 Custom { condition, .. } => condition(s),
220 }
221 }
222
223 /// The number of graphemes consumed when splitting at the boundary.
224 pub fn len(self) -> usize {
225 use Boundary::*;
226 match self {
227 Underscore | Hyphen | Space => 1,
228 LowerUpper | UpperLower | LowerDigit | UpperDigit | DigitLower | DigitUpper
229 | Acronym => 0,
230 Custom { len, .. } => len,
231 }
232 }
233
234 /// The index of the character to split at.
235 pub fn start(self) -> usize {
236 use Boundary::*;
237 match self {
238 Underscore | Hyphen | Space => 0,
239 LowerUpper | UpperLower | LowerDigit | UpperDigit | DigitLower | DigitUpper
240 | Acronym => 1,
241 Custom { start, .. } => start,
242 }
243 }
244
245 /// The default list of boundaries used when `Casing::to_case` is called directly
246 /// and in a `Converter` generated from `Converter::new()`.
247 /// ```
248 /// # use convert_case::Boundary;
249 /// assert_eq!(
250 /// Boundary::defaults(),
251 /// [
252 /// Boundary::Underscore,
253 /// Boundary::Hyphen,
254 /// Boundary::Space,
255 /// Boundary::LowerUpper,
256 /// Boundary::LowerDigit,
257 /// Boundary::UpperDigit,
258 /// Boundary::DigitLower,
259 /// Boundary::DigitUpper,
260 /// Boundary::Acronym,
261 /// ],
262 /// );
263 /// ```
264 pub const fn defaults() -> [Boundary; 9] {
265 [
266 Boundary::Underscore,
267 Boundary::Hyphen,
268 Boundary::Space,
269 Boundary::LowerUpper,
270 Boundary::LowerDigit,
271 Boundary::UpperDigit,
272 Boundary::DigitLower,
273 Boundary::DigitUpper,
274 Boundary::Acronym,
275 ]
276 }
277
278 /// Returns the boundaries that involve digits.
279 /// ```
280 /// # use convert_case::Boundary;
281 /// assert_eq!(
282 /// Boundary::digits(),
283 /// [
284 /// Boundary::LowerDigit,
285 /// Boundary::UpperDigit,
286 /// Boundary::DigitLower,
287 /// Boundary::DigitUpper,
288 /// ],
289 /// );
290 /// ```
291 pub const fn digits() -> [Boundary; 4] {
292 [
293 Boundary::LowerDigit,
294 Boundary::UpperDigit,
295 Boundary::DigitLower,
296 Boundary::DigitUpper,
297 ]
298 }
299
300 /// Returns the boundaries that are letters followed by digits.
301 /// ```
302 /// # use convert_case::Boundary;
303 /// assert_eq!(
304 /// Boundary::letter_digit(),
305 /// [
306 /// Boundary::LowerDigit,
307 /// Boundary::UpperDigit,
308 /// ],
309 /// );
310 /// ```
311 pub const fn letter_digit() -> [Boundary; 2] {
312 [Boundary::LowerDigit, Boundary::UpperDigit]
313 }
314
315 /// Returns the boundaries that are digits followed by letters.
316 /// ```
317 /// # use convert_case::Boundary;
318 /// assert_eq!(
319 /// Boundary::digit_letter(),
320 /// [
321 /// Boundary::DigitLower,
322 /// Boundary::DigitUpper
323 /// ],
324 /// );
325 /// ```
326 pub const fn digit_letter() -> [Boundary; 2] {
327 [Boundary::DigitLower, Boundary::DigitUpper]
328 }
329
330 /// Returns a list of all boundaries that are identified within the given string.
331 /// Could be a short of writing out all the boundaries in a list directly. This will not
332 /// identify boundary `UpperLower` if it also used as part of `Acronym`.
333 ///
334 /// If you want to be very explicit and not overlap boundaries, it is recommended to use a colon
335 /// character.
336 /// ```
337 /// # use convert_case::Boundary;
338 /// assert_eq!(
339 /// Boundary::defaults_from("aA8a -"),
340 /// vec![
341 /// Boundary::Hyphen,
342 /// Boundary::Space,
343 /// Boundary::LowerUpper,
344 /// Boundary::UpperDigit,
345 /// Boundary::DigitLower,
346 /// ],
347 /// );
348 /// assert_eq!(
349 /// Boundary::defaults_from("bD:0B:_:AAa"),
350 /// vec![
351 /// Boundary::Underscore,
352 /// Boundary::LowerUpper,
353 /// Boundary::DigitUpper,
354 /// Boundary::Acronym,
355 /// ],
356 /// );
357 /// ```
358 pub fn defaults_from(pattern: &str) -> Vec<Boundary> {
359 let mut boundaries = Vec::new();
360 for boundary in Boundary::defaults() {
361 let parts = split(&pattern, &[boundary]);
362 if parts.len() > 1 || parts.is_empty() || parts[0] != pattern {
363 boundaries.push(boundary);
364 }
365 }
366 boundaries
367 }
368}
369
370/// Split an identifier into a list of words using the list of boundaries.
371///
372/// This is used internally for splitting an identifier before mutating by
373/// a pattern and joining again with a delimiter.
374/// ```
375/// use convert_case::{Boundary, split};
376/// assert_eq!(
377/// split(&"one_two-three.four", &[Boundary::Underscore, Boundary::Hyphen]),
378/// vec!["one", "two", "three.four"],
379/// )
380/// ```
381pub fn split<'s, T>(s: &'s T, boundaries: &[Boundary]) -> Vec<&'s str>
382where
383 T: AsRef<str>,
384{
385 let s = s.as_ref();
386
387 if s.is_empty() {
388 return Vec::new();
389 }
390
391 let mut words = Vec::new();
392 let mut last_boundary_end = 0;
393
394 let (indices, graphemes): (Vec<_>, Vec<_>) = s.grapheme_indices(true).unzip();
395 let grapheme_length = indices[graphemes.len() - 1] + graphemes[graphemes.len() - 1].len();
396
397 // TODO:
398 // swapping the order of these would be faster
399 // end the loop sooner if any boundary condition is met
400 // could also hit a bitvector and do the splitting at the end? May or may not be faster
401 for i in 0..graphemes.len() {
402 for boundary in boundaries {
403 //let byte_index = indices[i];
404
405 if boundary.matches(&graphemes[i..]) {
406 // What if we find a condition at the end of the array?
407 // Maybe we can stop early based on length
408 // To do this, need to switch the loops
409 // TODO
410 let boundary_byte_start: usize = *indices
411 .get(i + boundary.start())
412 .unwrap_or(&grapheme_length);
413 let boundary_byte_end: usize = *indices
414 .get(i + boundary.start() + boundary.len())
415 .unwrap_or(&grapheme_length);
416
417 // todo clean this up a bit
418 words.push(&s[last_boundary_end..boundary_byte_start]);
419 last_boundary_end = boundary_byte_end;
420 break;
421 }
422 }
423 }
424 words.push(&s[last_boundary_end..]);
425 //words.into_iter().filter(|s| !s.is_empty()).collect()
426 words.into_iter().collect()
427}
428
429/// Create a new boundary based on a delimiter.
430/// ```
431/// # use convert_case::{Case, Converter, delim_boundary};
432/// let conv = Converter::new()
433/// .set_boundaries(&[delim_boundary!("::")])
434/// .to_case(Case::Camel);
435///
436/// assert_eq!(
437/// conv.convert("my::var::name"),
438/// "myVarName",
439/// )
440/// ```
441#[macro_export]
442macro_rules! delim_boundary {
443 ($delim:expr) => {
444 convert_case::Boundary::Custom {
445 condition: |s| s.join("").starts_with($delim),
446 start: 0,
447 len: $delim.len(),
448 }
449 };
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 #[test]
457 fn boundary_equality() {
458 let a = Boundary::Custom {
459 condition: |_| true,
460 start: 0,
461 len: 0,
462 };
463 let b = a;
464
465 assert_eq!(a, b)
466 }
467
468 #[test]
469 fn hyphen() {
470 let s = "a-b-c";
471 let v = split(&s, &[Boundary::Hyphen]);
472 assert_eq!(v, vec!["a", "b", "c"]);
473 }
474
475 #[test]
476 fn underscore() {
477 let s = "a_b_c";
478 let v = split(&s, &[Boundary::Underscore]);
479 assert_eq!(v, vec!["a", "b", "c"]);
480 }
481
482 #[test]
483 fn space() {
484 let s = "a b c";
485 let v = split(&s, &[Boundary::Space]);
486 assert_eq!(v, vec!["a", "b", "c"]);
487 }
488
489 #[test]
490 fn delimiters() {
491 let s = "aaa-bbb_ccc ddd ddd-eee";
492 let v = split(
493 &s,
494 &[Boundary::Space, Boundary::Underscore, Boundary::Hyphen],
495 );
496 assert_eq!(v, vec!["aaa", "bbb", "ccc", "ddd", "ddd", "eee"]);
497 }
498
499 #[test]
500 fn lower_upper() {
501 let s = "lowerUpperUpper";
502 let v = split(&s, &[Boundary::LowerUpper]);
503 assert_eq!(v, vec!["lower", "Upper", "Upper"]);
504 }
505
506 #[test]
507 fn acronym() {
508 let s = "XMLRequest";
509 let v = split(&s, &[Boundary::Acronym]);
510 assert_eq!(v, vec!["XML", "Request"]);
511 }
512
513 // TODO: add tests for other boundaries
514
515 #[test]
516 fn boundaries_found_in_string() {
517 // upper lower is not longer a default
518 assert_eq!(Vec::<Boundary>::new(), Boundary::defaults_from(".Aaaa"));
519 assert_eq!(
520 vec![Boundary::LowerUpper, Boundary::LowerDigit],
521 Boundary::defaults_from("a8.Aa.aA")
522 );
523 assert_eq!(
524 Boundary::digits().to_vec(),
525 Boundary::defaults_from("b1B1b")
526 );
527 assert_eq!(
528 vec![
529 Boundary::Underscore,
530 Boundary::Hyphen,
531 Boundary::Space,
532 Boundary::Acronym,
533 ],
534 Boundary::defaults_from("AAa -_")
535 );
536 }
537
538 #[test]
539 fn boundary_consts_same() {
540 assert_eq!(Boundary::Space, Boundary::Space);
541 }
542}