Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# Changelog
All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines.

- - -
## Unreleased
#### Features
- preserve PGN annotation block structure and add `Move.CommentBlocks()` for ordered comment items.

- - -
## v2.3.2 - 2025-10-08

Expand Down Expand Up @@ -148,4 +153,4 @@ All notable changes to this project will be documented in this file. See [conven

- - -

Changelog generated by [cocogitto](https://github.com/cocogitto/cocogitto).
Changelog generated by [cocogitto](https://github.com/cocogitto/cocogitto).
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,31 @@ fmt.Println(game)
*/
```

#### PGN comment annotations

Parsed PGN comments preserve comment block boundaries, command annotation order, and duplicate command annotations. Use
`Move.CommentBlocks()` when an importer needs structured access to each `{...}` block and the ordered text or command
items inside it. The returned blocks are defensive copies.

```go
game := chess.NewGame(pgn)
move := game.Moves()[0]

for _, block := range move.CommentBlocks() {
for _, item := range block.Items {
switch item.Kind {
case chess.CommentText:
fmt.Println(item.Text)
case chess.CommentCommand:
fmt.Printf("%s=%s\n", item.Key, item.Value)
}
}
}
```

The legacy helpers `Move.Comments()`, `Move.GetCommand()`, `Move.SetCommand()`, `Move.SetComment()`, and
`Move.AddComment()` remain available for callers that only need a flattened text comment or single command value.

#### Scan PGN

For parsing large PGN database files use Scanner:
Expand Down
78 changes: 36 additions & 42 deletions game.go
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ func (g *Game) String() string {
needTrailingSpace = !writeMoves(g.rootMove,
g.rootMove.Position().moveCount,
g.rootMove.Position().Turn() == White, &sb, false, false, true)
} else if g.rootMove.comments != "" || len(g.rootMove.command) > 0 {
} else if g.rootMove.hasAnnotations() {
writeAnnotations(g.rootMove, &sb)
}
}
Expand Down Expand Up @@ -515,7 +515,7 @@ func writeMoves(node *Move, moveNum int, isWhite bool, sb *strings.Builder,
}

// Handle root move comments before processing children
if isRoot && (node.comments != "" || len(node.command) > 0) {
if isRoot && node.hasAnnotations() {
writeAnnotations(node, sb)
}

Expand Down Expand Up @@ -588,62 +588,56 @@ func writeMoveEncoding(node *Move, currentMove *Move, subVariation bool, sb *str
}
}

func writeComments(move *Move, sb *strings.Builder) {
if move.comments != "" {
sb.WriteString(" {" + move.comments + "}")
}
}

func writeCommands(move *Move, sb *strings.Builder) {
if len(move.command) > 0 {
sb.WriteString(" {")
writeSortedCommands(move, sb)
sb.WriteString("}")
}
}

func writeSortedCommands(move *Move, sb *strings.Builder) {
keys := make([]string, 0, len(move.command))
for key := range move.command {
func sortedCommandKeys(commands map[string]string) []string {
keys := make([]string, 0, len(commands))
for key := range commands {
keys = append(keys, key)
}
slices.Sort(keys)
for _, key := range keys {
sb.WriteString(" [%")
sb.WriteString(key)
sb.WriteString(" ")
sb.WriteString(move.command[key])
sb.WriteString("]")
}
return keys
}

func writeAnnotations(move *Move, sb *strings.Builder) {
if move == nil {
return
}

hasComment := move.comments != ""
hasCommands := len(move.command) > 0

if !hasComment && !hasCommands {
move.ensureCommentBlocksFromLegacy()
if len(move.commentBlocks) > 0 {
writeCommentBlocks(move.commentBlocks, sb)
return
}
}

if hasComment && !hasCommands {
writeComments(move, sb)
return
}
func writeCommentBlocks(blocks []CommentBlock, sb *strings.Builder) {
for _, block := range blocks {
if len(block.Items) == 0 {
continue
}

if !hasComment {
writeCommands(move, sb)
return
sb.WriteString(" {")
for _, item := range block.Items {
switch item.Kind {
case CommentText:
sb.WriteString(item.Text)
case CommentCommand:
if needsCommandSeparator(sb) {
sb.WriteString(" ")
}
sb.WriteString("[%")
sb.WriteString(item.Key)
sb.WriteString(" ")
sb.WriteString(item.Value)
sb.WriteString("]")
}
}
sb.WriteString("}")
}
}

sb.WriteString(" {")
sb.WriteString(move.comments)
writeSortedCommands(move, sb)

sb.WriteString("}")
func needsCommandSeparator(sb *strings.Builder) bool {
s := sb.String()
return len(s) > 0 && s[len(s)-1] != ' '
}

func writeVariations(node *Move, moveNum int, isWhite bool, sb *strings.Builder) bool {
Expand Down
54 changes: 54 additions & 0 deletions game_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1602,6 +1602,60 @@ func TestRootMoveComments(t *testing.T) {
})
}

func TestWriteAnnotationsLegacyBranches(t *testing.T) {
t.Run("NilMove", func(t *testing.T) {
var sb strings.Builder
writeAnnotations(nil, &sb)
if sb.String() != "" {
t.Fatalf("expected empty annotation output, got %q", sb.String())
}
})

t.Run("CommentOnly", func(t *testing.T) {
move := &Move{comments: "Good move"}
move.ensureCommentBlocksFromLegacy()
move.commentBlocks = nil

var sb strings.Builder
writeAnnotations(move, &sb)
if sb.String() != " {Good move}" {
t.Fatalf("expected comment-only annotation, got %q", sb.String())
}
})

t.Run("CommandOnly", func(t *testing.T) {
move := &Move{command: map[string]string{"clk": "0:05:00"}}
move.ensureCommentBlocksFromLegacy()
move.commentBlocks = nil

var sb strings.Builder
writeAnnotations(move, &sb)
if sb.String() != " { [%clk 0:05:00]}" {
t.Fatalf("expected command-only annotation, got %q", sb.String())
}
})

t.Run("CommentAndCommand", func(t *testing.T) {
move := &Move{comments: "Good move", command: map[string]string{"clk": "0:05:00"}}
move.ensureCommentBlocksFromLegacy()
move.commentBlocks = nil

var sb strings.Builder
writeAnnotations(move, &sb)
if sb.String() != " {Good move [%clk 0:05:00]}" {
t.Fatalf("expected merged annotation, got %q", sb.String())
}
})

t.Run("EmptyStructuredBlock", func(t *testing.T) {
var sb strings.Builder
writeCommentBlocks([]CommentBlock{{}}, &sb)
if sb.String() != "" {
t.Fatalf("expected empty structured block to be skipped, got %q", sb.String())
}
})
}

func TestValidateSAN(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading
Loading