nth-check 0.1.1

Parse the CSS An+B microsyntax (2n+1, odd, -n+3) — as in :nth-child — and test whether a 1-based position matches. Zero dependencies, no_std.
Documentation
//! # nth-check — parse and test the CSS `An+B` microsyntax
//!
//! Parse the [`An+B` microsyntax](https://www.w3.org/TR/css-syntax-3/#anb-microsyntax)
//! used by CSS selectors like `:nth-child(2n+1)`, `:nth-of-type(odd)`, and
//! `:nth-last-child(-n+3)`, then test whether a 1-based position matches. The Rust
//! counterpart of the [`nth-check`](https://www.npmjs.com/package/nth-check) npm
//! package. Zero dependencies and `#![no_std]` (no `alloc` either).
//!
//! Parsing follows the CSS Syntax grammar strictly — the offset must carry an
//! explicit sign (`2n+7`, never `2n7`), as browsers require. This is slightly
//! stricter than node-`nth-check`'s lenient regex; for every input both accept, the
//! coefficients and position matching are identical.
//!
//! ```
//! use nth_check::parse;
//!
//! let odd = parse("2n+1").unwrap();        // or parse("odd")
//! assert!(odd.matches(1) && odd.matches(3));
//! assert!(!odd.matches(2));
//!
//! let first_three = parse("-n+3").unwrap();
//! assert!(first_three.matches(1) && first_three.matches(3));
//! assert!(!first_three.matches(4));
//! ```
//!
//! Positions are **1-based**, matching the CSS `:nth-child` convention (the first
//! element is position 1).

#![no_std]
#![doc(html_root_url = "https://docs.rs/nth-check/0.1.0")]

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

use core::fmt;
use core::str::FromStr;

/// A parsed `An+B` expression: the coefficients of `a·n + b`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Nth {
    a: i32,
    b: i32,
}

impl Nth {
    /// Construct an `An+B` expression from its coefficients directly.
    #[must_use]
    pub const fn new(a: i32, b: i32) -> Self {
        Self { a, b }
    }

    /// The `a` coefficient (the step).
    #[must_use]
    pub const fn a(self) -> i32 {
        self.a
    }

    /// The `b` coefficient (the offset).
    #[must_use]
    pub const fn b(self) -> i32 {
        self.b
    }

    /// Whether the **1-based** `index` matches this expression — that is, whether
    /// `index == a·n + b` for some non-negative integer `n`.
    ///
    /// ```
    /// assert!(nth_check::parse("2n").unwrap().matches(4)); // an even position
    /// ```
    #[must_use]
    pub const fn matches(self, index: i32) -> bool {
        // Widen to avoid overflow on extreme inputs.
        let diff = index as i64 - self.b as i64;
        let a = self.a as i64;
        if a == 0 {
            diff == 0
        } else {
            diff % a == 0 && diff / a >= 0
        }
    }
}

/// The error returned when a string is not a valid `An+B` expression.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseError;

impl fmt::Display for ParseError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("invalid An+B microsyntax")
    }
}

impl core::error::Error for ParseError {}

impl FromStr for Nth {
    type Err = ParseError;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        parse(s)
    }
}

impl fmt::Display for Nth {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.a == 0 {
            return write!(f, "{}", self.b);
        }
        match self.a {
            1 => f.write_str("n")?,
            -1 => f.write_str("-n")?,
            a => write!(f, "{a}n")?,
        }
        if self.b != 0 {
            write!(f, "{:+}", self.b)?;
        }
        Ok(())
    }
}

/// Parse an `An+B` expression such as `"2n+1"`, `"odd"`, `"even"`, `"-n+3"`, or `"5"`.
///
/// # Errors
///
/// Returns [`ParseError`] if `input` is not a valid `An+B` expression. Coefficients
/// are `i32`; a magnitude outside the `i32` range also returns [`ParseError`].
///
/// ```
/// let n = nth_check::parse("2n+1").unwrap();
/// assert_eq!((n.a(), n.b()), (2, 1));
/// ```
pub fn parse(input: &str) -> Result<Nth, ParseError> {
    let s = input.trim();
    if s.is_empty() {
        return Err(ParseError);
    }
    if s.eq_ignore_ascii_case("odd") {
        return Ok(Nth { a: 2, b: 1 });
    }
    if s.eq_ignore_ascii_case("even") {
        return Ok(Nth { a: 2, b: 0 });
    }

    match s.bytes().position(|c| c == b'n' || c == b'N') {
        // No `n`: the whole token must be a signed integer `b`, with `a = 0`.
        None => Ok(Nth {
            a: 0,
            b: parse_int(s)?,
        }),
        Some(pos) => {
            let before = &s[..pos];
            let after = &s[pos + 1..];
            // A second `n` is never valid.
            if after.bytes().any(|c| c == b'n' || c == b'N') {
                return Err(ParseError);
            }
            let a = match before {
                "" | "+" => 1,
                "-" => -1,
                other => parse_int(other)?,
            };
            Ok(Nth {
                a,
                b: parse_b(after)?,
            })
        }
    }
}

/// Parse a contiguous signed integer (no internal or surrounding whitespace).
fn parse_int(s: &str) -> Result<i32, ParseError> {
    s.parse::<i32>().map_err(|_| ParseError)
}

/// Parse the `B` part that follows `n`: empty (→ 0), or a sign then digits, with
/// whitespace allowed around the sign.
fn parse_b(after: &str) -> Result<i32, ParseError> {
    let t = after.trim();
    if t.is_empty() {
        return Ok(0);
    }
    let (sign, rest) = match t.as_bytes()[0] {
        b'+' => (1_i64, &t[1..]),
        b'-' => (-1_i64, &t[1..]),
        _ => return Err(ParseError),
    };
    let digits = rest.trim_start();
    if digits.is_empty() || !digits.bytes().all(|c| c.is_ascii_digit()) {
        return Err(ParseError);
    }
    // Parse via i64 so the signed offset reaches the full i32 range, including
    // i32::MIN (whose magnitude 2147483648 overflows i32 on its own).
    let magnitude = digits.parse::<i64>().map_err(|_| ParseError)?;
    i32::try_from(sign * magnitude).map_err(|_| ParseError)
}