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}