parse-numeric-range 0.1.0

Parse a string of numbers and ranges (e.g. 1,3-5,8) into a list of integers. A faithful port of the parse-numeric-range npm package. Zero dependencies, no_std.
Documentation
//! # parse-numeric-range — parse a string of numbers and ranges
//!
//! Expand a comma-separated string of numbers and ranges into a list of integers:
//! `"1,3-5,8"` → `[1, 3, 4, 5, 8]`. A faithful Rust port of the
//! [`parse-numeric-range`](https://www.npmjs.com/package/parse-numeric-range) npm package.
//!
//! ```
//! use parse_numeric_range::parse;
//!
//! assert_eq!(parse("1,3-5,8"), [1, 3, 4, 5, 8]);
//! assert_eq!(parse("5-1"), [5, 4, 3, 2, 1]);          // descending
//! assert_eq!(parse("-5--2"), [-5, -4, -3, -2]);       // negatives
//! ```
//!
//! ## Range separators
//!
//! | Separator        | Meaning                | Example   | Result            |
//! | ---------------- | ---------------------- | --------- | ----------------- |
//! | `-`, `..`, `‥`   | inclusive of the end   | `1-3`     | `[1, 2, 3]`       |
//! | `...`, `…`, `⋯`  | exclusive of the end   | `1...3`   | `[1, 2]`          |
//!
//! Whitespace around each comma-separated entry is trimmed; anything that is not a plain
//! number or a `number SEP number` range is ignored. Numbers are parsed as [`i64`].
//!
//! Note: like the reference, ranges are fully expanded, so a huge range (e.g. `0-1000000000`)
//! allocates one entry per value.
//!
//! **Zero dependencies** and `#![no_std]`.

#![no_std]
#![forbid(unsafe_code)]
#![doc(html_root_url = "https://docs.rs/parse-numeric-range/0.1.0")]

extern crate alloc;

use alloc::vec::Vec;

// Compile-test the README's examples as part of `cargo test`.
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;

/// Parse `input` into the list of integers it describes.
///
/// The input is split on commas; each entry is either a number (`"42"`, `"-3"`) or a range
/// (`"1-5"`, `"1...5"`). Unrecognized entries are skipped. Duplicates are kept (no
/// deduplication), matching the reference implementation.
///
/// ```
/// # use parse_numeric_range::parse;
/// assert_eq!(parse("1-3,7,9-11"), [1, 2, 3, 7, 9, 10, 11]);
/// assert_eq!(parse(",,1,,2,,"), [1, 2]);
/// assert_eq!(parse("nope"), [] as [i64; 0]);
/// ```
#[must_use]
pub fn parse(input: &str) -> Vec<i64> {
    let mut result = Vec::new();

    for part in input.split(',') {
        let entry = part.trim_matches(is_js_whitespace);

        if is_integer(entry) {
            if let Ok(value) = entry.parse::<i64>() {
                result.push(value);
            }
        } else if let Some((lhs, inclusive, rhs)) = parse_range(entry) {
            let increment: i64 = if lhs < rhs { 1 } else { -1 };
            // Inclusive separators move the stop point one step past the end.
            let end = if inclusive {
                rhs.wrapping_add(increment)
            } else {
                rhs
            };
            let mut current = lhs;
            while current != end {
                result.push(current);
                current = current.wrapping_add(increment);
            }
        }
    }

    result
}

/// `^-?[0-9]+$` — an optional minus sign followed by one or more ASCII digits.
fn is_integer(s: &str) -> bool {
    let bytes = s.as_bytes();
    let digits = if bytes.first() == Some(&b'-') {
        &bytes[1..]
    } else {
        bytes
    };
    !digits.is_empty() && digits.iter().all(u8::is_ascii_digit)
}

/// Take a leading `-?[0-9]+`, returning it and the remainder.
fn take_integer(s: &str) -> Option<(&str, &str)> {
    let bytes = s.as_bytes();
    let mut end = usize::from(bytes.first() == Some(&b'-'));
    let digits_start = end;
    while end < bytes.len() && bytes[end].is_ascii_digit() {
        end += 1;
    }
    if end == digits_start {
        return None;
    }
    Some((&s[..end], &s[end..]))
}

/// Take a leading range separator, returning whether it is inclusive and the remainder.
///
/// `-`, `..`, and `‥` (U+2025) are inclusive; `...`, `…` (U+2026), and `⋯` (U+22EF) are
/// exclusive. `...` is matched before `..` (the reference's `\.\.\.?` is greedy).
fn take_separator(s: &str) -> Option<(bool, &str)> {
    if let Some(rest) = s.strip_prefix('-') {
        Some((true, rest))
    } else if let Some(rest) = s.strip_prefix("...") {
        Some((false, rest))
    } else if let Some(rest) = s.strip_prefix("..") {
        Some((true, rest))
    } else if let Some(rest) = s.strip_prefix('\u{2025}') {
        Some((true, rest))
    } else if let Some(rest) = s.strip_prefix('\u{2026}') {
        Some((false, rest))
    } else if let Some(rest) = s.strip_prefix('\u{22EF}') {
        Some((false, rest))
    } else {
        None
    }
}

/// `^(-?[0-9]+)(SEP)(-?[0-9]+)$` → `(lhs, inclusive, rhs)`.
fn parse_range(s: &str) -> Option<(i64, bool, i64)> {
    let (lhs, rest) = take_integer(s)?;
    let (inclusive, rest) = take_separator(rest)?;
    let (rhs, rest) = take_integer(rest)?;
    if !rest.is_empty() {
        return None;
    }
    Some((lhs.parse().ok()?, inclusive, rhs.parse().ok()?))
}

/// `String.prototype.trim()` whitespace set.
fn is_js_whitespace(c: char) -> bool {
    matches!(
        c,
        '\u{0009}'
            | '\u{000A}'
            | '\u{000B}'
            | '\u{000C}'
            | '\u{000D}'
            | '\u{0020}'
            | '\u{00A0}'
            | '\u{1680}'
            | '\u{2000}'
            ..='\u{200A}'
                | '\u{2028}'
                | '\u{2029}'
                | '\u{202F}'
                | '\u{205F}'
                | '\u{3000}'
                | '\u{FEFF}'
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn numbers() {
        assert_eq!(parse("1,2,3"), [1, 2, 3]);
        assert_eq!(parse("0"), [0]);
        assert_eq!(parse("-0"), [0]);
        assert_eq!(parse("007"), [7]);
        assert_eq!(parse(",,1,,2,,"), [1, 2]);
        assert_eq!(parse("1,1"), [1, 1]); // no dedup
    }

    #[test]
    fn ranges() {
        assert_eq!(parse("1-5"), [1, 2, 3, 4, 5]);
        assert_eq!(parse("5-1"), [5, 4, 3, 2, 1]);
        assert_eq!(parse("3-3"), [3]);
        assert_eq!(parse("0-0"), [0]);
        assert_eq!(parse("10-8,1"), [10, 9, 8, 1]);
    }

    #[test]
    fn negatives() {
        assert_eq!(parse("-5--2"), [-5, -4, -3, -2]);
        assert_eq!(parse("-3-2"), [-3, -2, -1, 0, 1, 2]);
    }

    #[test]
    fn separators() {
        assert_eq!(parse("1..5"), [1, 2, 3, 4, 5]); // inclusive
        assert_eq!(parse("1...5"), [1, 2, 3, 4]); // exclusive
        assert_eq!(parse("1\u{2025}5"), [1, 2, 3, 4, 5]); // ‥ inclusive
        assert_eq!(parse("1\u{2026}5"), [1, 2, 3, 4]); // … exclusive
        assert_eq!(parse("1\u{22EF}5"), [1, 2, 3, 4]); // ⋯ exclusive
    }

    #[test]
    fn ignored() {
        assert_eq!(parse("a-c"), [] as [i64; 0]);
        assert_eq!(parse("2-"), [] as [i64; 0]);
        assert_eq!(parse("1-2-3"), [] as [i64; 0]);
        assert_eq!(parse("1.5"), [] as [i64; 0]);
        assert_eq!(parse(" 3 - 5 "), [] as [i64; 0]); // internal spaces
        assert_eq!(parse(""), [] as [i64; 0]);
    }
}