Skip to main content

nth_check/
lib.rs

1//! # nth-check — parse and test the CSS `An+B` microsyntax
2//!
3//! Parse the [`An+B` microsyntax](https://www.w3.org/TR/css-syntax-3/#anb-microsyntax)
4//! used by CSS selectors like `:nth-child(2n+1)`, `:nth-of-type(odd)`, and
5//! `:nth-last-child(-n+3)`, then test whether a 1-based position matches. The Rust
6//! counterpart of the [`nth-check`](https://www.npmjs.com/package/nth-check) npm
7//! package. Zero dependencies and `#![no_std]` (no `alloc` either).
8//!
9//! Parsing follows the CSS Syntax grammar strictly — the offset must carry an
10//! explicit sign (`2n+7`, never `2n7`), as browsers require. This is slightly
11//! stricter than node-`nth-check`'s lenient regex; for every input both accept, the
12//! coefficients and position matching are identical.
13//!
14//! ```
15//! use nth_check::parse;
16//!
17//! let odd = parse("2n+1").unwrap();        // or parse("odd")
18//! assert!(odd.matches(1) && odd.matches(3));
19//! assert!(!odd.matches(2));
20//!
21//! let first_three = parse("-n+3").unwrap();
22//! assert!(first_three.matches(1) && first_three.matches(3));
23//! assert!(!first_three.matches(4));
24//! ```
25//!
26//! Positions are **1-based**, matching the CSS `:nth-child` convention (the first
27//! element is position 1).
28
29#![no_std]
30#![doc(html_root_url = "https://docs.rs/nth-check/0.1.0")]
31
32// Compile-test the README's examples as part of `cargo test`.
33#[cfg(doctest)]
34#[doc = include_str!("../README.md")]
35struct ReadmeDoctests;
36
37use core::fmt;
38use core::str::FromStr;
39
40/// A parsed `An+B` expression: the coefficients of `a·n + b`.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42pub struct Nth {
43    a: i32,
44    b: i32,
45}
46
47impl Nth {
48    /// Construct an `An+B` expression from its coefficients directly.
49    #[must_use]
50    pub const fn new(a: i32, b: i32) -> Self {
51        Self { a, b }
52    }
53
54    /// The `a` coefficient (the step).
55    #[must_use]
56    pub const fn a(self) -> i32 {
57        self.a
58    }
59
60    /// The `b` coefficient (the offset).
61    #[must_use]
62    pub const fn b(self) -> i32 {
63        self.b
64    }
65
66    /// Whether the **1-based** `index` matches this expression — that is, whether
67    /// `index == a·n + b` for some non-negative integer `n`.
68    ///
69    /// ```
70    /// assert!(nth_check::parse("2n").unwrap().matches(4)); // an even position
71    /// ```
72    #[must_use]
73    pub const fn matches(self, index: i32) -> bool {
74        // Widen to avoid overflow on extreme inputs.
75        let diff = index as i64 - self.b as i64;
76        let a = self.a as i64;
77        if a == 0 {
78            diff == 0
79        } else {
80            diff % a == 0 && diff / a >= 0
81        }
82    }
83}
84
85/// The error returned when a string is not a valid `An+B` expression.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct ParseError;
88
89impl fmt::Display for ParseError {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        f.write_str("invalid An+B microsyntax")
92    }
93}
94
95impl core::error::Error for ParseError {}
96
97impl FromStr for Nth {
98    type Err = ParseError;
99    fn from_str(s: &str) -> Result<Self, Self::Err> {
100        parse(s)
101    }
102}
103
104impl fmt::Display for Nth {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        if self.a == 0 {
107            return write!(f, "{}", self.b);
108        }
109        match self.a {
110            1 => f.write_str("n")?,
111            -1 => f.write_str("-n")?,
112            a => write!(f, "{a}n")?,
113        }
114        if self.b != 0 {
115            write!(f, "{:+}", self.b)?;
116        }
117        Ok(())
118    }
119}
120
121/// Parse an `An+B` expression such as `"2n+1"`, `"odd"`, `"even"`, `"-n+3"`, or `"5"`.
122///
123/// # Errors
124///
125/// Returns [`ParseError`] if `input` is not a valid `An+B` expression. Coefficients
126/// are `i32`; a magnitude outside the `i32` range also returns [`ParseError`].
127///
128/// ```
129/// let n = nth_check::parse("2n+1").unwrap();
130/// assert_eq!((n.a(), n.b()), (2, 1));
131/// ```
132pub fn parse(input: &str) -> Result<Nth, ParseError> {
133    let s = input.trim();
134    if s.is_empty() {
135        return Err(ParseError);
136    }
137    if s.eq_ignore_ascii_case("odd") {
138        return Ok(Nth { a: 2, b: 1 });
139    }
140    if s.eq_ignore_ascii_case("even") {
141        return Ok(Nth { a: 2, b: 0 });
142    }
143
144    match s.bytes().position(|c| c == b'n' || c == b'N') {
145        // No `n`: the whole token must be a signed integer `b`, with `a = 0`.
146        None => Ok(Nth {
147            a: 0,
148            b: parse_int(s)?,
149        }),
150        Some(pos) => {
151            let before = &s[..pos];
152            let after = &s[pos + 1..];
153            // A second `n` is never valid.
154            if after.bytes().any(|c| c == b'n' || c == b'N') {
155                return Err(ParseError);
156            }
157            let a = match before {
158                "" | "+" => 1,
159                "-" => -1,
160                other => parse_int(other)?,
161            };
162            Ok(Nth {
163                a,
164                b: parse_b(after)?,
165            })
166        }
167    }
168}
169
170/// Parse a contiguous signed integer (no internal or surrounding whitespace).
171fn parse_int(s: &str) -> Result<i32, ParseError> {
172    s.parse::<i32>().map_err(|_| ParseError)
173}
174
175/// Parse the `B` part that follows `n`: empty (→ 0), or a sign then digits, with
176/// whitespace allowed around the sign.
177fn parse_b(after: &str) -> Result<i32, ParseError> {
178    let t = after.trim();
179    if t.is_empty() {
180        return Ok(0);
181    }
182    let (sign, rest) = match t.as_bytes()[0] {
183        b'+' => (1_i64, &t[1..]),
184        b'-' => (-1_i64, &t[1..]),
185        _ => return Err(ParseError),
186    };
187    let digits = rest.trim_start();
188    if digits.is_empty() || !digits.bytes().all(|c| c.is_ascii_digit()) {
189        return Err(ParseError);
190    }
191    // Parse via i64 so the signed offset reaches the full i32 range, including
192    // i32::MIN (whose magnitude 2147483648 overflows i32 on its own).
193    let magnitude = digits.parse::<i64>().map_err(|_| ParseError)?;
194    i32::try_from(sign * magnitude).map_err(|_| ParseError)
195}