sashite-feen 0.2.0

Field Expression Encoding Notation (FEEN): a compact, ASCII-only, no_std, zero-allocation validator and encoder for board-game positions in abstract strategy games, built on EPIN and SIN.
Documentation
//! Canonical serialization back to a FEEN string.
//!
//! Two entry points produce the same canonical bytes:
//!
//! - [`Display`](core::fmt::Display) for [`Feen`] echoes the already-validated
//!   placement and hands fields and rebuilds the style–turn field. It never
//!   allocates and needs no `alloc`.
//! - With the `alloc` feature, [`encode`] (and the sink-based [`write_feen`])
//!   serialize an owned [`sashite_qi::Qi`] position, re-deriving the placement
//!   (run-length empties, dimensional separators) and re-sorting each hand into
//!   canonical order — `Qi` stores hands keyed by piece, not by the FEEN order.

use core::fmt;

use crate::feen::Feen;

impl fmt::Display for Feen<'_> {
    /// Writes the canonical FEEN string for this position.
    ///
    /// The placement and hands fields were validated as canonical when the view
    /// was parsed, so they are echoed verbatim; the style–turn field is rebuilt
    /// from the two style tokens. The output is written straight to the
    /// formatter, so this never allocates and works under `no_std` without the
    /// `alloc` feature (use `write!`/a sink); with `alloc`, `to_string` follows
    /// from this impl.
    ///
    /// Parsing then displaying a canonical FEEN string reproduces it exactly.
    ///
    /// # Format specifiers are deliberately ignored
    ///
    /// Unlike this family's short-token crates — where `Display` routes through
    /// [`core::fmt::Formatter::pad`] so width, fill, alignment and precision
    /// behave as they do for [`str`] — a width here would have to be measured
    /// against the *finished* string, and a FEEN string runs to
    /// [`MAX_STRING_LENGTH`](crate::MAX_STRING_LENGTH) (4 KiB). Buffering that
    /// on the stack to satisfy a `{:>80}` would cost 4 KiB of stack in a crate
    /// whose whole point is to run on bare metal without an allocator, so the
    /// output is written straight to the sink and the spec is ignored. Callers
    /// needing a padded FEEN string can `to_string()` it first (with `alloc`)
    /// and pad the `String`.
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        // The bytes were validated as ASCII, so `from_utf8` cannot fail here; the
        // mapping to `fmt::Error` keeps the impl total without `unsafe`.
        let placement = core::str::from_utf8(self.placement_field()).map_err(|_| fmt::Error)?;
        let hands = core::str::from_utf8(self.hands_field()).map_err(|_| fmt::Error)?;

        write!(
            f,
            "{placement} {hands} {}/{}",
            self.active_style().to_char(),
            self.inactive_style().to_char(),
        )
    }
}

#[cfg(feature = "alloc")]
mod owned {
    use alloc::string::String;
    use alloc::vec::Vec;
    use core::cmp::Reverse;
    use core::fmt::{self, Write};

    use sashite_epin::Identifier as Piece;
    use sashite_qi::{Player, Qi};
    use sashite_sin::Identifier as Style;

    use crate::error::EncodeError;
    use crate::hands::token_key;
    use crate::limits::MAX_STRING_LENGTH;

    /// Encodes an owned [`Qi`] position as its canonical FEEN string.
    ///
    /// This is the inverse of [`Feen::to_qi`](crate::Feen::to_qi): for any
    /// canonical FEEN string `s`, `encode(&Feen::parse(s)?.to_qi())` is
    /// `Ok(s)`. Available with the `alloc` feature.
    ///
    /// # Errors
    ///
    /// `Qi` is a more permissive model than FEEN — it admits geometries FEEN
    /// cannot spell and sizes this crate will not read back — so the conversion
    /// is fallible; see [`EncodeError`]. **Whatever this returns as `Ok`,
    /// [`Feen::parse`](crate::Feen::parse) accepts**, and parsing it back yields
    /// the position it started from. A position that came from
    /// [`Feen::to_qi`](crate::Feen::to_qi) never fails.
    pub fn encode(position: &Qi<Piece, Style>) -> Result<String, EncodeError> {
        let mut out = String::new();
        write_feen(&mut out, position)?;
        Ok(out)
    }

    /// Writes the canonical FEEN string for `position` to a [`fmt::Write`] sink.
    ///
    /// Like [`encode`] but streams to an existing sink instead of returning a
    /// new string. Available with the `alloc` feature.
    ///
    /// # Errors
    ///
    /// Returns [`EncodeError::ShapeNotRepresentable`] or
    /// [`EncodeError::TooLong`] *before writing anything*, so a rejected
    /// position leaves the sink untouched, and [`EncodeError::Sink`] if the sink
    /// itself fails part-way through.
    pub fn write_feen<W: Write>(w: &mut W, position: &Qi<Piece, Style>) -> Result<(), EncodeError> {
        check_representable(position)?;
        // The length is measured by running the very same writer into a counting
        // sink, so the figure checked is by construction the figure produced —
        // no second, drift-prone size formula. It also means the sink sees
        // nothing at all when the position is over the cap.
        let mut counter = Counter(0);
        // `Counter` never fails, so this cannot be `Err`.
        let _ = write_body(&mut counter, position);
        if counter.0 > MAX_STRING_LENGTH {
            return Err(EncodeError::TooLong);
        }

        write_body(w, position).map_err(|_: fmt::Error| EncodeError::Sink)
    }

    /// Rejects a geometry FEEN has no spelling for.
    ///
    /// FEEN encodes dimensionality through separator *depth*, so a dimension is
    /// only visible if it is actually split: every dimension but the innermost
    /// must hold at least two elements. A single-element outer dimension would
    /// either vanish from the output (silently changing the shape) or force a
    /// `//` between structures containing no `/`, which the specification's
    /// dimensional-coherence rule forbids — and which this crate's own parser
    /// rejects.
    fn check_representable(position: &Qi<Piece, Style>) -> Result<(), EncodeError> {
        // `split_last` rather than `[..len - 1]`: no indexing, no subtraction,
        // and an empty shape (which `Qi::new` refuses to build) is handled.
        let Some((_innermost, outer)) = position.shape().split_last() else {
            return Err(EncodeError::ShapeNotRepresentable);
        };
        if outer.iter().any(|&size| size < 2) {
            return Err(EncodeError::ShapeNotRepresentable);
        }
        Ok(())
    }

    /// A [`fmt::Write`] sink that counts the bytes written and discards them.
    struct Counter(usize);

    impl Write for Counter {
        fn write_str(&mut self, s: &str) -> fmt::Result {
            self.0 += s.len();
            Ok(())
        }
    }

    /// Writes the three fields, with no representability or length checking.
    fn write_body<W: Write>(w: &mut W, position: &Qi<Piece, Style>) -> fmt::Result {
        write_placement(w, position)?;
        w.write_str(" ")?;
        write_hand(w, position.first_hand())?;
        w.write_str("/")?;
        write_hand(w, position.second_hand())?;

        // The active style is written first; its case already encodes its side.
        let (active, inactive) = match position.turn() {
            Player::First => (position.first_style(), position.second_style()),
            Player::Second => (position.second_style(), position.first_style()),
        };
        write!(w, " {}/{}", active.to_char(), inactive.to_char())
    }

    /// Re-encodes the board: run-length empties, with a separator after each rank
    /// whose depth follows the dimensional carry (`//` at a 3-D layer boundary).
    ///
    /// Only ever called after [`check_representable`], so every dimension but the
    /// innermost holds at least two elements. That is what makes the two strides
    /// distinct: with a single-rank layer, `layer_stride` would equal `files` and
    /// **every** rank boundary would be written as `//`.
    fn write_placement<W: Write>(w: &mut W, position: &Qi<Piece, Style>) -> fmt::Result {
        let dims = position.shape();
        let ndim = dims.len();
        let Some((&files, _)) = dims.split_last() else {
            return Ok(()); // unreachable: `Qi` has at least one dimension
        };
        let total = position.square_count();
        // Stride at which a rank boundary is also a layer boundary (3-D only).
        let layer_stride = if ndim >= 3 { files * dims[ndim - 2] } else { 0 };

        let mut empty_run: usize = 0;
        let mut emitted: usize = 0;
        for cell in position.board() {
            match cell {
                Some(piece) => {
                    flush_empties(w, &mut empty_run)?;
                    w.write_str(piece.encode().as_str())?;
                }
                None => empty_run += 1,
            }
            emitted += 1;

            if emitted % files == 0 {
                flush_empties(w, &mut empty_run)?;
                if emitted < total {
                    if ndim >= 3 && emitted % layer_stride == 0 {
                        w.write_str("//")?;
                    } else {
                        w.write_str("/")?;
                    }
                }
            }
        }
        Ok(())
    }

    /// Emits a pending empty-count token, if any, and resets the counter.
    fn flush_empties<W: Write>(w: &mut W, empty_run: &mut usize) -> fmt::Result {
        if *empty_run > 0 {
            write!(w, "{empty_run}")?;
            *empty_run = 0;
        }
        Ok(())
    }

    /// Re-sorts one hand into FEEN canonical order and serializes its items.
    ///
    /// `Qi` yields hand items keyed by piece; FEEN orders them by multiplicity
    /// (descending) first, then by the token comparator, so the items are
    /// collected and sorted before being written.
    fn write_hand<'a, W: Write>(
        w: &mut W,
        hand: impl Iterator<Item = (&'a Piece, usize)>,
    ) -> fmt::Result {
        let mut items: Vec<(Piece, usize)> = hand.map(|(piece, count)| (*piece, count)).collect();
        items.sort_unstable_by_key(|&(piece, count)| {
            let (letter, case, state, terminal, derived) = token_key(piece);
            (Reverse(count), letter, case, state, terminal, derived)
        });

        for (piece, count) in items {
            if count > 1 {
                write!(w, "{count}")?;
            }
            w.write_str(piece.encode().as_str())?;
        }
        Ok(())
    }
}

#[cfg(feature = "alloc")]
pub use owned::{encode, write_feen};