Merge pull request #54 from HaonRekcef/lazy-pgn

Add lazy parsing for multi-game PGN strings + tests
This commit is contained in:
Vincent Velociter
2026-05-19 10:18:00 +02:00
committed by GitHub
2 changed files with 75 additions and 1 deletions
+48 -1
View File
@@ -138,6 +138,29 @@ class PgnGame<T extends PgnNodeData> {
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<PgnLazyGame> parseMultiGameLazy(String pgn,
{PgnHeaders Function() initHeaders = defaultHeaders}) {
final multiGamePgnSplit = RegExp(r'\n\s+(?=\[)');
final List<PgnLazyGame> 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.
@@ -674,6 +697,7 @@ bool _isCommentLine(String line) => line.startsWith('%');
/// A class to read a string and create a [PgnGame]
class _PgnParser {
List<String> _lineBuf = [];
final bool parseMoves;
late bool _found;
late _ParserState _state = _ParserState.pre;
late PgnHeaders _gameHeaders;
@@ -688,7 +712,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;
}
@@ -784,6 +808,9 @@ class _PgnParser {
case _ParserState.moves:
{
if (!parseMoves) {
return;
}
if (freshLine) {
if (_isWhitespace(line) || _isCommentLine(line)) return;
}
@@ -854,6 +881,9 @@ class _PgnParser {
case _ParserState.comment:
{
if (!parseMoves) {
return;
}
final closeIndex = line.indexOf('}');
if (closeIndex == -1) {
_commentBuf.add(line);
@@ -913,3 +943,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<PgnNodeData> toPgnGame() => PgnGame.parsePgn(rawPgn);
}
+27
View File
@@ -393,6 +393,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<PgnLazyGame> 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<PgnLazyGame> 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);