sashite-feen 0.1.0

Field Expression Encoding Notation (FEEN): a compact, ASCII-only, no_std, zero-allocation validator and encoder for board-game positions in abstract strategy games, built on EPIN and SIN.
Documentation
//! Single-pass, allocation-free scanner and validator for Field 1 (piece
//! placement).
//!
//! [`validate`] walks the field once, left to right, computing the board
//! [`Shape`] and the number of pieces on the board while enforcing: segment
//! structure, empty-count and EPIN-token validity, board regularity,
//! dimensional coherence, and the dimension bounds. It never allocates and never
//! panics; all integer arithmetic is bounded by the dimension cap.

use crate::error::ParseError;
use crate::limits::{MAX_DIMENSIONS, MAX_DIMENSION_SIZE};
use crate::shape::Shape;
use crate::token::epin_token;

/// Validates the piece-placement field and returns `(shape, board_piece_count)`.
pub(crate) fn validate(field: &[u8]) -> Result<(Shape, u32), ParseError> {
    if field.is_empty() {
        return Err(ParseError::PlacementEmpty);
    }
    if field[0] == b'/' {
        return Err(ParseError::PlacementStartsWithSeparator);
    }
    if field[field.len() - 1] == b'/' {
        return Err(ParseError::PlacementEndsWithSeparator);
    }

    let mut i = 0usize;
    let mut piece_count: u32 = 0;
    // s1: cells per innermost segment (a "rank"); set by the first segment.
    let mut s1: Option<u32> = None;
    // Ranks accumulated in the current layer (reset at each layer boundary).
    let mut ranks_in_layer: u32 = 0;
    // s2: ranks per layer; set at the first layer boundary (`//`).
    let mut s2: Option<u32> = None;
    let mut completed_layers: u32 = 0;
    // Deepest separator group length seen: 0 (1D), 1 (2D), or 2 (3D).
    let mut max_sep: usize = 0;

    loop {
        let cells = read_segment(field, &mut i, &mut piece_count)?;
        match s1 {
            None => s1 = Some(cells),
            Some(v) if v != cells => return Err(ParseError::BoardNotRegular),
            Some(_) => {}
        }
        ranks_in_layer += 1;

        if i == field.len() {
            break;
        }

        // Read the maximal run of '/' that follows: a separator group.
        let group_start = i;
        while i < field.len() && field[i] == b'/' {
            i += 1;
        }
        let group_len = i - group_start;
        if group_len > MAX_DIMENSIONS - 1 {
            return Err(ParseError::TooManyDimensions);
        }
        if group_len > max_sep {
            max_sep = group_len;
        }

        if group_len == 2 {
            // Layer boundary. Coherence: a layer separated by `//` must itself
            // contain a `/`, i.e. hold at least two ranks.
            if ranks_in_layer < 2 {
                return Err(ParseError::DimensionalCoherence);
            }
            match s2 {
                None => s2 = Some(ranks_in_layer),
                Some(v) if v != ranks_in_layer => return Err(ParseError::BoardNotRegular),
                Some(_) => {}
            }
            completed_layers += 1;
            ranks_in_layer = 0;
        }
        // group_len == 1: a rank boundary within the current layer; nothing else.
    }

    // The loop ran at least once (the field is non-empty and does not start with
    // a separator), so `s1` is set; the fallback keeps the function panic-free.
    let Some(s1) = s1 else {
        return Err(ParseError::PlacementEmpty);
    };

    let (sizes, ndim) = finalize(max_sep, s1, ranks_in_layer, s2, completed_layers)?;
    Ok((Shape::from_sizes(sizes, ndim), piece_count))
}

/// Turns the accumulated counts into a `(sizes, ndim)` pair, outermost first,
/// enforcing per-dimension regularity and the size cap.
fn finalize(
    max_sep: usize,
    s1: u32,
    ranks_in_layer: u32,
    s2: Option<u32>,
    completed_layers: u32,
) -> Result<([u8; MAX_DIMENSIONS], u8), ParseError> {
    let files = to_dim(s1)?;
    match max_sep {
        0 => Ok(([files, 0, 0], 1)),
        1 => {
            // 2D: every rank belongs to the single (implicit) layer.
            let ranks = to_dim(ranks_in_layer)?;
            Ok(([ranks, files, 0], 2))
        }
        2 => {
            // 3D: close the final layer. As with an interior `//` boundary, a
            // layer must hold at least two ranks (coherence), and every layer
            // must hold the same number of ranks (regularity).
            let Some(ranks_per_layer) = s2 else {
                return Err(ParseError::DimensionalCoherence);
            };
            if ranks_in_layer < 2 {
                return Err(ParseError::DimensionalCoherence);
            }
            if ranks_in_layer != ranks_per_layer {
                return Err(ParseError::BoardNotRegular);
            }
            let ranks = to_dim(ranks_per_layer)?;
            let layers = to_dim(completed_layers + 1)?;
            Ok(([layers, ranks, files], 3))
        }
        _ => Err(ParseError::TooManyDimensions),
    }
}

/// Narrows a dimension size to a `u8`, mapping overflow to [`DimensionTooLarge`].
///
/// [`DimensionTooLarge`]: ParseError::DimensionTooLarge
fn to_dim(size: u32) -> Result<u8, ParseError> {
    u8::try_from(size).map_err(|_| ParseError::DimensionTooLarge)
}

/// Reads one segment (up to the next `/` or the end), returning its cell count
/// and advancing `i`. Counts pieces into `piece_count`.
fn read_segment(field: &[u8], i: &mut usize, piece_count: &mut u32) -> Result<u32, ParseError> {
    let cap = u32::from(MAX_DIMENSION_SIZE);
    let mut cells: u32 = 0;

    while *i < field.len() && field[*i] != b'/' {
        if field[*i].is_ascii_digit() {
            // Empty-count token: a non-zero base-10 integer with no leading zero.
            if field[*i] == b'0' {
                return Err(ParseError::InvalidEmptyCount);
            }
            let mut value: u32 = 0;
            while *i < field.len() && field[*i].is_ascii_digit() {
                value = value * 10 + u32::from(field[*i] - b'0');
                if value > cap {
                    return Err(ParseError::DimensionTooLarge);
                }
                *i += 1;
            }
            cells += value;
        } else {
            // Piece token: the maximal valid EPIN token at this position.
            match epin_token(&field[*i..]) {
                Some((len, _)) => {
                    cells += 1;
                    *piece_count += 1;
                    *i += len;
                }
                None => return Err(ParseError::InvalidPieceToken),
            }
        }

        if cells > cap {
            return Err(ParseError::DimensionTooLarge);
        }
    }

    if cells == 0 {
        // Defensive: the start/end-separator checks and maximal '/' grouping make
        // an empty segment unreachable, but a typed error beats a silent gap.
        return Err(ParseError::EmptySegment);
    }
    Ok(cells)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn shape_of(field: &str) -> (Vec<u8>, u32) {
        let (s, pieces) = validate(field.as_bytes()).expect("valid field");
        (s.dimensions().to_vec(), pieces)
    }

    fn err_of(field: &str) -> ParseError {
        validate(field.as_bytes()).unwrap_err()
    }

    // ---------- valid: real games ----------
    #[test]
    fn chess_start() {
        let f = "-rnbqk^bn-r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/-RNBQK^BN-R";
        assert_eq!(shape_of(f), (vec![8, 8], 32));
    }

    #[test]
    fn shogi_start() {
        let f = "lnsgk^gsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGK^GSNL";
        assert_eq!(shape_of(f), (vec![9, 9], 40));
    }

    #[test]
    fn xiangqi_start() {
        let f = "rheag^aehr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RHEAG^AEHR";
        assert_eq!(shape_of(f), (vec![10, 9], 32));
    }

    #[test]
    fn empty_board() {
        assert_eq!(shape_of("8/8/8/8/8/8/8/8"), (vec![8, 8], 0));
    }

    // ---------- valid: dimensionality ----------
    #[test]
    fn one_dimensional() {
        assert_eq!(shape_of("k^+p4+PK^"), (vec![8], 4));
    }

    #[test]
    fn three_dimensional() {
        assert_eq!(shape_of("ab/cd//AB/CD"), (vec![2, 2, 2], 8));
    }

    #[test]
    fn three_dimensional_three_layers() {
        assert_eq!(shape_of("ab/cd//AB/CD//ef/gh"), (vec![3, 2, 2], 12));
    }

    #[test]
    fn thin_two_dimensional() {
        assert_eq!(shape_of("a/b/c"), (vec![3, 1], 3));
    }

    // ---------- valid: maximal-munch EPIN tokens ----------
    #[test]
    fn full_modifier_token() {
        // "+K^'" is a single 4-byte EPIN token.
        assert_eq!(shape_of("+K^'"), (vec![1], 1));
    }

    #[test]
    fn adjacent_derived_then_plain() {
        // "K'K" must tokenise as [K', K] (two pieces), not one.
        assert_eq!(shape_of("K'K"), (vec![2], 2));
    }

    #[test]
    fn promoted_derived_token() {
        assert_eq!(shape_of("K^'"), (vec![1], 1));
    }

    #[test]
    fn max_dimension_size_exact() {
        // a single rank of exactly 255 empty cells is allowed
        assert_eq!(shape_of("255"), (vec![255], 0));
    }

    // ---------- invalid ----------
    #[test]
    fn empty_input() {
        assert_eq!(err_of(""), ParseError::PlacementEmpty);
    }

    #[test]
    fn starts_with_separator() {
        assert_eq!(err_of("/8/8"), ParseError::PlacementStartsWithSeparator);
    }

    #[test]
    fn ends_with_separator() {
        assert_eq!(err_of("8/8/"), ParseError::PlacementEndsWithSeparator);
    }

    #[test]
    fn interior_short_layer() {
        assert_eq!(err_of("rkr//PPPP"), ParseError::DimensionalCoherence);
    }

    #[test]
    fn final_short_layer() {
        // consistent with the interior case: a 1-rank layer is a coherence error
        assert_eq!(err_of("ab/cd//AB"), ParseError::DimensionalCoherence);
    }

    #[test]
    fn too_many_dimensions() {
        assert_eq!(err_of("a/b///c/d"), ParseError::TooManyDimensions);
    }

    #[test]
    fn irregular_ranks() {
        assert_eq!(err_of("rkr/pp/PPPP"), ParseError::BoardNotRegular);
    }

    #[test]
    fn irregular_layers() {
        // two layers with different rank counts (both >= 2): a regularity error
        assert_eq!(err_of("a/b//A/B/C"), ParseError::BoardNotRegular);
    }

    #[test]
    fn leading_zero_empty_count() {
        assert_eq!(err_of("08"), ParseError::InvalidEmptyCount);
    }

    #[test]
    fn bare_zero_empty_count() {
        assert_eq!(err_of("0"), ParseError::InvalidEmptyCount);
    }

    #[test]
    fn invalid_piece_token() {
        assert_eq!(err_of("k$k"), ParseError::InvalidPieceToken);
    }

    #[test]
    fn single_run_too_large() {
        assert_eq!(err_of("256"), ParseError::DimensionTooLarge);
    }

    #[test]
    fn segment_total_too_large() {
        // 200 + 1 piece + 100 = 301 cells in one segment
        assert_eq!(err_of("200A100"), ParseError::DimensionTooLarge);
    }
}