Skip to content

sashite/feen.rs

Repository files navigation

sashite-feen

Crates.io Docs.rs CI License

A no_std, zero-allocation validator and encoder for FEEN board-game positions.

Overview

Field Expression Encoding Notation (FEEN) is a compact, ASCII-only string that encodes a complete board-game position as three space-separated fields:

<piece-placement> <hands> <style-turn>
Field Encodes Example
Piece placement Board geometry and occupancy (runs of empties, dimensional /) 8/8/…/RNBQK^BNR
Hands Off-board pieces held by each player, with multiplicities 3P2B/3p2b
Style–turn One style per side, and which side is to move W/w

This crate implements the FEEN v1.0.0 specification. It delegates piece-token syntax to EPIN and style-token syntax to SIN, adding the field, dimensional, canonicality, and cardinality rules on top.

A FEEN string is variable-sized, so — unlike a fixed-width token — this crate is built as a borrowing, streaming validator rather than a parser that returns an owned tree: [Feen::parse] validates the input in a single pass and hands back a view that borrows it. Nothing is materialized and nothing is allocated. An owned, transformable position is available on demand behind the alloc feature as a Qi.

Quick Start

use sashite_feen::{Feen, Side};

// Validate and parse in one pass; the view borrows the input string.
let feen =
    Feen::parse("-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 / W/w")?;

assert_eq!(feen.square_count(), 64);
assert_eq!(feen.piece_count(), 32);
assert_eq!(feen.active_side(), Side::First); // active token `W` is uppercase

// A cheap boolean check when you don't need the view.
assert!(Feen::is_valid("k^+p4+PK^ / W/w")); // a 1-D, 8-square board
# Ok::<(), sashite_feen::ParseError>(())

Installation

cargo add sashite-feen

Or add it to Cargo.toml:

[dependencies]
sashite-feen = "0.1"

sashite-feen is no_std, forbids unsafe, and uses no regex engine. Its only required dependencies are sashite-epin and sashite-sin (both no_std and allocation-free). The minimum supported Rust version is 1.81.

Cargo features

The default build is strictly allocation-free: the alloc crate is not even linked, so validation, borrowing iteration, and encoding-to-a-sink work on targets without an allocator.

  • alloc (off by default) — enables the owned position type [sashite_qi::Qi] and the conversions that produce or consume it: [Feen::to_qi], [encode], and [write_feen]. This is the only part of the crate that allocates.
  • serde (off by default) — provides [feen_string], a #[serde(with = "…")] adapter that (de)serializes a Qi position as its canonical FEEN string. Implies alloc.
[dependencies]
sashite-feen = { version = "0.1", features = ["serde"] }

Usage

Validating and parsing

[Feen::is_valid] returns a bool; [Feen::parse] returns a borrowing view or a [ParseError] explaining the first violation.

use sashite_feen::Feen;

assert!(Feen::is_valid("8/8/8/8/8/8/8/8 / W/w"));
assert!(Feen::parse("8/8/8/8/8/8/8/8 / W/w").is_ok());
assert!(Feen::parse("8/8/8/8/8/8/8/8 W/w").is_err()); // missing a field

Reading a position

All accessors are const fn and borrow nothing beyond self. Geometry comes from [Shape]; sides and styles are reported both by turn (active / inactive) and by side (first / second).

use sashite_feen::{Feen, Side};

let feen =
    Feen::parse("lnsgk^gsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGK^GSNL / J/j")?;

let shape = feen.shape();
assert_eq!(shape.dimension_count(), 2);
assert_eq!(shape.square_count(), 81);          // also `feen.square_count()`
assert_eq!(shape.dimensions(), &[9u8, 9]);     // sizes along each dimension

assert_eq!(feen.piece_count(), 40);            // on-board + in-hand
assert_eq!(feen.board_piece_count(), 40);
assert_eq!(feen.hand_piece_count(), 0);

assert_eq!(feen.active_side(), Side::First);   // `J` is uppercase ⇒ first
assert_eq!(feen.inactive_side(), Side::Second);
# Ok::<(), sashite_feen::ParseError>(())

Iterating squares and hands

The board iterator yields one Option<Piece> per square in the board's serialization order (None for an empty square); the hand iterators yield [HandItem]s in canonical order. Both are lazy and allocation-free; Piece is an [sashite_epin::Identifier].

use sashite_feen::Feen;

let feen = Feen::parse("4P3/8 P/p W/w")?; // 2 ranks; first hand: one P, second: one p

let occupied = feen.squares().flatten().count();
assert_eq!(occupied, 1); // the lone `P` on the board

for item in feen.first_hand() {
    // `item.piece()` is an EPIN identifier; `item.count()` ≥ 1.
    assert_eq!(item.count(), 1);
}
# Ok::<(), sashite_feen::ParseError>(())

Owned positions (alloc)

With the alloc feature, [Feen::to_qi] materializes the view into a Qi — the ecosystem's owned, immutable position type — and [encode] / [write_feen] turn an owned position back into a canonical FEEN string. Re-encoding an unchanged position reproduces the input exactly.

use sashite_feen::{encode, Feen};

let feen = Feen::parse("8/8/8/8/8/8/8/8 / W/w")?;
let position = feen.to_qi();           // allocates the owned position

assert_eq!(encode(&position), "8/8/8/8/8/8/8/8 / W/w");

Because Qi is generic and transformable, you can read a position with FEEN, edit it with Qi's move-based API, and re-encode it — see examples/basic.rs (cargo run --example basic --features alloc).

Serde (serde)

[feen_string] lets a Qi field round-trip through its canonical FEEN string, which keeps the serialized form human-readable and portable:

use serde::{Deserialize, Serialize};
use sashite_feen::sashite_qi::Qi;
use sashite_feen::sashite_epin::Identifier as Piece;
use sashite_feen::sashite_sin::Identifier as Style;

#[derive(Serialize, Deserialize)]
struct Saved {
    #[serde(with = "sashite_feen::feen_string")]
    position: Qi<Piece, Style>,
}

The FEEN string

Piece placement

A run of consecutive empty squares is written as a base-10 count (≥ 1, no leading zeros); every other run is a sequence of EPIN piece tokens. Dimensions are separated hierarchically: a single / separates ranks, // separates 2-D layers, /// separates 3-D cubes, and so on. Dimensional coherence requires that a separator of length N only appear between structures that themselves contain separators of length N − 1.

rkr                         # 1-D, 3 squares
8/8/8/8/8/8/8/8             # 2-D, 8×8 = 64 empty squares
ab/cd//AB/CD               # 3-D, 2 layers × 2 ranks × 2 files

Stricter than the specification: this crate accepts regular boards only — every rank within a dimension must have the same length. Inputs the specification would allow as irregular are rejected with [ParseError::BoardNotRegular].

Hands

<first-hand>/<second-hand>, each a separator-free concatenation of items [<count>]<piece> in canonical order. The count is omitted when it is 1 and must be ≥ 2 when present. The piece's own side (its token's case) is independent of which hand holds it.

Style–turn

<active-style>/<inactive-style>, each a single SIN letter. Case encodes the player side (uppercase ⇒ first, lowercase ⇒ second); position encodes the turn (the first token is the active player). The two tokens must be of opposite case.

Bounds (assumed for safety)

Inputs are bounded before and during parsing, so memory and time stay bounded even on untrusted input:

Constant Meaning
[MAX_STRING_LENGTH] Maximum input length in bytes (checked first)
[MAX_DIMENSIONS] Maximum number of board dimensions
[MAX_DIMENSION_SIZE] Maximum number of cells along any one dimension

A separate internal cap on the total square count (rejected with [ParseError::TooManySquares]) guarantees that any FEEN string this crate accepts is constructible as a Qi without overflow.

Errors

[ParseError] reports the first violation found. The variants group by field:

Group Variants (selected)
Lexical InputTooLong, NonAscii, FieldCount
Placement PlacementEmpty, PlacementStartsWithSeparator, PlacementEndsWithSeparator, EmptySegment, InvalidEmptyCount, InvalidPieceToken, BoardNotRegular, DimensionalCoherence, TooManyDimensions, DimensionTooLarge
Hands InvalidHandsDelimiter, InvalidHandCount, HandNotAggregated, HandNotCanonical
Style–turn InvalidStyleTurnDelimiter, InvalidStyleToken, StylesSameCase
Cardinality TooManySquares, TooManyPieces

Design

  • Allocation-free, borrowing core. Validation is one left-to-right pass over the raw bytes; the view and its iterators borrow the input. The heap is touched only through the optional alloc-gated conversions.
  • No unsafe, no regex engine. Parsing matches bytes directly with bounded integer arithmetic, eliminating ReDoS as an attack vector.
  • Layered on EPIN and SIN. Piece and style tokens are validated by those crates; this crate owns only the FEEN-level structure.
  • Canonical-only. Non-minimal empty counts and non-canonical hand orderings are rejected, so an accepted string is already in canonical form.

Ecosystem

FEEN is the position-serialization layer of the Sashité ecosystem. It describes how a position is written, while the surrounding crates supply the pieces, the styles, and the owned model:

  • Qi — the owned, immutable position type
  • EPIN — piece token syntax
  • SIN — style token syntax
  • Game Protocol — the shared conceptual foundation

If a behavior here appears to conflict with the specification, the specification is normative.

License

Available as open source under the terms of the Apache License 2.0.

About

FEEN support for the Rust language.

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages