jsonc-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 +992 -0
- data/REEK +0 -0
- data/RUBOCOP.md +71 -0
- data/SECURITY.md +21 -0
- data/lib/jsonc/merge/comment_tracker.rb +195 -0
- data/lib/jsonc/merge/conflict_resolver.rb +373 -0
- data/lib/jsonc/merge/debug_logger.rb +43 -0
- data/lib/jsonc/merge/emitter.rb +163 -0
- data/lib/jsonc/merge/file_analysis.rb +325 -0
- data/lib/jsonc/merge/freeze_node.rb +102 -0
- data/lib/jsonc/merge/merge_result.rb +154 -0
- data/lib/jsonc/merge/node_wrapper.rb +328 -0
- data/lib/jsonc/merge/smart_merger.rb +154 -0
- data/lib/jsonc/merge/version.rb +12 -0
- data/lib/jsonc/merge.rb +123 -0
- data/lib/jsonc-merge.rb +6 -0
- data/sig/json/merge.rbs +259 -0
- data.tar.gz.sig +1 -0
- metadata +333 -0
- metadata.gz.sig +3 -0
data/REEK
ADDED
|
File without changes
|
data/RUBOCOP.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# RuboCop Usage Guide
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
A tale of two RuboCop plugin gems.
|
|
6
|
+
|
|
7
|
+
### RuboCop Gradual
|
|
8
|
+
|
|
9
|
+
This project uses `rubocop_gradual` instead of vanilla RuboCop for code style checking. The `rubocop_gradual` tool allows for gradual adoption of RuboCop rules by tracking violations in a lock file.
|
|
10
|
+
|
|
11
|
+
### RuboCop LTS
|
|
12
|
+
|
|
13
|
+
This project uses `rubocop-lts` to ensure, on a best-effort basis, compatibility with Ruby >= 1.9.2.
|
|
14
|
+
RuboCop rules are meticulously configured by the `rubocop-lts` family of gems to ensure that a project is compatible with a specific version of Ruby. See: https://rubocop-lts.gitlab.io for more.
|
|
15
|
+
|
|
16
|
+
## Checking RuboCop Violations
|
|
17
|
+
|
|
18
|
+
To check for RuboCop violations in this project, always use:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
bundle exec rake rubocop_gradual:check
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Do not use** the standard RuboCop commands like:
|
|
25
|
+
- `bundle exec rubocop`
|
|
26
|
+
- `rubocop`
|
|
27
|
+
|
|
28
|
+
## Understanding the Lock File
|
|
29
|
+
|
|
30
|
+
The `.rubocop_gradual.lock` file tracks all current RuboCop violations in the project. This allows the team to:
|
|
31
|
+
|
|
32
|
+
1. Prevent new violations while gradually fixing existing ones
|
|
33
|
+
2. Track progress on code style improvements
|
|
34
|
+
3. Ensure CI builds don't fail due to pre-existing violations
|
|
35
|
+
|
|
36
|
+
## Common Commands
|
|
37
|
+
|
|
38
|
+
- **Check violations**
|
|
39
|
+
- `bundle exec rake rubocop_gradual`
|
|
40
|
+
- `bundle exec rake rubocop_gradual:check`
|
|
41
|
+
- **(Safe) Autocorrect violations, and update lockfile if no new violations**
|
|
42
|
+
- `bundle exec rake rubocop_gradual:autocorrect`
|
|
43
|
+
- **Force update the lock file (w/o autocorrect) to match violations present in code**
|
|
44
|
+
- `bundle exec rake rubocop_gradual:force_update`
|
|
45
|
+
|
|
46
|
+
## Workflow
|
|
47
|
+
|
|
48
|
+
1. Before submitting a PR, run `bundle exec rake rubocop_gradual:autocorrect`
|
|
49
|
+
a. or just the default `bundle exec rake`, as autocorrection is a pre-requisite of the default task.
|
|
50
|
+
2. If there are new violations, either:
|
|
51
|
+
- Fix them in your code
|
|
52
|
+
- Run `bundle exec rake rubocop_gradual:force_update` to update the lock file (only for violations you can't fix immediately)
|
|
53
|
+
3. Commit the updated `.rubocop_gradual.lock` file along with your changes
|
|
54
|
+
|
|
55
|
+
## Never add inline RuboCop disables
|
|
56
|
+
|
|
57
|
+
Do not add inline `rubocop:disable` / `rubocop:enable` comments anywhere in the codebase (including specs, except when following the few existing `rubocop:disable` patterns for a rule already being disabled elsewhere in the code). We handle exceptions in two supported ways:
|
|
58
|
+
|
|
59
|
+
- Permanent/structural exceptions: prefer adjusting the RuboCop configuration (e.g., in `.rubocop.yml`) to exclude a rule for a path or file pattern when it makes sense project-wide.
|
|
60
|
+
- Temporary exceptions while improving code: record the current violations in `.rubocop_gradual.lock` via the gradual workflow:
|
|
61
|
+
- `bundle exec rake rubocop_gradual:autocorrect` (preferred; will autocorrect what it can and update the lock only if no new violations were introduced)
|
|
62
|
+
- If needed, `bundle exec rake rubocop_gradual:force_update` (as a last resort when you cannot fix the newly reported violations immediately)
|
|
63
|
+
|
|
64
|
+
In general, treat the rules as guidance to follow; fix violations rather than ignore them. For example, RSpec conventions in this project expect `described_class` to be used in specs that target a specific class under test.
|
|
65
|
+
|
|
66
|
+
## Benefits of rubocop_gradual
|
|
67
|
+
|
|
68
|
+
- Allows incremental adoption of code style rules
|
|
69
|
+
- Prevents CI failures due to pre-existing violations
|
|
70
|
+
- Provides a clear record of code style debt
|
|
71
|
+
- Enables focused efforts on improving code quality over time
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported Versions
|
|
4
|
+
|
|
5
|
+
| Version | Supported |
|
|
6
|
+
|----------|-----------|
|
|
7
|
+
| 1.latest | ✅ |
|
|
8
|
+
|
|
9
|
+
## Security contact information
|
|
10
|
+
|
|
11
|
+
To report a security vulnerability, please use the
|
|
12
|
+
[Tidelift security contact](https://tidelift.com/security).
|
|
13
|
+
Tidelift will coordinate the fix and disclosure.
|
|
14
|
+
|
|
15
|
+
## Additional Support
|
|
16
|
+
|
|
17
|
+
If you are interested in support for versions older than the latest release,
|
|
18
|
+
please consider sponsoring the project / maintainer @ https://liberapay.com/pboling/donate,
|
|
19
|
+
or find other sponsorship links in the [README].
|
|
20
|
+
|
|
21
|
+
[README]: README.md
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jsonc
|
|
4
|
+
module Merge
|
|
5
|
+
# Extracts and tracks comments with their line numbers from JSONC source.
|
|
6
|
+
# JSONC supports both single-line (//) and multi-line (/* */) comments.
|
|
7
|
+
#
|
|
8
|
+
# @example Basic usage
|
|
9
|
+
# tracker = CommentTracker.new(jsonc_source)
|
|
10
|
+
# tracker.comments # => [{line: 1, indent: 0, text: "This is a comment"}]
|
|
11
|
+
# tracker.comment_at(1) # => {line: 1, indent: 0, text: "This is a comment"}
|
|
12
|
+
#
|
|
13
|
+
# @example Comment types
|
|
14
|
+
# // Single-line comment
|
|
15
|
+
# /* Block comment */
|
|
16
|
+
# "key": "value" // Inline comment
|
|
17
|
+
class CommentTracker
|
|
18
|
+
# Regex to match full-line single-line comments
|
|
19
|
+
SINGLE_LINE_COMMENT_REGEX = %r{\A(\s*)//\s?(.*)\z}
|
|
20
|
+
|
|
21
|
+
# Regex to match full-line block comments (single line)
|
|
22
|
+
BLOCK_COMMENT_SINGLE_REGEX = %r{\A(\s*)/\*\s?(.*?)\s?\*/\s*\z}
|
|
23
|
+
|
|
24
|
+
# Regex to match inline single-line comments
|
|
25
|
+
INLINE_COMMENT_REGEX = %r{\s+//\s?(.*)$}
|
|
26
|
+
|
|
27
|
+
# @return [Array<Hash>] All extracted comments with metadata
|
|
28
|
+
attr_reader :comments
|
|
29
|
+
|
|
30
|
+
# @return [Array<String>] Source lines
|
|
31
|
+
attr_reader :lines
|
|
32
|
+
|
|
33
|
+
# Initialize comment tracker by scanning the source
|
|
34
|
+
#
|
|
35
|
+
# @param source [String] JSONC source code
|
|
36
|
+
def initialize(source)
|
|
37
|
+
@source = source
|
|
38
|
+
@lines = source.lines.map(&:chomp)
|
|
39
|
+
@comments = extract_comments
|
|
40
|
+
@comments_by_line = @comments.group_by { |c| c[:line] }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Get comment at a specific line
|
|
44
|
+
#
|
|
45
|
+
# @param line_num [Integer] 1-based line number
|
|
46
|
+
# @return [Hash, nil] Comment info or nil
|
|
47
|
+
def comment_at(line_num)
|
|
48
|
+
@comments_by_line[line_num]&.first
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Get all comments in a line range
|
|
52
|
+
#
|
|
53
|
+
# @param range [Range] Range of 1-based line numbers
|
|
54
|
+
# @return [Array<Hash>] Comments in the range
|
|
55
|
+
def comments_in_range(range)
|
|
56
|
+
@comments.select { |c| range.cover?(c[:line]) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Get leading comments before a line (consecutive comment lines immediately above)
|
|
60
|
+
#
|
|
61
|
+
# @param line_num [Integer] 1-based line number
|
|
62
|
+
# @return [Array<Hash>] Leading comments
|
|
63
|
+
def leading_comments_before(line_num)
|
|
64
|
+
leading = []
|
|
65
|
+
current = line_num - 1
|
|
66
|
+
|
|
67
|
+
while current >= 1
|
|
68
|
+
comment = comment_at(current)
|
|
69
|
+
break unless comment && comment[:full_line]
|
|
70
|
+
|
|
71
|
+
leading.unshift(comment)
|
|
72
|
+
current -= 1
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
leading
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Get trailing comment on the same line (inline comment)
|
|
79
|
+
#
|
|
80
|
+
# @param line_num [Integer] 1-based line number
|
|
81
|
+
# @return [Hash, nil] Inline comment or nil
|
|
82
|
+
def inline_comment_at(line_num)
|
|
83
|
+
comment = comment_at(line_num)
|
|
84
|
+
comment if comment && !comment[:full_line]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Check if a line is a full-line comment
|
|
88
|
+
#
|
|
89
|
+
# @param line_num [Integer] 1-based line number
|
|
90
|
+
# @return [Boolean]
|
|
91
|
+
def full_line_comment?(line_num)
|
|
92
|
+
comment = comment_at(line_num)
|
|
93
|
+
comment&.dig(:full_line) || false
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Check if a line is blank
|
|
97
|
+
#
|
|
98
|
+
# @param line_num [Integer] 1-based line number
|
|
99
|
+
# @return [Boolean]
|
|
100
|
+
def blank_line?(line_num)
|
|
101
|
+
return false if line_num < 1 || line_num > @lines.length
|
|
102
|
+
|
|
103
|
+
@lines[line_num - 1].strip.empty?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def extract_comments
|
|
109
|
+
comments = []
|
|
110
|
+
in_block_comment = false
|
|
111
|
+
block_comment_indent = 0
|
|
112
|
+
|
|
113
|
+
@lines.each_with_index do |line, idx|
|
|
114
|
+
line_num = idx + 1
|
|
115
|
+
|
|
116
|
+
# Handle multi-line block comments
|
|
117
|
+
if in_block_comment
|
|
118
|
+
if line.include?("*/")
|
|
119
|
+
in_block_comment = false
|
|
120
|
+
# Multi-line block comment ends - we already captured the start
|
|
121
|
+
end
|
|
122
|
+
next
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Check for block comment start
|
|
126
|
+
if line.include?("/*") && !line.include?("*/")
|
|
127
|
+
in_block_comment = true
|
|
128
|
+
match = line.match(/\A(\s*)/)
|
|
129
|
+
block_comment_indent = match ? match[1].length : 0
|
|
130
|
+
comments << {
|
|
131
|
+
line: line_num,
|
|
132
|
+
indent: block_comment_indent,
|
|
133
|
+
text: line.sub(/\A\s*\/\*\s?/, "").strip,
|
|
134
|
+
full_line: true,
|
|
135
|
+
block: true,
|
|
136
|
+
raw: line,
|
|
137
|
+
}
|
|
138
|
+
next
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Check for single-line block comment
|
|
142
|
+
if (match = line.match(BLOCK_COMMENT_SINGLE_REGEX))
|
|
143
|
+
comments << {
|
|
144
|
+
line: line_num,
|
|
145
|
+
indent: match[1].length,
|
|
146
|
+
text: match[2],
|
|
147
|
+
full_line: true,
|
|
148
|
+
block: true,
|
|
149
|
+
raw: line,
|
|
150
|
+
}
|
|
151
|
+
next
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Check for full-line single-line comment
|
|
155
|
+
if (match = line.match(SINGLE_LINE_COMMENT_REGEX))
|
|
156
|
+
comments << {
|
|
157
|
+
line: line_num,
|
|
158
|
+
indent: match[1].length,
|
|
159
|
+
text: match[2],
|
|
160
|
+
full_line: true,
|
|
161
|
+
block: false,
|
|
162
|
+
raw: line,
|
|
163
|
+
}
|
|
164
|
+
next
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Check for inline comment (after JSON content)
|
|
168
|
+
# Be careful not to match // inside strings
|
|
169
|
+
if line.include?("//")
|
|
170
|
+
# Simple heuristic: if there's content before //, it might be inline
|
|
171
|
+
# This doesn't handle all edge cases with strings containing //
|
|
172
|
+
parts = line.split("//", 2)
|
|
173
|
+
if parts.length == 2 && !parts[0].strip.empty?
|
|
174
|
+
# Verify it's not inside a string by checking quote balance
|
|
175
|
+
before_comment = parts[0]
|
|
176
|
+
quote_count = before_comment.count('"') - before_comment.scan('\\"').count
|
|
177
|
+
if quote_count.even?
|
|
178
|
+
comments << {
|
|
179
|
+
line: line_num,
|
|
180
|
+
indent: 0,
|
|
181
|
+
text: parts[1].strip,
|
|
182
|
+
full_line: false,
|
|
183
|
+
block: false,
|
|
184
|
+
raw: "// #{parts[1].strip}",
|
|
185
|
+
}
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
comments
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jsonc
|
|
4
|
+
module Merge
|
|
5
|
+
# Resolves conflicts between template and destination JSON content
|
|
6
|
+
# using structural signatures and configurable preferences.
|
|
7
|
+
#
|
|
8
|
+
# Inherits from Ast::Merge::ConflictResolverBase using the :batch strategy,
|
|
9
|
+
# which resolves all conflicts at once using signature maps.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# resolver = ConflictResolver.new(template_analysis, dest_analysis)
|
|
13
|
+
# resolver.resolve(result)
|
|
14
|
+
#
|
|
15
|
+
# @see Ast::Merge::ConflictResolverBase
|
|
16
|
+
class ConflictResolver < Ast::Merge::ConflictResolverBase
|
|
17
|
+
# Creates a new ConflictResolver
|
|
18
|
+
#
|
|
19
|
+
# @param template_analysis [FileAnalysis] Analyzed template file
|
|
20
|
+
# @param dest_analysis [FileAnalysis] Analyzed destination file
|
|
21
|
+
# @param preference [Symbol, Hash] Which version to prefer when
|
|
22
|
+
# nodes have matching signatures:
|
|
23
|
+
# - :destination (default) - Keep destination version (customizations)
|
|
24
|
+
# - :template - Use template version (updates)
|
|
25
|
+
# @param add_template_only_nodes [Boolean] Whether to add nodes only in template
|
|
26
|
+
# @param match_refiner [#call, nil] Optional match refiner for fuzzy matching
|
|
27
|
+
# @param options [Hash] Additional options for forward compatibility
|
|
28
|
+
# @param node_typing [Hash{Symbol,String => #call}, nil] Node typing configuration
|
|
29
|
+
# for per-node-type preferences
|
|
30
|
+
def initialize(template_analysis, dest_analysis, preference: :destination, add_template_only_nodes: false, match_refiner: nil, node_typing: nil, **options)
|
|
31
|
+
super(
|
|
32
|
+
strategy: :batch,
|
|
33
|
+
preference: preference,
|
|
34
|
+
template_analysis: template_analysis,
|
|
35
|
+
dest_analysis: dest_analysis,
|
|
36
|
+
add_template_only_nodes: add_template_only_nodes,
|
|
37
|
+
match_refiner: match_refiner,
|
|
38
|
+
**options
|
|
39
|
+
)
|
|
40
|
+
@node_typing = node_typing
|
|
41
|
+
@emitter = Emitter.new
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
protected
|
|
45
|
+
|
|
46
|
+
# Resolve conflicts and populate the result using tree-based merging
|
|
47
|
+
#
|
|
48
|
+
# @param result [MergeResult] Result object to populate
|
|
49
|
+
def resolve_batch(result)
|
|
50
|
+
DebugLogger.time("ConflictResolver#resolve") do
|
|
51
|
+
template_statements = @template_analysis.statements
|
|
52
|
+
dest_statements = @dest_analysis.statements
|
|
53
|
+
|
|
54
|
+
# Clear emitter for fresh merge
|
|
55
|
+
@emitter.clear
|
|
56
|
+
|
|
57
|
+
# Merge root-level statements via emitter
|
|
58
|
+
merge_node_lists_to_emitter(
|
|
59
|
+
template_statements,
|
|
60
|
+
dest_statements,
|
|
61
|
+
@template_analysis,
|
|
62
|
+
@dest_analysis,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Transfer emitter output to result
|
|
66
|
+
# For now, add as single content block - we'll improve decision tracking later
|
|
67
|
+
emitted_content = @emitter.to_s
|
|
68
|
+
unless emitted_content.empty?
|
|
69
|
+
emitted_content.lines.each do |line|
|
|
70
|
+
result.add_line(line.chomp, decision: MergeResult::DECISION_MERGED, source: :merged)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
DebugLogger.debug("Conflict resolution complete", {
|
|
75
|
+
template_statements: template_statements.size,
|
|
76
|
+
dest_statements: dest_statements.size,
|
|
77
|
+
result_lines: result.line_count,
|
|
78
|
+
})
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# Recursively merge two lists of nodes, emitting to emitter
|
|
85
|
+
# @param template_nodes [Array<NodeWrapper>] Template nodes
|
|
86
|
+
# @param dest_nodes [Array<NodeWrapper>] Destination nodes
|
|
87
|
+
# @param template_analysis [FileAnalysis] Template analysis for line access
|
|
88
|
+
# @param dest_analysis [FileAnalysis] Destination analysis for line access
|
|
89
|
+
def merge_node_lists_to_emitter(template_nodes, dest_nodes, template_analysis, dest_analysis)
|
|
90
|
+
# Build signature maps for matching
|
|
91
|
+
template_by_sig = build_signature_map(template_nodes, template_analysis)
|
|
92
|
+
dest_by_sig = build_signature_map(dest_nodes, dest_analysis)
|
|
93
|
+
|
|
94
|
+
# Build refined matches for nodes that don't match by signature
|
|
95
|
+
refined_matches = build_refined_matches(template_nodes, dest_nodes, template_by_sig, dest_by_sig)
|
|
96
|
+
refined_dest_to_template = refined_matches.invert
|
|
97
|
+
|
|
98
|
+
# Track which nodes have been processed
|
|
99
|
+
processed_template_sigs = ::Set.new
|
|
100
|
+
processed_dest_sigs = ::Set.new
|
|
101
|
+
|
|
102
|
+
# First pass: Process destination nodes
|
|
103
|
+
dest_nodes.each do |dest_node|
|
|
104
|
+
dest_sig = dest_analysis.generate_signature(dest_node)
|
|
105
|
+
|
|
106
|
+
# Freeze blocks from destination are always preserved
|
|
107
|
+
if freeze_node?(dest_node)
|
|
108
|
+
emit_freeze_block(dest_node)
|
|
109
|
+
processed_dest_sigs << dest_sig if dest_sig
|
|
110
|
+
next
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Check for signature match
|
|
114
|
+
if dest_sig && template_by_sig[dest_sig]
|
|
115
|
+
template_info = template_by_sig[dest_sig].first
|
|
116
|
+
template_node = template_info[:node]
|
|
117
|
+
|
|
118
|
+
# Both have this node - merge them (recursively if containers)
|
|
119
|
+
merge_matched_nodes_to_emitter(template_node, dest_node, template_analysis, dest_analysis)
|
|
120
|
+
|
|
121
|
+
processed_dest_sigs << dest_sig
|
|
122
|
+
processed_template_sigs << dest_sig
|
|
123
|
+
elsif refined_dest_to_template.key?(dest_node)
|
|
124
|
+
# Found refined match
|
|
125
|
+
template_node = refined_dest_to_template[dest_node]
|
|
126
|
+
template_sig = template_analysis.generate_signature(template_node)
|
|
127
|
+
|
|
128
|
+
# Merge matched nodes
|
|
129
|
+
merge_matched_nodes_to_emitter(template_node, dest_node, template_analysis, dest_analysis)
|
|
130
|
+
|
|
131
|
+
processed_dest_sigs << dest_sig if dest_sig
|
|
132
|
+
processed_template_sigs << template_sig if template_sig
|
|
133
|
+
else
|
|
134
|
+
# Destination-only node - always keep
|
|
135
|
+
emit_node(dest_node, dest_analysis)
|
|
136
|
+
processed_dest_sigs << dest_sig if dest_sig
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Second pass: Add template-only nodes if configured
|
|
141
|
+
return unless @add_template_only_nodes
|
|
142
|
+
|
|
143
|
+
template_nodes.each do |template_node|
|
|
144
|
+
template_sig = template_analysis.generate_signature(template_node)
|
|
145
|
+
|
|
146
|
+
# Skip if already processed
|
|
147
|
+
next if template_sig && processed_template_sigs.include?(template_sig)
|
|
148
|
+
|
|
149
|
+
# Skip freeze blocks from template
|
|
150
|
+
next if freeze_node?(template_node)
|
|
151
|
+
|
|
152
|
+
# Add template-only node
|
|
153
|
+
emit_node(template_node, template_analysis)
|
|
154
|
+
processed_template_sigs << template_sig if template_sig
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Keep old merge_node_lists for now (will be removed later)
|
|
159
|
+
# This allows gradual migration
|
|
160
|
+
|
|
161
|
+
# Merge two matched nodes - for containers, recursively merge children
|
|
162
|
+
# Emits to emitter instead of result
|
|
163
|
+
# @param template_node [NodeWrapper] Template node
|
|
164
|
+
# @param dest_node [NodeWrapper] Destination node
|
|
165
|
+
# @param template_analysis [FileAnalysis] Template analysis
|
|
166
|
+
# @param dest_analysis [FileAnalysis] Destination analysis
|
|
167
|
+
def merge_matched_nodes_to_emitter(template_node, dest_node, template_analysis, dest_analysis)
|
|
168
|
+
if dest_node.container? && template_node.container?
|
|
169
|
+
# Both are containers - recursively merge their children
|
|
170
|
+
merge_container_to_emitter(template_node, dest_node, template_analysis, dest_analysis)
|
|
171
|
+
elsif dest_node.pair? && template_node.pair?
|
|
172
|
+
# Both are pairs - check if their values are OBJECTS (not arrays) that need recursive merge
|
|
173
|
+
template_value = template_node.value_node
|
|
174
|
+
dest_value = dest_node.value_node
|
|
175
|
+
|
|
176
|
+
# Only recursively merge if BOTH values are objects (not arrays)
|
|
177
|
+
# Arrays are replaced atomically based on preference
|
|
178
|
+
if template_value&.type == :object && dest_value&.type == :object &&
|
|
179
|
+
template_value.container? && dest_value.container?
|
|
180
|
+
# Both values are objects - recursively merge
|
|
181
|
+
@emitter.emit_nested_object_start(dest_node.key_name)
|
|
182
|
+
|
|
183
|
+
# Recursively merge the value objects
|
|
184
|
+
merge_node_lists_to_emitter(
|
|
185
|
+
template_value.mergeable_children,
|
|
186
|
+
dest_value.mergeable_children,
|
|
187
|
+
template_analysis,
|
|
188
|
+
dest_analysis,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Emit closing brace
|
|
192
|
+
@emitter.emit_nested_object_end
|
|
193
|
+
elsif preference_for_pair(template_node, dest_node) == :destination
|
|
194
|
+
# Values are not both objects, or one/both are arrays - use preference and emit
|
|
195
|
+
# Arrays are always replaced, not merged
|
|
196
|
+
emit_node(dest_node, dest_analysis)
|
|
197
|
+
else
|
|
198
|
+
emit_node(template_node, template_analysis)
|
|
199
|
+
end
|
|
200
|
+
elsif preference_for_pair(template_node, dest_node) == :destination
|
|
201
|
+
# Leaf nodes or mismatched types - use preference
|
|
202
|
+
emit_node(dest_node, dest_analysis)
|
|
203
|
+
else
|
|
204
|
+
emit_node(template_node, template_analysis)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Merge container nodes by emitting via emitter
|
|
209
|
+
# @param template_node [NodeWrapper] Template container node
|
|
210
|
+
# @param dest_node [NodeWrapper] Destination container node
|
|
211
|
+
# @param template_analysis [FileAnalysis] Template analysis
|
|
212
|
+
# @param dest_analysis [FileAnalysis] Destination analysis
|
|
213
|
+
def merge_container_to_emitter(template_node, dest_node, template_analysis, dest_analysis)
|
|
214
|
+
# Emit opening bracket
|
|
215
|
+
if dest_node.object?
|
|
216
|
+
@emitter.emit_object_start
|
|
217
|
+
elsif dest_node.array?
|
|
218
|
+
@emitter.emit_array_start
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Recursively merge the children
|
|
222
|
+
template_children = template_node.mergeable_children
|
|
223
|
+
dest_children = dest_node.mergeable_children
|
|
224
|
+
|
|
225
|
+
merge_node_lists_to_emitter(
|
|
226
|
+
template_children,
|
|
227
|
+
dest_children,
|
|
228
|
+
template_analysis,
|
|
229
|
+
dest_analysis,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Emit closing bracket
|
|
233
|
+
if dest_node.object?
|
|
234
|
+
@emitter.emit_object_end
|
|
235
|
+
elsif dest_node.array?
|
|
236
|
+
@emitter.emit_array_end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def preference_for_pair(template_node, dest_node)
|
|
241
|
+
return @preference unless @preference.is_a?(Hash)
|
|
242
|
+
|
|
243
|
+
typed_template = apply_node_typing(template_node)
|
|
244
|
+
typed_dest = apply_node_typing(dest_node)
|
|
245
|
+
|
|
246
|
+
if Ast::Merge::NodeTyping.typed_node?(typed_template)
|
|
247
|
+
merge_type = Ast::Merge::NodeTyping.merge_type_for(typed_template)
|
|
248
|
+
return @preference.fetch(merge_type) { default_preference } if merge_type
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
if Ast::Merge::NodeTyping.typed_node?(typed_dest)
|
|
252
|
+
merge_type = Ast::Merge::NodeTyping.merge_type_for(typed_dest)
|
|
253
|
+
return @preference.fetch(merge_type) { default_preference } if merge_type
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
default_preference
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def apply_node_typing(node)
|
|
260
|
+
return node unless @node_typing
|
|
261
|
+
return node unless node
|
|
262
|
+
|
|
263
|
+
Ast::Merge::NodeTyping.process(node, @node_typing)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Emit a single node to the emitter
|
|
267
|
+
# @param node [NodeWrapper] Node to emit
|
|
268
|
+
# @param analysis [FileAnalysis] Analysis for accessing source
|
|
269
|
+
def emit_node(node, analysis)
|
|
270
|
+
return if freeze_node?(node) # Freeze nodes handled separately
|
|
271
|
+
|
|
272
|
+
# Emit leading comments
|
|
273
|
+
if node.start_line
|
|
274
|
+
leading = analysis.comment_tracker.leading_comments_before(node.start_line)
|
|
275
|
+
leading.each do |comment|
|
|
276
|
+
@emitter.emit_tracked_comment(comment)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Emit the node content
|
|
281
|
+
if node.pair?
|
|
282
|
+
# Emit as pair
|
|
283
|
+
key = node.key_name
|
|
284
|
+
value_node = node.value_node
|
|
285
|
+
|
|
286
|
+
if value_node
|
|
287
|
+
# Check if value is an object (not array) and needs recursive emission
|
|
288
|
+
if value_node.type == :object && value_node.container?
|
|
289
|
+
# Object value - emit structure recursively
|
|
290
|
+
@emitter.emit_nested_object_start(key)
|
|
291
|
+
# Recursively emit object children
|
|
292
|
+
value_node.mergeable_children.each do |child|
|
|
293
|
+
emit_node(child, analysis)
|
|
294
|
+
end
|
|
295
|
+
@emitter.emit_nested_object_end
|
|
296
|
+
else
|
|
297
|
+
# Leaf value or array - get its text and emit as simple pair
|
|
298
|
+
# Arrays are emitted as raw text (not recursively) because Emitter doesn't have emit_array_start(key)
|
|
299
|
+
value_text = if value_node.start_line == value_node.end_line
|
|
300
|
+
value_node.text
|
|
301
|
+
else
|
|
302
|
+
# Multi-line value - get all lines
|
|
303
|
+
lines = []
|
|
304
|
+
(value_node.start_line..value_node.end_line).each do |ln|
|
|
305
|
+
lines << analysis.line_at(ln)
|
|
306
|
+
end
|
|
307
|
+
lines.join("\n")
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
@emitter.emit_pair(key, value_text) if key && value_text
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
elsif node.start_line && node.end_line
|
|
314
|
+
# Emit raw content for non-pair nodes
|
|
315
|
+
if node.start_line == node.end_line
|
|
316
|
+
# Single line - add directly
|
|
317
|
+
@emitter.lines << node.text
|
|
318
|
+
else
|
|
319
|
+
# Multi-line - collect and emit
|
|
320
|
+
lines = []
|
|
321
|
+
(node.start_line..node.end_line).each do |ln|
|
|
322
|
+
line = analysis.line_at(ln)
|
|
323
|
+
lines << line if line
|
|
324
|
+
end
|
|
325
|
+
@emitter.emit_raw_lines(lines)
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Emit a freeze block
|
|
331
|
+
# @param freeze_node [FreezeNode] Freeze block to emit
|
|
332
|
+
def emit_freeze_block(freeze_node)
|
|
333
|
+
@emitter.emit_raw_lines(freeze_node.lines)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Build a map of refined matches using match_refiner
|
|
337
|
+
# @param template_nodes [Array<NodeWrapper>] Template nodes
|
|
338
|
+
# @param dest_nodes [Array<NodeWrapper>] Destination nodes
|
|
339
|
+
# @param template_by_sig [Hash] Template signature map
|
|
340
|
+
# @param dest_by_sig [Hash] Destination signature map
|
|
341
|
+
# @return [Hash] Map of template_node => dest_node for refined matches
|
|
342
|
+
def build_refined_matches(template_nodes, dest_nodes, template_by_sig, dest_by_sig)
|
|
343
|
+
return {} unless @match_refiner
|
|
344
|
+
|
|
345
|
+
# Find unmatched nodes
|
|
346
|
+
matched_sigs = template_by_sig.keys & dest_by_sig.keys
|
|
347
|
+
|
|
348
|
+
unmatched_template = template_nodes.reject do |node|
|
|
349
|
+
sig = @template_analysis.generate_signature(node)
|
|
350
|
+
sig && matched_sigs.include?(sig)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
unmatched_dest = dest_nodes.reject do |node|
|
|
354
|
+
sig = @dest_analysis.generate_signature(node)
|
|
355
|
+
sig && matched_sigs.include?(sig)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
return {} if unmatched_template.empty? || unmatched_dest.empty?
|
|
359
|
+
|
|
360
|
+
# Call the match refiner
|
|
361
|
+
matches = @match_refiner.call(unmatched_template, unmatched_dest, {
|
|
362
|
+
template_analysis: @template_analysis,
|
|
363
|
+
dest_analysis: @dest_analysis,
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
# Build result map: template node -> dest node
|
|
367
|
+
matches.each_with_object({}) do |match, hash|
|
|
368
|
+
hash[match.template_node] = match.dest_node
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|