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")
.expect("a valid FEEN string");
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 boardcargo add sashite-feenOr add it to Cargo.toml:
[dependencies]
sashite-feen = "0.2"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.2", 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 fieldThe scalar accessors — counts, sides, styles, [Feen::shape] — are const fn
and copy out of the view; the iterator constructors ([Feen::squares],
[Feen::first_hand], [Feen::second_hand]) are not const, and hand back
iterators borrowing the input string, not the view. 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")
.expect("a valid FEEN string");
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);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;
// 2 ranks; first hand: one P, second: one p
let feen = Feen::parse("4P3/8 P/p W/w").expect("a valid FEEN string");
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);
}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.
# #[cfg(feature = "alloc")] {
use sashite_feen::{encode, Feen};
let feen = Feen::parse("8/8/8/8/8/8/8/8 / W/w").expect("a valid FEEN string");
let position = feen.to_qi(); // allocates the owned position
assert_eq!(encode(&position).as_deref(), Ok("8/8/8/8/8/8/8/8 / W/w"));
# }Encoding is fallible, because Qi is a more permissive model than FEEN.
FEEN encodes dimensionality through separator depth, so every dimension but the
innermost must hold at least two elements — a Qi shaped [2, 1, 3] has no
FEEN spelling at all — and a densely occupied board can exceed
[MAX_STRING_LENGTH]. Rather than hand back a string [Feen::parse] would
reject, [encode] returns an [EncodeError]. The contract is worth stating
plainly:
Whatever [
encode] returns asOk, [Feen::parse] accepts, and parsing it back yields the position it started from.
A position obtained from [Feen::to_qi] never fails to encode. 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:
# #[cfg(feature = "serde")] {
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>,
}
# }Serialization inherits [encode]'s fallibility: a position with no canonical
FEEN form is reported as a serializer error rather than written out as a string
the matching deserialize would refuse.
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, in four ways. The specification places no ceiling on dimensionality, on a dimension's size, or on the square count, and it permits irregular boards; this crate rejects all four so that a position is a fixed-size, allocation-free value:
Input the specification allows Rejected with ranks of differing lengths within a dimension [ ParseError::BoardNotRegular]a 4-D board or deeper [ ParseError::TooManyDimensions]more than [ MAX_DIMENSION_SIZE] cells along an axis[ ParseError::DimensionTooLarge]more than [ MAX_SQUARE_COUNT] squares in total[ ParseError::TooManySquares]Every one of these is far beyond any real board, and none of them changes how a string the crate does accept is interpreted.
<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 | Value | Meaning |
|---|---|---|
[MAX_STRING_LENGTH] |
4096 | Maximum input length in bytes (checked first) |
[MAX_DIMENSIONS] |
3 | Maximum number of board dimensions |
[MAX_DIMENSION_SIZE] |
255 | Maximum number of cells along any one dimension |
[MAX_SQUARE_COUNT] |
65 025 | Maximum number of squares in total |
[MAX_SQUARE_COUNT] is the bound Qi itself uses, so any
FEEN string this crate accepts is constructible as a Qi without overflow.
Note which cap binds when: a piece token costs 1–4 bytes while a run of empties costs 1–3 bytes however long it is, so an empty 255 × 255 board fits in 1 019 bytes and is accepted, whereas a fully occupied one would need 65 279 and is not. In practice the byte cap limits an occupied board to roughly 4 090 squares — still more than fifty times a shōgi board.
[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 |
An empty field answers for itself rather than being lumped into FieldCount:
" / W/w" really does have three space-separated fields, so it reports
PlacementEmpty. EmptySegment is a defensive guard that no input reaches — a
test asserts as much — kept so a future change to the scanner fails loudly
instead of silently counting a zero-cell segment.
Going the other way, [encode] reports an [EncodeError]: Qi admits
positions FEEN cannot spell, and the encoder refuses them rather than emitting a
string [Feen::parse] would reject.
- 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.