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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +181 -1
- data/LICENSE.txt +1 -1
- data/README.md +294 -144
- data/lib/toml/merge/conflict_resolver.rb +60 -107
- data/lib/toml/merge/emitter.rb +120 -0
- data/lib/toml/merge/file_analysis.rb +122 -39
- data/lib/toml/merge/merge_result.rb +10 -4
- data/lib/toml/merge/node_type_normalizer.rb +256 -0
- data/lib/toml/merge/node_wrapper.rb +342 -177
- data/lib/toml/merge/smart_merger.rb +42 -4
- data/lib/toml/merge/table_match_refiner.rb +4 -2
- data/lib/toml/merge/version.rb +1 -1
- data/lib/toml/merge.rb +8 -33
- data.tar.gz.sig +0 -0
- metadata +22 -22
- metadata.gz.sig +0 -0
|
@@ -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 <
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
#
|
|
44
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
#
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
161
|
+
emit_node(dest_node, dest_analysis)
|
|
153
162
|
else
|
|
154
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
92
|
-
next unless child.type.to_s == "pair"
|
|
135
|
+
root = @ast.root_node
|
|
93
136
|
|
|
94
|
-
|
|
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
|
-
#
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
|
82
|
+
return unless node.start_line
|
|
82
83
|
|
|
83
|
-
|
|
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
|
|