Skip to main content

to_regex_range/
lib.rs

1//! # to-regex-range — a regex that matches an integer range
2//!
3//! Generate a compact regular-expression source string matching every integer in a
4//! range: `(1, 99)` becomes `[1-9]|[1-9][0-9]`. A faithful Rust port of the
5//! [`to-regex-range`](https://www.npmjs.com/package/to-regex-range) npm package (a
6//! micromatch building block). Zero dependencies and `#![no_std]`.
7//!
8//! ```
9//! use to_regex_range::{to_regex_range, to_regex_range_with_options, Options};
10//!
11//! assert_eq!(to_regex_range(1, 5), "[1-5]");
12//! assert_eq!(to_regex_range(1, 99), "(?:[1-9]|[1-9][0-9])");
13//! assert_eq!(to_regex_range(-10, -1), "(?:-[1-9]|-10)");
14//!
15//! let opts = Options::default().shorthand(true);
16//! assert_eq!(to_regex_range_with_options(1, 99, &opts), "(?:[1-9]|[1-9]\\d)");
17//! ```
18//!
19//! The result is a regex *source* (no delimiters); wrap it in your regex engine of
20//! choice. Operates on integer ranges; zero-padded ranges (`001..100`) are out of
21//! scope.
22
23#![no_std]
24#![doc(html_root_url = "https://docs.rs/to-regex-range/0.1.0")]
25
26extern crate alloc;
27
28use alloc::collections::BTreeSet;
29use alloc::format;
30use alloc::string::{String, ToString};
31use alloc::vec;
32use alloc::vec::Vec;
33
34// Compile-test the README's examples as part of `cargo test`.
35#[cfg(doctest)]
36#[doc = include_str!("../README.md")]
37struct ReadmeDoctests;
38
39/// Options controlling the generated regex. Construct with [`Options::default`] and
40/// the builder methods.
41///
42/// ```
43/// use to_regex_range::Options;
44/// let opts = Options::default().capture(true).shorthand(true);
45/// ```
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct Options {
48    /// Wrap the result in a capture group `( … )` instead of `(?: … )`.
49    pub capture: bool,
50    /// Use `\d` instead of `[0-9]`.
51    pub shorthand: bool,
52    /// Wrap a multi-alternative result in `(?: … )` (the default).
53    pub wrap: bool,
54}
55
56impl Default for Options {
57    fn default() -> Self {
58        Self {
59            capture: false,
60            shorthand: false,
61            wrap: true,
62        }
63    }
64}
65
66impl Options {
67    /// See [`Options::capture`].
68    #[must_use]
69    pub fn capture(mut self, value: bool) -> Self {
70        self.capture = value;
71        self
72    }
73    /// See [`Options::shorthand`].
74    #[must_use]
75    pub fn shorthand(mut self, value: bool) -> Self {
76        self.shorthand = value;
77        self
78    }
79    /// See [`Options::wrap`].
80    #[must_use]
81    pub fn wrap(mut self, value: bool) -> Self {
82        self.wrap = value;
83        self
84    }
85}
86
87/// Generate a regex source matching every integer from `min` to `max` (inclusive),
88/// using the default [`Options`].
89///
90/// ```
91/// assert_eq!(to_regex_range::to_regex_range(1, 10), "(?:[1-9]|10)");
92/// ```
93#[must_use]
94pub fn to_regex_range(min: i64, max: i64) -> String {
95    to_regex_range_with_options(min, max, &Options::default())
96}
97
98/// Generate a regex source matching every integer from `min` to `max` (inclusive).
99#[must_use]
100pub fn to_regex_range_with_options(min: i64, max: i64, options: &Options) -> String {
101    if min == max {
102        return min.to_string();
103    }
104
105    let a = min.min(max);
106    let b = min.max(max);
107
108    if a.abs_diff(b) == 1 {
109        let result = format!("{min}|{max}");
110        if options.capture {
111            return format!("({result})");
112        }
113        if !options.wrap {
114            return result;
115        }
116        return format!("(?:{result})");
117    }
118
119    let mut a = a;
120    let mut negatives: Vec<Token> = Vec::new();
121    let mut positives: Vec<Token> = Vec::new();
122
123    if a < 0 {
124        // `saturating_abs` keeps `i64::MIN` from overflowing; ranges that extreme are
125        // pathological for a regex and may lose the single most-negative integer.
126        let new_min = if b < 0 { b.saturating_abs() } else { 1 };
127        negatives = split_to_patterns(new_min, a.saturating_abs(), *options);
128        a = 0;
129    }
130
131    if b >= 0 {
132        positives = split_to_patterns(a, b, *options);
133    }
134
135    let mut result = collate_patterns(&negatives, &positives);
136
137    if options.capture {
138        result = format!("({result})");
139    } else if options.wrap && (positives.len() + negatives.len()) > 1 {
140        result = format!("(?:{result})");
141    }
142
143    result
144}
145
146struct Token {
147    pattern: String,
148    count: Vec<i64>,
149    string: String,
150}
151
152fn collate_patterns(neg: &[Token], pos: &[Token]) -> String {
153    let mut subpatterns = filter_patterns(neg, pos, "-", false);
154    subpatterns.extend(filter_patterns(neg, pos, "-?", true));
155    subpatterns.extend(filter_patterns(pos, neg, "", false));
156    subpatterns.join("|")
157}
158
159fn filter_patterns(
160    arr: &[Token],
161    comparison: &[Token],
162    prefix: &str,
163    intersection: bool,
164) -> Vec<String> {
165    let mut result = Vec::new();
166    for ele in arr {
167        let present = comparison.iter().any(|e| e.string == ele.string);
168        if intersection == present {
169            result.push(format!("{prefix}{}", ele.string));
170        }
171    }
172    result
173}
174
175#[allow(clippy::cast_possible_truncation)] // each inserted stop is bounded by `max`
176fn split_to_ranges(min: i64, max: i64) -> Vec<i64> {
177    let (min128, max128) = (i128::from(min), i128::from(max));
178    let mut stops: BTreeSet<i64> = BTreeSet::new();
179    stops.insert(max);
180
181    // Compute the nine-boundary stops in i128 so an all-nines value can exceed `max`
182    // (and stop the loop) even when `max == i64::MAX`.
183    let mut nines = 1;
184    let mut stop = count_nines(min, nines);
185    while min128 <= stop && stop <= max128 {
186        stops.insert(stop as i64);
187        nines += 1;
188        stop = count_nines(min, nines);
189    }
190
191    let mut zeros = 1;
192    let mut stop = count_zeros(max.saturating_add(1), zeros) - 1;
193    while min < stop && stop <= max {
194        stops.insert(stop);
195        zeros += 1;
196        stop = count_zeros(max.saturating_add(1), zeros) - 1;
197    }
198
199    stops.into_iter().collect()
200}
201
202fn split_to_patterns(min: i64, max: i64, options: Options) -> Vec<Token> {
203    let ranges = split_to_ranges(min, max);
204    let mut tokens: Vec<Token> = Vec::new();
205    let mut start = min;
206    let mut prev: Option<usize> = None;
207
208    for &stop in &ranges {
209        let obj = range_to_pattern(start, stop, options);
210
211        if let Some(pi) = prev {
212            if tokens[pi].pattern == obj.pattern {
213                if tokens[pi].count.len() > 1 {
214                    tokens[pi].count.pop();
215                }
216                tokens[pi].count.push(obj.count[0]);
217                let quant = to_quantifier(&tokens[pi].count);
218                tokens[pi].string = format!("{}{quant}", tokens[pi].pattern);
219                start = stop.saturating_add(1);
220                continue;
221            }
222        }
223
224        let quant = to_quantifier(&obj.count);
225        let string = format!("{}{quant}", obj.pattern);
226        tokens.push(Token {
227            pattern: obj.pattern,
228            count: obj.count,
229            string,
230        });
231        start = stop.saturating_add(1);
232        prev = Some(tokens.len() - 1);
233    }
234
235    tokens
236}
237
238struct RangePattern {
239    pattern: String,
240    count: Vec<i64>,
241}
242
243fn range_to_pattern(start: i64, stop: i64, options: Options) -> RangePattern {
244    if start == stop {
245        return RangePattern {
246            pattern: start.to_string(),
247            count: vec![0],
248        };
249    }
250
251    let s = start.to_string();
252    let t = stop.to_string();
253    let mut pattern = String::new();
254    let mut count = 0;
255
256    for (sd, td) in s.chars().zip(t.chars()) {
257        if sd == td {
258            pattern.push(sd);
259        } else if sd != '0' || td != '9' {
260            pattern.push_str(&to_character_class(sd, td));
261        } else {
262            count += 1;
263        }
264    }
265
266    if count > 0 {
267        pattern.push_str(if options.shorthand { "\\d" } else { "[0-9]" });
268    }
269
270    RangePattern {
271        pattern,
272        count: vec![count],
273    }
274}
275
276fn to_quantifier(digits: &[i64]) -> String {
277    let start = digits.first().copied().unwrap_or(0);
278    let stop = digits.get(1).copied();
279    let stop_truthy = stop.is_some_and(|s| s != 0);
280    if stop_truthy {
281        format!("{{{start},{}}}", stop.unwrap())
282    } else if start > 1 {
283        format!("{{{start}}}")
284    } else {
285        String::new()
286    }
287}
288
289fn to_character_class(a: char, b: char) -> String {
290    let dash = if (b as i64 - a as i64) == 1 { "" } else { "-" };
291    format!("[{a}{dash}{b}]")
292}
293
294/// `Number(String(min).slice(0, -len) + '9'.repeat(len))`, in `i128` so all-nines
295/// values that exceed `i64::MAX` can still terminate the loop in `split_to_ranges`.
296fn count_nines(min: i64, len: usize) -> i128 {
297    let s = min.to_string();
298    let keep = s.len().saturating_sub(len);
299    let mut out = String::from(&s[..keep]);
300    for _ in 0..len {
301        out.push('9');
302    }
303    out.parse::<i128>().unwrap_or(i128::MAX)
304}
305
306/// `integer - (integer % 10^zeros)`.
307#[allow(clippy::cast_possible_truncation)] // result is bounded by `integer` (an i64)
308fn count_zeros(integer: i64, zeros: u32) -> i64 {
309    let pow = 10i128.pow(zeros);
310    let i = i128::from(integer);
311    (i - (i % pow)) as i64
312}