chess_inator/src/fen.rs
2024-10-25 22:30:03 -04:00

458 lines
16 KiB
Rust

/*
This file is part of chess_inator.
chess_inator is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
chess_inator is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with chess_inator. If not, see https://www.gnu.org/licenses/.
Copyright © 2024 dogeystamp <dogeystamp@disroot.org>
*/
use crate::{Board, ColPiece, Color, Square, SquareIdx, BOARD_HEIGHT, BOARD_WIDTH};
pub const START_POSITION: &str = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1";
pub trait FromFen {
type Error;
fn from_fen(_: &str) -> Result<Self, Self::Error>
where
Self: std::marker::Sized;
}
pub trait ToFen {
fn to_fen(&self) -> String;
}
/// FEN parsing error, with index of issue if applicable.
#[derive(Debug)]
pub enum FenError {
/// Invalid character.
BadChar(usize, char),
/// FEN is too short, and missing information.
MissingFields,
/// Too many pieces on a single row.
TooManyPieces(usize),
/// Too little pieces on a single row.
NotEnoughPieces(usize),
/// Too many rows.
TooManyRows(usize),
/// Too little rows.
NotEnoughRows(usize),
/// Parser refuses to keep parsing move counter because it is too big.
TooManyMoves,
/// Error in the parser.
InternalError(usize),
}
impl FromFen for Board {
type Error = FenError;
fn from_fen(fen: &str) -> Result<Board, FenError> {
//! Parse FEN string into position.
/// Parser state machine.
#[derive(Debug, Clone, Copy)]
enum FenState {
/// Parses space characters between arguments, and jumps to next state.
Space,
/// Accepts pieces in a row, or a slash, and stores row and column (0-indexed)
Piece(usize, usize),
/// Player whose turn it is
Side,
/// Castling ability
Castle,
/// En passant square, letter part
EnPassantFile,
/// En passant square, digit part
EnPassantRank,
/// Half-move counter for 50-move draw rule
HalfMove,
/// Full-move counter
FullMove,
}
let mut pos = Board::default();
let mut parser_state = FenState::Piece(0, 0);
let mut next_state = FenState::Space;
/// Create parse error at a given index
macro_rules! bad_char {
($idx:ident, $char:ident) => {
Err(FenError::BadChar($idx, $char))
};
}
/// Parse a space character, then jump to the given state
macro_rules! parse_space_and_goto {
($next:expr) => {
parser_state = FenState::Space;
next_state = $next;
};
}
for (i, c) in fen.chars().enumerate() {
match parser_state {
FenState::Space => {
match c {
' ' => {
parser_state = next_state;
}
_ => return bad_char!(i, c),
};
}
FenState::Piece(mut row, mut col) => {
// FEN stores rows differently from our bitboard
let real_row = BOARD_HEIGHT - 1 - row;
match c {
'/' => {
if col < BOARD_WIDTH {
return Err(FenError::NotEnoughPieces(i));
} else if row >= BOARD_HEIGHT {
return Err(FenError::TooManyRows(i));
}
col = 0;
row += 1;
parser_state = FenState::Piece(row, col)
}
pc_char @ ('a'..='z' | 'A'..='Z') => {
let pc = ColPiece::try_from(pc_char).or(bad_char!(i, c))?;
if col > 7 {
return Err(FenError::TooManyPieces(i));
};
pos.set_piece(
Square::from_row_col(real_row, col)
.or(Err(FenError::InternalError(i)))?,
pc,
);
col += 1;
parser_state = FenState::Piece(row, col)
}
number @ '1'..='9' => {
if let Some(n) = number.to_digit(10) {
col += n as usize;
if col > BOARD_WIDTH {
return Err(FenError::TooManyPieces(i));
};
parser_state = FenState::Piece(row, col);
} else {
return bad_char!(i, c);
}
}
' ' => {
if row < BOARD_HEIGHT - 1 {
return Err(FenError::NotEnoughRows(i));
} else if col < BOARD_WIDTH {
return Err(FenError::NotEnoughPieces(i));
}
parser_state = FenState::Side
}
_ => return bad_char!(i, c),
};
}
FenState::Side => {
match c {
'w' => pos.turn = Color::White,
'b' => pos.turn = Color::Black,
_ => return bad_char!(i, c),
}
parse_space_and_goto!(FenState::Castle);
}
FenState::Castle => {
macro_rules! wc {
() => {
pos.castle.0[Color::White as usize]
};
}
macro_rules! bc {
() => {
pos.castle.0[Color::Black as usize]
};
}
match c {
'Q' => wc!().q = true,
'q' => bc!().q = true,
'K' => wc!().k = true,
'k' => bc!().k = true,
' ' => parser_state = FenState::EnPassantRank,
'-' => {
parse_space_and_goto!(FenState::EnPassantRank);
}
_ => return bad_char!(i, c),
}
}
FenState::EnPassantRank => {
match c {
'-' => {
parse_space_and_goto!(FenState::HalfMove);
}
'a'..='h' => {
pos.ep_square = Some(Square(c as SquareIdx - b'a'));
parser_state = FenState::EnPassantFile;
}
_ => return bad_char!(i, c),
};
}
FenState::EnPassantFile => {
if let Some(digit) = c.to_digit(10) {
pos.ep_square = Some(Square(
SquareIdx::from(pos.ep_square.unwrap_or(Square(0)))
+ (digit as SquareIdx - 1) * 8,
));
} else {
return bad_char!(i, c);
}
parse_space_and_goto!(FenState::HalfMove);
}
FenState::HalfMove => {
if let Some(digit) = c.to_digit(10) {
if pos.half_moves > Board::MAX_MOVES {
return Err(FenError::TooManyMoves);
}
pos.half_moves *= 10;
pos.half_moves += digit as usize;
} else if c == ' ' {
parser_state = FenState::FullMove;
} else {
return bad_char!(i, c);
}
}
FenState::FullMove => {
if let Some(digit) = c.to_digit(10) {
if pos.half_moves > Board::MAX_MOVES {
return Err(FenError::TooManyMoves);
}
pos.full_moves *= 10;
pos.full_moves += digit as usize;
} else {
return bad_char!(i, c);
}
}
}
}
// parser is always ready to receive another full move digit,
// so there is no real "stop" state
if matches!(parser_state, FenState::FullMove) {
Ok(pos)
} else {
Err(FenError::MissingFields)
}
}
}
impl ToFen for Board {
fn to_fen(&self) -> String {
let pieces_str = (0..BOARD_HEIGHT)
.rev()
.map(|row| {
let mut row_str = String::with_capacity(8);
let mut empty_counter = 0;
macro_rules! compact_empty {
() => {
if empty_counter > 0 {
row_str.push_str(&empty_counter.to_string());
}
};
}
for col in 0..BOARD_WIDTH {
let idx = Square::from_row_col(row, col).unwrap();
if let Some(pc) = self.get_piece(idx) {
compact_empty!();
empty_counter = 0;
row_str.push(pc.into())
} else {
empty_counter += 1;
}
}
compact_empty!();
row_str
})
.collect::<Vec<String>>()
.join("/");
let turn = char::from(self.turn);
let castle = self.castle.to_string();
let ep_square = match self.ep_square {
Some(sqr) => sqr.to_string(),
None => "-".to_string(),
};
let half_move = self.half_moves.to_string();
let full_move = self.full_moves.to_string();
format!("{pieces_str} {turn} {castle} {ep_square} {half_move} {full_move}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{CastlePlayer, CastleRights, N_SQUARES};
#[test]
fn test_fen_pieces() {
let fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1";
let board = Board::from_fen(fen.into()).unwrap();
assert_eq!(
(0..SquareIdx::try_from(N_SQUARES).unwrap())
.map(Square)
.map(|i| board.get_piece(i))
.map(ColPiece::opt_to_char)
.collect::<String>(),
"RNBQKBNRPPPP.PPP............P...................pppppppprnbqkbnr"
);
assert_eq!(board.ep_square.unwrap(), Square(20));
assert_eq!(board.turn, Color::Black);
}
macro_rules! make_board {
($fen_fmt: expr) => {
Board::from_fen(&format!($fen_fmt)).unwrap()
};
}
#[test]
fn test_fen_ep_square() {
let test_cases = [("e3", 20), ("h8", 63), ("a8", 56), ("h4", 31), ("a1", 0)];
for (sqr, idx) in test_cases {
let board = make_board!("8/8/8/8/8/8/8/8 w - {sqr} 0 0");
assert_eq!(board.ep_square.unwrap(), Square(idx));
}
let board = make_board!("8/8/8/8/8/8/8/8 w - - 0 0");
assert_eq!(board.ep_square, None);
}
#[test]
fn test_fen_turn() {
let test_cases = [("w", Color::White), ("b", Color::Black)];
for (col_char, col) in test_cases {
let board = make_board!("8/8/8/8/8/8/8/8 {col_char} - - 0 0");
assert_eq!(board.turn, col);
}
}
#[test]
fn test_fen_castle_rights() {
let test_cases = [
(
"-",
[
CastlePlayer { k: false, q: false },
CastlePlayer { k: false, q: false },
],
),
(
"k",
[
CastlePlayer { k: false, q: false },
CastlePlayer { k: true, q: false },
],
),
(
"kq",
[
CastlePlayer { k: false, q: false },
CastlePlayer { k: true, q: true },
],
),
// This is the wrong order, but parsers should be lenient
(
"qk",
[
CastlePlayer { k: false, q: false },
CastlePlayer { k: true, q: true },
],
),
(
"KQkq",
[
CastlePlayer { k: true, q: true },
CastlePlayer { k: true, q: true },
],
),
(
"KQ",
[
CastlePlayer { k: true, q: true },
CastlePlayer { k: false, q: false },
],
),
(
"QK",
[
CastlePlayer { k: true, q: true },
CastlePlayer { k: false, q: false },
],
),
];
for (castle_str, castle) in test_cases {
let board = make_board!("8/8/8/8/8/8/8/8 w {castle_str} - 0 0");
assert_eq!(board.castle, CastleRights(castle));
}
}
#[test]
fn test_fen_half_move_counter() {
for i in 0..=Board::MAX_MOVES {
let board = make_board!("8/8/8/8/8/8/8/8 w - - {i} 0");
assert_eq!(board.half_moves, i);
assert_eq!(board.full_moves, 0);
}
}
#[test]
fn test_fen_move_counter() {
for i in 0..=Board::MAX_MOVES {
let board = make_board!("8/8/8/8/8/8/8/8 w - - 0 {i}");
assert_eq!(board.half_moves, 0);
assert_eq!(board.full_moves, i);
}
}
#[test]
fn test_fen_printing() {
//! Test that FENs printed are equivalent to the original.
// FENs sourced from https://gist.github.com/peterellisjones/8c46c28141c162d1d8a0f0badbc9cff9
let test_cases = [
"8/8/8/2k5/2pP4/8/B7/4K3 b - d3 0 3",
"r6r/1b2k1bq/8/8/7B/8/8/R3K2R b KQ - 3 2",
"r1bqkbnr/pppppppp/n7/8/8/P7/1PPPPPPP/RNBQKBNR w KQkq - 2 2",
"r3k2r/p1pp1pb1/bn2Qnp1/2qPN3/1p2P3/2N5/PPPBBPPP/R3K2R b KQkq - 3 2",
"2kr3r/p1ppqpb1/bn2Qnp1/3PN3/1p2P3/2N5/PPPBBPPP/R3K2R b KQ - 3 2",
"rnb2k1r/pp1Pbppp/2p5/q7/2B5/8/PPPQNnPP/RNB1K2R w KQ - 3 9",
"2r5/3pk3/8/2P5/8/2K5/8/8 w - - 5 4",
"rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8",
"r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10",
"3k4/3p4/8/K1P4r/8/8/8/8 b - - 0 1",
"8/8/4k3/8/2p5/8/B2P2K1/8 w - - 0 1",
"8/8/1k6/2b5/2pP4/8/5K2/8 b - d3 0 1",
"5k2/8/8/8/8/8/8/4K2R w K - 0 1",
"3k4/8/8/8/8/8/8/R3K3 w Q - 0 1",
"r3k2r/1b4bq/8/8/8/8/7B/R3K2R w KQkq - 0 1",
"r3k2r/8/3Q4/8/8/5q2/8/R3K2R b KQkq - 0 1",
"2K2r2/4P3/8/8/8/8/8/3k4 w - - 0 1",
"8/8/1P2K3/8/2n5/1q6/8/5k2 b - - 0 1",
"4k3/1P6/8/8/8/8/K7/8 w - - 0 1",
"8/P1k5/K7/8/8/8/8/8 w - - 0 1",
"K1k5/8/P7/8/8/8/8/8 w - - 0 1",
"8/k1P5/8/1K6/8/8/8/8 w - - 0 1",
"8/8/2k5/5q2/5n2/8/5K2/8 b - - 0 1",
];
for fen1 in test_cases {
println!("fen1: {fen1:?}");
let fen2 = Board::from_fen(fen1).unwrap().to_fen();
assert_eq!(fen1.to_string(), fen2, "FEN not equivalent")
}
}
}