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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +48 -0
- data/CITATION.cff +20 -0
- data/CODE_OF_CONDUCT.md +134 -0
- data/CONTRIBUTING.md +227 -0
- data/FUNDING.md +74 -0
- data/LICENSE.txt +21 -0
- data/README.md +966 -0
- data/REEK +0 -0
- data/RUBOCOP.md +71 -0
- data/SECURITY.md +21 -0
- data/lib/json/merge/conflict_resolver.rb +249 -0
- data/lib/json/merge/debug_logger.rb +41 -0
- data/lib/json/merge/emitter.rb +183 -0
- data/lib/json/merge/file_analysis.rb +190 -0
- data/lib/json/merge/merge_result.rb +136 -0
- data/lib/json/merge/node_wrapper.rb +279 -0
- data/lib/json/merge/object_match_refiner.rb +339 -0
- data/lib/json/merge/smart_merger.rb +149 -0
- data/lib/json/merge/version.rb +12 -0
- data/lib/json/merge.rb +113 -0
- data/lib/json-merge.rb +6 -0
- data/sig/json/merge.rbs +201 -0
- data.tar.gz.sig +0 -0
- metadata +332 -0
- metadata.gz.sig +0 -0
|
@@ -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
|