Skip to content

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")
        .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 board

Installation

cargo add sashite-feen

Or 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.

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.2", 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

The 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);

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;

// 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);
}

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.

# #[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 as Ok, [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).

Serde (serde)

[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.

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, 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.

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 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.

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

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.

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

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages