json-merge 1.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.
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Merge
5
+ # Analyzes JSON file structure, extracting statements for merging.
6
+ # This is the main analysis class that prepares JSON content for merging.
7
+ #
8
+ # @example Basic usage
9
+ # analysis = FileAnalysis.new(json_source)
10
+ # analysis.valid? # => true
11
+ # analysis.statements # => [NodeWrapper, ...]
12
+ class FileAnalysis
13
+ include Ast::Merge::FileAnalyzable
14
+
15
+ # @return [TreeHaver::Tree, nil] Parsed AST
16
+ attr_reader :ast
17
+
18
+ # @return [Array] Parse errors if any
19
+ attr_reader :errors
20
+
21
+ class << self
22
+ # Find the parser library path using TreeHaver::GrammarFinder
23
+ #
24
+ # @return [String, nil] Path to the parser library or nil if not found
25
+ def find_parser_path
26
+ TreeHaver::GrammarFinder.new(:json).find_library_path
27
+ end
28
+ end
29
+
30
+ # Initialize file analysis
31
+ #
32
+ # @param source [String] JSON source code to analyze
33
+ # @param signature_generator [Proc, nil] Custom signature generator
34
+ # @param parser_path [String, nil] Path to tree-sitter-json parser library
35
+ # @param options [Hash] Additional options (forward compatibility - freeze_token, node_typing, etc.)
36
+ def initialize(source, signature_generator: nil, parser_path: nil, **options)
37
+ @source = source
38
+ @lines = source.lines.map(&:chomp)
39
+ @signature_generator = signature_generator
40
+ @parser_path = parser_path || self.class.find_parser_path
41
+ @errors = []
42
+ # **options captures any additional parameters (e.g., freeze_token, node_typing) for forward compatibility
43
+
44
+ # Parse the JSON
45
+ DebugLogger.time("FileAnalysis#parse_json") { parse_json }
46
+
47
+ @statements = integrate_nodes
48
+
49
+ DebugLogger.debug("FileAnalysis initialized", {
50
+ signature_generator: signature_generator ? "custom" : "default",
51
+ statements_count: @statements.size,
52
+ valid: valid?,
53
+ })
54
+ end
55
+
56
+ # Check if parse was successful
57
+ # @return [Boolean]
58
+ def valid?
59
+ @errors.empty? && !@ast.nil?
60
+ end
61
+
62
+ # Override to detect tree-sitter nodes for signature generator fallthrough
63
+ # @param value [Object] The value to check
64
+ # @return [Boolean] true if this is a fallthrough node
65
+ def fallthrough_node?(value)
66
+ value.is_a?(NodeWrapper) || super
67
+ end
68
+
69
+ # Get the root node of the parse tree
70
+ # @return [NodeWrapper, nil]
71
+ def root_node
72
+ return unless valid?
73
+
74
+ NodeWrapper.new(@ast.root_node, lines: @lines, source: @source)
75
+ end
76
+
77
+ # Get the root object if the JSON document is an object
78
+ # @return [NodeWrapper, nil]
79
+ def root_object
80
+ return unless valid?
81
+
82
+ root = @ast.root_node
83
+ return unless root
84
+
85
+ # JSON root should be a document containing an object or array
86
+ root.each do |child|
87
+ if child.type.to_s == "object"
88
+ return NodeWrapper.new(child, lines: @lines, source: @source)
89
+ end
90
+ end
91
+ nil
92
+ end
93
+
94
+ # Get the opening brace line of the root object (the line containing `{`)
95
+ # @return [String, nil]
96
+ def root_object_open_line
97
+ obj = root_object
98
+ return unless obj&.start_line
99
+
100
+ line_at(obj.start_line)&.chomp
101
+ end
102
+
103
+ # Get the closing brace line of the root object (the line containing `}`)
104
+ # @return [String, nil]
105
+ def root_object_close_line
106
+ obj = root_object
107
+ return unless obj&.end_line
108
+
109
+ line_at(obj.end_line)&.chomp
110
+ end
111
+
112
+ # Get key-value pairs from the root object
113
+ # @return [Array<NodeWrapper>]
114
+ def root_pairs
115
+ obj = root_object
116
+ return [] unless obj
117
+
118
+ obj.pairs
119
+ end
120
+
121
+ private
122
+
123
+ def parse_json
124
+ # Use TreeHaver's high-level API - it handles:
125
+ # - Grammar auto-discovery
126
+ # - Backend selection
127
+ parser = TreeHaver.parser_for(:json, library_path: @parser_path)
128
+
129
+ @ast = parser.parse(@source)
130
+
131
+ # Check for parse errors in the tree
132
+ if @ast&.root_node&.has_error?
133
+ collect_parse_errors(@ast.root_node)
134
+ end
135
+ rescue TreeHaver::Error => e
136
+ # TreeHaver::Error inherits from Exception, not StandardError.
137
+ # This also catches TreeHaver::NotAvailable (subclass of Error).
138
+ @errors << e.message
139
+ @ast = nil
140
+ rescue StandardError => e
141
+ @errors << e
142
+ @ast = nil
143
+ end
144
+
145
+ def collect_parse_errors(node)
146
+ # Collect ERROR and MISSING nodes from the tree
147
+ if node.type.to_s == "ERROR" || node.missing?
148
+ @errors << {
149
+ type: node.type.to_s,
150
+ start_point: node.start_point,
151
+ end_point: node.end_point,
152
+ text: node.to_s,
153
+ }
154
+ end
155
+
156
+ node.each { |child| collect_parse_errors(child) }
157
+ end
158
+
159
+ def integrate_nodes
160
+ return [] unless valid?
161
+
162
+ result = []
163
+ root = @ast.root_node
164
+ return result unless root
165
+
166
+ # Return all root-level nodes (document children)
167
+ # For JSON, this is typically just the root object or array
168
+ # The tree structure is preserved - children are accessed via NodeWrapper#children
169
+ root.each do |child|
170
+ # Skip whitespace-only or empty nodes
171
+ next if child.type.to_s == "comment" # Comments handled separately in JSONC
172
+
173
+ wrapper = NodeWrapper.new(child, lines: @lines, source: @source)
174
+ next unless wrapper.start_line && wrapper.end_line
175
+
176
+ result << wrapper
177
+ end
178
+
179
+ # Sort by start line
180
+ result.sort_by { |node| node.start_line || 0 }
181
+ end
182
+
183
+ def compute_node_signature(node)
184
+ return unless node.is_a?(NodeWrapper)
185
+
186
+ node.signature
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Merge
5
+ # Tracks the result of a merge operation, including the merged content,
6
+ # decisions made, and statistics.
7
+ #
8
+ # Inherits decision constants and base functionality from Ast::Merge::MergeResultBase.
9
+ #
10
+ # @example Basic usage
11
+ # result = MergeResult.new
12
+ # result.add_line('"key": "value"', decision: :kept_template, source: :template)
13
+ # result.to_json # => '"key": "value"\n'
14
+ class MergeResult < Ast::Merge::MergeResultBase
15
+ # Inherit decision constants from base class
16
+ DECISION_KEPT_TEMPLATE = Ast::Merge::MergeResultBase::DECISION_KEPT_TEMPLATE
17
+ DECISION_KEPT_DEST = Ast::Merge::MergeResultBase::DECISION_KEPT_DEST
18
+ DECISION_MERGED = Ast::Merge::MergeResultBase::DECISION_MERGED
19
+ DECISION_ADDED = Ast::Merge::MergeResultBase::DECISION_ADDED
20
+
21
+ # @return [Hash] Statistics about the merge
22
+ attr_reader :statistics
23
+
24
+ # Initialize a new merge result
25
+ # @param options [Hash] Additional options for forward compatibility
26
+ def initialize(**options)
27
+ super(**options)
28
+ @statistics = {
29
+ template_lines: 0,
30
+ dest_lines: 0,
31
+ merged_lines: 0,
32
+ total_decisions: 0,
33
+ }
34
+ end
35
+
36
+ # Add a single line to the result
37
+ #
38
+ # @param line [String] Line content
39
+ # @param decision [Symbol] Decision that led to this line
40
+ # @param source [Symbol] Source of the line (:template, :destination, :merged)
41
+ # @param original_line [Integer, nil] Original line number
42
+ def add_line(line, decision:, source:, original_line: nil)
43
+ @lines << {
44
+ content: line,
45
+ decision: decision,
46
+ source: source,
47
+ original_line: original_line,
48
+ }
49
+
50
+ track_statistics(decision, source)
51
+ track_decision(decision, source, line: original_line)
52
+ end
53
+
54
+ # Add multiple lines to the result
55
+ #
56
+ # @param lines [Array<String>] Lines to add
57
+ # @param decision [Symbol] Decision for all lines
58
+ # @param source [Symbol] Source of the lines
59
+ # @param start_line [Integer, nil] Starting original line number
60
+ def add_lines(lines, decision:, source:, start_line: nil)
61
+ lines.each_with_index do |line, idx|
62
+ original_line = start_line ? start_line + idx : nil
63
+ add_line(line, decision: decision, source: source, original_line: original_line)
64
+ end
65
+ end
66
+
67
+ # Add a blank line
68
+ #
69
+ # @param decision [Symbol] Decision for the blank line
70
+ # @param source [Symbol] Source
71
+ def add_blank_line(decision: DECISION_MERGED, source: :merged)
72
+ add_line("", decision: decision, source: source)
73
+ end
74
+
75
+ # Add content from a node wrapper
76
+ #
77
+ # @param node [NodeWrapper] Node to add
78
+ # @param decision [Symbol] Decision that led to keeping this node
79
+ # @param source [Symbol] Source of the node
80
+ # @param analysis [FileAnalysis] Analysis for accessing source lines
81
+ def add_node(node, decision:, source:, analysis:)
82
+ return unless node.start_line && node.end_line
83
+
84
+ (node.start_line..node.end_line).each do |line_num|
85
+ line = analysis.line_at(line_num)
86
+ next unless line
87
+
88
+ add_line(line.chomp, decision: decision, source: source, original_line: line_num)
89
+ end
90
+ end
91
+
92
+ # Get the merged content as a JSON string
93
+ #
94
+ # @return [String]
95
+ def to_json
96
+ content = @lines.map { |l| l[:content] }.join("\n")
97
+ # Ensure trailing newline
98
+ content += "\n" unless content.end_with?("\n") || content.empty?
99
+ content
100
+ end
101
+
102
+ # Alias for to_json
103
+ # @return [String]
104
+ def content
105
+ to_json
106
+ end
107
+
108
+ # Alias for to_json (used by SmartMerger#merge)
109
+ # @return [String]
110
+ def to_s
111
+ to_json
112
+ end
113
+
114
+ # Get line count
115
+ # @return [Integer]
116
+ def line_count
117
+ @lines.size
118
+ end
119
+
120
+ private
121
+
122
+ def track_statistics(decision, source)
123
+ @statistics[:total_decisions] += 1
124
+
125
+ case decision
126
+ when DECISION_KEPT_TEMPLATE
127
+ @statistics[:template_lines] += 1
128
+ when DECISION_KEPT_DEST
129
+ @statistics[:dest_lines] += 1
130
+ else
131
+ @statistics[:merged_lines] += 1
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json
4
+ module Merge
5
+ # Wraps TreeHaver nodes with comment associations, line information, and signatures.
6
+ # This provides a unified interface for working with JSON AST nodes during merging.
7
+ #
8
+ # Inherits common functionality from Ast::Merge::NodeWrapperBase:
9
+ # - Source context (lines, source, comments)
10
+ # - Line info extraction
11
+ # - Basic methods: #type, #type?, #text, #content, #signature
12
+ #
13
+ # @example Basic usage
14
+ # parser = TreeHaver::Parser.new
15
+ # parser.language = TreeHaver::Language.json
16
+ # tree = parser.parse(source)
17
+ # wrapper = NodeWrapper.new(tree.root_node, lines: source.lines, source: source)
18
+ # wrapper.signature # => [:object, ...]
19
+ #
20
+ # @see Ast::Merge::NodeWrapperBase
21
+ class NodeWrapper < Ast::Merge::NodeWrapperBase
22
+ # Check if this is a JSON object
23
+ # @return [Boolean]
24
+ def object?
25
+ @node.type.to_s == "object"
26
+ end
27
+
28
+ # Check if this is a JSON array
29
+ # @return [Boolean]
30
+ def array?
31
+ @node.type.to_s == "array"
32
+ end
33
+
34
+ # Check if this is a JSON string
35
+ # @return [Boolean]
36
+ def string?
37
+ @node.type.to_s == "string"
38
+ end
39
+
40
+ # Check if this is a JSON number
41
+ # @return [Boolean]
42
+ def number?
43
+ @node.type.to_s == "number"
44
+ end
45
+
46
+ # Check if this is a JSON boolean (true/false)
47
+ # @return [Boolean]
48
+ def boolean?
49
+ %w[true false].include?(@node.type.to_s)
50
+ end
51
+
52
+ # Check if this is a JSON null
53
+ # @return [Boolean]
54
+ def null?
55
+ @node.type.to_s == "null"
56
+ end
57
+
58
+ # Check if this is a key-value pair
59
+ # @return [Boolean]
60
+ def pair?
61
+ @node.type.to_s == "pair"
62
+ end
63
+
64
+ # Check if this is a comment
65
+ # @return [Boolean]
66
+ def comment?
67
+ @node.type.to_s == "comment"
68
+ end
69
+
70
+ # Get the key name if this is a pair node
71
+ # @return [String, nil]
72
+ def key_name
73
+ return unless pair?
74
+
75
+ # In JSON tree-sitter, pair has key and value children
76
+ key_node = find_child_by_field("key")
77
+ return unless key_node
78
+
79
+ # Key is typically a string, extract its content without quotes using byte positions
80
+ key_text = node_text(key_node)
81
+ # Remove surrounding quotes if present
82
+ key_text&.gsub(/\A"|"\z/, "")
83
+ end
84
+
85
+ # Get the value node if this is a pair
86
+ # @return [NodeWrapper, nil]
87
+ def value_node
88
+ return unless pair?
89
+
90
+ value = find_child_by_field("value")
91
+ return unless value
92
+
93
+ NodeWrapper.new(value, lines: @lines, source: @source)
94
+ end
95
+
96
+ # Get key-value pairs if this is an object
97
+ # @return [Array<NodeWrapper>]
98
+ def pairs
99
+ return [] unless object?
100
+
101
+ result = []
102
+ @node.each do |child|
103
+ next if child.type.to_s == "comment"
104
+ next unless child.type.to_s == "pair"
105
+
106
+ result << NodeWrapper.new(child, lines: @lines, source: @source)
107
+ end
108
+ result
109
+ end
110
+
111
+ # Get array elements if this is an array
112
+ # @return [Array<NodeWrapper>]
113
+ def elements
114
+ return [] unless array?
115
+
116
+ result = []
117
+ @node.each do |child|
118
+ child_type = child.type.to_s
119
+ # Skip punctuation and comments
120
+ next if child_type == "comment"
121
+ next if child_type == ","
122
+ next if child_type == "["
123
+ next if child_type == "]"
124
+
125
+ result << NodeWrapper.new(child, lines: @lines, source: @source)
126
+ end
127
+ result
128
+ end
129
+
130
+ # Get mergeable children - the semantically meaningful children for tree merging
131
+ # For objects, returns pairs. For arrays, returns elements.
132
+ # For other node types, returns empty array (leaf nodes).
133
+ # @return [Array<NodeWrapper>]
134
+ def mergeable_children
135
+ case type
136
+ when :object
137
+ pairs
138
+ when :array
139
+ elements
140
+ else
141
+ []
142
+ end
143
+ end
144
+
145
+ # Check if this node is a container (has mergeable children)
146
+ # @return [Boolean]
147
+ def container?
148
+ object? || array?
149
+ end
150
+
151
+ # Get the opening line for a container node (the line with { or [)
152
+ # Returns the full line content including any leading whitespace
153
+ # @return [String, nil]
154
+ def opening_line
155
+ return unless container? && @start_line
156
+
157
+ @lines[@start_line - 1]
158
+ end
159
+
160
+ # Get the closing line for a container node (the line with } or ])
161
+ # Returns the full line content including any leading whitespace
162
+ # @return [String, nil]
163
+ def closing_line
164
+ return unless container? && @end_line
165
+
166
+ @lines[@end_line - 1]
167
+ end
168
+
169
+ # Get the opening bracket character for this container
170
+ # @return [String, nil]
171
+ def opening_bracket
172
+ return "{" if object?
173
+ return "[" if array?
174
+
175
+ nil
176
+ end
177
+
178
+ # Get the closing bracket character for this container
179
+ # @return [String, nil]
180
+ def closing_bracket
181
+ return "}" if object?
182
+ return "]" if array?
183
+
184
+ nil
185
+ end
186
+
187
+ # Find a child by field name
188
+ # @param field_name [String] Field name to look for
189
+ # @return [TreeSitter::Node, nil]
190
+ def find_child_by_field(field_name)
191
+ return unless @node.respond_to?(:child_by_field_name)
192
+
193
+ @node.child_by_field_name(field_name)
194
+ end
195
+
196
+ # Find a child by type
197
+ # @param type_name [String] Type name to look for
198
+ # @return [TreeSitter::Node, nil]
199
+ def find_child_by_type(type_name)
200
+ return unless @node.respond_to?(:each)
201
+
202
+ @node.each do |child|
203
+ return child if child.type.to_s == type_name
204
+ end
205
+ nil
206
+ end
207
+
208
+ protected
209
+
210
+ # Override wrap_child to use Json::Merge::NodeWrapper
211
+ def wrap_child(child)
212
+ NodeWrapper.new(child, lines: @lines, source: @source)
213
+ end
214
+
215
+ def compute_signature(node)
216
+ node_type = node.type.to_s
217
+
218
+ case node_type
219
+ when "document"
220
+ # Root document - signature based on root content type
221
+ child = nil
222
+ node.each { |c|
223
+ child = c unless c.type.to_s == "comment"
224
+ break if child
225
+ }
226
+ child_type = child&.type&.to_s
227
+ [:document, child_type]
228
+ when "object"
229
+ # Objects identified by their keys
230
+ keys = extract_object_keys(node)
231
+ [:object, keys.sort]
232
+ when "array"
233
+ # Arrays identified by their length and first few elements
234
+ elements_count = 0
235
+ node.each { |c| elements_count += 1 unless %w[comment , \[ \]].include?(c.type.to_s) }
236
+ [:array, elements_count]
237
+ when "pair"
238
+ # Pairs identified by their key name
239
+ key = key_name
240
+ [:pair, key]
241
+ when "string"
242
+ # Strings identified by their content
243
+ [:string, node_text(node)]
244
+ when "number"
245
+ # Numbers identified by their value
246
+ [:number, node_text(node)]
247
+ when "true", "false"
248
+ # Booleans
249
+ [:boolean, node.type.to_s]
250
+ when "null"
251
+ [:null]
252
+ when "comment"
253
+ # Comments identified by their content
254
+ [:comment, node_text(node)&.strip]
255
+ else
256
+ # Generic fallback
257
+ content_preview = node_text(node)&.slice(0, 50)&.strip
258
+ [node_type.to_sym, content_preview]
259
+ end
260
+ end
261
+
262
+ private
263
+
264
+ def extract_object_keys(object_node)
265
+ keys = []
266
+ object_node.each do |child|
267
+ next unless child.type.to_s == "pair"
268
+
269
+ key_node = child.respond_to?(:child_by_field_name) ? child.child_by_field_name("key") : nil
270
+ next unless key_node
271
+
272
+ key_text = node_text(key_node)&.gsub(/\A"|"\z/, "")
273
+ keys << key_text if key_text
274
+ end
275
+ keys
276
+ end
277
+ end
278
+ end
279
+ end