toml-merge 1.0.0 → 2.0.0

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.
@@ -5,13 +5,25 @@ module Toml
5
5
  # Wraps tree-sitter nodes with comment associations, line information, and signatures.
6
6
  # This provides a unified interface for working with TOML AST nodes during merging.
7
7
  #
8
+ # Inherits common functionality from Ast::Merge::NodeWrapperBase:
9
+ # - Source context (lines, source, comments)
10
+ # - Line info extraction
11
+ # - Basic methods: #type, #text, #signature
12
+ #
13
+ # Adds TOML-specific functionality:
14
+ # - Backend awareness for Citrus/tree-sitter normalization
15
+ # - Type predicates using NodeTypeNormalizer
16
+ # - Structural normalization for Citrus backend (pairs as siblings)
17
+ #
8
18
  # @example Basic usage
9
19
  # parser = TreeHaver::Parser.new
10
- # parser.language = TreeHaver::Language.load("toml", path)
11
- # tree = parser.parse_string(nil, source)
20
+ # parser.language = TreeHaver::Language.toml
21
+ # tree = parser.parse(source)
12
22
  # wrapper = NodeWrapper.new(tree.root_node, lines: source.lines, source: source)
13
23
  # wrapper.signature # => [:table, "section"]
14
- class NodeWrapper
24
+ #
25
+ # @see Ast::Merge::NodeWrapperBase
26
+ class NodeWrapper < Ast::Merge::NodeWrapperBase
15
27
  class << self
16
28
  # Wrap a tree-sitter node, returning nil for nil input.
17
29
  #
@@ -20,141 +32,120 @@ module Toml
20
32
  # @param source [String, nil] Original source string
21
33
  # @param leading_comments [Array<Hash>] Comments before this node
22
34
  # @param inline_comment [Hash, nil] Inline comment on the node's line
35
+ # @param backend [Symbol] The backend used for parsing (:tree_sitter or :citrus)
23
36
  # @return [NodeWrapper, nil] Wrapped node or nil if node is nil
24
- def wrap(node, lines, source: nil, leading_comments: [], inline_comment: nil)
37
+ def wrap(node, lines, source: nil, leading_comments: [], inline_comment: nil, backend: :tree_sitter)
25
38
  return if node.nil?
26
39
 
27
- new(node, lines: lines, source: source, leading_comments: leading_comments, inline_comment: inline_comment)
40
+ new(
41
+ node,
42
+ lines: lines,
43
+ source: source,
44
+ leading_comments: leading_comments,
45
+ inline_comment: inline_comment,
46
+ backend: backend,
47
+ )
28
48
  end
29
49
  end
30
50
 
31
- # @return [TreeHaver::Node] The wrapped tree-sitter node
32
- attr_reader :node
33
-
34
- # @return [Array<Hash>] Leading comments associated with this node
35
- attr_reader :leading_comments
36
-
37
- # @return [String] The original source string
38
- attr_reader :source
39
-
40
- # @return [Hash, nil] Inline/trailing comment on the same line
41
- attr_reader :inline_comment
42
-
43
- # @return [Integer] Start line (1-based)
44
- attr_reader :start_line
45
-
46
- # @return [Integer] End line (1-based)
47
- attr_reader :end_line
51
+ # @return [Symbol] The backend used for parsing
52
+ attr_reader :backend
48
53
 
49
- # @return [Array<String>] Source lines
50
- attr_reader :lines
54
+ # @return [TreeHaver::Node, nil] The document root node for sibling lookups
55
+ attr_reader :document_root
51
56
 
52
- # @param node [TreeHaver::Node] tree-sitter node to wrap
53
- # @param lines [Array<String>] Source lines for content extraction
54
- # @param source [String] Original source string for byte-based text extraction
55
- # @param leading_comments [Array<Hash>] Comments before this node
56
- # @param inline_comment [Hash, nil] Inline comment on the node's line
57
- def initialize(node, lines:, source: nil, leading_comments: [], inline_comment: nil)
58
- @node = node
59
- @lines = lines
60
- @source = source || lines.join("\n")
61
- @leading_comments = leading_comments
62
- @inline_comment = inline_comment
63
-
64
- # Extract line information from the tree-sitter node (0-indexed to 1-indexed)
65
- @start_line = node.start_point.row + 1 if node.respond_to?(:start_point)
66
- @end_line = node.end_point.row + 1 if node.respond_to?(:end_point)
67
-
68
- # Handle edge case where end_line might be before start_line
69
- @end_line = @start_line if @start_line && @end_line && @end_line < @start_line
57
+ # Process TOML-specific options (backend, document_root)
58
+ # @param options [Hash] Additional options
59
+ def process_additional_options(options)
60
+ @backend = options.fetch(:backend, :tree_sitter)
61
+ @document_root = options[:document_root]
70
62
  end
71
63
 
72
- # Generate a signature for this node for matching purposes.
73
- # Signatures are used to identify corresponding nodes between template and destination.
74
- #
75
- # @return [Array, nil] Signature array or nil if not signaturable
76
- def signature
77
- compute_signature(@node)
78
- end
79
-
80
- # Get the node type as a symbol
64
+ # Get the canonical (normalized) type for this node
81
65
  # @return [Symbol]
82
- def type
83
- @node.type.to_sym
66
+ def canonical_type
67
+ NodeTypeNormalizer.canonical_type(@node.type, @backend)
84
68
  end
85
69
 
86
- # Check if this node has a specific type
70
+ # Check if this node has a specific type (checks both raw and canonical)
87
71
  # @param type_name [Symbol, String] Type to check
88
72
  # @return [Boolean]
89
73
  def type?(type_name)
90
- @node.type.to_s == type_name.to_s
74
+ type_sym = type_name.to_sym
75
+ @node.type.to_sym == type_sym || canonical_type == type_sym
91
76
  end
92
77
 
93
78
  # Check if this is a TOML table (section)
94
79
  # @return [Boolean]
95
80
  def table?
96
- @node.type.to_s == "table"
81
+ canonical_type == :table
97
82
  end
98
83
 
99
84
  # Check if this is a TOML array of tables
85
+ # Uses NodeTypeNormalizer for backend-agnostic type checking.
100
86
  # @return [Boolean]
101
87
  def array_of_tables?
102
- type_str = @node.type.to_s
103
- type_str == "array_of_tables" || type_str == "table_array_element"
88
+ canonical_type == :array_of_tables
104
89
  end
105
90
 
106
91
  # Check if this is a TOML inline table
107
92
  # @return [Boolean]
108
93
  def inline_table?
109
- @node.type.to_s == "inline_table"
94
+ canonical_type == :inline_table
110
95
  end
111
96
 
112
97
  # Check if this is a TOML array
113
98
  # @return [Boolean]
114
99
  def array?
115
- @node.type.to_s == "array"
100
+ canonical_type == :array
116
101
  end
117
102
 
118
103
  # Check if this is a TOML string
119
104
  # @return [Boolean]
120
105
  def string?
121
- %w[string basic_string literal_string multiline_basic_string multiline_literal_string].include?(@node.type.to_s)
106
+ canonical_type == :string
122
107
  end
123
108
 
124
109
  # Check if this is a TOML integer
125
110
  # @return [Boolean]
126
111
  def integer?
127
- @node.type.to_s == "integer"
112
+ canonical_type == :integer
128
113
  end
129
114
 
130
115
  # Check if this is a TOML float
131
116
  # @return [Boolean]
132
117
  def float?
133
- @node.type.to_s == "float"
118
+ canonical_type == :float
134
119
  end
135
120
 
136
121
  # Check if this is a TOML boolean
137
122
  # @return [Boolean]
138
123
  def boolean?
139
- @node.type.to_s == "boolean"
124
+ canonical_type == :boolean
140
125
  end
141
126
 
142
127
  # Check if this is a key-value pair
143
128
  # @return [Boolean]
144
129
  def pair?
145
- @node.type.to_s == "pair"
130
+ canonical_type == :pair
146
131
  end
147
132
 
148
133
  # Check if this is a comment
149
134
  # @return [Boolean]
150
135
  def comment?
151
- @node.type.to_s == "comment"
136
+ canonical_type == :comment
152
137
  end
153
138
 
154
139
  # Check if this is a datetime
155
140
  # @return [Boolean]
156
141
  def datetime?
157
- %w[offset_date_time local_date_time local_date local_time].include?(@node.type.to_s)
142
+ canonical_type == :datetime
143
+ end
144
+
145
+ # Check if this is the document root
146
+ # @return [Boolean]
147
+ def document?
148
+ canonical_type == :document
158
149
  end
159
150
 
160
151
  # Get the table name (header) if this is a table
@@ -164,9 +155,10 @@ module Toml
164
155
 
165
156
  # Find the dotted_key or bare_key child that represents the table name
166
157
  @node.each do |child|
167
- child_type = child.type.to_s
168
- if %w[dotted_key bare_key quoted_key].include?(child_type)
169
- return node_text(child)
158
+ child_canonical = NodeTypeNormalizer.canonical_type(child.type, @backend)
159
+ if NodeTypeNormalizer.key_type?(child_canonical)
160
+ # Strip whitespace (Citrus backend includes trailing space in key nodes)
161
+ return node_text(child)&.strip
170
162
  end
171
163
  end
172
164
  nil
@@ -177,13 +169,14 @@ module Toml
177
169
  def key_name
178
170
  return unless pair?
179
171
 
180
- # In TOML tree-sitter, pair has key children (bare_key, quoted_key, or dotted_key)
172
+ # In TOML, pair has key children (bare_key, quoted_key, or dotted_key)
181
173
  @node.each do |child|
182
- child_type = child.type.to_s
183
- if %w[bare_key quoted_key dotted_key].include?(child_type)
174
+ child_canonical = NodeTypeNormalizer.canonical_type(child.type, @backend)
175
+ if NodeTypeNormalizer.key_type?(child_canonical)
184
176
  key_text = node_text(child)
185
- # Remove surrounding quotes if present
186
- return key_text&.gsub(/\A["']|["']\z/, "")
177
+ # Remove surrounding quotes if present, and strip whitespace
178
+ # (Citrus backend includes trailing space in key nodes)
179
+ return key_text&.gsub(/\A["']|["']\z/, "")&.strip
187
180
  end
188
181
  end
189
182
  nil
@@ -195,61 +188,59 @@ module Toml
195
188
  return unless pair?
196
189
 
197
190
  @node.each do |child|
198
- child_type = child.type.to_s
199
- # Skip keys, get the value
200
- next if %w[bare_key quoted_key dotted_key =].include?(child_type)
201
-
202
- return NodeWrapper.new(child, lines: @lines, source: @source)
191
+ child_canonical = NodeTypeNormalizer.canonical_type(child.type, @backend)
192
+ # Skip keys, equals sign, whitespace, and unknown (Citrus uses these for delimiters)
193
+ next if NodeTypeNormalizer.key_type?(child_canonical)
194
+ next if %i[equals whitespace unknown space].include?(child_canonical)
195
+
196
+ return NodeWrapper.new(
197
+ child,
198
+ lines: @lines,
199
+ source: @source,
200
+ backend: @backend,
201
+ document_root: @document_root,
202
+ )
203
203
  end
204
204
  nil
205
205
  end
206
206
 
207
- # Get key-value pairs from a table or inline_table
207
+ # Get key-value pairs from a table or inline_table.
208
+ #
209
+ # Handles structural differences between backends:
210
+ # - Tree-sitter: pairs are children of the table node
211
+ # - Citrus: pairs are siblings at document level (table only contains header)
212
+ #
213
+ # For Citrus backend, when no pair children are found, we look for sibling
214
+ # pairs in the document that belong to this table (pairs after this table's
215
+ # header but before the next table).
216
+ #
208
217
  # @return [Array<NodeWrapper>]
209
218
  def pairs
210
- return [] unless table? || inline_table? || document?
219
+ return [] unless table? || inline_table? || document? || array_of_tables?
211
220
 
212
- result = []
213
- @node.each do |child|
214
- next unless child.type.to_s == "pair"
221
+ # First, try to find pairs as direct children (tree-sitter structure)
222
+ result = collect_child_pairs
223
+ return result if result.any?
215
224
 
216
- result << NodeWrapper.new(child, lines: @lines, source: @source)
217
- end
218
- result
219
- end
225
+ # For Citrus backend: pairs are siblings, not children
226
+ # Look for pairs in document that belong to this table
227
+ return [] if @document_root.nil? || !(table? || array_of_tables?)
220
228
 
221
- # Check if this is the document root
222
- # @return [Boolean]
223
- def document?
224
- @node.type.to_s == "document"
229
+ collect_sibling_pairs_for_table
225
230
  end
226
231
 
227
232
  # Get array elements if this is an array
233
+ #
234
+ # Handles structural differences between backends:
235
+ # - Tree-sitter: values are direct children of array node
236
+ # - Citrus: values are nested inside array_elements container
237
+ #
228
238
  # @return [Array<NodeWrapper>]
229
239
  def elements
230
240
  return [] unless array?
231
241
 
232
242
  result = []
233
- @node.each do |child|
234
- child_type = child.type.to_s
235
- # Skip punctuation and comments
236
- next if child_type == "comment"
237
- next if %w[, \[ \]].include?(child_type)
238
-
239
- result << NodeWrapper.new(child, lines: @lines, source: @source)
240
- end
241
- result
242
- end
243
-
244
- # Get children wrapped as NodeWrappers
245
- # @return [Array<NodeWrapper>]
246
- def children
247
- return [] unless @node.respond_to?(:each)
248
-
249
- result = []
250
- @node.each do |child|
251
- result << NodeWrapper.new(child, lines: @lines, source: @source)
252
- end
243
+ collect_array_elements(@node, result)
253
244
  result
254
245
  end
255
246
 
@@ -258,8 +249,8 @@ module Toml
258
249
  # For other node types, returns empty array (leaf nodes).
259
250
  # @return [Array<NodeWrapper>]
260
251
  def mergeable_children
261
- case type
262
- when :table, :inline_table
252
+ case canonical_type
253
+ when :table, :inline_table, :array_of_tables
263
254
  pairs
264
255
  when :array
265
256
  elements
@@ -267,10 +258,16 @@ module Toml
267
258
  # Return top-level pairs and tables
268
259
  result = []
269
260
  @node.each do |child|
270
- child_type = child.type.to_s
271
- next if child_type == "comment"
272
-
273
- result << NodeWrapper.new(child, lines: @lines, source: @source)
261
+ child_canonical = NodeTypeNormalizer.canonical_type(child.type, @backend)
262
+ next if child_canonical == :comment
263
+
264
+ result << NodeWrapper.new(
265
+ child,
266
+ lines: @lines,
267
+ source: @source,
268
+ backend: @backend,
269
+ document_root: @document_root,
270
+ )
274
271
  end
275
272
  result
276
273
  else
@@ -284,12 +281,6 @@ module Toml
284
281
  table? || array_of_tables? || inline_table? || array? || document?
285
282
  end
286
283
 
287
- # Check if this node is a leaf (no mergeable children)
288
- # @return [Boolean]
289
- def leaf?
290
- !container?
291
- end
292
-
293
284
  # Get the opening line for a table (the line with [table_name])
294
285
  # @return [String, nil]
295
286
  def opening_line
@@ -308,109 +299,283 @@ module Toml
308
299
  @lines[@end_line - 1]
309
300
  end
310
301
 
311
- # Get the text content for this node by extracting from source using byte positions
302
+ # Get the content for this node from source lines.
303
+ #
304
+ # Handles structural differences between backends:
305
+ # - Tree-sitter: table nodes include pairs, so start_line..end_line covers everything
306
+ # - Citrus: table nodes only include header, so we extend to include associated pairs
307
+ #
312
308
  # @return [String]
313
- def text
314
- node_text(@node)
315
- end
309
+ def content
310
+ return "" unless @start_line
316
311
 
317
- # Extract text from a tree-sitter node using byte positions
318
- # @param ts_node [TreeHaver::Node] The tree-sitter node
319
- # @return [String]
320
- def node_text(ts_node)
321
- return "" unless ts_node.respond_to?(:start_byte) && ts_node.respond_to?(:end_byte)
312
+ # For tables with Citrus backend, extend end_line to include pairs
313
+ effective_end = effective_end_line
314
+ return "" unless effective_end
322
315
 
323
- @source[ts_node.start_byte...ts_node.end_byte] || ""
316
+ (@start_line..effective_end).map { |ln| @lines[ln - 1] }.compact.join("\n")
324
317
  end
325
318
 
326
- # Get the content for this node from source lines
327
- # @return [String]
328
- def content
329
- return "" unless @start_line && @end_line
319
+ # Get the effective end line for this node, accounting for Citrus backend.
320
+ # For Citrus tables, this extends to the line before the next table.
321
+ # @return [Integer, nil]
322
+ def effective_end_line
323
+ return @end_line if !(table? || array_of_tables?) || @document_root.nil?
330
324
 
331
- (@start_line..@end_line).map { |ln| @lines[ln - 1] }.compact.join("\n")
332
- end
325
+ # Check if we have pairs as children (tree-sitter structure)
326
+ child_pairs = collect_child_pairs
327
+ return @end_line if child_pairs.any?
333
328
 
334
- # String representation for debugging
335
- # @return [String]
336
- def inspect
337
- "#<#{self.class.name} type=#{@node.type} lines=#{@start_line}..#{@end_line}>"
329
+ # Citrus structure: find the last pair that belongs to us
330
+ sibling_pairs = collect_sibling_pairs_for_table
331
+ return @end_line if sibling_pairs.empty?
332
+
333
+ # Return the end line of the last pair
334
+ sibling_pairs.map(&:end_line).compact.max || @end_line
338
335
  end
339
336
 
340
- private
337
+ protected
338
+
339
+ # Override wrap_child to use Toml::Merge::NodeWrapper with proper options
340
+ def wrap_child(child)
341
+ NodeWrapper.new(
342
+ child,
343
+ lines: @lines,
344
+ source: @source,
345
+ backend: @backend,
346
+ document_root: @document_root,
347
+ )
348
+ end
341
349
 
342
350
  def compute_signature(node)
343
- node_type = node.type.to_s
351
+ # Use canonical type for signature generation
352
+ # Pass @backend to ensure correct type mapping for Citrus vs tree-sitter
353
+ canonical = NodeTypeNormalizer.canonical_type(node.type, @backend)
344
354
 
345
- case node_type
346
- when "document"
355
+ case canonical
356
+ when :document
347
357
  # Root document
348
358
  [:document]
349
- when "table"
359
+ when :table
350
360
  # Tables identified by their header name
351
361
  name = table_name
352
362
  [:table, name]
353
- when "array_of_tables", "table_array_element"
363
+ when :array_of_tables
354
364
  # Array of tables identified by their header name
355
365
  name = table_name
356
366
  [:array_of_tables, name]
357
- when "pair"
367
+ when :pair
358
368
  # Pairs identified by their key name
359
369
  key = key_name
360
370
  [:pair, key]
361
- when "inline_table"
371
+ when :inline_table
362
372
  # Inline tables identified by their keys
363
373
  keys = extract_inline_table_keys(node)
364
374
  [:inline_table, keys.sort]
365
- when "array"
375
+ when :array
366
376
  # Arrays identified by their length
367
377
  elements_count = 0
368
- node.each do |c|
369
- next if %w[comment , \[ \]].include?(c.type.to_s)
370
-
371
- elements_count += 1
372
- end
378
+ node.each { |c| elements_count += 1 unless %i[comment comma bracket_open bracket_close].include?(NodeTypeNormalizer.canonical_type(c.type, @backend)) }
373
379
  [:array, elements_count]
374
- when "string", "basic_string", "literal_string", "multiline_basic_string", "multiline_literal_string"
380
+ when :string
375
381
  # Strings identified by their content
376
382
  [:string, node_text(node)]
377
- when "integer"
383
+ when :integer
378
384
  # Integers identified by their value
379
385
  [:integer, node_text(node)]
380
- when "float"
386
+ when :float
381
387
  # Floats identified by their value
382
388
  [:float, node_text(node)]
383
- when "boolean"
389
+ when :boolean
384
390
  # Booleans
385
391
  [:boolean, node_text(node)]
386
- when "offset_date_time", "local_date_time", "local_date", "local_time"
392
+ when :datetime
387
393
  # Datetime values
388
394
  [:datetime, node_text(node)]
389
- when "comment"
395
+ when :comment
390
396
  # Comments identified by their content
391
397
  [:comment, node_text(node)&.strip]
392
398
  else
393
- # Generic fallback
399
+ # Generic fallback - use canonical type in signature
394
400
  content_preview = node_text(node)&.slice(0, 50)&.strip
395
- [node_type.to_sym, content_preview]
401
+ [canonical, content_preview]
396
402
  end
397
403
  end
398
404
 
405
+ private
406
+
399
407
  def extract_inline_table_keys(inline_table_node)
400
408
  keys = []
401
- inline_table_node.each do |child|
402
- next unless child.type.to_s == "pair"
403
-
404
- child.each do |pair_child|
405
- child_type = pair_child.type.to_s
406
- if %w[bare_key quoted_key dotted_key].include?(child_type)
407
- key_text = node_text(pair_child)&.gsub(/\A["']|["']\z/, "")
408
- keys << key_text if key_text
409
- break
409
+ collect_inline_table_keys_recursive(inline_table_node, keys)
410
+ keys
411
+ end
412
+
413
+ # Recursively collect keys from inline table, handling both tree-sitter and Citrus structures.
414
+ #
415
+ # Tree-sitter inline_table structure:
416
+ # inline_table -> pair -> bare_key (direct children)
417
+ #
418
+ # Citrus inline_table structure:
419
+ # inline_table -> optional -> keyvalue -> keyvalue -> stripped_key -> key -> bare_key
420
+ # With repeat and unknown nodes containing additional key-values
421
+ #
422
+ def collect_inline_table_keys_recursive(node, keys)
423
+ node.each do |child|
424
+ child_canonical = NodeTypeNormalizer.canonical_type(child.type, @backend)
425
+ child_type_raw = child.type.to_sym
426
+
427
+ # For Citrus: recurse into container nodes that hold pairs/keys
428
+ # For tree-sitter: only recurse into pairs
429
+ if @backend == :citrus
430
+ if %i[optional repeat keyvalue unknown].include?(child_type_raw)
431
+ collect_inline_table_keys_recursive(child, keys)
432
+ next
433
+ end
434
+ elsif child_canonical == :pair
435
+ # Tree-sitter: pairs contain keys directly
436
+ child.each do |pair_child|
437
+ pair_child_canonical = NodeTypeNormalizer.canonical_type(pair_child.type, @backend)
438
+ if NodeTypeNormalizer.key_type?(pair_child_canonical)
439
+ key_text = node_text(pair_child)&.gsub(/\A["']|["']\z/, "")&.strip
440
+ keys << key_text if key_text && !key_text.empty?
441
+ break
442
+ end
410
443
  end
444
+ next
445
+ end
446
+
447
+ # Skip whitespace and punctuation
448
+ next if %i[whitespace space brace_open brace_close comma].include?(child_canonical)
449
+
450
+ # Found a key node - extract the key text (Citrus path)
451
+ if NodeTypeNormalizer.key_type?(child_canonical)
452
+ key_text = node_text(child)&.gsub(/\A["']|["']\z/, "")&.strip
453
+ keys << key_text if key_text && !key_text.empty?
411
454
  end
412
455
  end
413
- keys
456
+ end
457
+
458
+ # Collect pairs that are direct children of this node.
459
+ # This is the standard tree-sitter structure.
460
+ # @return [Array<NodeWrapper>]
461
+ def collect_child_pairs
462
+ result = []
463
+ @node.each do |child|
464
+ child_canonical = NodeTypeNormalizer.canonical_type(child.type, @backend)
465
+ next unless child_canonical == :pair
466
+
467
+ result << NodeWrapper.new(
468
+ child,
469
+ lines: @lines,
470
+ source: @source,
471
+ backend: @backend,
472
+ document_root: @document_root,
473
+ )
474
+ end
475
+ result
476
+ end
477
+
478
+ # Collect pairs from document siblings that belong to this table.
479
+ # Used for Citrus backend where pairs are siblings, not children.
480
+ #
481
+ # A pair belongs to this table if:
482
+ # - It appears after this table's header line
483
+ # - It appears before the next table's header line (or end of document)
484
+ #
485
+ # @return [Array<NodeWrapper>]
486
+ def collect_sibling_pairs_for_table
487
+ result = []
488
+ my_start = @start_line
489
+ return result unless my_start
490
+
491
+ # Find the next table's start line (to know where our pairs end)
492
+ next_table_start = find_next_table_start_line
493
+
494
+ # Iterate through document children to find pairs in our range
495
+ @document_root.each do |sibling|
496
+ sibling_canonical = NodeTypeNormalizer.canonical_type(sibling.type, @backend)
497
+ next unless sibling_canonical == :pair
498
+
499
+ sibling_start = sibling.respond_to?(:start_point) ? sibling.start_point.row + 1 : nil
500
+ next unless sibling_start
501
+
502
+ # Pair must be after our header
503
+ next if sibling_start <= my_start
504
+
505
+ # Pair must be before the next table (if there is one)
506
+ next if next_table_start && sibling_start >= next_table_start
507
+
508
+ result << NodeWrapper.new(
509
+ sibling,
510
+ lines: @lines,
511
+ source: @source,
512
+ backend: @backend,
513
+ document_root: @document_root,
514
+ )
515
+ end
516
+
517
+ result
518
+ end
519
+
520
+ # Find the start line of the next table in the document.
521
+ # Returns nil if this is the last table.
522
+ # @return [Integer, nil]
523
+ def find_next_table_start_line
524
+ return unless @document_root
525
+
526
+ my_start = @start_line
527
+ next_table_start = nil
528
+
529
+ @document_root.each do |sibling|
530
+ sibling_canonical = NodeTypeNormalizer.canonical_type(sibling.type, @backend)
531
+ next unless NodeTypeNormalizer.table_type?(sibling_canonical)
532
+
533
+ sibling_start = sibling.respond_to?(:start_point) ? sibling.start_point.row + 1 : nil
534
+ next unless sibling_start
535
+ next if sibling_start <= my_start
536
+
537
+ # Found a table after us - track the closest one
538
+ if next_table_start.nil? || sibling_start < next_table_start
539
+ next_table_start = sibling_start
540
+ end
541
+ end
542
+
543
+ next_table_start
544
+ end
545
+
546
+ # Recursively collect array elements, handling Citrus's nested structure.
547
+ #
548
+ # Citrus array structure:
549
+ # array -> array_elements -> array_elements -> decimal_integer + repeat
550
+ # repeat -> indent -> decimal_integer (for each subsequent element)
551
+ #
552
+ # @param node [Object] Node to collect elements from
553
+ # @param result [Array<NodeWrapper>] Array to append elements to
554
+ def collect_array_elements(node, result)
555
+ node.each do |child|
556
+ child_canonical = NodeTypeNormalizer.canonical_type(child.type, @backend)
557
+ child_type_raw = child.type.to_sym
558
+
559
+ # For Citrus: recurse into container nodes that hold values
560
+ if %i[array_elements repeat indent].include?(child_type_raw)
561
+ collect_array_elements(child, result)
562
+ next
563
+ end
564
+
565
+ # Skip punctuation, comments, whitespace, and structural nodes
566
+ next if child_canonical == :comment
567
+ next if %i[comma bracket_open bracket_close].include?(child_canonical)
568
+ next if %i[whitespace unknown space array_comments sign].include?(child_canonical)
569
+
570
+ # This is an actual value element (integer, string, boolean, etc.)
571
+ result << NodeWrapper.new(
572
+ child,
573
+ lines: @lines,
574
+ source: @source,
575
+ backend: @backend,
576
+ document_root: @document_root,
577
+ )
578
+ end
414
579
  end
415
580
  end
416
581
  end