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.
@@ -8,7 +8,7 @@ module Toml
8
8
  # @example Basic usage
9
9
  # resolver = ConflictResolver.new(template_analysis, dest_analysis)
10
10
  # resolver.resolve(result)
11
- class ConflictResolver < ::Ast::Merge::ConflictResolverBase
11
+ class ConflictResolver < Ast::Merge::ConflictResolverBase
12
12
  # Creates a new ConflictResolver
13
13
  #
14
14
  # @param template_analysis [FileAnalysis] Analyzed template file
@@ -19,15 +19,18 @@ module Toml
19
19
  # - :template - Use template version (updates)
20
20
  # @param add_template_only_nodes [Boolean] Whether to add nodes only in template
21
21
  # @param match_refiner [#call, nil] Optional match refiner for fuzzy matching
22
- def initialize(template_analysis, dest_analysis, preference: :destination, add_template_only_nodes: false, match_refiner: nil)
22
+ # @param options [Hash] Additional options for forward compatibility
23
+ def initialize(template_analysis, dest_analysis, preference: :destination, add_template_only_nodes: false, match_refiner: nil, **options)
23
24
  super(
24
25
  strategy: :batch,
25
26
  preference: preference,
26
27
  template_analysis: template_analysis,
27
28
  dest_analysis: dest_analysis,
28
- add_template_only_nodes: add_template_only_nodes
29
+ add_template_only_nodes: add_template_only_nodes,
30
+ match_refiner: match_refiner,
31
+ **options
29
32
  )
30
- @match_refiner = match_refiner
33
+ @emitter = Emitter.new
31
34
  end
32
35
 
33
36
  protected
@@ -40,15 +43,25 @@ module Toml
40
43
  template_statements = @template_analysis.statements
41
44
  dest_statements = @dest_analysis.statements
42
45
 
43
- # Merge root-level statements (tables, array_of_tables, pairs)
44
- merge_node_lists(
46
+ # Clear emitter for fresh merge
47
+ @emitter.clear
48
+
49
+ # Merge root-level statements via emitter
50
+ merge_node_lists_to_emitter(
45
51
  template_statements,
46
52
  dest_statements,
47
53
  @template_analysis,
48
54
  @dest_analysis,
49
- result,
50
55
  )
51
56
 
57
+ # Transfer emitter output to result
58
+ emitted_content = @emitter.to_s
59
+ unless emitted_content.empty?
60
+ emitted_content.lines.each do |line|
61
+ result.add_line(line.chomp, decision: MergeResult::DECISION_MERGED, source: :merged)
62
+ end
63
+ end
64
+
52
65
  DebugLogger.debug("Conflict resolution complete", {
53
66
  template_statements: template_statements.size,
54
67
  dest_statements: dest_statements.size,
@@ -59,13 +72,12 @@ module Toml
59
72
 
60
73
  private
61
74
 
62
- # Recursively merge two lists of nodes (tree-based merge)
75
+ # Recursively merge two lists of nodes, emitting to emitter
63
76
  # @param template_nodes [Array<NodeWrapper>] Template nodes
64
77
  # @param dest_nodes [Array<NodeWrapper>] Destination nodes
65
78
  # @param template_analysis [FileAnalysis] Template analysis for line access
66
79
  # @param dest_analysis [FileAnalysis] Destination analysis for line access
67
- # @param result [MergeResult] Result to populate
68
- def merge_node_lists(template_nodes, dest_nodes, template_analysis, dest_analysis, result)
80
+ def merge_node_lists_to_emitter(template_nodes, dest_nodes, template_analysis, dest_analysis)
69
81
  # Build signature maps for matching
70
82
  template_by_sig = build_signature_map(template_nodes, template_analysis)
71
83
  dest_by_sig = build_signature_map(dest_nodes, dest_analysis)
@@ -82,20 +94,13 @@ module Toml
82
94
  dest_nodes.each do |dest_node|
83
95
  dest_sig = dest_analysis.generate_signature(dest_node)
84
96
 
85
- # Freeze blocks from destination are always preserved
86
- if freeze_node?(dest_node)
87
- add_node_to_result(dest_node, result, :destination, MergeResult::DECISION_FREEZE_BLOCK, dest_analysis)
88
- processed_dest_sigs << dest_sig if dest_sig
89
- next
90
- end
91
-
92
97
  # Check for signature match
93
98
  if dest_sig && template_by_sig[dest_sig]
94
99
  template_info = template_by_sig[dest_sig].first
95
100
  template_node = template_info[:node]
96
101
 
97
- # Both have this node - merge them (recursively if containers)
98
- merge_matched_nodes(template_node, dest_node, template_analysis, dest_analysis, result)
102
+ # Both have this node - merge them
103
+ merge_matched_nodes_to_emitter(template_node, dest_node, template_analysis, dest_analysis)
99
104
 
100
105
  processed_dest_sigs << dest_sig
101
106
  processed_template_sigs << dest_sig
@@ -105,13 +110,13 @@ module Toml
105
110
  template_sig = template_analysis.generate_signature(template_node)
106
111
 
107
112
  # Merge matched nodes
108
- merge_matched_nodes(template_node, dest_node, template_analysis, dest_analysis, result)
113
+ merge_matched_nodes_to_emitter(template_node, dest_node, template_analysis, dest_analysis)
109
114
 
110
115
  processed_dest_sigs << dest_sig if dest_sig
111
116
  processed_template_sigs << template_sig if template_sig
112
117
  else
113
118
  # Destination-only node - always keep
114
- add_node_to_result(dest_node, result, :destination, MergeResult::DECISION_KEPT_DEST, dest_analysis)
119
+ emit_node(dest_node, dest_analysis)
115
120
  processed_dest_sigs << dest_sig if dest_sig
116
121
  end
117
122
  end
@@ -125,38 +130,56 @@ module Toml
125
130
  # Skip if already processed
126
131
  next if template_sig && processed_template_sigs.include?(template_sig)
127
132
 
128
- # Skip freeze blocks from template
129
- next if freeze_node?(template_node)
130
-
131
133
  # Add template-only node
132
- add_node_to_result(template_node, result, :template, MergeResult::DECISION_ADDED, template_analysis)
134
+ emit_node(template_node, template_analysis)
133
135
  processed_template_sigs << template_sig if template_sig
134
136
  end
135
137
  end
136
138
 
137
- # Merge two matched nodes - for containers, recursively merge children
139
+ # Merge two matched nodes
138
140
  # @param template_node [NodeWrapper] Template node
139
141
  # @param dest_node [NodeWrapper] Destination node
140
142
  # @param template_analysis [FileAnalysis] Template analysis
141
143
  # @param dest_analysis [FileAnalysis] Destination analysis
142
- # @param result [MergeResult] Result to populate
143
- def merge_matched_nodes(template_node, dest_node, template_analysis, dest_analysis, result)
144
+ def merge_matched_nodes_to_emitter(template_node, dest_node, template_analysis, dest_analysis)
145
+ # For TOML, tables can be merged recursively
144
146
  if dest_node.table? && template_node.table?
145
- # Both are tables - merge their contents
146
- merge_table_nodes(template_node, dest_node, template_analysis, dest_analysis, result)
147
- elsif dest_node.container? && template_node.container?
148
- # Both are containers - recursively merge their children
149
- merge_container_nodes(template_node, dest_node, template_analysis, dest_analysis, result)
147
+ # Emit table header and merge children
148
+ @emitter.emit_table_header(dest_node.table_name || template_node.table_name)
149
+
150
+ template_children = template_node.children
151
+ dest_children = dest_node.children
152
+
153
+ merge_node_lists_to_emitter(
154
+ template_children,
155
+ dest_children,
156
+ template_analysis,
157
+ dest_analysis,
158
+ )
150
159
  elsif @preference == :destination
151
160
  # Leaf nodes or mismatched types - use preference
152
- add_node_to_result(dest_node, result, :destination, MergeResult::DECISION_KEPT_DEST, dest_analysis)
161
+ emit_node(dest_node, dest_analysis)
153
162
  else
154
- add_node_to_result(template_node, result, :template, MergeResult::DECISION_KEPT_TEMPLATE, template_analysis)
163
+ emit_node(template_node, template_analysis)
164
+ end
165
+ end
166
+
167
+ # Emit a single node to the emitter
168
+ # @param node [NodeWrapper] Node to emit
169
+ # @param analysis [FileAnalysis] Analysis for accessing source
170
+ def emit_node(node, analysis)
171
+ # Emit the node content
172
+ if node.start_line && node.end_line
173
+ lines = []
174
+ (node.start_line..node.end_line).each do |line_num|
175
+ line = analysis.line_at(line_num)
176
+ lines << line if line
177
+ end
178
+ @emitter.emit_raw_lines(lines)
155
179
  end
156
180
  end
157
181
 
158
- # Build a map of refined matches from template node to destination node.
159
- # Uses the match_refiner to find additional pairings for nodes that didn't match by signature.
182
+ # Build a map of refined matches
160
183
  # @param template_nodes [Array<NodeWrapper>] Template nodes
161
184
  # @param dest_nodes [Array<NodeWrapper>] Destination nodes
162
185
  # @param template_by_sig [Hash] Template signature map
@@ -189,76 +212,6 @@ module Toml
189
212
  h[match.template_node] = match.dest_node
190
213
  end
191
214
  end
192
-
193
- # Merge two table nodes by emitting the table header and recursively merging pairs
194
- # @param template_node [NodeWrapper] Template table node
195
- # @param dest_node [NodeWrapper] Destination table node
196
- # @param template_analysis [FileAnalysis] Template analysis
197
- # @param dest_analysis [FileAnalysis] Destination analysis
198
- # @param result [MergeResult] Result to populate
199
- def merge_table_nodes(template_node, dest_node, template_analysis, dest_analysis, result)
200
- # Use destination's table header line
201
- header = dest_node.opening_line || template_node.opening_line
202
- result.add_line(header, decision: MergeResult::DECISION_MERGED, source: :merged) if header
203
-
204
- # Recursively merge the pairs within the table
205
- template_pairs = template_node.pairs
206
- dest_pairs = dest_node.pairs
207
-
208
- merge_node_lists(
209
- template_pairs,
210
- dest_pairs,
211
- template_analysis,
212
- dest_analysis,
213
- result,
214
- )
215
- end
216
-
217
- # Merge two container nodes by emitting opening, recursively merging children, then closing
218
- # @param template_node [NodeWrapper] Template container node
219
- # @param dest_node [NodeWrapper] Destination container node
220
- # @param template_analysis [FileAnalysis] Template analysis
221
- # @param dest_analysis [FileAnalysis] Destination analysis
222
- # @param result [MergeResult] Result to populate
223
- def merge_container_nodes(template_node, dest_node, template_analysis, dest_analysis, result)
224
- # Recursively merge the children
225
- template_children = template_node.mergeable_children
226
- dest_children = dest_node.mergeable_children
227
-
228
- merge_node_lists(
229
- template_children,
230
- dest_children,
231
- template_analysis,
232
- dest_analysis,
233
- result,
234
- )
235
- end
236
-
237
- # Add a node to the result (non-container or leaf node)
238
- # @param node [NodeWrapper] Node to add
239
- # @param result [MergeResult] Result to populate
240
- # @param source [Symbol] :template or :destination
241
- # @param decision [String] Decision constant
242
- # @param analysis [FileAnalysis] Analysis for line access
243
- def add_node_to_result(node, result, source, decision, analysis)
244
- if node.is_a?(NodeWrapper)
245
- add_wrapper_to_result(node, result, source, decision, analysis)
246
- else
247
- DebugLogger.debug("Unknown node type", {node_type: node.class.name})
248
- end
249
- end
250
-
251
- def add_wrapper_to_result(wrapper, result, source, decision, analysis)
252
- return unless wrapper.start_line && wrapper.end_line
253
-
254
- # Add the node content line by line
255
- (wrapper.start_line..wrapper.end_line).each do |line_num|
256
- line = analysis.line_at(line_num)
257
- next unless line
258
-
259
- result.add_line(line.chomp, decision: decision, source: source, original_line: line_num)
260
- end
261
- end
262
215
  end
263
216
  end
264
217
  end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toml
4
+ module Merge
5
+ # Custom TOML emitter that preserves comments and formatting.
6
+ # This class provides utilities for emitting TOML while maintaining
7
+ # the original structure, comments, and style choices.
8
+ #
9
+ # Inherits common emitter functionality from Ast::Merge::EmitterBase.
10
+ #
11
+ # @example Basic usage
12
+ # emitter = Emitter.new
13
+ # emitter.emit_table_header("section")
14
+ # emitter.emit_key_value("key", "value")
15
+ class Emitter < Ast::Merge::EmitterBase
16
+ # Initialize subclass-specific state
17
+ def initialize_subclass_state(**options)
18
+ # TOML doesn't need separator tracking like JSON
19
+ end
20
+
21
+ # Clear subclass-specific state
22
+ def clear_subclass_state
23
+ # Nothing to clear for TOML
24
+ end
25
+
26
+ # Emit a tracked comment from CommentTracker
27
+ # @param comment [Hash] Comment with :text, :indent
28
+ def emit_tracked_comment(comment)
29
+ indent = " " * (comment[:indent] || 0)
30
+ @lines << "#{indent}# #{comment[:text]}"
31
+ end
32
+
33
+ # Emit a comment line
34
+ #
35
+ # @param text [String] Comment text (without #)
36
+ # @param inline [Boolean] Whether this is an inline comment
37
+ def emit_comment(text, inline: false)
38
+ if inline
39
+ # Inline comments are appended to the last line
40
+ return if @lines.empty?
41
+
42
+ @lines[-1] = "#{@lines[-1]} # #{text}"
43
+ else
44
+ @lines << "#{current_indent}# #{text}"
45
+ end
46
+ end
47
+
48
+ # Emit a table header
49
+ #
50
+ # @param name [String] Table name (e.g., "package" or "dependencies.dev")
51
+ def emit_table_header(name)
52
+ @lines << "[#{name}]"
53
+ end
54
+
55
+ # Emit an array of tables header
56
+ #
57
+ # @param name [String] Array of tables name
58
+ def emit_array_of_tables_header(name)
59
+ @lines << "[[#{name}]]"
60
+ end
61
+
62
+ # Emit a key-value pair
63
+ #
64
+ # @param key [String] Key name
65
+ # @param value [String] Value (already formatted as TOML)
66
+ # @param inline_comment [String, nil] Optional inline comment
67
+ def emit_key_value(key, value, inline_comment: nil)
68
+ line = "#{current_indent}#{key} = #{value}"
69
+ line += " # #{inline_comment}" if inline_comment
70
+ @lines << line
71
+ end
72
+
73
+ # Emit an inline table
74
+ #
75
+ # @param key [String] Key name
76
+ # @param pairs [Hash] Key-value pairs for the inline table
77
+ def emit_inline_table(key, pairs)
78
+ formatted_pairs = pairs.map { |k, v| "#{k} = #{v}" }.join(", ")
79
+ @lines << "#{current_indent}#{key} = { #{formatted_pairs} }"
80
+ end
81
+
82
+ # Emit an inline array
83
+ #
84
+ # @param key [String] Key name
85
+ # @param items [Array] Array items (already formatted)
86
+ def emit_inline_array(key, items)
87
+ formatted_items = items.join(", ")
88
+ @lines << "#{current_indent}#{key} = [#{formatted_items}]"
89
+ end
90
+
91
+ # Emit a multi-line array start
92
+ #
93
+ # @param key [String] Key name
94
+ def emit_array_start(key)
95
+ @lines << "#{current_indent}#{key} = ["
96
+ indent
97
+ end
98
+
99
+ # Emit an array item
100
+ #
101
+ # @param value [String] Item value (already formatted)
102
+ def emit_array_item(value)
103
+ @lines << "#{current_indent}#{value},"
104
+ end
105
+
106
+ # Emit an array end
107
+ def emit_array_end
108
+ dedent
109
+ @lines << "#{current_indent}]"
110
+ end
111
+
112
+ # Get the output as a TOML string
113
+ #
114
+ # @return [String]
115
+ def to_toml
116
+ to_s
117
+ end
118
+ end
119
+ end
120
+ end
@@ -18,15 +18,36 @@ module Toml
18
18
  # @return [Array] Parse errors if any
19
19
  attr_reader :errors
20
20
 
21
+ # @return [Symbol] The backend used for parsing (:tree_sitter or :citrus)
22
+ attr_reader :backend
23
+
24
+ class << self
25
+ # Find the parser library path using TreeHaver::GrammarFinder
26
+ #
27
+ # @return [String, nil] Path to the parser library or nil if not found
28
+ def find_parser_path
29
+ TreeHaver::GrammarFinder.new(:toml).find_library_path
30
+ end
31
+ end
32
+
21
33
  # Initialize file analysis
22
34
  #
23
35
  # @param source [String] TOML source code to analyze
36
+ # @param source [String] TOML source code to analyze
24
37
  # @param signature_generator [Proc, nil] Custom signature generator
25
- def initialize(source, signature_generator: nil)
38
+ # @param parser_path [String, nil] Path to tree-sitter-toml parser library
39
+ # @param options [Hash] Additional options (forward compatibility - freeze_token, node_typing, etc.)
40
+ #
41
+ # @note To force a specific backend, use TreeHaver.with_backend or TREE_HAVER_BACKEND env var.
42
+ # TreeHaver handles backend selection, auto-detection, and fallback.
43
+ def initialize(source, signature_generator: nil, parser_path: nil, **options)
26
44
  @source = source
27
45
  @lines = source.lines.map(&:chomp)
28
46
  @signature_generator = signature_generator
47
+ @parser_path = parser_path || self.class.find_parser_path
29
48
  @errors = []
49
+ @backend = :tree_sitter # Default, will be updated during parsing
50
+ # **options captures any additional parameters (e.g., freeze_token, node_typing) for forward compatibility
30
51
 
31
52
  # Parse the TOML
32
53
  DebugLogger.time("FileAnalysis#parse_toml") { parse_toml }
@@ -58,7 +79,14 @@ module Toml
58
79
  def root_node
59
80
  return unless valid?
60
81
 
61
- NodeWrapper.new(@ast.root_node, lines: @lines, source: @source)
82
+ root = @ast.root_node
83
+ NodeWrapper.new(
84
+ root,
85
+ lines: @lines,
86
+ source: @source,
87
+ backend: @backend,
88
+ document_root: root,
89
+ )
62
90
  end
63
91
 
64
92
  # Get a hash mapping signatures to nodes
@@ -68,30 +96,73 @@ module Toml
68
96
  end
69
97
 
70
98
  # Get all top-level tables (sections) in the TOML document
99
+ # Uses NodeTypeNormalizer for backend-agnostic type checking.
100
+ # Passes document_root to enable Citrus backend normalization (pairs as siblings).
71
101
  # @return [Array<NodeWrapper>]
72
102
  def tables
73
103
  return [] unless valid?
74
104
 
75
105
  result = []
76
- @ast.root_node.each do |child|
77
- child_type = child.type.to_s
78
- next unless %w[table array_of_tables].include?(child_type)
79
-
80
- result << NodeWrapper.new(child, lines: @lines, source: @source)
106
+ root = @ast.root_node
107
+ root.each do |child|
108
+ canonical_type = NodeTypeNormalizer.canonical_type(child.type, @backend)
109
+ next unless NodeTypeNormalizer.table_type?(canonical_type)
110
+
111
+ result << NodeWrapper.new(
112
+ child,
113
+ lines: @lines,
114
+ source: @source,
115
+ backend: @backend,
116
+ document_root: root,
117
+ )
81
118
  end
82
119
  result
83
120
  end
84
121
 
85
122
  # Get all top-level key-value pairs (not in tables)
123
+ #
124
+ # For tree-sitter backend: pairs are nested under tables, so root-level
125
+ # pairs are direct children of the document.
126
+ #
127
+ # For Citrus backend: ALL pairs are siblings at document level (flat structure).
128
+ # We must filter to only include pairs that appear BEFORE the first table header.
129
+ #
86
130
  # @return [Array<NodeWrapper>]
87
131
  def root_pairs
88
132
  return [] unless valid?
89
133
 
90
134
  result = []
91
- @ast.root_node.each do |child|
92
- next unless child.type.to_s == "pair"
135
+ root = @ast.root_node
93
136
 
94
- result << NodeWrapper.new(child, lines: @lines, source: @source)
137
+ # Find the line number of the first table (if any)
138
+ first_table_line = nil
139
+ root.each do |child|
140
+ canonical_type = NodeTypeNormalizer.canonical_type(child.type, @backend)
141
+ if NodeTypeNormalizer.table_type?(canonical_type)
142
+ child_line = child.respond_to?(:start_point) ? child.start_point.row + 1 : nil
143
+ if child_line && (first_table_line.nil? || child_line < first_table_line)
144
+ first_table_line = child_line
145
+ end
146
+ end
147
+ end
148
+
149
+ root.each do |child|
150
+ canonical_type = NodeTypeNormalizer.canonical_type(child.type, @backend)
151
+ next unless canonical_type == :pair
152
+
153
+ # For Citrus backend, only include pairs before the first table
154
+ if first_table_line
155
+ child_line = child.respond_to?(:start_point) ? child.start_point.row + 1 : nil
156
+ next if child_line && child_line >= first_table_line
157
+ end
158
+
159
+ result << NodeWrapper.new(
160
+ child,
161
+ lines: @lines,
162
+ source: @source,
163
+ backend: @backend,
164
+ document_root: root,
165
+ )
95
166
  end
96
167
  result
97
168
  end
@@ -99,33 +170,37 @@ module Toml
99
170
  private
100
171
 
101
172
  def parse_toml
102
- # Check if TreeHaver is available
103
- unless defined?(TreeHaver)
104
- error_msg = "TreeHaver not available. Install tree_haver gem."
105
- @errors << error_msg
106
- @ast = nil
107
- raise StandardError, error_msg
108
- end
109
-
110
- begin
111
- # Use TreeHaver's unified interface
112
- # TreeHaver automatically handles backend selection and Citrus fallback
113
- # when tree-sitter-toml is not available
114
- parser = TreeHaver::Parser.new
115
- parser.language = TreeHaver::Language.toml
116
- @ast = parser.parse(@source)
117
-
118
- # Check for parse errors in the tree
119
- if @ast&.root_node&.has_error?
120
- collect_parse_errors(@ast.root_node)
121
- # Raise to allow SmartMergerBase to wrap with appropriate error type
122
- raise StandardError, "TOML parse error: #{@errors.first}"
123
- end
124
- rescue StandardError => e
125
- @errors << e unless @errors.include?(e)
126
- @ast = nil
127
- raise
173
+ # TreeHaver handles everything:
174
+ # - Backend selection (via TREE_HAVER_BACKEND env or TreeHaver.backend)
175
+ # - Grammar auto-discovery
176
+ # - Fallback to Citrus if tree-sitter unavailable
177
+ # - CITRUS_DEFAULTS already includes toml configuration
178
+ parser_options = {}
179
+ parser_options[:library_path] = @parser_path if @parser_path
180
+
181
+ parser = TreeHaver.parser_for(:toml, **parser_options)
182
+
183
+ # For NodeTypeNormalizer, we only care: is it Citrus or tree-sitter format?
184
+ # All native backends (mri, rust, ffi, java) produce tree-sitter AST format.
185
+ @backend = (parser.backend == :citrus) ? :citrus : :tree_sitter
186
+
187
+ @ast = parser.parse(@source)
188
+
189
+ # Check for parse errors in the tree
190
+ if @ast&.root_node&.has_error?
191
+ collect_parse_errors(@ast.root_node)
192
+ # Don't raise here - let SmartMergerBase detect via valid? check
193
+ # This is consistent with how other FileAnalysis classes handle parse errors
128
194
  end
195
+ rescue TreeHaver::Error => e
196
+ # TreeHaver::Error inherits from Exception, not StandardError.
197
+ # This also catches TreeHaver::NotAvailable (subclass of Error).
198
+ # Catch parse errors from Citrus backend and other TreeHaver errors.
199
+ @errors << e.message
200
+ @ast = nil
201
+ rescue StandardError => e
202
+ @errors << e unless @errors.include?(e)
203
+ @ast = nil
129
204
  end
130
205
 
131
206
  def collect_parse_errors(node)
@@ -151,11 +226,19 @@ module Toml
151
226
 
152
227
  # Return all root-level nodes (document children)
153
228
  # For TOML, this includes tables, array_of_tables, and top-level pairs
229
+ # Pass document_root to enable Citrus backend normalization (pairs as siblings)
154
230
  root.each do |child|
155
231
  # Skip comments (handled separately)
156
- next if child.type.to_s == "comment"
157
-
158
- wrapper = NodeWrapper.new(child, lines: @lines, source: @source)
232
+ canonical_type = NodeTypeNormalizer.canonical_type(child.type, @backend)
233
+ next if canonical_type == :comment
234
+
235
+ wrapper = NodeWrapper.new(
236
+ child,
237
+ lines: @lines,
238
+ source: @source,
239
+ backend: @backend,
240
+ document_root: root,
241
+ )
159
242
  next unless wrapper.start_line && wrapper.end_line
160
243
 
161
244
  result << wrapper
@@ -22,8 +22,9 @@ module Toml
22
22
  attr_reader :statistics
23
23
 
24
24
  # Initialize a new merge result
25
- def initialize
26
- super
25
+ # @param options [Hash] Additional options for forward compatibility
26
+ def initialize(**options)
27
+ super(**options)
27
28
  @statistics = {
28
29
  template_lines: 0,
29
30
  dest_lines: 0,
@@ -78,9 +79,14 @@ module Toml
78
79
  # @param source [Symbol] Source of the node
79
80
  # @param analysis [FileAnalysis] Analysis for accessing source lines
80
81
  def add_node(node, decision:, source:, analysis:)
81
- return unless node.start_line && node.end_line
82
+ return unless node.start_line
82
83
 
83
- (node.start_line..node.end_line).each do |line_num|
84
+ # Use effective_end_line for tables to include associated pairs on Citrus backend
85
+ # (Citrus has flat structure where pairs are siblings, not children of tables)
86
+ end_line = node.respond_to?(:effective_end_line) ? node.effective_end_line : node.end_line
87
+ return unless end_line
88
+
89
+ (node.start_line..end_line).each do |line_num|
84
90
  line = analysis.line_at(line_num)
85
91
  next unless line
86
92