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:
+2
-1
@@ -33,7 +33,8 @@ val versions = mapOf(
|
|||||||
"SCALAFX" to "21.0.0-R32",
|
"SCALAFX" to "21.0.0-R32",
|
||||||
"JAVAFX" to "21.0.1",
|
"JAVAFX" to "21.0.1",
|
||||||
"JUNIT_BOM" to "5.13.4",
|
"JUNIT_BOM" to "5.13.4",
|
||||||
"SCALA_PARSER_COMBINATORS" to "2.4.0"
|
"SCALA_PARSER_COMBINATORS" to "2.4.0",
|
||||||
|
"FASTPARSE" to "3.0.2"
|
||||||
)
|
)
|
||||||
extra["VERSIONS"] = versions
|
extra["VERSIONS"] = versions
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ dependencies {
|
|||||||
}
|
}
|
||||||
|
|
||||||
implementation("org.scala-lang.modules:scala-parser-combinators_3:${versions["SCALA_PARSER_COMBINATORS"]!!}")
|
implementation("org.scala-lang.modules:scala-parser-combinators_3:${versions["SCALA_PARSER_COMBINATORS"]!!}")
|
||||||
|
implementation("com.lihaoyi:fastparse_3:${versions["FASTPARSE"]!!}")
|
||||||
|
|
||||||
implementation(project(":modules:api"))
|
implementation(project(":modules:api"))
|
||||||
implementation(project(":modules:rule"))
|
implementation(project(":modules:rule"))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import de.nowchess.api.board.*
|
|||||||
import de.nowchess.api.game.GameContext
|
import de.nowchess.api.game.GameContext
|
||||||
import de.nowchess.io.GameContextImport
|
import de.nowchess.io.GameContextImport
|
||||||
import scala.util.parsing.combinator.RegexParsers
|
import scala.util.parsing.combinator.RegexParsers
|
||||||
|
import FenParserSupport.*
|
||||||
|
|
||||||
object FenParserCombinators extends RegexParsers with GameContextImport:
|
object FenParserCombinators extends RegexParsers with GameContextImport:
|
||||||
|
|
||||||
@@ -11,15 +12,6 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
|
|||||||
|
|
||||||
// ── Piece character ──────────────────────────────────────────────────────
|
// ── 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] =
|
private def pieceChar: Parser[Piece] =
|
||||||
"[prnbqkPRNBQK]".r ^^ { s =>
|
"[prnbqkPRNBQK]".r ^^ { s =>
|
||||||
val c = s.head
|
val c = s.head
|
||||||
@@ -32,11 +24,6 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
|
|||||||
|
|
||||||
// ── Rank parser ──────────────────────────────────────────────────────────
|
// ── 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] =
|
private def rankToken: Parser[RankToken] =
|
||||||
pieceChar ^^ PieceToken.apply | emptyCount ^^ EmptyToken.apply
|
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. */
|
* Fails if total file count != 8 or any piece placement exceeds board bounds. */
|
||||||
private def rankParser(rank: Rank): Parser[List[(Square, Piece)]] =
|
private def rankParser(rank: Rank): Parser[List[(Square, Piece)]] =
|
||||||
rankTokens >> { tokens =>
|
rankTokens >> { tokens =>
|
||||||
val result = tokens.foldLeft(Option((List.empty[(Square, Piece)], 0))):
|
buildSquares(rank, tokens) match
|
||||||
case (None, _) => None
|
case Some(squares) => success(squares)
|
||||||
case (Some((acc, fileIdx)), PieceToken(piece)) =>
|
case None => failure(s"Rank $rank is invalid")
|
||||||
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 ─────────────────────────────────────────────────────────
|
// ── 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 }
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package de.nowchess.io.fen
|
||||||
|
|
||||||
|
import de.nowchess.api.board.*
|
||||||
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
|
class FenParserFastParseTest 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"
|
||||||
|
|
||||||
|
FenParserFastParse.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn))
|
||||||
|
FenParserFastParse.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(Some(Piece.BlackKing))
|
||||||
|
FenParserFastParse.parseBoard(empty).map(_.pieces.size) shouldBe Some(0)
|
||||||
|
FenParserFastParse.parseBoard(partial).map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(Some(Piece.BlackKing))
|
||||||
|
|
||||||
|
FenParserFastParse.parseBoard(initial).map(FenExporter.boardToFen) shouldBe Some(initial)
|
||||||
|
FenParserFastParse.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
|
||||||
|
|
||||||
|
test("parseFen parses full state for common valid inputs"):
|
||||||
|
FenParserFastParse.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
|
||||||
|
)
|
||||||
|
|
||||||
|
FenParserFastParse.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))
|
||||||
|
)
|
||||||
|
|
||||||
|
FenParserFastParse.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"):
|
||||||
|
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true
|
||||||
|
FenParserFastParse.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"
|
||||||
|
FenParserFastParse.importGameContext(fen).isRight shouldBe true
|
||||||
|
FenParserFastParse.importGameContext("invalid fen string").isLeft shouldBe true
|
||||||
|
|
||||||
|
test("parseBoard rejects malformed board shapes and invalid piece symbols"):
|
||||||
|
FenParserFastParse.parseBoard("8/8/8/8/8/8/8") shouldBe None
|
||||||
|
FenParserFastParse.parseBoard("9/8/8/8/8/8/8/8") shouldBe None
|
||||||
|
FenParserFastParse.parseBoard("8p/8/8/8/8/8/8/8") shouldBe None
|
||||||
|
FenParserFastParse.parseBoard("7/8/8/8/8/8/8/8") shouldBe None
|
||||||
|
FenParserFastParse.parseBoard("8/8/8/8/8/8/8/7X") shouldBe None
|
||||||
|
|
||||||
|
test("parseBoard rejects ranks that overflow via multiple tokens"):
|
||||||
|
FenParserFastParse.parseBoard("p8/8/8/8/8/8/8/8") shouldBe None
|
||||||
|
FenParserFastParse.parseBoard("8pp/8/8/8/8/8/8/8") shouldBe None
|
||||||
Reference in New Issue
Block a user