pgn2 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/pgn/fen.rb CHANGED
@@ -1,4 +1,44 @@
1
1
  module PGN
2
+ # Shared FEN/EPD board-field parsing and serialization: the piece-placement
3
+ # field, and the '-'-normalized castling/en-passant fields. {PGN::FEN} and
4
+ # {PGN::EPD} both include this rather than each carrying their own copy.
5
+ module PositionFields
6
+ def castling=(val)
7
+ @castling = val.nil? || val.empty? ? '-' : val
8
+ end
9
+
10
+ def en_passant=(val)
11
+ @en_passant = val.nil? ? '-' : val
12
+ end
13
+
14
+ # @param board_fen [String] the FEN/EPD representation of the board
15
+ def board_string=(board_fen)
16
+ squares = board_fen.gsub(/\d/) { |match| '_' * match.to_i }
17
+ .split('/')
18
+ .map(&:chars)
19
+ .map { |row| row.map { |e| e == '_' ? nil : e } }
20
+ .reverse
21
+ .transpose
22
+ self.board = PGN::Board.new(squares)
23
+ end
24
+
25
+ # @return [String] the FEN/EPD board-string portion
26
+ def board_string
27
+ board.fen_board_string
28
+ end
29
+
30
+ private
31
+
32
+ # The player/castling-rights/en-passant args shared by FEN#to_position
33
+ # and EPD#to_position (which differ only in halfmove/fullmove).
34
+ def position_fields
35
+ player = active == 'w' ? :white : :black
36
+ castling_rights = castling.chars - ['-']
37
+ ep = en_passant == '-' ? nil : en_passant
38
+ [player, castling_rights, ep]
39
+ end
40
+ end
41
+
2
42
  # {PGN::FEN} is responsible for translating between strings in FEN
3
43
  # notation and an internal representation of the board.
4
44
  #
@@ -35,14 +75,17 @@ module PGN
35
75
  # plays.
36
76
  #
37
77
  class FEN
78
+ include PositionFields
79
+
38
80
  # The FEN string representing the starting position in chess
39
81
  #
40
82
  INITIAL = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'.freeze
41
83
 
42
84
  attr_accessor :board, :active, :halfmove, :fullmove
43
- # `castling` and `en_passant` have custom writers below (normalize nil/empty
44
- # to '-'), so expose readers here and define the writers explicitly to avoid
45
- # redefining the accessors generated by attr_accessor (Lint/DuplicateMethods).
85
+ # `castling` and `en_passant` have custom writers in {PositionFields}
86
+ # (normalize nil/empty to '-'), so expose readers here and rely on the
87
+ # module for the writers, to avoid redefining the accessors generated by
88
+ # attr_accessor (Lint/DuplicateMethods).
46
89
  attr_reader :castling, :en_passant
47
90
 
48
91
  # @return [PGN::FEN] a {PGN::FEN} object representing the starting
@@ -75,52 +118,19 @@ module PGN
75
118
  self.fullmove = fen_string.split
76
119
  end
77
120
 
78
- def en_passant=(val)
79
- @en_passant = val.nil? ? '-' : val
80
- end
81
-
82
- def castling=(val)
83
- @castling = val.nil? || val.empty? ? '-' : val
84
- end
85
-
86
- # @param board_fen [String] the fen representation of the board
87
- # @example
88
- # fen.board_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
89
- #
90
- def board_string=(board_fen)
91
- squares = board_fen.gsub(/\d/) { |match| '_' * match.to_i }
92
- .split('/')
93
- .map(&:chars)
94
- .map { |row| row.map { |e| e == '_' ? nil : e } }
95
- .reverse
96
- .transpose
97
- self.board = PGN::Board.new(squares)
98
- end
99
-
100
- # @return [String] the fen representation of the board
101
- # @example
102
- # PGN::FEN.start.board_string #=> "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
103
- #
104
- def board_string
105
- board.fen_board_string
106
- end
107
-
108
121
  # @return [PGN::Position] a {PGN::Position} representing the current
109
122
  # position
110
123
  #
111
124
  def to_position
112
- player = active == 'w' ? :white : :black
113
- castling = self.castling.chars - ['-']
114
- en_passant = nil if self.en_passant == '-'
125
+ player, castling_rights, ep = position_fields
126
+ PGN::Position.new(board, player, castling_rights, ep, halfmove.to_i, fullmove.to_i)
127
+ end
115
128
 
116
- PGN::Position.new(
117
- board,
118
- player,
119
- castling,
120
- en_passant,
121
- halfmove.to_i,
122
- fullmove.to_i
123
- )
129
+ # @return [String] the EPD string for this position (the first four FEN
130
+ # fields, dropping the halfmove/fullmove counters)
131
+ #
132
+ def to_epd
133
+ PGN::EPD.new("#{board_string} #{active} #{castling} #{en_passant}").to_s
124
134
  end
125
135
 
126
136
  # @return [String] the FEN string
data/lib/pgn/game.rb CHANGED
@@ -29,10 +29,20 @@ module PGN
29
29
  @notation
30
30
  end
31
31
 
32
+ # Normalize UCI-style castling ('0-0') to canonical SAN ('O-O').
33
+ def self.normalize_castling(san)
34
+ san.include?('0') ? san.gsub('0', 'O') : san
35
+ end
36
+
32
37
  def clean_text(text)
33
38
  return unless text
34
39
 
35
- text.gsub(/{(.*)}/, '\1').gsub(/\s+/, ' ').strip
40
+ # Strip the single outermost brace pair, then unescape \{ \} \\ so
41
+ # the internal representation is the raw comment body. {Serializer}
42
+ # re-escapes on output, so parse -> serialize -> parse is byte-stable.
43
+ text = text[1..-2] if text.start_with?('{') && text.end_with?('}')
44
+ text = text.gsub(/\\([\\{}])/, '\1')
45
+ text.gsub(/\s+/, ' ').strip
36
46
  end
37
47
  end
38
48
 
@@ -135,6 +145,70 @@ module PGN
135
145
  positions.map { |p| p.to_fen.inspect }
136
146
  end
137
147
 
148
+ # The current {PGN::Position} (the last position after replaying all
149
+ # moves), or the starting position when there are no moves.
150
+ #
151
+ # @return [PGN::Position]
152
+ def current_position
153
+ positions.last
154
+ end
155
+
156
+ # Append a move in SAN, validating legality when the native engine is
157
+ # available. Raises ArgumentError for an illegal move (when the engine is
158
+ # loaded). Grows the memoized position list in step, if it is populated.
159
+ #
160
+ # @param san [String, PGN::MoveText] the move to append
161
+ # @return [self]
162
+ def push(san)
163
+ move = standardize_castling(san)
164
+ if PGN::Bitboard.const_defined?(:Engine, false) && !current_position.legal?(move.notation)
165
+ raise ArgumentError, "illegal move: #{san}"
166
+ end
167
+
168
+ @moves << move
169
+ @positions << @positions.last.move(move.notation) if @positions
170
+ self
171
+ end
172
+
173
+ # Remove and return the last move, or nil if there are none. Shrinks the
174
+ # memoized position list in step, if it is populated.
175
+ #
176
+ # @return [PGN::MoveText, nil]
177
+ def pop
178
+ return nil if @moves.empty?
179
+
180
+ move = @moves.pop
181
+ @positions&.pop
182
+ move
183
+ end
184
+
185
+ # Whether any position has occurred three times in this game (the
186
+ # threefold-repetition draw). Uses {PGN::Position#hash} (the Zobrist
187
+ # hash of the FEN-relevant state) over {#positions}, so a prior or
188
+ # subsequent call that also needs the position list shares the replay.
189
+ #
190
+ # @return [Boolean]
191
+ def threefold?
192
+ counts = Hash.new(0)
193
+ positions.each { |position| counts[position.hash] += 1 }
194
+ counts.any? { |_, count| count >= 3 }
195
+ end
196
+
197
+ # The terminal status of the game: :checkmate, :stalemate, or :draw
198
+ # (insufficient material, 50-move rule, or threefold repetition). nil
199
+ # if the game is still in progress. Requires the native extension for
200
+ # checkmate/stalemate detection.
201
+ #
202
+ # @return [Symbol, nil]
203
+ def outcome
204
+ final = positions.last
205
+ result = final&.outcome
206
+ return result if result
207
+ return :draw if threefold?
208
+
209
+ nil
210
+ end
211
+
138
212
  # Interactively step through the game
139
213
  #
140
214
  # Use +d+ to move forward, +a+ to move backward, and +^C+ to exit.
@@ -159,17 +233,27 @@ module PGN
159
233
  end
160
234
  end
161
235
 
236
+ # Build a fresh, navigable {PGN::Node} tree over the mainline. The tree
237
+ # is a live view of the underlying +MoveText+ structure; mutate it
238
+ # through the node API, then call +#root+ again for a fresh tree.
239
+ def root
240
+ PGN::Node.new(
241
+ move: nil, parent: nil, line: @moves, index: -1,
242
+ starting_position: starting_position, game: self
243
+ )
244
+ end
245
+
162
246
  private
163
247
 
164
248
  # A MoveText is reused as-is (no new object) when its notation needs no
165
- # '0'->'O' fix and its comment has no braces left to clean; a braced
166
- # comment still needs MoveText.new's second clean_text pass to match the
167
- # legacy whittle byte-output.
249
+ # '0'->'O' fix; otherwise a new MoveText is built. clean_text is idempotent
250
+ # (it only strips a *single* outermost brace pair), so reusing or rebuilding
251
+ # a MoveText never corrupts a comment that still contains inner braces.
168
252
  def standardize_castling(entry)
169
- return MoveText.new(entry.include?('0') ? entry.gsub('0', 'O') : entry) if entry.is_a?(String)
253
+ return MoveText.new(MoveText.normalize_castling(entry)) if entry.is_a?(String)
170
254
 
171
- notation = entry.notation.include?('0') ? entry.notation.gsub('0', 'O') : entry.notation
172
- return entry if notation.equal?(entry.notation) && (entry.comment.nil? || !entry.comment.include?('{'))
255
+ notation = MoveText.normalize_castling(entry.notation)
256
+ return entry if notation.equal?(entry.notation)
173
257
 
174
258
  MoveText.new(notation, entry.annotation, entry.comment, entry.variations)
175
259
  end
data/lib/pgn/move.rb CHANGED
@@ -74,7 +74,7 @@ module PGN
74
74
  (?<capture> x ){0}
75
75
  (?<disambiguation> [a-h]?[1-8]? ){0}
76
76
 
77
- (?<castle> O-O(?:-O)? ){0}
77
+ (?<castle> [O0]-[O0](?:-[O0])? ){0}
78
78
 
79
79
  (?<normal>
80
80
  \g<piece>?
@@ -112,7 +112,7 @@ module PGN
112
112
  # Castling SAN (O-O / O-O-O) has no piece attribute; the castle attribute
113
113
  # is set later in #initialize. Use a non-allocating prefix check instead
114
114
  # of `san.match('O-O')`, which allocated a MatchData on every Move.new.
115
- return if san.start_with?('O')
115
+ return if san.start_with?('O') || san.start_with?('0')
116
116
 
117
117
  val ||= 'P'
118
118
  @piece = black? ? val.downcase : val
@@ -136,8 +136,8 @@ module PGN
136
136
  def castle=(val)
137
137
  return unless val
138
138
 
139
- @castle = 'K' if val == 'O-O'
140
- @castle = 'Q' if val == 'O-O-O'
139
+ @castle = 'K' if %w[O-O 0-0].include?(val)
140
+ @castle = 'Q' if %w[O-O-O 0-0-0].include?(val)
141
141
  @castle.downcase! if black?
142
142
  end
143
143
 
data/lib/pgn/node.rb ADDED
@@ -0,0 +1,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGN
4
+ # {PGN::Node} is a live, lazily-built view over the {PGN::MoveText} tree.
5
+ #
6
+ # A node represents the position reached by playing +move+ from its
7
+ # +parent+'s position; the root has no move and is the game's starting
8
+ # position. The underlying {PGN::MoveText} structure is the source of
9
+ # truth (the parser builds it and the serializer reads it), so a node
10
+ # tree never disagrees with the serialized output.
11
+ #
12
+ # Mutations edit the underlying +MoveText+ arrays in place and, for
13
+ # sibling reorders, normalize the affected branching point to flat
14
+ # sibling storage. After any structural mutation, {PGN::Game#root}
15
+ # returns a fresh tree — outstanding node references are stale.
16
+ class Node
17
+ attr_reader :move, :parent, :line, :index
18
+
19
+ # @param move [PGN::MoveText, nil] the move played to reach this node
20
+ # @param parent [PGN::Node, nil] the parent node (nil for the root)
21
+ # @param line [Array<PGN::MoveText>, nil] the line this node's move
22
+ # lives in (the mainline for mainline nodes, the variation Array for
23
+ # variation nodes; nil conceptually for the root, which passes the
24
+ # mainline)
25
+ # @param index [Integer] index of this node's move within +line+
26
+ # (-1 for the root)
27
+ # @param starting_position [PGN::Position] the root's position
28
+ # @param game [PGN::Game] back-reference so mutations can return a
29
+ # fresh root
30
+ def initialize(move:, parent:, line:, index:, starting_position: nil, game: nil)
31
+ @move = move
32
+ @parent = parent
33
+ @line = line
34
+ @index = index
35
+ @starting_position = starting_position
36
+ @game = game
37
+ end
38
+
39
+ def root?
40
+ @move.nil?
41
+ end
42
+
43
+ def notation
44
+ @move&.notation
45
+ end
46
+
47
+ def annotation
48
+ @move&.annotation
49
+ end
50
+
51
+ def comment
52
+ @move&.comment
53
+ end
54
+
55
+ # All moves playable from this node's position: the continuation move
56
+ # (the next MoveText in this node's line) plus every variation
57
+ # first-move branching at that same position, recursively through
58
+ # nested brackets. The first child is the mainline continuation; the
59
+ # rest are variations in source order.
60
+ def children
61
+ @children ||= begin
62
+ cont = continuation_movetext
63
+ list = []
64
+ collect_first_moves(cont, @line, @index + 1) do |mt, l, idx|
65
+ list << Node.new(move: mt, parent: self, line: l, index: idx, game: @game)
66
+ end
67
+ list
68
+ end
69
+ end
70
+
71
+ # The non-mainline alternatives at this node's position.
72
+ def variations
73
+ children[1..] || []
74
+ end
75
+
76
+ # The mainline continuation, or nil at a terminal position.
77
+ # (Defined via +define_method+ because +next+ is a Ruby keyword and
78
+ # cannot be used with +def+.)
79
+ define_method(:next) { children.first }
80
+
81
+ # The parent node, or nil for the root.
82
+ def previous
83
+ @parent
84
+ end
85
+
86
+ def [](idx)
87
+ children[idx]
88
+ end
89
+
90
+ # Yields each mainline node from +self.next+ onward (the root is
91
+ # excluded), so +main_line.map(&:notation) == game.moves.map(&:notation)+
92
+ # and +main_line.map(&:position) == game.positions[1..]+. Returns an
93
+ # Enumerator when called without a block.
94
+ def main_line
95
+ return enum_for(:main_line) unless block_given?
96
+
97
+ node = self.next
98
+ while node
99
+ yield node
100
+ node = node.next
101
+ end
102
+ end
103
+
104
+ # The position reached at this node: the starting position for the
105
+ # root, otherwise +parent.position+ with +move.notation+ applied. Pure
106
+ # Ruby (no native engine); raises on an illegal SAN exactly like
107
+ # {PGN::Game#positions}. Cached on the node.
108
+ def position
109
+ return @position if defined?(@position)
110
+
111
+ @position = if root?
112
+ @starting_position
113
+ else
114
+ @parent.position.then { |p| p.move(@move.notation) }
115
+ end
116
+ end
117
+
118
+ # Append a new variation line (a single SAN String or an Array<String>)
119
+ # branching before this node's next mainline move. Returns a fresh
120
+ # +game.root+. Raises +ArgumentError+ at a terminal node (a variation
121
+ # must branch before an existing move; use +add_main_variation+ to
122
+ # extend the line).
123
+ def add_variation(move_or_moves)
124
+ cont = continuation_movetext
125
+ raise ArgumentError, 'cannot add a variation at a terminal node' if cont.nil?
126
+
127
+ normalize_branch_point(cont)
128
+ cont.variations << build_movetexts(move_or_moves)
129
+ @game.root
130
+ end
131
+
132
+ # Make the given moves the new mainline continuation from this node's
133
+ # position. At a terminal node this extends the line; otherwise the old
134
+ # continuation becomes a variation of the new first move. Returns a
135
+ # fresh +game.root+.
136
+ def add_main_variation(move_or_moves)
137
+ new_line = build_movetexts(move_or_moves)
138
+ cont = continuation_movetext
139
+
140
+ if cont.nil?
141
+ @line.push(*new_line)
142
+ else
143
+ normalize_branch_point(cont)
144
+ make_mainline(@line, @index + 1, cont, new_line, cont.variations, old_main_position: :first)
145
+ end
146
+ @game.root
147
+ end
148
+
149
+ # Move this node one slot toward the mainline among its siblings.
150
+ # No-op for the mainline (index 0) or the first variation (index 1).
151
+ # Returns a fresh +game.root+.
152
+ def promote
153
+ mutate_sibling(noop: ->(i) { i <= 1 }) do |i, _cont, vars|
154
+ vars[i - 1], vars[i - 2] = vars[i - 2], vars[i - 1]
155
+ end
156
+ end
157
+
158
+ # Move this node one slot toward the end among its siblings. At index 0
159
+ # (the mainline) this is the inverse swap: variation #1 becomes the new
160
+ # mainline and the old mainline becomes variation #1. No-op at the last
161
+ # index. Returns a fresh +game.root+.
162
+ def demote
163
+ mutate_sibling(noop: ->(i) { i == @parent.children.size - 1 }) do |i, cont, vars|
164
+ if i.zero?
165
+ v1 = vars.shift
166
+ make_mainline(@parent.line, @parent.index + 1, cont, v1, vars, old_main_position: :first)
167
+ else
168
+ vars[i - 1], vars[i] = vars[i], vars[i - 1]
169
+ end
170
+ end
171
+ end
172
+
173
+ # Make this node the mainline at its branching point (the old mainline
174
+ # becomes variation #1). No-op if already the mainline. Returns a
175
+ # fresh +game.root+.
176
+ def promote_to_main
177
+ mutate_sibling(noop: lambda(&:zero?)) do |i, cont, vars|
178
+ vk = vars.delete_at(i - 1)
179
+ make_mainline(@parent.line, @parent.index + 1, cont, vk, vars, old_main_position: :first)
180
+ end
181
+ end
182
+
183
+ # Move this node to the last position among its siblings. At index 0
184
+ # the last variation becomes the new mainline and the old mainline
185
+ # becomes the last variation. Returns a fresh +game.root+.
186
+ def demote_to_last
187
+ mutate_sibling do |i, cont, vars|
188
+ if i.zero?
189
+ unless vars.empty?
190
+ last = vars.pop
191
+ make_mainline(@parent.line, @parent.index + 1, cont, last, vars, old_main_position: :last)
192
+ end
193
+ else
194
+ el = vars.delete_at(i - 1)
195
+ vars.push(el)
196
+ end
197
+ end
198
+ end
199
+
200
+ # Remove this node and its subtree. If it is the mainline continuation,
201
+ # the first remaining variation (if any) takes its place; otherwise the
202
+ # line is truncated at this point. Returns a fresh +game.root+.
203
+ def delete
204
+ mutate_sibling do |i, cont, vars|
205
+ if i.zero?
206
+ delete_mainline_continuation(cont, vars)
207
+ else
208
+ vars.delete_at(i - 1)
209
+ end
210
+ end
211
+ end
212
+
213
+ private
214
+
215
+ # The MoveText played from this node's position in the current line:
216
+ # the next entry in +line+ (nil at a terminal position). For the root,
217
+ # +index+ is -1, so this is +line[0]+ (the first mainline move).
218
+ def continuation_movetext
219
+ @line && @line[@index + 1]
220
+ end
221
+
222
+ # Yield every first-move branching at the position before +m+ (i.e. at
223
+ # this node's position): +m+ itself (in +l+ at +idx+), plus the first
224
+ # move of each of +m+'s variations, plus — recursively — the first
225
+ # moves of any variations nested on those first moves (they branch at
226
+ # the same point, since a variation branches before the move it
227
+ # attaches to).
228
+ def collect_first_moves(movetext, line, idx, &block)
229
+ return if movetext.nil?
230
+
231
+ block.call(movetext, line, idx)
232
+ (movetext.variations || []).each do |variation|
233
+ next unless variation && variation[0]
234
+
235
+ collect_first_moves(variation[0], variation, 0, &block)
236
+ end
237
+ end
238
+
239
+ # Index of this node among its parent's children, or nil if root.
240
+ def sibling_index
241
+ @parent.children.index(self)
242
+ end
243
+
244
+ # The MoveText that is the mainline continuation at the PARENT's
245
+ # position (the move this node is an alternative to, or this node
246
+ # itself if it is the continuation).
247
+ def parent_continuation
248
+ @parent.line[@parent.index + 1]
249
+ end
250
+
251
+ # Build an Array<PGN::MoveText> from a single SAN String or an Array of
252
+ # SANs, applying the same castling 0->O normalization as Game#moves=.
253
+ def build_movetexts(move_or_moves)
254
+ sans = move_or_moves.is_a?(String) ? [move_or_moves] : move_or_moves
255
+ sans.map { |s| PGN::MoveText.new(PGN::MoveText.normalize_castling(s)) }
256
+ end
257
+
258
+ # Shared prologue/epilogue for the sibling-reordering mutators (promote,
259
+ # demote, promote_to_main, demote_to_last, delete): no-op at the root or
260
+ # per +noop+ (checked against the sibling index before any mutation),
261
+ # otherwise normalize the branching point and yield the sibling index,
262
+ # the parent's mainline continuation, and its (now flat) variations to
263
+ # +block+ for the mutation proper. Always returns a fresh +game.root+.
264
+ def mutate_sibling(noop: nil)
265
+ return @game.root if root?
266
+
267
+ i = sibling_index
268
+ return @game.root if i.nil? || noop&.call(i)
269
+
270
+ cont = parent_continuation
271
+ normalize_branch_point(cont)
272
+ yield i, cont, cont.variations
273
+ @game.root
274
+ end
275
+
276
+ # Hoist every variation first-move branching at the position before
277
+ # +cont+ (recursively through nested brackets) into a single flat
278
+ # +cont.variations+ array, clearing the first-moves' at-point variations
279
+ # (they are now flat siblings). Internal structure of each variation
280
+ # line (tail moves and their deeper variations) is untouched. After
281
+ # this, +cont.variations+ is a flat Array<Array<MoveText>> in the same
282
+ # order +children+ yields.
283
+ def normalize_branch_point(cont)
284
+ return if cont.nil?
285
+
286
+ lines = []
287
+ collect_variation_lines(cont) { |v| lines << v }
288
+ lines.each { |v| v[0].variations = [] }
289
+ cont.variations = lines
290
+ end
291
+
292
+ # Yield each variation line branching at the position before +m+ (in
293
+ # DFS order): +m+'s direct variations, plus — recursively — the
294
+ # variations nested on each variation's first move (they branch at the
295
+ # same point).
296
+ def collect_variation_lines(movetext, &block)
297
+ (movetext.variations || []).each do |variation|
298
+ next unless variation && variation[0]
299
+
300
+ block.call(variation)
301
+ collect_variation_lines(variation[0], &block)
302
+ end
303
+ end
304
+
305
+ # Splice +movetexts+ (an Array<MoveText>) into +line+ starting at +pos+:
306
+ # its first entry replaces +line[pos]+, and the rest replace the tail
307
+ # after it (so a shorter +movetexts+ truncates +line+).
308
+ def splice_line!(line, pos, movetexts)
309
+ line[pos] = movetexts[0]
310
+ line[(pos + 1)..] = (movetexts[1..] || [])
311
+ end
312
+
313
+ # Make +vk+ (= [vk0, *tail]) the new mainline continuation at +line+/+pos+
314
+ # (the position before +cont+), replacing +cont+. The old mainline
315
+ # (+cont+ and its tail) becomes a variation appended to +others+ either
316
+ # before (+old_main_position == :first+) or after (+:last+).
317
+ # +cont.variations+ is cleared (its at-point variations are now +vk0+'s
318
+ # siblings).
319
+ def make_mainline(line, pos, cont, variation, others, old_main_position:)
320
+ old_tail = line[(pos + 1)..] || []
321
+ variation0 = variation[0]
322
+ splice_line!(line, pos, variation)
323
+ cont.variations = []
324
+ old_var = [cont, *old_tail]
325
+ variation0.variations =
326
+ old_main_position == :first ? [old_var, *others] : [*others, old_var]
327
+ end
328
+
329
+ # Replace the mainline continuation +cont+ with its first variation
330
+ # (+vars+ is +cont.variations+, flat). If there are no variations, the
331
+ # line is truncated at the continuation; otherwise the first variation
332
+ # takes the continuation's place and the remaining variations become
333
+ # its variations.
334
+ def delete_mainline_continuation(cont, vars)
335
+ line = @parent.line
336
+ pos = @parent.index + 1
337
+ if vars.empty?
338
+ line[pos..] = []
339
+ else
340
+ first_variation = vars.shift
341
+ splice_line!(line, pos, first_variation)
342
+ cont.variations = []
343
+ first_variation[0].variations = vars
344
+ end
345
+ end
346
+ end
347
+ end