Skip to main content

numeric_range/
lib.rs

1//! # numeric-range — compact integer-range strings
2//!
3//! Parse and format the familiar `"1,3-5,7"` range syntax used by print dialogs
4//! (page ranges), CPU affinity lists (`taskset`/cgroups), line selectors, and
5//! CLI flags — in **both** directions.
6//!
7//! ```
8//! assert_eq!(numeric_range::parse("1,3-5,7").unwrap(), vec![1, 3, 4, 5, 7]);
9//! assert_eq!(numeric_range::format(&[1, 3, 4, 5, 7]), "1,3-5,7");
10//! ```
11//!
12//! Zero dependencies, `#![no_std]` (needs only `alloc`).
13
14#![no_std]
15
16extern crate alloc;
17
18use alloc::string::String;
19use alloc::vec::Vec;
20use core::fmt::{self, Write};
21
22/// Maximum number of values [`parse`] will expand, to bound memory on untrusted
23/// input (a tiny string like `"1-9999999999"` would otherwise request gigabytes).
24const MAX_VALUES: u128 = 10_000_000;
25
26/// An error produced by [`parse`].
27#[derive(Debug, Clone, PartialEq, Eq)]
28#[non_exhaustive]
29pub enum ParseError {
30    /// A comma-separated part was empty (e.g. `"1,,2"` or a trailing comma).
31    EmptyPart,
32    /// A part was not a valid unsigned integer.
33    InvalidNumber(String),
34    /// A part had the wrong shape for a range (not `a` or `a-b`).
35    InvalidRange(String),
36    /// A range's start was greater than its end (e.g. `"5-1"`).
37    DescendingRange {
38        /// The (larger) start value.
39        start: u64,
40        /// The (smaller) end value.
41        end: u64,
42    },
43    /// Expansion would exceed the supported maximum number of values
44    /// (guards against memory-exhaustion from a tiny input).
45    RangeTooLarge {
46        /// The range's start value.
47        start: u64,
48        /// The range's end value.
49        end: u64,
50    },
51}
52
53impl fmt::Display for ParseError {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        match self {
56            ParseError::EmptyPart => f.write_str("empty range part"),
57            ParseError::InvalidNumber(s) => write!(f, "invalid number: {s:?}"),
58            ParseError::InvalidRange(s) => write!(f, "invalid range: {s:?}"),
59            ParseError::DescendingRange { start, end } => {
60                write!(f, "descending range: {start}-{end}")
61            }
62            ParseError::RangeTooLarge { start, end } => {
63                write!(f, "range too large to expand: {start}-{end}")
64            }
65        }
66    }
67}
68
69impl core::error::Error for ParseError {}
70
71/// Parse a compact range string like `"1,3-5,7"` into the expanded values,
72/// preserving input order.
73///
74/// An empty (or whitespace-only) input yields an empty `Vec`.
75///
76/// # Errors
77///
78/// Returns [`ParseError`] for empty parts, non-numeric parts, malformed ranges,
79/// or descending ranges.
80pub fn parse(input: &str) -> Result<Vec<u64>, ParseError> {
81    let trimmed = input.trim();
82    if trimmed.is_empty() {
83        return Ok(Vec::new());
84    }
85
86    let mut out = Vec::new();
87    for raw in trimmed.split(',') {
88        let part = raw.trim();
89        if part.is_empty() {
90            return Err(ParseError::EmptyPart);
91        }
92        let mut bounds = part.split('-');
93        let first = bounds.next().unwrap_or("").trim();
94        match bounds.next() {
95            None => out.push(number(first)?),
96            Some(second) => {
97                if bounds.next().is_some() {
98                    return Err(ParseError::InvalidRange(part.into()));
99                }
100                let start = number(first)?;
101                let end = number(second.trim())?;
102                if start > end {
103                    return Err(ParseError::DescendingRange { start, end });
104                }
105                // Bound expansion so a tiny string can't exhaust memory.
106                let span = u128::from(end - start) + 1;
107                if out.len() as u128 + span > MAX_VALUES {
108                    return Err(ParseError::RangeTooLarge { start, end });
109                }
110                out.extend(start..=end);
111            }
112        }
113    }
114    Ok(out)
115}
116
117fn number(s: &str) -> Result<u64, ParseError> {
118    // `u64::from_str` tolerates a leading '+'; reject it to stay strict and keep
119    // parse/format round-trips canonical.
120    if s.starts_with('+') {
121        return Err(ParseError::InvalidNumber(s.into()));
122    }
123    s.parse().map_err(|_| ParseError::InvalidNumber(s.into()))
124}
125
126/// Format a slice of integers into a compact range string, e.g.
127/// `[1, 3, 4, 5, 7]` → `"1,3-5,7"`. Values are sorted and de-duplicated first.
128#[must_use]
129pub fn format(values: &[u64]) -> String {
130    if values.is_empty() {
131        return String::new();
132    }
133    let mut sorted = values.to_vec();
134    sorted.sort_unstable();
135    sorted.dedup();
136
137    let mut out = String::new();
138    let mut i = 0;
139    while i < sorted.len() {
140        let start = sorted[i];
141        let mut end = start;
142        // Extend the run while the next value is exactly end + 1 (no overflow).
143        while i + 1 < sorted.len() && end.checked_add(1) == Some(sorted[i + 1]) {
144            end = sorted[i + 1];
145            i += 1;
146        }
147        if !out.is_empty() {
148            out.push(',');
149        }
150        // Writing to a `String` is infallible.
151        if start == end {
152            let _ = write!(out, "{start}");
153        } else {
154            let _ = write!(out, "{start}-{end}");
155        }
156        i += 1;
157    }
158    out
159}