# feen.ex
[](https://hex.pm/packages/sashite_feen)
[](https://hexdocs.pm/sashite_feen)
[](https://github.com/sashite/feen.ex/actions)
[](https://github.com/sashite/feen.ex/blob/main/LICENSE)
> **FEEN** (Field Expression Encoding Notation) implementation for Elixir.
## Overview
This library implements the [FEEN Specification v1.0.0](https://sashite.dev/specs/feen/1.0.0/), providing serialization and deserialization of board game positions between FEEN strings and [`Qi`](https://github.com/sashite/qi.ex) objects.
FEEN is a rule-agnostic, canonical position encoding for two-player, turn-based board games built on the [Sashité Game Protocol](https://sashite.dev/game-protocol/). A FEEN string encodes exactly three fields: **piece placement** (board structure and occupancy), **hands** (off-board pieces), and **style–turn** (player styles and active player).
### Implementation Constraints
| Constraint | Value | Rationale |
|------------|-------|-----------|
| Regular shapes only | Required | All ranks must have equal length within each dimension |
| Max string length | 4096 | Sufficient for realistic board positions |
| Max board dimensions | 3 | Sufficient for 1D, 2D, 3D boards |
| Max dimension size | 255 | Fits in 8-bit integer; covers 255×255×255 boards |
These constraints enable bounded memory usage and safe parsing.
Only regular board shapes are supported — every rank within a dimension must contain the same number of cells. For example, `9x10` and `8x8` boards are valid. Irregular structures where ranks have different sizes (e.g., ranks of 3, 2, and 4 cells) are not supported.
## Installation
```elixir
# In your mix.exs
def deps do
[
{:sashite_feen, "~> 2.0"}
]
end
```
## Dependencies
```elixir
{:qi, "~> 3.0"} # Position model
{:sashite_epin, "~> 1.2"} # Extended Piece Identifier Notation
{:sashite_sin, "~> 3.1"} # Style Identifier Notation
```
## Usage
### Parsing (FEEN String → Qi)
Convert a FEEN string into a `Qi` object.
```elixir
# Parse a Shōgi starting position
{:ok, position} = Sashite.Feen.parse("lnsgk^gsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGK^GSNL / S/s")
# The result is a Qi
position.shape
# => [9, 9]
position.board
# => {"l", "n", "s", "g", "k^", "g", "s", "n", "l",
# nil, "r", nil, nil, nil, nil, nil, "b", nil,
# "p", "p", "p", "p", "p", "p", "p", "p", "p",
# nil, nil, nil, nil, nil, nil, nil, nil, nil,
# nil, nil, nil, nil, nil, nil, nil, nil, nil,
# nil, nil, nil, nil, nil, nil, nil, nil, nil,
# "P", "P", "P", "P", "P", "P", "P", "P", "P",
# nil, "B", nil, nil, nil, nil, nil, "R", nil,
# "L", "N", "S", "G", "K^", "G", "S", "N", "L"}
Qi.to_nested(position)
# => [["l", "n", "s", "g", "k^", "g", "s", "n", "l"],
# [nil, "r", nil, nil, nil, nil, nil, "b", nil],
# ["p", "p", "p", "p", "p", "p", "p", "p", "p"],
# [nil, nil, nil, nil, nil, nil, nil, nil, nil],
# [nil, nil, nil, nil, nil, nil, nil, nil, nil],
# [nil, nil, nil, nil, nil, nil, nil, nil, nil],
# ["P", "P", "P", "P", "P", "P", "P", "P", "P"],
# [nil, "B", nil, nil, nil, nil, nil, "R", nil],
# ["L", "N", "S", "G", "K^", "G", "S", "N", "L"]]
position.first_player_hand # => %{}
position.second_player_hand # => %{}
position.first_player_style # => "S"
position.second_player_style # => "s"
position.turn # => :first
# Invalid input returns an error tuple
Sashite.Feen.parse("invalid") # => {:error, :invalid_field_count}
# Bang version (raises on invalid input)
position = Sashite.Feen.parse!("lnsgk^gsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGK^GSNL / S/s")
```
### Dumping (Qi → FEEN String)
Convert a `Qi` back to a canonical FEEN string.
```elixir
# From an existing Qi
{:ok, position} = Sashite.Feen.parse("8/8/8/8/8/8/8/8 / C/c")
Sashite.Feen.dump(position)
# => "8/8/8/8/8/8/8/8 / C/c"
# From a Qi built manually
position = Qi.new([1, 8], first_player_style: "C", second_player_style: "c")
|> Qi.board_diff([{0, "K^"}, {7, "k^"}])
Sashite.Feen.dump(position)
# => "K^6k^ / C/c"
```
### Validation
```elixir
# Boolean check (never raises)
Sashite.Feen.valid?("lnsgk^gsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGK^GSNL / S/s") # => true
Sashite.Feen.valid?("8/8/8/8/8/8/8/8 / C/c") # => true (empty board)
Sashite.Feen.valid?("k^+p4+PK^ / C/c") # => true (1D board)
Sashite.Feen.valid?("a/b//c/d / G/g") # => true (3D board)
Sashite.Feen.valid?("rkr//PPPP / G/g") # => false (dimensional coherence)
Sashite.Feen.valid?("invalid") # => false
Sashite.Feen.valid?(nil) # => false
```
### Round-trip Examples
FEEN parsing and dumping are perfect inverses — any valid FEEN string round-trips through `Qi` without loss.
```elixir
# Chess starting position
feen = "-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 / C/c"
{:ok, position} = Sashite.Feen.parse(feen)
Sashite.Feen.dump(position) == feen # => true
# Xiangqi starting position
feen = "rheag^aehr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RHEAG^AEHR / X/x"
{:ok, position} = Sashite.Feen.parse(feen)
Sashite.Feen.dump(position) == feen # => true
```
### Hands
Pieces in hand are represented as count maps (`%{String.t() => pos_integer()}`) in `Qi`. FEEN automatically handles aggregation (for serialization) and expansion (for parsing).
```elixir
# Shōgi mid-game with captured pieces
{:ok, position} = Sashite.Feen.parse("lnsgk^gsnl/1r5b1/pppp1pppp/9/9/9/PPPP1PPPP/1B5R1/LNSGK^GSNL P/p S/s")
position.first_player_hand # => %{"P" => 1}
position.second_player_hand # => %{"p" => 1}
# Multiple identical pieces are aggregated in FEEN
position = Qi.new([3, 3], first_player_style: "S", second_player_style: "s")
|> Qi.board_diff([{4, "K^"}])
|> Qi.first_player_hand_diff([{"P", 2}, {"B", 1}])
|> Qi.second_player_hand_diff([{"p", 1}])
Sashite.Feen.dump(position)
# => "3/1K^1/3 2PB/p S/s"
```
### Multi-dimensional Boards
`Qi` supports 1D, 2D, and 3D boards natively.
```elixir
# 1D board
{:ok, position} = Sashite.Feen.parse("k^+p4+PK^ / C/c")
position.shape # => [8]
position.board
# => {"k^", "+p", nil, nil, nil, nil, "+P", "K^"}
# 3D board (2 layers × 2 ranks × 2 files)
{:ok, position} = Sashite.Feen.parse("ab/cd//AB/CD / G/g")
position.shape # => [2, 2, 2]
Qi.to_nested(position)
# => [[["a", "b"], ["c", "d"]],
# [["A", "B"], ["C", "D"]]]
```
### Style–Turn Mapping
The FEEN style–turn field maps directly to `Qi`'s style and turn accessors.
```elixir
# First player to move (uppercase style is active)
{:ok, position} = Sashite.Feen.parse("8/8/8/8/8/8/8/8 / C/c")
position.first_player_style # => "C"
position.second_player_style # => "c"
position.turn # => :first
# Second player to move (lowercase style is active)
{:ok, position} = Sashite.Feen.parse("8/8/8/8/8/8/8/8 / c/C")
position.first_player_style # => "C"
position.second_player_style # => "c"
position.turn # => :second
```
## Format Overview
A FEEN string consists of **three fields** separated by single ASCII spaces:
```
<PIECE-PLACEMENT> <HANDS> <STYLE-TURN>
```
### Example: Chess Starting Position
```
-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 / C/c
|----------------------------------------------------| |-| |---|
Piece Placement Hands Style-Turn
```
### Field 1 — Piece Placement
Encodes board occupancy as a stream of tokens organized into segments separated by `/`:
- **Empty-count tokens**: integers representing runs of empty squares (e.g., `8` = 8 empty squares)
- **Piece tokens**: valid EPIN tokens (e.g., `+K^`, `p`, `R'`)
Multi-dimensional boards use separator groups: `//` for layers (3D).
### Field 2 — Hands
Encodes pieces held by each player, separated by `/`:
```
<FIRST-HAND>/<SECOND-HAND>
```
- Each hand is a concatenation of `[count]<piece>` items
- Count is optional (absent = 1, present ≥ 2)
- Empty hands are represented as empty strings
```elixir
"/" # Both hands empty
"2P/p" # First has 2 pawns, second has 1 pawn
"3P2B/2p" # First has 3 pawns + 2 bishops, second has 2 pawns
```
### Field 3 — Style-Turn
Encodes native styles and active player:
```
<ACTIVE-STYLE>/<INACTIVE-STYLE>
```
- Each style is a valid SIN token (single ASCII letter)
- Uppercase = Side `first`, lowercase = Side `second`
- Position determines who is active
```elixir
"C/c" # First player (Chess-style) to move
"c/C" # Second player (Chess-style) to move
"S/s" # First player (Shogi-style) to move
"M/c" # First player (Makruk-style) vs second player (Chess-style), first to move
```
## Game Examples
### Western Chess
```elixir
# Starting position
"-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 / C/c"
# After 1.e4
"-rnbqk^bn-r/+p+p+p+p+p+p+p+p/8/8/4P3/8/+P+P+P+P1+P+P+P/-RNBQK^BN-R / c/C"
# After 1.e4 c5 (Sicilian Defense)
"-rnbqk^bn-r/+p+p1+p+p+p+p+p/8/2p5/4P3/8/+P+P+P+P1+P+P+P/-RNBQK^BN-R / C/c"
```
### Japanese Shogi
```elixir
# Starting position
"lnsgk^gsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGK^GSNL / S/s"
# After 1.P-7f
"lnsgk^gsnl/1r5b1/ppppppppp/9/9/2P6/PP1PPPPPP/1B5R1/LNSGK^GSNL / s/S"
```
### Chinese Xiangqi
```elixir
# Starting position
"rheag^aehr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RHEAG^AEHR / X/x"
```
### Cross-Style Games
```elixir
# Chess vs Makruk hybrid
"rnsmk^snr/8/pppppppp/8/8/8/+P+P+P+P+P+P+P+P/-RNBQK^BN-R / C/m"
```
## API Reference
### Module Methods
```elixir
# Parses a FEEN string into a Qi.
# Pieces on the board are EPIN token strings; empty squares are nil.
# Returns {:ok, %Qi{}} or {:error, reason}.
@spec Sashite.Feen.parse(String.t()) :: {:ok, Qi.t()} | {:error, atom()}
# Parses a FEEN string into a Qi.
# Raises ArgumentError if the string is not valid.
@spec Sashite.Feen.parse!(String.t()) :: Qi.t()
# Reports whether string is a valid FEEN position.
# Never raises; returns false for any invalid input.
# Uses an exception-free code path internally for performance.
@spec Sashite.Feen.valid?(term()) :: boolean()
# Serializes a Qi to a canonical FEEN string.
# Board pieces must be valid EPIN token strings.
# Style values must be valid SIN token strings.
@spec Sashite.Feen.dump(Qi.t()) :: String.t()
```
### Constants
```elixir
Sashite.Feen.max_string_length() # => 4096
```
### Errors
Parsing errors are returned as atoms in `{:error, reason}` tuples:
| Atom | Cause |
|------|-------|
| `:not_a_string` | Input is not a binary |
| `:input_too_long` | String exceeds 4096 bytes |
| `:non_ascii_input` | Non-ASCII bytes detected |
| `:invalid_field_count` | Not exactly 3 space-separated fields |
| `:piece_placement_empty` | Field 1 is empty |
| `:piece_placement_starts_with_separator` | Field 1 starts with `/` |
| `:piece_placement_ends_with_separator` | Field 1 ends with `/` |
| `:empty_segment` | Empty segment between separators |
| `:invalid_empty_count` | Empty count is zero or has leading zeros |
| `:invalid_piece_token` | Token is not a valid EPIN identifier |
| `:board_not_regular` | Ranks have different sizes within a dimension |
| `:dimensional_coherence_violation` | Separator depth mismatch |
| `:exceeds_max_dimensions` | Board has more than 3 dimensions |
| `:dimension_size_exceeds_limit` | A dimension exceeds 255 squares |
| `:invalid_hands_delimiter` | Field 2 missing `/` or has multiple |
| `:invalid_hand_count` | Multiplicity is 0, 1, or has leading zeros |
| `:hand_items_not_aggregated` | Identical EPIN tokens not combined |
| `:hand_items_not_in_canonical_order` | Items violate ordering rules |
| `:invalid_style_turn_delimiter` | Field 3 missing `/` or has multiple |
| `:invalid_style_token` | Token is not a valid SIN identifier |
| `:style_tokens_same_case` | Both tokens same case |
| `:too_many_pieces` | Total pieces exceeds total squares |
The bang variant `parse!/1` raises `ArgumentError` with descriptive messages.
## Canonical Form
FEEN output is always **canonical**:
- Empty-count tokens use minimal base-10 integers (no leading zeros, consecutive empties merged)
- Hand items are aggregated and sorted deterministically:
1. By multiplicity (descending)
2. By EPIN base letter (case-insensitive alphabetical)
3. By EPIN letter case (uppercase before lowercase)
4. By EPIN state modifier (`-` before `+` before none)
5. By EPIN terminal marker (absent before present)
6. By EPIN derivation marker (absent before present)
```elixir
# Non-canonical input is rejected
Sashite.Feen.parse("8/8/8/8/8/8/8/8 PpP/p C/c")
# => {:error, :hand_items_not_aggregated}
```
## Security
This library is designed for backend use where inputs may come from untrusted clients. The implementation enforces **zero attack surface** through bounded resource consumption and defensive parsing.
### Bounded resource consumption
All inputs are rejected at the byte level before any allocation occurs:
- **Length check first**: Inputs longer than 4096 bytes are rejected with a single `byte_size/1` check before any parsing begins. This cap is sufficient for any realistic board position (a 255×255 board with all pieces would produce ~1500 bytes).
- **No regex engine**: All parsing uses raw binary pattern matching (`<<byte, rest::binary>>`), eliminating ReDoS as an attack vector.
- **No `String` module on the hot path**: Parsing operates on raw binaries. `String.trim/1`, `String.split/2`, `String.contains?/2`, and `String.at/2` are never called on untrusted input.
- **Bounded numeric parsing**: Empty-count and hand-count digit sequences are bounded by the maximum FEEN string length. Integer overflow is impossible.
### Atom table safety
The BEAM atom table is finite and not garbage-collected. This implementation **never calls `String.to_atom/1` at runtime**. Every atom in the output structs comes from compile-time literals in the EPIN and SIN dependencies. Untrusted input cannot introduce new atoms into the system.
### Rejection guarantees
Any input that is not a valid, canonical FEEN string is rejected with an `{:error, atom()}` tuple. The rejection path does not raise exceptions, does not allocate atoms, and does not allocate intermediate strings.
### Validation checklist
| FEEN spec requirement | Section | Status |
|-----------------------|---------|--------|
| Syntactic validation | §11.1 | ✓ Enforced |
| Canonicality validation | §11.2 | ✓ Enforced (empty-counts and hand ordering) |
| Dimensional coherence | §11.3 | ✓ Enforced |
| Cardinality constraints | §11.4 | ✓ Enforced (n ≥ 1, p ≤ n) |
| Robustness limits | §12 | ✓ Enforced (4096 bytes, 3 dimensions, 255 per axis) |
## Design Principles
- **Spec conformance**: Strict adherence to FEEN v1.0.0, including all MUST-level validation
- **Qi integration**: Parses to and dumps from `Qi`, the shared position model across Sashité libraries
- **Bounded input first**: `byte_size > 4096` is the first check; no code path processes unbounded input
- **Binary pattern matching on the hot path**: Parsing operates on `<<byte, rest::binary>>` destructuring — no `String` module, no regex
- **Zero dynamic atoms**: All atoms are compile-time literals via EPIN and SIN dependencies
- **Zero external parsing dependencies**: EPIN and SIN validation is inlined for performance; only `Qi` is required at runtime
- **Canonical output**: `dump/1` always produces canonical form
- **Elixir idioms**: `{:ok, _} | {:error, _}` tuples, `parse!` bang variant, `parse`/`dump` symmetry
### Performance Architecture
Parsing is internally split into two layers to avoid using exceptions for control flow:
- **Validation layer** — Each sub-parser (`PiecePlacement`, `PiecesInHand`, `StyleTurn`) performs all structural validation and data extraction without raising exceptions. This path operates on raw binaries in a single left-to-right pass, without allocating exception objects or capturing backtraces.
- **Public API layer** — `parse/1` calls the validation layer internally. On failure, it returns `{:error, atom()}`. `parse!/1` calls `parse/1` and raises `ArgumentError` exactly once, at the boundary. `valid?/1` calls `parse/1` and returns a boolean directly, never raising and never constructing a `Qi` object on invalid input.
This dual-path design eliminates the cost of exception-based control flow on the hot path. Since `valid?/1` is commonly called on untrusted or invalid input, avoiding `raise`/`rescue` per rejection keeps validation at pure function-call speed.
Serialization follows the same architecture: `dump/1` delegates to internal dumpers (`Dumper.PiecePlacement`, `Dumper.PiecesInHand`, `Dumper.StyleTurn`), each producing canonical output for one field.
## Related Specifications
- [Game Protocol](https://sashite.dev/game-protocol/) — Conceptual foundation
- [FEEN Specification](https://sashite.dev/specs/feen/1.0.0/) — Official specification
- [FEEN Examples](https://sashite.dev/specs/feen/1.0.0/examples/) — Usage examples
- [EPIN Specification](https://sashite.dev/specs/epin/1.0.0/) — Piece token format
- [SIN Specification](https://sashite.dev/specs/sin/1.0.0/) — Style token format
## License
Available as open source under the [Apache License 2.0](https://opensource.org/licenses/Apache-2.0).