From b66a44af84cc0dfdc631706a75034ab458e5267b Mon Sep 17 00:00:00 2001 From: LQ63 Date: Tue, 7 Apr 2026 12:21:09 +0200 Subject: [PATCH] feat(io): Fen Parser using ParserCombinators Added FEN Parser using ParserCombinators --- .../io/fen/FenParserCombinators.scala | 145 ++++++++++++++++++ .../io/fen/FenParserCombinatorsTest.scala | 60 ++++++++ 2 files changed, 205 insertions(+) create mode 100644 modules/io/src/main/scala/de/nowchess/io/fen/FenParserCombinators.scala create mode 100644 modules/io/src/test/scala/de/nowchess/io/fen/FenParserCombinatorsTest.scala diff --git a/modules/io/src/main/scala/de/nowchess/io/fen/FenParserCombinators.scala b/modules/io/src/main/scala/de/nowchess/io/fen/FenParserCombinators.scala new file mode 100644 index 0000000..b1a4b8f --- /dev/null +++ b/modules/io/src/main/scala/de/nowchess/io/fen/FenParserCombinators.scala @@ -0,0 +1,145 @@ +package de.nowchess.io.fen + +import de.nowchess.api.board.* +import de.nowchess.api.game.GameContext +import de.nowchess.io.GameContextImport +import scala.util.parsing.combinator.RegexParsers + +object FenParserCombinators extends RegexParsers with GameContextImport: + + override val skipWhitespace: Boolean = false + + // ── Piece character ────────────────────────────────────────────────────── + + private val charToPieceType: Map[Char, PieceType] = Map( + 'p' -> PieceType.Pawn, + 'r' -> PieceType.Rook, + 'n' -> PieceType.Knight, + 'b' -> PieceType.Bishop, + 'q' -> PieceType.Queen, + 'k' -> PieceType.King + ) + + private def pieceChar: Parser[Piece] = + "[prnbqkPRNBQK]".r ^^ { s => + val c = s.head + val color = if c.isUpper then Color.White else Color.Black + Piece(color, charToPieceType(c.toLower)) + } + + private def emptyCount: Parser[Int] = + "[1-8]".r ^^ { s => s.toInt } + + // ── Rank parser ────────────────────────────────────────────────────────── + + /** Parse a sequence of piece-chars and empty-counts, returning tagged tokens. */ + private sealed trait RankToken + private case class PieceToken(piece: Piece) extends RankToken + private case class EmptyToken(count: Int) extends RankToken + + private def rankToken: Parser[RankToken] = + pieceChar ^^ PieceToken.apply | emptyCount ^^ EmptyToken.apply + + private def rankTokens: Parser[List[RankToken]] = rep1(rankToken) + + /** Parse rank string for a given Rank, producing (Square, Piece) pairs. + * Fails if total file count != 8 or any piece placement exceeds board bounds. */ + private def rankParser(rank: Rank): Parser[List[(Square, Piece)]] = + rankTokens >> { tokens => + val result = tokens.foldLeft(Option((List.empty[(Square, Piece)], 0))): + case (None, _) => None + case (Some((acc, fileIdx)), PieceToken(piece)) => + if fileIdx > 7 then None + else + val sq = Square(File.values(fileIdx), rank) + Some((acc :+ (sq -> piece), fileIdx + 1)) + case (Some((acc, fileIdx)), EmptyToken(n)) => + val next = fileIdx + n + if next > 8 then None + else Some((acc, next)) + result match + case Some((squares, 8)) => success(squares) + case Some((_, total)) => failure(s"Rank $rank has $total files, expected 8") + case None => failure(s"Rank $rank exceeds board width") + } + + // ── Board parser ───────────────────────────────────────────────────────── + + private def rankSep: Parser[String] = "/" + + /** Parse all 8 rank strings separated by '/', rank 8 down to rank 1. */ + private def boardParser: Parser[Board] = + rankParser(Rank.R8) ~ + (rankSep ~> rankParser(Rank.R7)) ~ + (rankSep ~> rankParser(Rank.R6)) ~ + (rankSep ~> rankParser(Rank.R5)) ~ + (rankSep ~> rankParser(Rank.R4)) ~ + (rankSep ~> rankParser(Rank.R3)) ~ + (rankSep ~> rankParser(Rank.R2)) ~ + (rankSep ~> rankParser(Rank.R1)) ^^ { + case r8 ~ r7 ~ r6 ~ r5 ~ r4 ~ r3 ~ r2 ~ r1 => + Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap) + } + + // ── Color parser ───────────────────────────────────────────────────────── + + private def colorParser: Parser[Color] = + ("w" | "b") ^^ { + case "w" => Color.White + case _ => Color.Black + } + + // ── Castling parser ────────────────────────────────────────────────────── + + private def castlingParser: Parser[CastlingRights] = + "-" ^^^ CastlingRights.None | + "[KQkq]{1,4}".r ^^ { s => + CastlingRights( + whiteKingSide = s.contains('K'), + whiteQueenSide = s.contains('Q'), + blackKingSide = s.contains('k'), + blackQueenSide = s.contains('q') + ) + } + + // ── En passant parser ──────────────────────────────────────────────────── + + private def enPassantParser: Parser[Option[Square]] = + "-" ^^^ Option.empty[Square] | + "[a-h][1-8]".r ^^ { s => Square.fromAlgebraic(s) } + + // ── Clock parser ───────────────────────────────────────────────────────── + + private def clockParser: Parser[Int] = + """\d+""".r ^^ { _.toInt } + + // ── Full FEN parser ────────────────────────────────────────────────────── + + private def fenParser: Parser[GameContext] = + boardParser ~ (" " ~> colorParser) ~ (" " ~> castlingParser) ~ + (" " ~> enPassantParser) ~ (" " ~> clockParser) ~ (" " ~> clockParser) ^^ { + case board ~ color ~ castling ~ ep ~ halfMove ~ _ => + GameContext( + board = board, + turn = color, + castlingRights = castling, + enPassantSquare = ep, + halfMoveClock = halfMove, + moves = List.empty + ) + } + + // ── Public API ─────────────────────────────────────────────────────────── + + def parseFen(fen: String): Either[String, GameContext] = + parseAll(fenParser, fen) match + case Success(ctx, _) => Right(ctx) + case other => Left(s"Invalid FEN: ${other.toString}") + + def parseBoard(fen: String): Option[Board] = + parseAll(boardParser, fen) match + case Success(board, _) => Some(board) + case _ => None + + def importGameContext(input: String): Either[String, GameContext] = + parseFen(input) diff --git a/modules/io/src/test/scala/de/nowchess/io/fen/FenParserCombinatorsTest.scala b/modules/io/src/test/scala/de/nowchess/io/fen/FenParserCombinatorsTest.scala new file mode 100644 index 0000000..e9a2857 --- /dev/null +++ b/modules/io/src/test/scala/de/nowchess/io/fen/FenParserCombinatorsTest.scala @@ -0,0 +1,60 @@ +package de.nowchess.io.fen + +import de.nowchess.api.board.* +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class FenParserCombinatorsTest extends AnyFunSuite with Matchers: + + test("parseBoard parses canonical positions and supports round-trip"): + val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR" + val empty = "8/8/8/8/8/8/8/8" + val partial = "8/8/4k3/8/4K3/8/8/8" + + FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn)) + FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(Some(Piece.BlackKing)) + FenParserCombinators.parseBoard(empty).map(_.pieces.size) shouldBe Some(0) + FenParserCombinators.parseBoard(partial).map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(Some(Piece.BlackKing)) + + FenParserCombinators.parseBoard(initial).map(FenExporter.boardToFen) shouldBe Some(initial) + FenParserCombinators.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty) + + test("parseFen parses full state for common valid inputs"): + FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx => + ctx.turn shouldBe Color.White + ctx.castlingRights.whiteKingSide shouldBe true + ctx.enPassantSquare shouldBe None + ctx.halfMoveClock shouldBe 0 + ) + + FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx => + ctx.turn shouldBe Color.Black + ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)) + ) + + FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx => + ctx.castlingRights.whiteKingSide shouldBe false + ctx.castlingRights.blackQueenSide shouldBe false + ) + + test("parseFen rejects invalid color and castling tokens"): + FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true + FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w XYZ - 0 1").isLeft shouldBe true + + test("importGameContext returns Right for valid and Left for invalid FEN"): + val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + FenParserCombinators.importGameContext(fen).isRight shouldBe true + FenParserCombinators.importGameContext("invalid fen string").isLeft shouldBe true + + test("parseBoard rejects malformed board shapes and invalid piece symbols"): + FenParserCombinators.parseBoard("8/8/8/8/8/8/8") shouldBe None + FenParserCombinators.parseBoard("9/8/8/8/8/8/8/8") shouldBe None + FenParserCombinators.parseBoard("8p/8/8/8/8/8/8/8") shouldBe None + FenParserCombinators.parseBoard("7/8/8/8/8/8/8/8") shouldBe None + FenParserCombinators.parseBoard("8/8/8/8/8/8/8/7X") shouldBe None + + test("parseBoard rejects ranks that overflow via multiple tokens"): + // EmptyToken overflow: piece then 8 empties = 9 total + FenParserCombinators.parseBoard("p8/8/8/8/8/8/8/8") shouldBe None + // fold short-circuit: 8 empties followed by two pieces = 10 total, exercises the None-propagation path + FenParserCombinators.parseBoard("8pp/8/8/8/8/8/8/8") shouldBe None