feat: NCS-30 FEN Parser using ParserCombinators (#21)
Build & Test (NowChessSystems) TeamCity build finished

Summary

  - Added scala-parser-combinators_3:2.4.0 dependency to modules/io
  - Implemented FenParserCombinators as an alternative FEN parser using RegexParsers, with the same public API as the
  existing FenParser
  - Parsers are built bottom-up: piece characters → rank tokens → rank → board, composed with explicit field separators
  into a full FEN parser
  - Added FenParserCombinatorsTest mirroring the existing FenParserTest to prove behavioural equivalence

  Test plan

  - All existing tests pass — FenParser and all other modules untouched
  - FenParserCombinatorsTest covers all cases: valid FEN, invalid color, invalid castling, invalid board shapes, en
  passant, round-trip via FenExporter
  - 100% line/branch/method coverage on FenParserCombinators

Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #21
Reviewed-by: Janis <janis-e@gmx.de>
Co-authored-by: Leon Hermann <lq@blackhole.local>
Co-committed-by: Leon Hermann <lq@blackhole.local>
This commit was merged in pull request #21.
This commit is contained in:
2026-04-07 12:28:43 +02:00
committed by Janis
parent 8959c3a849
commit b4bc72f7e4
4 changed files with 209 additions and 1 deletions
@@ -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)