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 deleted file mode 100644 index e69de29..0000000 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/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 new file mode 100644 index 0000000..df129c3 --- /dev/null +++ b/modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala @@ -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) 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 } 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