json-merge 1.1.2 → 7.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.
@@ -1,190 +0,0 @@
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
@@ -1,136 +0,0 @@
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
@@ -1,307 +0,0 @@
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
-
78
- return unless key_node
79
-
80
- # Key is typically a string, extract its content without quotes using byte positions
81
- key_text = node_text(key_node)
82
- # Remove surrounding quotes if present
83
- key_text&.gsub(/\A"|"\z/, "")
84
- end
85
-
86
- # Get the value node if this is a pair
87
- # @return [NodeWrapper, nil]
88
- def value_node
89
- return unless pair?
90
-
91
- value = find_child_by_field("value")
92
-
93
- return unless value
94
-
95
- NodeWrapper.new(value, lines: @lines, source: @source)
96
- end
97
-
98
- # Get key-value pairs if this is an object
99
- # @return [Array<NodeWrapper>]
100
- def pairs
101
- return [] unless object?
102
-
103
- result = []
104
- @node.each do |child|
105
- next if child.type.to_s == "comment"
106
- next unless child.type.to_s == "pair"
107
-
108
- result << NodeWrapper.new(child, lines: @lines, source: @source)
109
- end
110
- result
111
- end
112
-
113
- # Get array elements if this is an array
114
- # @return [Array<NodeWrapper>]
115
- def elements
116
- return [] unless array?
117
-
118
- result = []
119
- @node.each do |child|
120
- child_type = child.type.to_s
121
- # Skip punctuation and comments
122
- next if child_type == "comment"
123
- next if child_type == ","
124
- next if child_type == "["
125
- next if child_type == "]"
126
-
127
- result << NodeWrapper.new(child, lines: @lines, source: @source)
128
- end
129
- result
130
- end
131
-
132
- # Get mergeable children - the semantically meaningful children for tree merging
133
- # For objects, returns pairs. For arrays, returns elements.
134
- # For other node types, returns empty array (leaf nodes).
135
- # @return [Array<NodeWrapper>]
136
- def mergeable_children
137
- case type
138
- when :object
139
- pairs
140
- when :array
141
- elements
142
- else
143
- []
144
- end
145
- end
146
-
147
- # Check if this node is a container (has mergeable children)
148
- # @return [Boolean]
149
- def container?
150
- object? || array?
151
- end
152
-
153
- # Check if this is a root-level container (direct child of document)
154
- # Root-level containers get a generic signature so they always match.
155
- # @return [Boolean]
156
- def root_level_container?
157
- return false unless container?
158
-
159
- # Check if parent is a document node
160
- parent_node = @node.parent if @node.respond_to?(:parent)
161
- return false unless parent_node
162
-
163
- parent_node.type.to_s == "document"
164
- end
165
-
166
- # Get the opening line for a container node (the line with { or [)
167
- # Returns the full line content including any leading whitespace
168
- # @return [String, nil]
169
- def opening_line
170
- return unless container? && @start_line
171
-
172
- @lines[@start_line - 1]
173
- end
174
-
175
- # Get the closing line for a container node (the line with } or ])
176
- # Returns the full line content including any leading whitespace
177
- # @return [String, nil]
178
- def closing_line
179
- return unless container? && @end_line
180
-
181
- @lines[@end_line - 1]
182
- end
183
-
184
- # Get the opening bracket character for this container
185
- # @return [String, nil]
186
- def opening_bracket
187
- return "{" if object?
188
- return "[" if array?
189
-
190
- nil
191
- end
192
-
193
- # Get the closing bracket character for this container
194
- # @return [String, nil]
195
- def closing_bracket
196
- return "}" if object?
197
- return "]" if array?
198
-
199
- nil
200
- end
201
-
202
- # Find a child by field name
203
- # @param field_name [String] Field name to look for
204
- # @return [TreeSitter::Node, nil]
205
- def find_child_by_field(field_name)
206
- return unless @node.respond_to?(:child_by_field_name)
207
-
208
- @node.child_by_field_name(field_name)
209
- end
210
-
211
- # Find a child by type
212
- # @param type_name [String] Type name to look for
213
- # @return [TreeSitter::Node, nil]
214
- def find_child_by_type(type_name)
215
- return unless @node.respond_to?(:each)
216
-
217
- @node.each do |child|
218
- return child if child.type.to_s == type_name
219
- end
220
- nil
221
- end
222
-
223
- protected
224
-
225
- # Override wrap_child to use Json::Merge::NodeWrapper
226
- def wrap_child(child)
227
- NodeWrapper.new(child, lines: @lines, source: @source)
228
- end
229
-
230
- def compute_signature(node)
231
- node_type = node.type.to_s
232
-
233
- case node_type
234
- when "document"
235
- # Root document - signature based on root content type
236
- child = nil
237
- node.each { |c|
238
- child = c unless c.type.to_s == "comment"
239
- break if child
240
- }
241
- child_type = child&.type&.to_s
242
- [:document, child_type]
243
- when "object"
244
- # For root-level objects (direct child of document), use a generic signature
245
- # that always matches so merging happens at the pair level.
246
- # This is critical for JSON merging - there's typically only one root object/array.
247
- if root_level_container?
248
- [:root_object]
249
- else
250
- # Nested objects identified by their keys
251
- keys = extract_object_keys(node)
252
- [:object, keys.sort]
253
- end
254
- when "array"
255
- # For root-level arrays (direct child of document), use a generic signature
256
- if root_level_container?
257
- [:root_array]
258
- else
259
- # Nested arrays identified by their length and first few elements
260
- elements_count = 0
261
- node.each { |c| elements_count += 1 unless %w[comment , \[ \]].include?(c.type.to_s) }
262
- [:array, elements_count]
263
- end
264
- when "pair"
265
- # Pairs identified by their key name
266
- key = key_name
267
- [:pair, key]
268
- when "string"
269
- # Strings identified by their content
270
- [:string, node_text(node)]
271
- when "number"
272
- # Numbers identified by their value
273
- [:number, node_text(node)]
274
- when "true", "false"
275
- # Booleans
276
- [:boolean, node.type.to_s]
277
- when "null"
278
- [:null]
279
- when "comment"
280
- # Comments identified by their content
281
- [:comment, node_text(node)&.strip]
282
- else
283
- # Generic fallback
284
- content_preview = node_text(node)&.slice(0, 50)&.strip
285
- [node_type.to_sym, content_preview]
286
- end
287
- end
288
-
289
- private
290
-
291
- def extract_object_keys(object_node)
292
- keys = []
293
- object_node.each do |child|
294
- next unless child.type.to_s == "pair"
295
-
296
- key_node = child.respond_to?(:child_by_field_name) ? child.child_by_field_name("key") : nil
297
-
298
- next unless key_node
299
-
300
- key_text = node_text(key_node)&.gsub(/\A"|"\z/, "")
301
- keys << key_text if key_text
302
- end
303
- keys
304
- end
305
- end
306
- end
307
- end