Skip to main content

parse_numeric_range/
lib.rs

1//! # parse-numeric-range — parse a string of numbers and ranges
2//!
3//! Expand a comma-separated string of numbers and ranges into a list of integers:
4//! `"1,3-5,8"` → `[1, 3, 4, 5, 8]`. A faithful Rust port of the
5//! [`parse-numeric-range`](https://www.npmjs.com/package/parse-numeric-range) npm package.
6//!
7//! ```
8//! use parse_numeric_range::parse;
9//!
10//! assert_eq!(parse("1,3-5,8"), [1, 3, 4, 5, 8]);
11//! assert_eq!(parse("5-1"), [5, 4, 3, 2, 1]);          // descending
12//! assert_eq!(parse("-5--2"), [-5, -4, -3, -2]);       // negatives
13//! ```
14//!
15//! ## Range separators
16//!
17//! | Separator        | Meaning                | Example   | Result            |
18//! | ---------------- | ---------------------- | --------- | ----------------- |
19//! | `-`, `..`, `‥`   | inclusive of the end   | `1-3`     | `[1, 2, 3]`       |
20//! | `...`, `…`, `⋯`  | exclusive of the end   | `1...3`   | `[1, 2]`          |
21//!
22//! Whitespace around each comma-separated entry is trimmed; anything that is not a plain
23//! number or a `number SEP number` range is ignored. Numbers are parsed as [`i64`].
24//!
25//! Note: like the reference, ranges are fully expanded, so a huge range (e.g. `0-1000000000`)
26//! allocates one entry per value.
27//!
28//! **Zero dependencies** and `#![no_std]`.
29
30#![no_std]
31#![forbid(unsafe_code)]
32#![doc(html_root_url = "https://docs.rs/parse-numeric-range/0.1.0")]
33
34extern crate alloc;
35
36use alloc::vec::Vec;
37
38// Compile-test the README's examples as part of `cargo test`.
39#[cfg(doctest)]
40#[doc = include_str!("../README.md")]
41struct ReadmeDoctests;
42
43/// Parse `input` into the list of integers it describes.
44///
45/// The input is split on commas; each entry is either a number (`"42"`, `"-3"`) or a range
46/// (`"1-5"`, `"1...5"`). Unrecognized entries are skipped. Duplicates are kept (no
47/// deduplication), matching the reference implementation.
48///
49/// ```
50/// # use parse_numeric_range::parse;
51/// assert_eq!(parse("1-3,7,9-11"), [1, 2, 3, 7, 9, 10, 11]);
52/// assert_eq!(parse(",,1,,2,,"), [1, 2]);
53/// assert_eq!(parse("nope"), [] as [i64; 0]);
54/// ```
55#[must_use]
56pub fn parse(input: &str) -> Vec<i64> {
57    let mut result = Vec::new();
58
59    for part in input.split(',') {
60        let entry = part.trim_matches(is_js_whitespace);
61
62        if is_integer(entry) {
63            if let Ok(value) = entry.parse::<i64>() {
64                result.push(value);
65            }
66        } else if let Some((lhs, inclusive, rhs)) = parse_range(entry) {
67            let increment: i64 = if lhs < rhs { 1 } else { -1 };
68            // Inclusive separators move the stop point one step past the end.
69            let end = if inclusive {
70                rhs.wrapping_add(increment)
71            } else {
72                rhs
73            };
74            let mut current = lhs;
75            while current != end {
76                result.push(current);
77                current = current.wrapping_add(increment);
78            }
79        }
80    }
81
82    result
83}
84
85/// `^-?[0-9]+$` — an optional minus sign followed by one or more ASCII digits.
86fn is_integer(s: &str) -> bool {
87    let bytes = s.as_bytes();
88    let digits = if bytes.first() == Some(&b'-') {
89        &bytes[1..]
90    } else {
91        bytes
92    };
93    !digits.is_empty() && digits.iter().all(u8::is_ascii_digit)
94}
95
96/// Take a leading `-?[0-9]+`, returning it and the remainder.
97fn take_integer(s: &str) -> Option<(&str, &str)> {
98    let bytes = s.as_bytes();
99    let mut end = usize::from(bytes.first() == Some(&b'-'));
100    let digits_start = end;
101    while end < bytes.len() && bytes[end].is_ascii_digit() {
102        end += 1;
103    }
104    if end == digits_start {
105        return None;
106    }
107    Some((&s[..end], &s[end..]))
108}
109
110/// Take a leading range separator, returning whether it is inclusive and the remainder.
111///
112/// `-`, `..`, and `‥` (U+2025) are inclusive; `...`, `…` (U+2026), and `⋯` (U+22EF) are
113/// exclusive. `...` is matched before `..` (the reference's `\.\.\.?` is greedy).
114fn take_separator(s: &str) -> Option<(bool, &str)> {
115    if let Some(rest) = s.strip_prefix('-') {
116        Some((true, rest))
117    } else if let Some(rest) = s.strip_prefix("...") {
118        Some((false, rest))
119    } else if let Some(rest) = s.strip_prefix("..") {
120        Some((true, rest))
121    } else if let Some(rest) = s.strip_prefix('\u{2025}') {
122        Some((true, rest))
123    } else if let Some(rest) = s.strip_prefix('\u{2026}') {
124        Some((false, rest))
125    } else if let Some(rest) = s.strip_prefix('\u{22EF}') {
126        Some((false, rest))
127    } else {
128        None
129    }
130}
131
132/// `^(-?[0-9]+)(SEP)(-?[0-9]+)$` → `(lhs, inclusive, rhs)`.
133fn parse_range(s: &str) -> Option<(i64, bool, i64)> {
134    let (lhs, rest) = take_integer(s)?;
135    let (inclusive, rest) = take_separator(rest)?;
136    let (rhs, rest) = take_integer(rest)?;
137    if !rest.is_empty() {
138        return None;
139    }
140    Some((lhs.parse().ok()?, inclusive, rhs.parse().ok()?))
141}
142
143/// `String.prototype.trim()` whitespace set.
144fn is_js_whitespace(c: char) -> bool {
145    matches!(
146        c,
147        '\u{0009}'
148            | '\u{000A}'
149            | '\u{000B}'
150            | '\u{000C}'
151            | '\u{000D}'
152            | '\u{0020}'
153            | '\u{00A0}'
154            | '\u{1680}'
155            | '\u{2000}'
156            ..='\u{200A}'
157                | '\u{2028}'
158                | '\u{2029}'
159                | '\u{202F}'
160                | '\u{205F}'
161                | '\u{3000}'
162                | '\u{FEFF}'
163    )
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn numbers() {
172        assert_eq!(parse("1,2,3"), [1, 2, 3]);
173        assert_eq!(parse("0"), [0]);
174        assert_eq!(parse("-0"), [0]);
175        assert_eq!(parse("007"), [7]);
176        assert_eq!(parse(",,1,,2,,"), [1, 2]);
177        assert_eq!(parse("1,1"), [1, 1]); // no dedup
178    }
179
180    #[test]
181    fn ranges() {
182        assert_eq!(parse("1-5"), [1, 2, 3, 4, 5]);
183        assert_eq!(parse("5-1"), [5, 4, 3, 2, 1]);
184        assert_eq!(parse("3-3"), [3]);
185        assert_eq!(parse("0-0"), [0]);
186        assert_eq!(parse("10-8,1"), [10, 9, 8, 1]);
187    }
188
189    #[test]
190    fn negatives() {
191        assert_eq!(parse("-5--2"), [-5, -4, -3, -2]);
192        assert_eq!(parse("-3-2"), [-3, -2, -1, 0, 1, 2]);
193    }
194
195    #[test]
196    fn separators() {
197        assert_eq!(parse("1..5"), [1, 2, 3, 4, 5]); // inclusive
198        assert_eq!(parse("1...5"), [1, 2, 3, 4]); // exclusive
199        assert_eq!(parse("1\u{2025}5"), [1, 2, 3, 4, 5]); // ‥ inclusive
200        assert_eq!(parse("1\u{2026}5"), [1, 2, 3, 4]); // … exclusive
201        assert_eq!(parse("1\u{22EF}5"), [1, 2, 3, 4]); // ⋯ exclusive
202    }
203
204    #[test]
205    fn ignored() {
206        assert_eq!(parse("a-c"), [] as [i64; 0]);
207        assert_eq!(parse("2-"), [] as [i64; 0]);
208        assert_eq!(parse("1-2-3"), [] as [i64; 0]);
209        assert_eq!(parse("1.5"), [] as [i64; 0]);
210        assert_eq!(parse(" 3 - 5 "), [] as [i64; 0]); // internal spaces
211        assert_eq!(parse(""), [] as [i64; 0]);
212    }
213}