From 13d4a62a719a5dbecd8aa21920ab866433bd3474 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Tue, 7 Apr 2026 12:59:12 +0200 Subject: [PATCH 1/4] feat(io): FastParse FEN Added FastParse FEN and dependencies --- build.gradle.kts | 3 +- docs/unresolved.md | 9 ++ modules/io/build.gradle.kts | 1 + .../nowchess/io/fen/FenParserFastParse.scala | 152 ++++++++++++++++++ .../io/fen/FenParserFastParseTest.scala | 58 +++++++ 5 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala create mode 100644 modules/io/src/test/scala/de/nowchess/io/fen/FenParserFastParseTest.scala diff --git a/build.gradle.kts b/build.gradle.kts index 711654d..11ad6e7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -33,7 +33,8 @@ val versions = mapOf( "SCALAFX" to "21.0.0-R32", "JAVAFX" to "21.0.1", "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 diff --git a/docs/unresolved.md b/docs/unresolved.md index e69de29..2519fe5 100644 --- a/docs/unresolved.md +++ b/docs/unresolved.md @@ -0,0 +1,9 @@ +## [2026-04-06] FenParserFastParse: scoverage cannot reach 100% due to fastparse inline macro incompatibility + +**Requirement/Bug:** 100% line/branch/method scoverage on `FenParserFastParse.scala` as specified in the coverage target. + +**Root Cause:** fastparse 3.x uses Scala 3 `inline` macros for its `parse(...)` entry points. When scoverage instruments the compiled output, it generates synthetic proxy methods (e.g. `parse0$proxy9`, `parse0$proxy10`) from the macro expansion. These proxies are attributed to the source lines of the parser definitions but are never directly invoked at runtime — the actual execution goes through the inlined code paths. This results in 33% of statements being reported as uncovered despite all parser branches being exercised by the test suite. + +**Attempted Fixes:** All six test cases (parseBoard valid, parseFen valid, reject invalid color/castling, importGameContext, reject malformed boards, reject overflow ranks) exercise every parser path. The issue is not in test coverage of logic but in how scoverage tracks inline-macro-generated bytecode. No source-level change can cause scoverage to instrument through `inline` expansion boundaries. + +**Next Step:** Either accept the coverage gap for this file (it is an instrumentation artefact, not a logic gap) or replace the `parse(...)` call sites with a non-inline wrapper that shields the proxy generation from the coverage-instrumented compilation unit. diff --git a/modules/io/build.gradle.kts b/modules/io/build.gradle.kts index f86790d..9f47163 100644 --- a/modules/io/build.gradle.kts +++ b/modules/io/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { } 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:rule")) diff --git a/modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala b/modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala new file mode 100644 index 0000000..f27a441 --- /dev/null +++ b/modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala @@ -0,0 +1,152 @@ +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 + +object FenParserFastParse extends GameContextImport: + + // ── Rank token ADT ────────────────────────────────────────────────────── + + private sealed trait RankToken + private case class PieceToken(piece: Piece) extends RankToken + private case class EmptyToken(count: Int) extends RankToken + + // ── Char-to-piece mapping ──────────────────────────────────────────────── + + 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 + ) + + // ── 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 validation helper ─────────────────────────────────────────────── + + private 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 } + + // ── 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) diff --git a/modules/io/src/test/scala/de/nowchess/io/fen/FenParserFastParseTest.scala b/modules/io/src/test/scala/de/nowchess/io/fen/FenParserFastParseTest.scala new file mode 100644 index 0000000..4647d2e --- /dev/null +++ b/modules/io/src/test/scala/de/nowchess/io/fen/FenParserFastParseTest.scala @@ -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 -- 2.52.0 From fec602aca74e889984a62a9d202213429a31f6b9 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Wed, 8 Apr 2026 08:07:20 +0200 Subject: [PATCH 2/4] feat(io): FastParse FEN Added Shared Parser support --- .../de/nowchess/io/fen/FenParserSupport.scala | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 modules/io/src/main/scala/de/nowchess/io/fen/FenParserSupport.scala diff --git a/modules/io/src/main/scala/de/nowchess/io/fen/FenParserSupport.scala b/modules/io/src/main/scala/de/nowchess/io/fen/FenParserSupport.scala new file mode 100644 index 0000000..ea33502 --- /dev/null +++ b/modules/io/src/main/scala/de/nowchess/io/fen/FenParserSupport.scala @@ -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 } -- 2.52.0 From 50f88ef753a14ffd76f9502a988f8dd8b65d9025 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Wed, 8 Apr 2026 08:40:27 +0200 Subject: [PATCH 3/4] feat(io): FastParse FEN Added Shared Parser support --- .../io/fen/FenParserCombinators.scala | 33 +++--------------- .../nowchess/io/fen/FenParserFastParse.scala | 34 +------------------ 2 files changed, 5 insertions(+), 62 deletions(-) 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 index b1a4b8f..a8e77ca 100644 --- a/modules/io/src/main/scala/de/nowchess/io/fen/FenParserCombinators.scala +++ b/modules/io/src/main/scala/de/nowchess/io/fen/FenParserCombinators.scala @@ -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 ───────────────────────────────────────────────────────── diff --git a/modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala b/modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala index f27a441..df129c3 100644 --- a/modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala +++ b/modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala @@ -5,26 +5,10 @@ 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: - // ── Rank token ADT ────────────────────────────────────────────────────── - - private sealed trait RankToken - private case class PieceToken(piece: Piece) extends RankToken - private case class EmptyToken(count: Int) extends RankToken - - // ── Char-to-piece mapping ──────────────────────────────────────────────── - - 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 - ) - // ── Low-level parsers ──────────────────────────────────────────────────── private def pieceChar(using P[Any]): P[Piece] = @@ -40,22 +24,6 @@ object FenParserFastParse extends GameContextImport: private def rankToken(using P[Any]): P[RankToken] = pieceChar.map(PieceToken.apply) | emptyCount.map(EmptyToken.apply) - // ── Rank validation helper ─────────────────────────────────────────────── - - private 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 } - // ── Rank parser ────────────────────────────────────────────────────────── private def rankParser(rank: Rank)(using P[Any]): P[List[(Square, Piece)]] = -- 2.52.0 From b23ec1070205f906a00764fc9c86458b980c6efc Mon Sep 17 00:00:00 2001 From: LQ63 Date: Wed, 8 Apr 2026 09:28:46 +0200 Subject: [PATCH 4/4] feat(io): FastParse FEN Removed Unresolved doc --- docs/unresolved.md | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 docs/unresolved.md diff --git a/docs/unresolved.md b/docs/unresolved.md deleted file mode 100644 index 2519fe5..0000000 --- a/docs/unresolved.md +++ /dev/null @@ -1,9 +0,0 @@ -## [2026-04-06] FenParserFastParse: scoverage cannot reach 100% due to fastparse inline macro incompatibility - -**Requirement/Bug:** 100% line/branch/method scoverage on `FenParserFastParse.scala` as specified in the coverage target. - -**Root Cause:** fastparse 3.x uses Scala 3 `inline` macros for its `parse(...)` entry points. When scoverage instruments the compiled output, it generates synthetic proxy methods (e.g. `parse0$proxy9`, `parse0$proxy10`) from the macro expansion. These proxies are attributed to the source lines of the parser definitions but are never directly invoked at runtime — the actual execution goes through the inlined code paths. This results in 33% of statements being reported as uncovered despite all parser branches being exercised by the test suite. - -**Attempted Fixes:** All six test cases (parseBoard valid, parseFen valid, reject invalid color/castling, importGameContext, reject malformed boards, reject overflow ranks) exercise every parser path. The issue is not in test coverage of logic but in how scoverage tracks inline-macro-generated bytecode. No source-level change can cause scoverage to instrument through `inline` expansion boundaries. - -**Next Step:** Either accept the coverage gap for this file (it is an instrumentation artefact, not a logic gap) or replace the `parse(...)` call sites with a non-inline wrapper that shields the proxy generation from the coverage-instrumented compilation unit. -- 2.52.0