From a4f9f10104d6d24a3e2decd7f21fc9c554d76e3d Mon Sep 17 00:00:00 2001 From: Noah Date: Thu, 14 May 2026 21:46:16 +0200 Subject: [PATCH] Add lazy parsing for multi-game PGN strings + tests --- lib/src/pgn.dart | 49 +++++++++++++++++++++++++++++++++++++++++++++- test/pgn_test.dart | 27 +++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/lib/src/pgn.dart b/lib/src/pgn.dart index 5bb6340..a65f208 100644 --- a/lib/src/pgn.dart +++ b/lib/src/pgn.dart @@ -139,6 +139,29 @@ class PgnGame { return games; } + /// Parse a multi-game PGN string lazily. + /// + /// Returns a list of [PgnLazyGame] where only the headers are parsed and the move tree is parsed on-demand when [toPgnGame] is called. + static List parseMultiGameLazy(String pgn, + {PgnHeaders Function() initHeaders = defaultHeaders}) { + final multiGamePgnSplit = RegExp(r'\n\s+(?=\[)'); + final List games = []; + final pgnGames = pgn.split(multiGamePgnSplit); + + for (final pgnGame in pgnGames) { + PgnHeaders? extractedHeaders; + _PgnParser((game) { + extractedHeaders = game.headers; + }, initHeaders, parseMoves: false) + .parse(pgnGame); + + if (extractedHeaders != null) { + games.add(PgnLazyGame(headers: extractedHeaders!, rawPgn: pgnGame)); + } + } + return games; + } + /// Create a [Position] for a Variant from the headers. /// /// Headers can include an optional 'Variant' and 'Fen' key. @@ -672,6 +695,7 @@ bool _isCommentLine(String line) => line.startsWith('%'); /// A class to read a string and create a [PgnGame] class _PgnParser { List _lineBuf = []; + final bool parseMoves; late bool _found; late _ParserState _state = _ParserState.pre; late PgnHeaders _gameHeaders; @@ -686,7 +710,7 @@ class _PgnParser { /// Function to create the headers final PgnHeaders Function() initHeaders; - _PgnParser(this.emitGame, this.initHeaders) { + _PgnParser(this.emitGame, this.initHeaders, {this.parseMoves = true}) { _resetGame(); _state = _ParserState.bom; } @@ -782,6 +806,9 @@ class _PgnParser { case _ParserState.moves: { + if (!parseMoves) { + return; + } if (freshLine) { if (_isWhitespace(line) || _isCommentLine(line)) return; } @@ -852,6 +879,9 @@ class _PgnParser { case _ParserState.comment: { + if (!parseMoves) { + return; + } final closeIndex = line.indexOf('}'); if (closeIndex == -1) { _commentBuf.add(line); @@ -911,3 +941,20 @@ String _makeClk(Duration duration) { intVal.toString().padLeft(2, '0'); // get the decimal part of seconds return '$hours:${minutes.toString().padLeft(2, "0")}:$dec$frac'; } + +/// Parse a multi-game PGN string lazily. +/// +/// Returns a list of [PgnLazyGame], where only the tags/headers fall +/// under initial evaluation. You can call [toPgnGame()] on an element +/// to evaluate the move tree on-demand. +class PgnLazyGame { + const PgnLazyGame({ + required this.headers, + required this.rawPgn, + }); + + final PgnHeaders headers; + final String rawPgn; + + PgnGame toPgnGame() => PgnGame.parsePgn(rawPgn); +} diff --git a/test/pgn_test.dart b/test/pgn_test.dart index ce9be5b..69a63b5 100644 --- a/test/pgn_test.dart +++ b/test/pgn_test.dart @@ -359,6 +359,33 @@ the players are also trying to learn as much as possible about the opponent's pr expect(games.length, 1); }); + test('parseMultiGameLazy - parses headers without moves', () { + final String data = + File('./data/kasparov-deep-blue-1997.pgn').readAsStringSync(); + final List lazyGames = PgnGame.parseMultiGameLazy(data); + + expect(lazyGames.length, 6); + + // Verify headers were extracted + expect(lazyGames[0].headers['Event'], 'IBM Man-Machine, New York USA'); + expect(lazyGames[0].headers['White'], 'Garry Kasparov'); + expect(lazyGames[0].headers['Black'], 'Deep Blue (Computer)'); + }); + + test('parseMultiGameLazy - toPgnGame parses move tree on-demand', () { + final String data = + File('./data/kasparov-deep-blue-1997.pgn').readAsStringSync(); + final List lazyGames = PgnGame.parseMultiGameLazy(data); + + // Convert the first lazy game to a full PgnGame + final fullGame = lazyGames[0].toPgnGame(); + + // Verify moves were successfully built + expect(fullGame.headers['White'], 'Garry Kasparov'); + final moves = fullGame.moves.mainline().toList(); + expect(moves.first.san, 'Nf3'); + }); + test('crazyhouse from prod', () { final game = PgnGame.parsePgn(PgnFixtures.crazyhouseFromProd); expect(game.moves.mainline().length, 49);