From 149aa841c915f848fcb129701223db0d7b688a16 Mon Sep 17 00:00:00 2001 From: Paul-Nicolas Madelaine Date: Thu, 23 Oct 2025 23:34:31 +0200 Subject: [PATCH] update interface for text records --- README.md | 2 +- src/lib.rs | 2 +- src/position.rs | 18 +++- src/setup.rs | 264 +++++++++++++++++++++++++----------------------- tests/tests.rs | 91 ++++++++--------- 5 files changed, 198 insertions(+), 179 deletions(-) diff --git a/README.md b/README.md index 7b5071c..7133e25 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ time that no illegal move is played, with no runtime checks and no potential pan use eschac::prelude::*; // read a position from a text record -let setup = "7k/4P1rp/5Q2/5p2/1Pp1bP2/8/r4K1P/6R1 w - -".parse::()?; +let setup = Setup::from_text_record("7k/4P1rp/5Q2/5p2/1Pp1bP2/8/r4K1P/6R1 w - -")?; let position = setup.into_position()?; // read a move in algebraic notation diff --git a/src/lib.rs b/src/lib.rs index 19f553b..6b5e3c5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,7 +27,7 @@ //! use eschac::prelude::*; //! //! // read a position from a text record -//! let setup = "7k/4P1rp/5Q2/5p2/1Pp1bP2/8/r4K1P/6R1 w - -".parse::()?; +//! let setup = Setup::from_text_record("7k/4P1rp/5Q2/5p2/1Pp1bP2/8/r4K1P/6R1 w - -")?; //! let position = setup.into_position()?; //! //! // read a move in algebraic notation diff --git a/src/position.rs b/src/position.rs index 1d4e9d2..df270ed 100644 --- a/src/position.rs +++ b/src/position.rs @@ -82,16 +82,28 @@ impl Position { /// ``` /// # use eschac::setup::Setup; /// # |s: &str| -> Option { - /// s.parse::().ok().and_then(|pos| pos.into_position().ok()) + /// Setup::from_text_record(s).ok().and_then(|pos| pos.into_position().ok()) /// # }; /// ``` #[inline] pub fn from_text_record(s: &str) -> Option { - s.parse::() + Setup::from_text_record(s) .ok() .and_then(|pos| pos.into_position().ok()) } + /// Returns the text record of the position. + /// + /// This is a shortcut for: + /// ``` + /// # |position: eschac::position::Position| { + /// position.as_setup().to_text_record() + /// # }; + #[inline] + pub fn to_text_record(&self) -> String { + self.as_setup().to_text_record() + } + /// Returns all the legal moves on the position. #[inline] pub fn legal_moves<'l>(&'l self) -> Moves<'l> { @@ -469,7 +481,7 @@ impl Position { impl std::fmt::Debug for Position { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { f.debug_tuple("Position") - .field(&self.as_setup().to_string()) + .field(&TextRecord(self.as_setup())) .finish() } } diff --git a/src/setup.rs b/src/setup.rs index 8978d24..ac06172 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -13,13 +13,14 @@ use crate::position::*; /// It must be validated and converted to a [`Position`] using the [`Setup::into_position`] method /// before generating moves. /// -/// This type implements [`FromStr`](std::str::FromStr) and [`Display`](std::fmt::Display) to parse -/// and print positions from text records. +/// ## Text description /// /// Forsyth-Edwards Notation (FEN) is typically used to describe chess positions as text. eschac -/// uses a slightly different notation, which simply removes the last two fields of the FEN string +/// uses a slightly different notation, which simply removes the last two fields of the FEN record /// (i.e. the halfmove clock and the fullmove number) as the [`Position`] type does not keep -/// track of those. +/// track of those. For example, the starting position is recorded as +/// `rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -`. [`Setup::from_text_record`] and +/// [`Setup::to_text_record`] can be used to read and write these records. #[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct Setup { pub(crate) w: Bitboard, @@ -48,9 +49,23 @@ impl Setup { } } + /// Reads a position from a text record. + /// + /// This is a shortcut for: + /// ``` + /// # use eschac::setup::Setup; + /// # |record: &str| { + /// Setup::from_ascii_record(record.as_bytes()) + /// # }; + /// ``` + #[inline] + pub fn from_text_record(record: &str) -> Result { + Self::from_ascii_record(record.as_bytes()) + } + /// Reads a position from an ascii record. - pub fn from_ascii(s: &[u8]) -> Result { - let mut s = s.iter().copied().peekable(); + pub fn from_ascii_record(record: &[u8]) -> Result { + let mut s = record.iter().copied().peekable(); let mut setup = Setup::new(); (|| { let mut accept_empty_square = true; @@ -85,19 +100,14 @@ impl Setup { } (rank == 0).then_some(())?; (file == 8).then_some(())?; - Some(()) - })() - .ok_or(ParseSetupError::InvalidBoard)?; - (|| { + match s.next()? { b'w' => setup.set_turn(Color::White), b'b' => setup.set_turn(Color::Black), _ => return None, } - (s.next()? == b' ').then_some(()) - })() - .ok_or(ParseSetupError::InvalidTurn)?; - (|| { + (s.next()? == b' ').then_some(())?; + if s.next_if_eq(&b'-').is_none() { if s.next_if_eq(&b'K').is_some() { setup.set_castling_rights(Color::White, CastlingSide::Short, true); @@ -112,10 +122,8 @@ impl Setup { setup.set_castling_rights(Color::Black, CastlingSide::Long, true); } } - (s.next()? == b' ').then_some(()) - })() - .ok_or(ParseSetupError::InvalidCastlingRights)?; - (|| { + (s.next()? == b' ').then_some(())?; + match s.next()? { b'-' => (), file => setup.set_en_passant_target_square(Some(Square::new( @@ -123,10 +131,95 @@ impl Setup { Rank::from_ascii(s.next()?)?, ))), } - s.next().is_none().then_some(()) + s.next().is_none().then_some(())?; + Some(setup) })() - .ok_or(ParseSetupError::InvalidEnPassantTargetSquare)?; - Ok(setup) + .ok_or_else(|| ParseRecordError { + byte: record.len() - s.len(), + }) + } + + /// Returns the text record of the position. + pub fn to_text_record(&self) -> String { + let mut record = String::with_capacity(81); + self.write_text_record(&mut record).unwrap(); + record + } + + /// Writes the text record of the position. + pub fn write_text_record(&self, w: &mut W) -> std::fmt::Result + where + W: std::fmt::Write, + { + for rank in Rank::all().into_iter().rev() { + let mut count = 0; + for file in File::all() { + match self.get(Square::new(file, rank)) { + Some(piece) => { + if count > 0 { + w.write_char(char::from_u32('0' as u32 + count).unwrap())?; + } + count = 0; + w.write_char(match piece.color { + Color::White => piece.role.to_char_uppercase(), + Color::Black => piece.role.to_char_lowercase(), + })?; + } + None => { + count += 1; + } + } + } + if count > 0 { + w.write_char(char::from_u32('0' as u32 + count).unwrap())?; + } + if rank != Rank::First { + w.write_char('/')?; + } + } + + w.write_char(' ')?; + + w.write_char(match self.turn { + Color::White => 'w', + Color::Black => 'b', + })?; + + w.write_char(' ')?; + + let mut no_castle_available = true; + if self.castling_rights(Color::White, CastlingSide::Short) { + w.write_char('K')?; + no_castle_available = false; + } + if self.castling_rights(Color::White, CastlingSide::Long) { + w.write_char('Q')?; + no_castle_available = false; + } + if self.castling_rights(Color::Black, CastlingSide::Short) { + w.write_char('k')?; + no_castle_available = false; + } + if self.castling_rights(Color::Black, CastlingSide::Long) { + w.write_char('q')?; + no_castle_available = false; + } + if no_castle_available { + w.write_char('-')?; + } + + w.write_char(' ')?; + + match self.en_passant.try_into_square() { + Some(sq) => { + w.write_str(sq.to_str())?; + } + None => { + w.write_char('-')?; + } + } + + Ok(()) } /// Returns the occupancy of a square. @@ -424,119 +517,28 @@ impl TryFrom for Position { } } -impl std::fmt::Debug for Setup { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { - f.debug_tuple("Setup").field(&self.to_string()).finish() +pub(crate) struct TextRecord<'a>(pub(crate) &'a Setup); +impl<'a> std::fmt::Display for TextRecord<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.write_text_record(f) } } - -impl std::fmt::Display for Setup { +impl<'a> std::fmt::Debug for TextRecord<'a> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { use std::fmt::Write; - - for rank in Rank::all().into_iter().rev() { - let mut count = 0; - for file in File::all() { - match self.get(Square::new(file, rank)) { - Some(piece) => { - if count > 0 { - f.write_char(char::from_u32('0' as u32 + count).unwrap())?; - } - count = 0; - f.write_char(match piece.color { - Color::White => piece.role.to_char_uppercase(), - Color::Black => piece.role.to_char_lowercase(), - })?; - } - None => { - count += 1; - } - } - } - if count > 0 { - f.write_char(char::from_u32('0' as u32 + count).unwrap())?; - } - if rank != Rank::First { - f.write_char('/')?; - } - } - - f.write_char(' ')?; - - f.write_char(match self.turn { - Color::White => 'w', - Color::Black => 'b', - })?; - - f.write_char(' ')?; - - let mut no_castle_available = true; - if self.castling_rights(Color::White, CastlingSide::Short) { - f.write_char('K')?; - no_castle_available = false; - } - if self.castling_rights(Color::White, CastlingSide::Long) { - f.write_char('Q')?; - no_castle_available = false; - } - if self.castling_rights(Color::Black, CastlingSide::Short) { - f.write_char('k')?; - no_castle_available = false; - } - if self.castling_rights(Color::Black, CastlingSide::Long) { - f.write_char('q')?; - no_castle_available = false; - } - if no_castle_available { - f.write_char('-')?; - } - - f.write_char(' ')?; - - match self.en_passant.try_into_square() { - Some(sq) => { - f.write_str(sq.to_str())?; - } - None => { - write!(f, "-")?; - } - } - + f.write_char('"')?; + self.0.write_text_record(f)?; + f.write_char('"')?; Ok(()) } } -impl std::str::FromStr for Setup { - type Err = ParseSetupError; - #[inline] - fn from_str(s: &str) -> Result { - Self::from_ascii(s.as_bytes()) +impl std::fmt::Debug for Setup { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + f.debug_tuple("Setup").field(&TextRecord(&self)).finish() } } -/// An error when trying to parse a position record. -/// -/// The variant indicates the field that caused the error. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ParseSetupError { - InvalidBoard, - InvalidTurn, - InvalidCastlingRights, - InvalidEnPassantTargetSquare, -} -impl std::fmt::Display for ParseSetupError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let details = match self { - Self::InvalidBoard => "board", - Self::InvalidTurn => "turn", - Self::InvalidCastlingRights => "castling rights", - Self::InvalidEnPassantTargetSquare => "en passant target square", - }; - write!(f, "invalid text record ({details})") - } -} -impl std::error::Error for ParseSetupError {} - /// An invalid position. /// /// This is an illegal position that can't be represented with the [`Position`] type. @@ -549,7 +551,7 @@ impl std::fmt::Display for IllegalPosition { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { use std::fmt::Write; let setup = &self.setup; - write!(f, "`{setup}` is illegal:")?; + write!(f, "`{}` is illegal:", &TextRecord(setup))?; let mut first = true; for reason in self.reasons { if !first { @@ -654,3 +656,17 @@ impl std::fmt::Display for IllegalPositionReason { f.write_str(self.to_str()) } } + +/// An error when trying to parse a position record. +/// +/// The variant indicates the field that caused the error. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ParseRecordError { + pub byte: usize, +} +impl std::fmt::Display for ParseRecordError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "invalid text record (at byte {})", self.byte) + } +} +impl std::error::Error for ParseRecordError {} diff --git a/tests/tests.rs b/tests/tests.rs index e8b8799..c817881 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -13,13 +13,7 @@ static P6: &'static str = "r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QP fn recursive_check_aux(position: Position, depth: usize) { assert_eq!( position, - position - .as_setup() - .to_string() - .parse::() - .unwrap() - .into_position() - .unwrap(), + Position::from_text_record(&position.as_setup().to_text_record()).unwrap(), ); if let Some(passed) = position.pass() { @@ -75,7 +69,7 @@ fn recursive_check_aux(position: Position, depth: usize) { } } fn recursive_check(record: &str) { - recursive_check_aux(record.parse::().unwrap().into_position().unwrap(), 4); + recursive_check_aux(Position::from_text_record(record).unwrap(), 4); } #[test] fn recursive_check_1() { @@ -104,34 +98,29 @@ fn recursive_check_6() { #[test] fn setup() { - assert_eq!(Position::new().as_setup().to_string(), P1); - assert_eq!(Setup::new().to_string(), "8/8/8/8/8/8/8/8 w - -"); + assert_eq!(Position::new().as_setup().to_text_record(), P1); + assert_eq!(Setup::new().to_text_record(), "8/8/8/8/8/8/8/8 w - -"); assert_eq!( - "8/8/8/8/1Pp5/8/R1k5/K7 w - b3" - .parse::() + Setup::from_text_record("8/8/8/8/1Pp5/8/R1k5/K7 w - b3") .unwrap() - .to_string(), + .to_text_record(), "8/8/8/8/1Pp5/8/R1k5/K7 w - b3", ); - for (record, err) in [ - ("", ParseSetupError::InvalidBoard), - (" w - -", ParseSetupError::InvalidBoard), - ("8/8/8/8/8/8/8 w - -", ParseSetupError::InvalidBoard), - ("1/1/1/1/1/1/1/1 w - -", ParseSetupError::InvalidBoard), - ( - "44/44/44/44/44/44/44/44 w - -", - ParseSetupError::InvalidBoard, - ), - ("8/8/8/8/8/8/8/8/8 w - -", ParseSetupError::InvalidBoard), - ("p8/8/8/8/8/8/8/8 w - -", ParseSetupError::InvalidBoard), - ("8/8/8/8/8/8/8/8 - - - ", ParseSetupError::InvalidTurn), - ( - "8/8/8/8/8/8/8/8 w QQQQ -", - ParseSetupError::InvalidCastlingRights, - ), + for (record, byte) in [ + ("", 0), + (" w - -", 1), + ("8/8/8/8/8/8/8 w - -", 14), + ("1/1/1/1/1/1/1/1 w - -", 2), + ("44/44/44/44/44/44/44/44 w - -", 2), + ("8/8/8/8/8/8/8/8/8 w - -", 16), + ("p8/8/8/8/8/8/8/8 w - -", 2), + ("8/8/8/8/8/8/8/8 - - - ", 17), + ("8/8/8/8/8/8/8/8 w QQQQ -", 20), ] { - assert_eq!(record.parse::(), Err(err), "{record}"); + let res = Setup::from_text_record(record); + assert!(res.is_err()); + assert_eq!(res.unwrap_err().byte, byte, "{record}"); } for (record, reason) in [ ( @@ -192,12 +181,12 @@ fn setup() { ), ] { assert!( - record.parse::().map(|record| record.to_string()) == Ok(record.to_string()), + Setup::from_text_record(record).map(|setup| setup.to_text_record()) + == Ok(record.to_string()), "{record}", ); assert!( - record - .parse::() + Setup::from_text_record(record) .unwrap() .into_position() .is_err_and(|e| e.reasons.contains(reason)), @@ -212,28 +201,34 @@ fn setup() { "3kr3/8/8/8/4P3/8/8/4K3 b - e3", "8/8/8/3k4/3P4/8/8/3RK3 b - d3", ] { - assert!(record.parse::().is_ok(), "{record}"); + assert!(Setup::from_text_record(record).is_ok(), "{record}"); } + + assert_eq!( + Setup::from_text_record( + "p1p1p1p1/p1p1p1p1/p1p1p1p1/p1p1p1p1/p1p1p1p1/p1p1p1p1/p1p1p1p1/p1p1p1p1 w KQkq a1" + ) + .unwrap() + .to_text_record() + .len(), + 81 + ); } #[test] fn mirror() { assert_eq!(Position::new().pass(), Some(Position::new().mirror())); - let position = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b Kq e3" - .parse::() - .unwrap() - .into_position() - .unwrap(); - let mirror = "rnbqkbnr/pppp1ppp/8/4p3/8/8/PPPPPPPP/RNBQKBNR w Qk e6" - .parse::() - .unwrap() - .into_position() - .unwrap(); + let position = + Position::from_text_record("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b Kq e3") + .unwrap(); + let mirror = + Position::from_text_record("rnbqkbnr/pppp1ppp/8/4p3/8/8/PPPPPPPP/RNBQKBNR w Qk e6") + .unwrap(); assert_eq!(mirror, position.mirror()); } fn perft_aux(record: &str, tests: &[u128]) { - let position = record.parse::().unwrap().into_position().unwrap(); + let position = Position::from_text_record(record).unwrap(); for (depth, value) in tests.iter().copied().enumerate() { assert_eq!( position.perft(depth), @@ -272,11 +267,7 @@ fn perft_6() { #[test] fn san() { - let position = "8/2KN1p2/5p2/3N1B1k/5PNp/7P/7P/8 w - -" - .parse::() - .unwrap() - .into_position() - .unwrap(); + let position = Position::from_text_record("8/2KN1p2/5p2/3N1B1k/5PNp/7P/7P/8 w - -").unwrap(); let san1 = "N7xf6#".parse::().unwrap(); let m1 = san1.to_move(&position).unwrap(); let san2 = "N5xf6#".parse::().unwrap();