regex_charclass/lib.rs
1//! Manipulate and convert regular expression character classes.
2//!
3//! A character class is a set of `char` values. This crate stores one as an
4//! [`irange::RangeSet`] of [`Char`], which keeps it as a sorted collection
5//! of non-overlapping inclusive ranges, and adds the operations a regular expression
6//! engine needs on top of it through the [`CharacterClass`] trait: building a set from
7//! a range of `char` or of codepoints, counting its members, and rendering it back as
8//! the shortest character class it can.
9//!
10//! Set algebra — union, intersection, difference and complement — comes from
11//! [`irange`]. [`RangeSet`], [`AnyRange`] and [`Char`] are re-exported at the crate
12//! root, so a dependent needs neither its own dependency on `irange` nor a version kept
13//! in step with this crate's. The whole crate is also re-exported as
14//! [`regex_charclass::irange`](irange).
15//!
16//! # Example
17//!
18//! ```
19//! use regex_charclass::{AnyRange, Char, CharacterClass, RangeSet};
20//!
21//! let letters = RangeSet::new_from_range_char('a'..='z');
22//! assert_eq!(26, letters.get_cardinality());
23//! assert_eq!("[a-z]", letters.to_regex());
24//!
25//! let hex = RangeSet::new_from_ranges(&[
26//! AnyRange::from(Char::new('0')..=Char::new('9')),
27//! AnyRange::from(Char::new('A')..=Char::new('F')),
28//! AnyRange::from(Char::new('a')..=Char::new('f')),
29//! ]);
30//! // A set matching a named Unicode property is rendered as that property.
31//! assert_eq!("\\p{ASCII_Hex_Digit}", hex.to_regex());
32//! assert_eq!("\\P{ASCII_Hex_Digit}", hex.complement().to_regex());
33//!
34//! assert_eq!(".", hex.union(&hex.complement()).to_regex());
35//! assert_eq!("[]", hex.intersection(&hex.complement()).to_regex());
36//! assert_eq!("[g-z]", letters.difference(&hex).to_regex());
37//! ```
38//!
39//! # The generated regular expression
40//!
41//! [`to_regex`](CharacterClass::to_regex) returns the shortest form it finds for a set,
42//! in this order:
43//!
44//! | Form | When |
45//! |---|---|
46//! | `[]` | the set is empty |
47//! | `.` | the set holds every `char` |
48//! | `\n`, `\r`, `\t`, `\v` | the set holds only that one `char` |
49//! | `\d`, `\s`, `\w`, `\D`, `\S`, `\W` | the set is exactly that Perl class |
50//! | `\p{Name}`, `\P{Name}` | the set is exactly a Unicode general category, script or boolean property |
51//! | `a`, `\.` | the set holds one `char`, escaped if it is a metacharacter |
52//! | `[a-z0-9]`, `[^a-z]` | otherwise, complemented when that is shorter |
53//!
54//! Three of them need care from whoever reads them back:
55//!
56//! - `[]` is a placeholder for the empty set, not a pattern. An empty bracketed class is
57//! a parse error in every engine, so a set that may be empty has to be handled before
58//! the output reaches one. `[^\s\S]` is the usual pattern for a class matching
59//! nothing.
60//! - `.` stands for the total set, which includes `\n`, so the pattern needs
61//! dot-matches-newline (`(?s)`).
62//! - `\p{Name}` and the Perl classes stand for Unicode sets, so the pattern needs
63//! Unicode mode, which is the default in most engines. The tables here are generated
64//! from the Unicode character database a `ucd-*` feature selects, [`UCD_VERSION`], so
65//! an engine built against another version may resolve a name to a slightly different
66//! set.
67//!
68//! # The surrogate block
69//!
70//! `char` is not contiguous: the codepoints `U+D800..=U+DFFF` are surrogates and hold no
71//! `char`. [`Char`] counts by index rather than by codepoint and skips that
72//! block, so `'\u{D7FF}'` and `'\u{E000}'` are adjacent and the total set is one range
73//! of 1,112,064 values rather than two. See [`Char`] for the details.
74//!
75//! # Feature flags
76//!
77//! The crate has no default features.
78//!
79//! - `serde`: implement `Serialize` and `Deserialize` for [`Char`], and
80//! enable the same feature on `irange` so that a `RangeSet<Char>` round-trips.
81//! - `ucd-17`, `ucd-16`: the version of the Unicode character database the named
82//! classes are generated from, which decides both the set of names
83//! [`to_regex`](CharacterClass::to_regex) can return and the `char` each of them
84//! covers. Pick the one matching the engine that will read the output; the version in
85//! use is [`UCD_VERSION`].
86//!
87//! A build that names none of them gets the newest version. Only the selected version
88//! is compiled into the crate, so an unused one costs neither build time nor binary
89//! size.
90//!
91//! Naming a version is what pins it: since none of them is a default feature,
92//! `features = ["ucd-16"]` selects Unicode 16 rather than being unified with a default
93//! that outranks it, and it stays on 16 when a later release adds a newer version. A
94//! build that names none floats to the newest version each release.
95//!
96//! Cargo features are additive, though, so this only pins the version for a binary. If
97//! one dependent asks for `ucd-16` while another asks for `ucd-17`, both features are
98//! enabled and the *highest* wins for the whole build. That keeps the outcome
99//! deterministic, but it does mean a library cannot pin the UCD version its dependents
100//! get. Where the exact version matters, assert on [`UCD_VERSION`] rather than assuming
101//! the feature you named is the one in force.
102
103#![forbid(unsafe_code)]
104#![warn(missing_docs)]
105#![cfg_attr(docsrs, feature(doc_cfg))]
106
107pub mod char;
108mod tokens;
109
110/// The `rust` code blocks in `README.md`, compiled and run as doctests so that the
111/// README cannot drift from the API. It exists only while rustdoc is collecting
112/// doctests, so it is not part of the crate or of its documentation.
113#[cfg(doctest)]
114#[doc = include_str!("../README.md")]
115struct Readme;
116
117use std::ops::{Bound, RangeBounds};
118
119use irange::integer::Bounded;
120use tokens::identify_character;
121
122pub use crate::char::Char;
123pub use irange;
124
125/// The version of the Unicode character database the named classes come from, as it is
126/// written in the UCD itself, for instance `"17.0.0"`.
127///
128/// This is the version of the tables compiled into the crate, selected by the `ucd-*`
129/// feature flags described in [the crate documentation](crate#feature-flags). It is not
130/// necessarily the version the engine reading a `\p{Name}` class resolves it against.
131///
132/// # Example
133///
134/// ```
135/// assert_eq!(3, regex_charclass::UCD_VERSION.split('.').count());
136/// ```
137pub const UCD_VERSION: &str = tokens::UCD_VERSION;
138/// The set type this crate operates on, and the range type its constructors take, are
139/// `irange`'s. They are re-exported here so that a dependent needs neither its own
140/// dependency on `irange` nor a version kept in step with this crate's.
141pub use irange::{range::AnyRange, RangeSet};
142
143/// The `char` set operations a [`RangeSet<Char>`](irange::RangeSet) gains.
144///
145/// This trait is implemented for `RangeSet<Char>` and is not meant to be implemented
146/// elsewhere. Import it to call the methods below on a set of `char`.
147///
148/// # Example
149///
150/// ```
151/// use regex_charclass::{AnyRange, Char, CharacterClass, RangeSet};
152///
153/// let range1 = RangeSet::new_from_range_char('a'..='z');
154/// assert_eq!(26, range1.get_cardinality());
155/// assert_eq!("[a-z]", range1.to_regex());
156///
157/// let range2 = RangeSet::new_from_ranges(&[
158/// AnyRange::from(Char::new('0')..=Char::new('9')),
159/// AnyRange::from(Char::new('A')..=Char::new('F')),
160/// AnyRange::from(Char::new('a')..=Char::new('f')),
161/// ]);
162/// assert_eq!("\\p{ASCII_Hex_Digit}", range2.to_regex());
163///
164/// let range2_complement = range2.complement();
165/// assert_eq!("\\P{ASCII_Hex_Digit}", range2_complement.to_regex());
166///
167/// assert_eq!(".", range2.union(&range2_complement).to_regex());
168/// assert_eq!("[]", range2.intersection(&range2_complement).to_regex());
169///
170/// assert_eq!("[g-z]", range1.difference(&range2).to_regex());
171/// ```
172pub trait CharacterClass: Sized {
173 /// Create a new instance from the given range of codepoints, or return `None` if a
174 /// bound is not a `char`.
175 ///
176 /// A range holding no codepoint, such as `0..0`, gives the empty set.
177 fn new_from_range_u32<R: RangeBounds<u32>>(range: R) -> Option<Self>;
178
179 /// Create a new instance from the given range of `char`.
180 ///
181 /// A range holding no `char`, such as `'a'..'a'`, gives the empty set.
182 fn new_from_range_char<R: RangeBounds<char>>(range: R) -> Self;
183
184 /// Return the number of `char` values contained, at most 1,112,064.
185 fn get_cardinality(&self) -> u32;
186
187 /// Return the set as a regular expression character class.
188 ///
189 /// See [the crate documentation](crate#the-generated-regular-expression) for the
190 /// forms this returns and the flags they assume.
191 fn to_regex(&self) -> String;
192}
193
194impl CharacterClass for RangeSet<Char> {
195 /// Create a new instance from the given range of codepoints, or return `None` if a
196 /// bound is not a `char`, i.e. if it is a surrogate or above [`char::MAX`].
197 ///
198 /// A range holding no codepoint gives the empty set.
199 ///
200 /// # Example
201 ///
202 /// ```
203 /// use regex_charclass::{CharacterClass, RangeSet};
204 ///
205 /// let range = RangeSet::new_from_range_u32(97..=122).unwrap();
206 /// assert_eq!("[a-z]", range.to_regex());
207 ///
208 /// // An empty range, and a bound that is not a `char`.
209 /// assert!(RangeSet::new_from_range_u32(97..97).unwrap().is_empty());
210 /// assert_eq!(None, RangeSet::new_from_range_u32(0xD800..=0xD800));
211 /// ```
212 #[inline]
213 fn new_from_range_u32<R: RangeBounds<u32>>(range: R) -> Option<Self> {
214 let min = match range.start_bound() {
215 Bound::Included(&t) => Some(Char::from_u32(t)?),
216 // A `char` may be excluded, but only if it is one in the first place.
217 Bound::Excluded(&t) => Char::from_u32(t)?.next(),
218 Bound::Unbounded => Some(Char::min_value()),
219 };
220 let max = match range.end_bound() {
221 Bound::Included(&t) => Some(Char::from_u32(t)?),
222 Bound::Excluded(&t) => Char::from_u32(t)?.previous(),
223 Bound::Unbounded => Some(Char::max_value()),
224 };
225
226 Some(new_from_bounds(min, max))
227 }
228
229 /// Create a new instance from the given range of `char`.
230 ///
231 /// A range holding no `char` gives the empty set.
232 ///
233 /// # Example
234 ///
235 /// ```
236 /// use regex_charclass::{CharacterClass, RangeSet};
237 ///
238 /// let range = RangeSet::new_from_range_char('a'..='z');
239 /// assert_eq!("[a-z]", range.to_regex());
240 ///
241 /// assert_eq!("[a-y]", RangeSet::new_from_range_char('a'..'z').to_regex());
242 /// assert!(RangeSet::new_from_range_char('a'..'a').is_empty());
243 /// ```
244 #[inline]
245 fn new_from_range_char<R: RangeBounds<char>>(range: R) -> Self {
246 let min = match range.start_bound() {
247 Bound::Included(&t) => Some(Char::new(t)),
248 Bound::Excluded(&t) => Char::new(t).next(),
249 Bound::Unbounded => Some(Char::min_value()),
250 };
251 let max = match range.end_bound() {
252 Bound::Included(&t) => Some(Char::new(t)),
253 Bound::Excluded(&t) => Char::new(t).previous(),
254 Bound::Unbounded => Some(Char::max_value()),
255 };
256
257 new_from_bounds(min, max)
258 }
259
260 /// Return the number of `char` values contained, at most 1,112,064.
261 ///
262 /// The surrogate block holds no `char`, so a range spanning it does not count it.
263 ///
264 /// # Example
265 ///
266 /// ```
267 /// use regex_charclass::{CharacterClass, RangeSet};
268 ///
269 /// let range = RangeSet::new_from_range_char('a'..='z');
270 /// assert_eq!(26, range.get_cardinality());
271 ///
272 /// assert_eq!(1_112_064, RangeSet::total().get_cardinality());
273 /// ```
274 #[inline]
275 fn get_cardinality(&self) -> u32 {
276 self.0
277 .chunks_exact(2)
278 .map(|bounds| {
279 bounds[1]
280 .index()
281 .checked_sub(bounds[0].index())
282 .map_or(0, |size| size + 1)
283 })
284 .fold(0, u32::saturating_add)
285 }
286
287 /// Return the set as a regular expression character class.
288 ///
289 /// See [the crate documentation](crate#the-generated-regular-expression) for the
290 /// forms this returns and the flags they assume.
291 ///
292 /// # Example
293 ///
294 /// ```
295 /// use regex_charclass::{AnyRange, Char, CharacterClass, RangeSet};
296 ///
297 /// let range = RangeSet::new_from_range_char('a'..='z');
298 /// assert_eq!("[a-z]", range.to_regex());
299 ///
300 /// let range = RangeSet::<Char>::new_from_ranges(&[
301 /// AnyRange::from(Char::new('0')..=Char::new('9')),
302 /// AnyRange::from(Char::new('A')..=Char::new('F')),
303 /// AnyRange::from(Char::new('a')..=Char::new('f')),
304 /// ]);
305 /// assert_eq!("\\p{ASCII_Hex_Digit}", range.to_regex());
306 ///
307 /// // A single `char` needs no class, but is escaped if it is a metacharacter.
308 /// assert_eq!("\\.", RangeSet::new_from_range_char('.'..='.').to_regex());
309 /// ```
310 #[inline]
311 fn to_regex(&self) -> String {
312 if self.is_empty() {
313 String::from("[]")
314 } else if self.is_total() {
315 String::from(".")
316 } else if let Some(token) = tokens::identify_class(self) {
317 token
318 } else {
319 convert_to_regex(self)
320 }
321 }
322}
323
324/// The set of every `char` from `min` to `max`, or the empty set if either bound fell
325/// outside `char` or the range is inverted.
326#[inline]
327fn new_from_bounds(min: Option<Char>, max: Option<Char>) -> RangeSet<Char> {
328 match (min, max) {
329 // `new_from_range` gives the empty set for an inverted range.
330 (Some(min), Some(max)) => RangeSet::new_from_range(min..=max),
331 _ => RangeSet::empty(),
332 }
333}
334
335/// Render a set that matched no named class as a bracketed character class, or as a
336/// single escaped `char` when it holds only one.
337fn convert_to_regex(range: &RangeSet<Char>) -> String {
338 // A set with few gaps is shorter written as the complement of those gaps.
339 let complement = range.complement();
340 let (range_to_use, is_complement) = if complement.0.len() < range.0.len() {
341 (&complement, true)
342 } else {
343 (range, false)
344 };
345
346 let mut sb = String::new();
347 // Whether what was written is a single `char`, which needs no brackets around it.
348 let mut is_single_char = true;
349
350 for bounds in range_to_use.0.chunks_exact(2) {
351 let (min, max) = (bounds[0], bounds[1]);
352 if min == max {
353 is_single_char = sb.is_empty();
354 sb.push_str(&get_printable_char(min.to_char()));
355 continue;
356 }
357
358 is_single_char = false;
359 let separator = if min.next() == Some(max) { "" } else { "-" };
360 sb.push_str(&get_printable_char(min.to_char()));
361 sb.push_str(separator);
362 sb.push_str(&get_printable_char(max.to_char()));
363 }
364
365 if sb.is_empty() {
366 // Only reachable from a malformed set, whose bounds do not pair up.
367 String::from("[]")
368 } else if is_complement {
369 format!("[^{sb}]")
370 } else if is_single_char {
371 sb
372 } else {
373 format!("[{sb}]")
374 }
375}
376
377/// A `char` as it appears inside a character class: itself when it is printable ASCII,
378/// escaped when it is a metacharacter, and its codepoint otherwise.
379fn get_printable_char(character: char) -> String {
380 if ('\u{20}'..='\u{7E}').contains(&character) {
381 // Escaped whether or not the `char` ends up inside brackets, since `-` and `^`
382 // are special inside them and `$` and `.` are special outside them.
383 if matches!(
384 character,
385 '*' | '+'
386 | '?'
387 | '('
388 | ')'
389 | '['
390 | ']'
391 | '{'
392 | '}'
393 | '|'
394 | '\\'
395 | '-'
396 | '^'
397 | '.'
398 | '$'
399 ) {
400 format!("\\{character}")
401 } else {
402 String::from(character)
403 }
404 } else if let Some(c) = identify_character(character) {
405 c.to_owned()
406 } else {
407 format!("\\u{{{:04x}}}", character as u32)
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use irange::range::AnyRange;
414
415 use super::*;
416
417 #[test]
418 fn test_empty_and_total() {
419 let range = RangeSet::<Char>::empty();
420 assert!(range.is_empty());
421 assert_eq!("[]", range.to_regex());
422 assert_eq!(0, range.get_cardinality());
423
424 let range = RangeSet::<Char>::total();
425 assert!(range.is_total());
426 assert_eq!(".", range.to_regex());
427 assert_eq!(Char::COUNT, range.get_cardinality());
428 assert_eq!(1_112_064, range.get_cardinality());
429 }
430
431 #[test]
432 fn test_operations() {
433 let range1 = RangeSet::new_from_range_char('a'..='z');
434 assert_eq!("[a-z]", range1.to_regex());
435
436 for char in range1.iter() {
437 assert!(range1.contains(char))
438 }
439
440 let range2 = RangeSet::<Char>::new_from_ranges(&[
441 AnyRange::from(Char::new('0')..Char::new('2')),
442 AnyRange::from(Char::new('A')..=Char::new('F')),
443 AnyRange::from(Char::new('a')..=Char::new('f')),
444 ]);
445 assert_eq!("[01A-Fa-f]", range2.to_regex());
446
447 for char in range2.iter() {
448 assert!(range2.contains(char))
449 }
450
451 let intersection = range1.intersection(&range2);
452 assert_eq!("[a-f]", intersection.to_regex());
453
454 for char in intersection.iter() {
455 assert!(intersection.contains(char))
456 }
457 }
458
459 #[test]
460 fn test_to_regex() {
461 let range = RangeSet::<Char>::new_from_range_char('.'..='.');
462 assert_eq!("\\.", range.to_regex());
463
464 let range = RangeSet::<Char>::new_from_ranges(&[
465 AnyRange::from(Char::new('0')..=Char::new('9')),
466 AnyRange::from(Char::new('A')..=Char::new('F')),
467 AnyRange::from(Char::new('a')..=Char::new('f')),
468 ]);
469 assert_eq!("\\p{ASCII_Hex_Digit}", range.to_regex());
470 }
471
472 /// `$` used to be written bare, which reads as an anchor rather than as the `char`.
473 #[test]
474 fn metacharacters_are_escaped() {
475 for character in [
476 '*', '+', '?', '(', ')', '[', ']', '{', '}', '|', '\\', '-', '^', '.', '$',
477 ] {
478 let range = RangeSet::new_from_range_char(character..=character);
479 assert_eq!(format!("\\{character}"), range.to_regex());
480 }
481 }
482
483 /// `~` is printable, but the check used to exclude it and wrote its codepoint.
484 #[test]
485 fn the_whole_printable_ascii_range_is_written_as_itself() {
486 assert_eq!("~", RangeSet::new_from_range_char('~'..='~').to_regex());
487 assert_eq!("[ -~]", RangeSet::new_from_range_char(' '..='~').to_regex());
488 }
489
490 /// The bounds are a public field, so a malformed set can reach any method.
491 #[test]
492 fn no_method_panics_on_a_malformed_set() {
493 let malformed = [
494 // An odd number of bounds.
495 vec![Char::new('a')],
496 // An inverted range.
497 vec![Char::new('z'), Char::new('a')],
498 // Unsorted ranges.
499 vec![
500 Char::new('x'),
501 Char::new('z'),
502 Char::new('a'),
503 Char::new('c'),
504 ],
505 // Ranges that should have been merged.
506 vec![
507 Char::new('a'),
508 Char::new('b'),
509 Char::new('c'),
510 Char::new('d'),
511 ],
512 ];
513
514 for bounds in malformed {
515 let range = RangeSet(bounds);
516 let _ = range.get_cardinality();
517 let _ = range.to_regex();
518 }
519 }
520
521 #[test]
522 #[cfg(feature = "serde")]
523 fn test_serde() {
524 let range = RangeSet::empty();
525 let serialized = serde_json::to_string(&range).unwrap();
526 let unserialized: RangeSet<Char> = serde_json::from_str(&serialized).unwrap();
527 assert_eq!(range, unserialized);
528
529 let range = RangeSet::<Char>::total();
530 let serialized = serde_json::to_string(&range).unwrap();
531 let unserialized: RangeSet<Char> = serde_json::from_str(&serialized).unwrap();
532 assert_eq!(range, unserialized);
533
534 let range = RangeSet::new_from_ranges(&[
535 AnyRange::from(Char::new('3')..=Char::new('4')),
536 AnyRange::from(Char::new('7')..Char::new('9')),
537 ]);
538 let serialized = serde_json::to_string(&range).unwrap();
539 let unserialized: RangeSet<Char> = serde_json::from_str(&serialized).unwrap();
540 assert_eq!(range, unserialized);
541 }
542}