A
no_std, zero-allocation validator and encoder for FEEN board-game positions.
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.
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>(())cargo add sashite-feenOr 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.
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 aQiposition as its canonical FEEN string. Impliesalloc.
[dependencies]
sashite-feen = { version = "0.1", features = ["serde"] }[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 fieldAll 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>(())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>(())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).
[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>,
}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].
<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.
<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.
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.
[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 |
- 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.
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.
Available as open source under the terms of the Apache License 2.0.