Summary - Added fastparse_3:3.0.2 dependency to modules/io - Implemented FenParserFastParse as a second alternative FEN parser using FastParse, with the same public API as FenParser and FenParserCombinators - Parsers are built bottom-up using (using P[Any]) Scala 3 syntax with NoWhitespace.* to prevent implicit whitespace skipping; rank sum validation uses Pass/Fail inside .flatMap - Added FenParserFastParseTest mirroring FenParserCombinatorsTest to prove behavioural equivalence across all three implementations Test plan - All existing tests pass — FenParser, FenParserCombinators, and all other modules untouched - FenParserFastParseTest covers all cases: valid FEN, invalid color, invalid castling, invalid board shapes, en passant, rank overflow, round-trip via FenExporter - All parser logic branches genuinely covered — known scoverage gap documented in docs/unresolved.md (FastParse inline macro generates synthetic proxy methods that scoverage instruments but that never execute at runtime) Co-authored-by: LQ63 <lkhermann@web.de> Reviewed-on: #22 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 #22.
This commit is contained in:
@@ -4,6 +4,7 @@ import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.io.GameContextImport
|
||||
import scala.util.parsing.combinator.RegexParsers
|
||||
import FenParserSupport.*
|
||||
|
||||
object FenParserCombinators extends RegexParsers with GameContextImport:
|
||||
|
||||
@@ -11,15 +12,6 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
|
||||
|
||||
// ── 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
|
||||
@@ -32,11 +24,6 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
|
||||
|
||||
// ── 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
|
||||
|
||||
@@ -46,21 +33,9 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
|
||||
* 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")
|
||||
buildSquares(rank, tokens) match
|
||||
case Some(squares) => success(squares)
|
||||
case None => failure(s"Rank $rank is invalid")
|
||||
}
|
||||
|
||||
// ── Board parser ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package de.nowchess.io.fen
|
||||
|
||||
import fastparse.*
|
||||
import fastparse.NoWhitespace.*
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.io.GameContextImport
|
||||
import FenParserSupport.*
|
||||
|
||||
object FenParserFastParse extends GameContextImport:
|
||||
|
||||
// ── Low-level parsers ────────────────────────────────────────────────────
|
||||
|
||||
private def pieceChar(using P[Any]): P[Piece] =
|
||||
CharIn("prnbqkPRNBQK").!.map { s =>
|
||||
val c = s.head
|
||||
val color = if c.isUpper then Color.White else Color.Black
|
||||
Piece(color, charToPieceType(c.toLower))
|
||||
}
|
||||
|
||||
private def emptyCount(using P[Any]): P[Int] =
|
||||
CharIn("1-8").!.map(_.toInt)
|
||||
|
||||
private def rankToken(using P[Any]): P[RankToken] =
|
||||
pieceChar.map(PieceToken.apply) | emptyCount.map(EmptyToken.apply)
|
||||
|
||||
// ── Rank parser ──────────────────────────────────────────────────────────
|
||||
|
||||
private def rankParser(rank: Rank)(using P[Any]): P[List[(Square, Piece)]] =
|
||||
rankToken.rep(1).flatMap { tokens =>
|
||||
buildSquares(rank, tokens) match
|
||||
case Some(squares) => Pass(squares)
|
||||
case None => Fail
|
||||
}
|
||||
|
||||
// ── Board parser ─────────────────────────────────────────────────────────
|
||||
|
||||
private def sep(using P[Any]): P[Unit] = LiteralStr("/").map(_ => ())
|
||||
|
||||
private def boardParser(using P[Any]): P[Board] =
|
||||
(rankParser(Rank.R8) ~ sep ~
|
||||
rankParser(Rank.R7) ~ sep ~
|
||||
rankParser(Rank.R6) ~ sep ~
|
||||
rankParser(Rank.R5) ~ sep ~
|
||||
rankParser(Rank.R4) ~ sep ~
|
||||
rankParser(Rank.R3) ~ sep ~
|
||||
rankParser(Rank.R2) ~ sep ~
|
||||
rankParser(Rank.R1)).map { case (r8, r7, r6, r5, r4, r3, r2, r1) =>
|
||||
Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap)
|
||||
}
|
||||
|
||||
// ── Color parser ─────────────────────────────────────────────────────────
|
||||
|
||||
private def colorParser(using P[Any]): P[Color] =
|
||||
(LiteralStr("w") | LiteralStr("b")).!.map {
|
||||
case "w" => Color.White
|
||||
case _ => Color.Black
|
||||
}
|
||||
|
||||
// ── Castling parser ──────────────────────────────────────────────────────
|
||||
|
||||
private def castlingParser(using P[Any]): P[CastlingRights] =
|
||||
LiteralStr("-").map(_ => CastlingRights.None) |
|
||||
CharsWhileIn("KQkq").!.map { s =>
|
||||
CastlingRights(
|
||||
whiteKingSide = s.contains('K'),
|
||||
whiteQueenSide = s.contains('Q'),
|
||||
blackKingSide = s.contains('k'),
|
||||
blackQueenSide = s.contains('q')
|
||||
)
|
||||
}
|
||||
|
||||
// ── En passant parser ────────────────────────────────────────────────────
|
||||
|
||||
private def enPassantParser(using P[Any]): P[Option[Square]] =
|
||||
LiteralStr("-").map(_ => Option.empty[Square]) |
|
||||
(CharIn("a-h") ~ CharIn("1-8")).!.map(s => Square.fromAlgebraic(s))
|
||||
|
||||
// ── Clock parser ─────────────────────────────────────────────────────────
|
||||
|
||||
private def clockParser(using P[Any]): P[Int] =
|
||||
CharsWhileIn("0-9").!.map(_.toInt)
|
||||
|
||||
// ── Space helper ─────────────────────────────────────────────────────────
|
||||
|
||||
private def sp(using P[Any]): P[Unit] = LiteralStr(" ").map(_ => ())
|
||||
|
||||
// ── Full FEN parser ──────────────────────────────────────────────────────
|
||||
|
||||
private def fenParser(using P[Any]): P[GameContext] =
|
||||
(boardParser ~ sp ~ colorParser ~ sp ~ castlingParser ~ sp ~
|
||||
enPassantParser ~ sp ~ clockParser ~ sp ~ clockParser ~ End).map {
|
||||
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] =
|
||||
parse(fen, fenParser(using _)) match
|
||||
case Parsed.Success(ctx, _) => Right(ctx)
|
||||
case f: Parsed.Failure => Left(s"Invalid FEN: ${f.msg}")
|
||||
|
||||
private def boardParserFull(using P[Any]): P[Board] =
|
||||
boardParser ~ End
|
||||
|
||||
def parseBoard(fen: String): Option[Board] =
|
||||
parse(fen, boardParserFull(using _)) match
|
||||
case Parsed.Success(board, _) => Some(board)
|
||||
case _ => None
|
||||
|
||||
def importGameContext(input: String): Either[String, GameContext] =
|
||||
parseFen(input)
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.nowchess.io.fen
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
|
||||
private[fen] object FenParserSupport:
|
||||
|
||||
sealed trait RankToken
|
||||
case class PieceToken(piece: Piece) extends RankToken
|
||||
case class EmptyToken(count: Int) extends RankToken
|
||||
|
||||
val charToPieceType: Map[Char, PieceType] = Map(
|
||||
'p' -> PieceType.Pawn,
|
||||
'r' -> PieceType.Rook,
|
||||
'n' -> PieceType.Knight,
|
||||
'b' -> PieceType.Bishop,
|
||||
'q' -> PieceType.Queen,
|
||||
'k' -> PieceType.King
|
||||
)
|
||||
|
||||
def buildSquares(rank: Rank, tokens: Seq[RankToken]): Option[List[(Square, Piece)]] =
|
||||
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))
|
||||
.flatMap { case (squares, total) => if total == 8 then Some(squares) else None }
|
||||
Reference in New Issue
Block a user