Skip to main content

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}