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
//! End-to-end behavioral spec for `nth-check`.
//!
//! Coefficient and matching expectations are cross-checked against the CSS Syntax
//! `An+B` microsyntax and the `nth-check` npm package.

use nth_check::{parse, Nth};

fn ab(s: &str) -> (i32, i32) {
    let n = parse(s).unwrap();
    (n.a(), n.b())
}

// ---------------------------------------------------------------------------
// Parsing — keywords & integers
// ---------------------------------------------------------------------------

#[test]
fn keywords() {
    assert_eq!(ab("odd"), (2, 1));
    assert_eq!(ab("even"), (2, 0));
    assert_eq!(ab("ODD"), (2, 1)); // case-insensitive
    assert_eq!(ab("Even"), (2, 0));
}

#[test]
fn plain_integers() {
    assert_eq!(ab("0"), (0, 0));
    assert_eq!(ab("3"), (0, 3));
    assert_eq!(ab("+3"), (0, 3));
    assert_eq!(ab("-2"), (0, -2));
}

// ---------------------------------------------------------------------------
// Parsing — An+B forms
// ---------------------------------------------------------------------------

#[test]
fn n_forms() {
    assert_eq!(ab("n"), (1, 0));
    assert_eq!(ab("+n"), (1, 0));
    assert_eq!(ab("-n"), (-1, 0));
    assert_eq!(ab("2n"), (2, 0));
    assert_eq!(ab("+2n"), (2, 0));
    assert_eq!(ab("-2n"), (-2, 0));
    assert_eq!(ab("N"), (1, 0)); // uppercase N
}

#[test]
fn an_plus_b_forms() {
    assert_eq!(ab("2n+1"), (2, 1));
    assert_eq!(ab("2n-1"), (2, -1));
    assert_eq!(ab("n+3"), (1, 3));
    assert_eq!(ab("n-1"), (1, -1));
    assert_eq!(ab("-n+3"), (-1, 3));
    assert_eq!(ab("-2n-3"), (-2, -3));
}

#[test]
fn whitespace_is_tolerated() {
    assert_eq!(ab(" 2n + 1 "), (2, 1));
    assert_eq!(ab("2n +1"), (2, 1));
    assert_eq!(ab("2n+ 1"), (2, 1));
    assert_eq!(ab("  odd  "), (2, 1));
    assert_eq!(ab("-n + 6"), (-1, 6));
}

// ---------------------------------------------------------------------------
// Parsing — rejects invalid input
// ---------------------------------------------------------------------------

#[test]
fn invalid_inputs_error() {
    for bad in [
        "", " ", "n+", "n-", "2x", "n2", "2 n", "++1", "a", "1.5", "n n", "+", "-", "2n+1x", "odde",
    ] {
        assert!(parse(bad).is_err(), "{bad:?} should be invalid");
    }
}

// ---------------------------------------------------------------------------
// matches() — 1-based position test
// ---------------------------------------------------------------------------

#[test]
fn matches_positions() {
    let odd = parse("2n+1").unwrap(); // odd positions
    assert!(odd.matches(1) && odd.matches(3) && odd.matches(5));
    assert!(!odd.matches(2) && !odd.matches(4));

    let even = parse("2n").unwrap(); // even positions
    assert!(even.matches(2) && even.matches(4));
    assert!(!even.matches(1) && !even.matches(3));

    let third = parse("3").unwrap(); // only the 3rd
    assert!(third.matches(3));
    assert!(!third.matches(2) && !third.matches(4));

    let first_three = parse("-n+3").unwrap(); // first three
    assert!(first_three.matches(1) && first_three.matches(2) && first_three.matches(3));
    assert!(!first_three.matches(4));

    let every_third = parse("3n").unwrap();
    assert!(every_third.matches(3) && every_third.matches(6) && every_third.matches(9));
    assert!(!every_third.matches(1) && !every_third.matches(4));

    let all = parse("n").unwrap();
    assert!(all.matches(1) && all.matches(2) && all.matches(1000));
}

// ---------------------------------------------------------------------------
// Display round-trips to a canonical form
// ---------------------------------------------------------------------------

#[test]
fn display_canonical() {
    assert_eq!(Nth::new(2, 1).to_string(), "2n+1");
    assert_eq!(Nth::new(2, -1).to_string(), "2n-1");
    assert_eq!(Nth::new(1, 0).to_string(), "n");
    assert_eq!(Nth::new(-1, 3).to_string(), "-n+3");
    assert_eq!(Nth::new(0, 3).to_string(), "3");
    assert_eq!(Nth::new(0, -2).to_string(), "-2");
    assert_eq!(Nth::new(3, 0).to_string(), "3n");
    // round-trip: re-parsing the Display output yields the same coefficients
    for s in ["2n+1", "-n+3", "n", "3", "3n", "-2"] {
        let n = parse(s).unwrap();
        assert_eq!(parse(&n.to_string()).unwrap(), n, "round-trip {s}");
    }
}

#[test]
fn from_str_works() {
    let n: Nth = "2n+1".parse().unwrap();
    assert_eq!((n.a(), n.b()), (2, 1));
}

// Regression: the offset must reach the full i32 range symmetrically, including
// i32::MIN, and such values must round-trip through Display.
#[test]
fn offset_reaches_i32_min() {
    assert_eq!(ab("n-2147483648"), (1, i32::MIN));
    assert_eq!(ab("2n-2147483648"), (2, i32::MIN));
    assert_eq!(ab("-2147483648"), (0, i32::MIN));
    // out of range still errors cleanly (no panic)
    assert!(parse("n-2147483649").is_err());
    assert!(parse("2n+2147483648").is_err());
    // Display round-trips at the boundary
    for a in [1, -1, 2, i32::MIN] {
        let n = Nth::new(a, i32::MIN);
        assert_eq!(parse(&n.to_string()).unwrap(), n, "round-trip a={a}, b=MIN");
    }
}