Skip to content

Commit 362a897

Browse files
CorentinGSCopilot
andauthored
fix: pgn disambiguation squares (#75)
* fix: implement full square disambiguation for PGN parsing * fix: remove redundant full square disambiguation logic in PGN parsing * fix: add support for full square disambiguation in PGN parsing * fix: remove unused nextToken function from PGN parser * Update lexer.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update lexer.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: correct expected token types for queen with full square disambiguation test --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 34f57bc commit 362a897

File tree

3 files changed

+176
-29
lines changed

3 files changed

+176
-29
lines changed

lexer.go

Lines changed: 42 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -31,35 +31,36 @@ type TokenType int
3131
const (
3232
EOF TokenType = iota
3333
Undefined
34-
TagStart // [
35-
TagEnd // ]
36-
TagKey // The key part of a tag (e.g., "Site")
37-
TagValue // The value part of a tag (e.g., "Internet")
38-
MoveNumber // 1, 2, 3, etc.
39-
DOT // .
40-
ELLIPSIS // ...
41-
PIECE // N, B, R, Q, K
42-
SQUARE // e4, e5, etc.
43-
CommentStart // {
44-
CommentEnd // }
45-
COMMENT // The comment text
46-
RESULT // 1-0, 0-1, 1/2-1/2, *
47-
CAPTURE // 'x' in moves
48-
FILE // a-h in moves when used as disambiguation
49-
RANK // 1-8 in moves when used as disambiguation
50-
KingsideCastle // 0-0
51-
QueensideCastle // 0-0-0
52-
PROMOTION // = in moves
53-
PromotionPiece // The piece being promoted to (Q, R, B, N)
54-
CHECK // + in moves
55-
CHECKMATE // # in moves
56-
NAG // Numeric Annotation Glyph (e.g., $1, $2, etc.)
57-
VariationStart // ( for starting a variation
58-
VariationEnd // ) for ending a variation
59-
CommandStart // [%
60-
CommandName // The command name (e.g., clk, eval)
61-
CommandParam // Command parameter
62-
CommandEnd // ]
34+
TagStart // [
35+
TagEnd // ]
36+
TagKey // The key part of a tag (e.g., "Site")
37+
TagValue // The value part of a tag (e.g., "Internet")
38+
MoveNumber // 1, 2, 3, etc.
39+
DOT // .
40+
ELLIPSIS // ...
41+
PIECE // N, B, R, Q, K
42+
SQUARE // e4, e5, etc.
43+
CommentStart // {
44+
CommentEnd // }
45+
COMMENT // The comment text
46+
RESULT // 1-0, 0-1, 1/2-1/2, *
47+
CAPTURE // 'x' in moves
48+
FILE // a-h in moves when used as disambiguation
49+
RANK // 1-8 in moves when used as disambiguation
50+
KingsideCastle // 0-0
51+
QueensideCastle // 0-0-0
52+
PROMOTION // = in moves
53+
PromotionPiece // The piece being promoted to (Q, R, B, N)
54+
CHECK // + in moves
55+
CHECKMATE // # in moves
56+
NAG // Numeric Annotation Glyph (e.g., $1, $2, etc.)
57+
VariationStart // ( for starting a variation
58+
VariationEnd // ) for ending a variation
59+
CommandStart // [%
60+
CommandName // The command name (e.g., clk, eval)
61+
CommandParam // Command parameter
62+
CommandEnd // ]
63+
DeambiguationSquare // Full square disambiguation (e.g., e8 in Qe8f7)
6364
)
6465

6566
func (t TokenType) String() string {
@@ -317,6 +318,7 @@ func (l *Lexer) readPieceMove() Token {
317318

318319
func (l *Lexer) readMove() Token {
319320
const disambiguationLength = 3
321+
const disambiguationSquareLength = 4
320322

321323
position := l.position
322324

@@ -348,6 +350,17 @@ func (l *Lexer) readMove() Token {
348350
// Get the total length of what we read
349351
length := l.position - position
350352

353+
// Handle full square disambiguation (e.g., "e8f7" -> "e8" then "f7")
354+
if length == disambiguationSquareLength &&
355+
isFile(l.input[position]) && isDigit(l.input[position+1]) &&
356+
isFile(l.input[position+2]) && isDigit(l.input[position+3]) {
357+
// Reset to return just the first square
358+
l.position = position + 2
359+
l.readPosition = position + 3
360+
l.ch = l.input[l.position] // set current char to the first character of the second square
361+
return Token{Type: DeambiguationSquare, Value: l.input[position : position+2]}
362+
}
363+
351364
// If we read 3 characters, first one is disambiguation
352365
if length == disambiguationLength {
353366
l.readPosition = position + 1

pgn.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,11 +338,20 @@ func (p *Parser) parseMove() (*Move, error) {
338338
} else if p.currentToken().Type == RANK {
339339
moveData.originRank = p.currentToken().Value
340340
p.advance()
341+
} else if p.currentToken().Type == DeambiguationSquare {
342+
// Full square disambiguation (e.g., "Qe8f7" -> piece: Q, origin: e8, dest: f7)
343+
originSquare := p.currentToken().Value
344+
if len(originSquare) == 2 {
345+
moveData.originFile = string(originSquare[0])
346+
moveData.originRank = string(originSquare[1])
347+
}
348+
p.advance()
341349
}
342350

343351
case FILE:
344352
moveData.originFile = p.currentToken().Value
345353
p.advance()
354+
346355
}
347356

348357
// Handle capture

pgn_disambiguation_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package chess
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
// Test cases for PGN disambiguation parsing issue #73
9+
// The issue is that moves like "Qe8f7" (queen from e8 to f7) fail to parse
10+
// because the parser doesn't handle full square disambiguation properly.
11+
12+
func TestPGNDisambiguationSquares(t *testing.T) {
13+
tests := []struct {
14+
name string
15+
pgn string
16+
shouldFail bool
17+
description string
18+
}{
19+
{
20+
name: "multiple_pieces_same_type_complex",
21+
pgn: `1. h4 d6 2. g4 Kd7 3. f4 Kc6 4. f5 Kd7 5. g5 Ke8 6. h5 Kd7 7. h6 Kc6 8. g6 Kb6 9. f6 Kc6 10. hxg7 Kd7 11. gxf7 Kc6 12. fxe7 Kb6 13. gxf8=Q Kc6 14. fxg8=Q Kb6 15. e8=Q Ka6 16. Qfe7 Kb6 17. Qe8f7`,
22+
shouldFail: false,
23+
description: "Complex game without full square disambiguation (should work)",
24+
},
25+
}
26+
27+
for _, tc := range tests {
28+
t.Run(tc.name, func(t *testing.T) {
29+
reader := strings.NewReader(tc.pgn)
30+
scanner := NewScanner(reader)
31+
32+
game, err := scanner.ParseNext()
33+
if err != nil {
34+
if !tc.shouldFail {
35+
t.Errorf("Expected PGN parsing to succeed but got error: %v", err)
36+
}
37+
// If we expected it to fail, verify it's the right kind of error
38+
if tc.shouldFail && !strings.Contains(err.Error(), "invalid destination square") {
39+
t.Logf("Expected 'invalid destination square' error but got: %v", err)
40+
}
41+
return
42+
}
43+
44+
if tc.shouldFail {
45+
t.Errorf("Expected test case '%s' to fail but it succeeded. Description: %s", tc.name, tc.description)
46+
}
47+
48+
// Additional validation for successful cases
49+
if !tc.shouldFail {
50+
if game == nil {
51+
t.Errorf("Game should not be nil for successful parsing")
52+
}
53+
}
54+
})
55+
}
56+
}
57+
58+
// TestTokenizerDisambiguationSquares tests that the lexer correctly tokenizes disambiguation squares
59+
func TestTokenizerDisambiguationSquares(t *testing.T) {
60+
tests := []struct {
61+
name string
62+
input string
63+
expected []TokenType
64+
}{
65+
{
66+
name: "queen_with_full_square_disambiguation",
67+
input: "Qe8f7",
68+
expected: []TokenType{PIECE, DeambiguationSquare, SQUARE},
69+
},
70+
{
71+
name: "rook_with_full_square_disambiguation",
72+
input: "Ra1d1",
73+
expected: []TokenType{PIECE, DeambiguationSquare, SQUARE},
74+
},
75+
{
76+
name: "knight_with_full_square_disambiguation",
77+
input: "Nb1c3",
78+
expected: []TokenType{PIECE, DeambiguationSquare, SQUARE},
79+
},
80+
{
81+
name: "standard_piece_move",
82+
input: "Nf3",
83+
expected: []TokenType{PIECE, SQUARE},
84+
},
85+
{
86+
name: "piece_with_file_disambiguation",
87+
input: "Nbd2",
88+
expected: []TokenType{PIECE, FILE, SQUARE},
89+
},
90+
{
91+
name: "piece_with_rank_disambiguation",
92+
input: "R1d2",
93+
expected: []TokenType{PIECE, RANK, SQUARE},
94+
},
95+
}
96+
97+
for _, tc := range tests {
98+
t.Run(tc.name, func(t *testing.T) {
99+
lexer := NewLexer(tc.input)
100+
var actualTypes []TokenType
101+
102+
for {
103+
token := lexer.NextToken()
104+
if token.Type == EOF {
105+
break
106+
}
107+
actualTypes = append(actualTypes, token.Type)
108+
t.Logf("Token: %s, Value: %s", token.Type, token.Value)
109+
}
110+
111+
if len(actualTypes) != len(tc.expected) {
112+
t.Errorf("Expected %d tokens, got %d", len(tc.expected), len(actualTypes))
113+
t.Errorf("Expected: %v", tc.expected)
114+
t.Errorf("Actual: %v", actualTypes)
115+
return
116+
}
117+
118+
for i, expected := range tc.expected {
119+
if actualTypes[i] != expected {
120+
t.Errorf("Token %d: expected %s, got %s", i, expected, actualTypes[i])
121+
}
122+
}
123+
})
124+
}
125+
}

0 commit comments

Comments
 (0)