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
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,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Json
|
|
4
|
+
module Merge
|
|
5
|
+
# Resolves conflicts between template and destination JSON content
|
|
6
|
+
# using structural signatures and configurable preferences.
|
|
7
|
+
#
|
|
8
|
+
# @example Basic usage
|
|
9
|
+
# resolver = ConflictResolver.new(template_analysis, dest_analysis)
|
|
10
|
+
# resolver.resolve(result)
|
|
11
|
+
class ConflictResolver < ::Ast::Merge::ConflictResolverBase
|
|
12
|
+
# Creates a new ConflictResolver
|
|
13
|
+
#
|
|
14
|
+
# @param template_analysis [FileAnalysis] Analyzed template file
|
|
15
|
+
# @param dest_analysis [FileAnalysis] Analyzed destination file
|
|
16
|
+
# @param preference [Symbol] Which version to prefer when
|
|
17
|
+
# nodes have matching signatures:
|
|
18
|
+
# - :destination (default) - Keep destination version (customizations)
|
|
19
|
+
# - :template - Use template version (updates)
|
|
20
|
+
# @param add_template_only_nodes [Boolean] Whether to add nodes only in template
|
|
21
|
+
# @param match_refiner [#call, nil] Optional match refiner for fuzzy matching
|
|
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)
|
|
24
|
+
super(
|
|
25
|
+
strategy: :batch,
|
|
26
|
+
preference: preference,
|
|
27
|
+
template_analysis: template_analysis,
|
|
28
|
+
dest_analysis: dest_analysis,
|
|
29
|
+
add_template_only_nodes: add_template_only_nodes,
|
|
30
|
+
match_refiner: match_refiner,
|
|
31
|
+
**options
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
protected
|
|
36
|
+
|
|
37
|
+
# Resolve conflicts and populate the result using tree-based merging
|
|
38
|
+
#
|
|
39
|
+
# @param result [MergeResult] Result object to populate
|
|
40
|
+
def resolve_batch(result)
|
|
41
|
+
DebugLogger.time("ConflictResolver#resolve") do
|
|
42
|
+
template_statements = @template_analysis.statements
|
|
43
|
+
dest_statements = @dest_analysis.statements
|
|
44
|
+
|
|
45
|
+
# Merge root-level statements (typically just the root object)
|
|
46
|
+
merge_node_lists(
|
|
47
|
+
template_statements,
|
|
48
|
+
dest_statements,
|
|
49
|
+
@template_analysis,
|
|
50
|
+
@dest_analysis,
|
|
51
|
+
result,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
DebugLogger.debug("Conflict resolution complete", {
|
|
55
|
+
template_statements: template_statements.size,
|
|
56
|
+
dest_statements: dest_statements.size,
|
|
57
|
+
result_lines: result.line_count,
|
|
58
|
+
})
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# Recursively merge two lists of nodes (tree-based merge)
|
|
65
|
+
# @param template_nodes [Array<NodeWrapper>] Template nodes
|
|
66
|
+
# @param dest_nodes [Array<NodeWrapper>] Destination nodes
|
|
67
|
+
# @param template_analysis [FileAnalysis] Template analysis for line access
|
|
68
|
+
# @param dest_analysis [FileAnalysis] Destination analysis for line access
|
|
69
|
+
# @param result [MergeResult] Result to populate
|
|
70
|
+
def merge_node_lists(template_nodes, dest_nodes, template_analysis, dest_analysis, result)
|
|
71
|
+
# Build signature maps for matching
|
|
72
|
+
template_by_sig = build_signature_map(template_nodes, template_analysis)
|
|
73
|
+
dest_by_sig = build_signature_map(dest_nodes, dest_analysis)
|
|
74
|
+
|
|
75
|
+
# Build refined matches for nodes that don't match by signature
|
|
76
|
+
refined_matches = build_refined_matches(template_nodes, dest_nodes, template_by_sig, dest_by_sig)
|
|
77
|
+
refined_dest_to_template = refined_matches.invert
|
|
78
|
+
|
|
79
|
+
# Track which nodes have been processed
|
|
80
|
+
processed_template_sigs = ::Set.new
|
|
81
|
+
processed_dest_sigs = ::Set.new
|
|
82
|
+
|
|
83
|
+
# First pass: Process destination nodes
|
|
84
|
+
dest_nodes.each do |dest_node|
|
|
85
|
+
dest_sig = dest_analysis.generate_signature(dest_node)
|
|
86
|
+
|
|
87
|
+
# Freeze blocks from destination are always preserved
|
|
88
|
+
if freeze_node?(dest_node)
|
|
89
|
+
add_node_to_result(dest_node, result, :destination, MergeResult::DECISION_FREEZE_BLOCK, dest_analysis)
|
|
90
|
+
processed_dest_sigs << dest_sig if dest_sig
|
|
91
|
+
next
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Check for signature match
|
|
95
|
+
if dest_sig && template_by_sig[dest_sig]
|
|
96
|
+
template_info = template_by_sig[dest_sig].first
|
|
97
|
+
template_node = template_info[:node]
|
|
98
|
+
|
|
99
|
+
# Both have this node - merge them (recursively if containers)
|
|
100
|
+
merge_matched_nodes(template_node, dest_node, template_analysis, dest_analysis, result)
|
|
101
|
+
|
|
102
|
+
processed_dest_sigs << dest_sig
|
|
103
|
+
processed_template_sigs << dest_sig
|
|
104
|
+
elsif refined_dest_to_template.key?(dest_node)
|
|
105
|
+
# Found refined match
|
|
106
|
+
template_node = refined_dest_to_template[dest_node]
|
|
107
|
+
template_sig = template_analysis.generate_signature(template_node)
|
|
108
|
+
|
|
109
|
+
# Merge matched nodes
|
|
110
|
+
merge_matched_nodes(template_node, dest_node, template_analysis, dest_analysis, result)
|
|
111
|
+
|
|
112
|
+
processed_dest_sigs << dest_sig if dest_sig
|
|
113
|
+
processed_template_sigs << template_sig if template_sig
|
|
114
|
+
else
|
|
115
|
+
# Destination-only node - always keep
|
|
116
|
+
add_node_to_result(dest_node, result, :destination, MergeResult::DECISION_KEPT_DEST, dest_analysis)
|
|
117
|
+
processed_dest_sigs << dest_sig if dest_sig
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Second pass: Add template-only nodes if configured
|
|
122
|
+
return unless @add_template_only_nodes
|
|
123
|
+
|
|
124
|
+
template_nodes.each do |template_node|
|
|
125
|
+
template_sig = template_analysis.generate_signature(template_node)
|
|
126
|
+
|
|
127
|
+
# Skip if already processed
|
|
128
|
+
next if template_sig && processed_template_sigs.include?(template_sig)
|
|
129
|
+
|
|
130
|
+
# Skip freeze blocks from template
|
|
131
|
+
next if freeze_node?(template_node)
|
|
132
|
+
|
|
133
|
+
# Add template-only node
|
|
134
|
+
add_node_to_result(template_node, result, :template, MergeResult::DECISION_ADDED, template_analysis)
|
|
135
|
+
processed_template_sigs << template_sig if template_sig
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Merge two matched nodes - for containers, recursively merge children
|
|
140
|
+
# @param template_node [NodeWrapper] Template node
|
|
141
|
+
# @param dest_node [NodeWrapper] Destination node
|
|
142
|
+
# @param template_analysis [FileAnalysis] Template analysis
|
|
143
|
+
# @param dest_analysis [FileAnalysis] Destination analysis
|
|
144
|
+
# @param result [MergeResult] Result to populate
|
|
145
|
+
def merge_matched_nodes(template_node, dest_node, template_analysis, dest_analysis, result)
|
|
146
|
+
if dest_node.container? && template_node.container?
|
|
147
|
+
# Both are containers - recursively merge their children
|
|
148
|
+
merge_container_nodes(template_node, dest_node, template_analysis, dest_analysis, result)
|
|
149
|
+
elsif @preference == :destination
|
|
150
|
+
# Leaf nodes or mismatched types - use preference
|
|
151
|
+
add_node_to_result(dest_node, result, :destination, MergeResult::DECISION_KEPT_DEST, dest_analysis)
|
|
152
|
+
else
|
|
153
|
+
add_node_to_result(template_node, result, :template, MergeResult::DECISION_KEPT_TEMPLATE, template_analysis)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Build a map of refined matches from template node to destination node.
|
|
158
|
+
# Uses the match_refiner to find additional pairings for nodes that didn't match by signature.
|
|
159
|
+
# @param template_nodes [Array<NodeWrapper>] Template nodes
|
|
160
|
+
# @param dest_nodes [Array<NodeWrapper>] Destination nodes
|
|
161
|
+
# @param template_by_sig [Hash] Template signature map
|
|
162
|
+
# @param dest_by_sig [Hash] Destination signature map
|
|
163
|
+
# @return [Hash] Map of template_node => dest_node
|
|
164
|
+
def build_refined_matches(template_nodes, dest_nodes, template_by_sig, dest_by_sig)
|
|
165
|
+
return {} unless @match_refiner
|
|
166
|
+
|
|
167
|
+
# Find unmatched nodes
|
|
168
|
+
matched_sigs = template_by_sig.keys & dest_by_sig.keys
|
|
169
|
+
unmatched_t_nodes = template_nodes.reject do |n|
|
|
170
|
+
sig = @template_analysis.generate_signature(n)
|
|
171
|
+
sig && matched_sigs.include?(sig)
|
|
172
|
+
end
|
|
173
|
+
unmatched_d_nodes = dest_nodes.reject do |n|
|
|
174
|
+
sig = @dest_analysis.generate_signature(n)
|
|
175
|
+
sig && matched_sigs.include?(sig)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
return {} if unmatched_t_nodes.empty? || unmatched_d_nodes.empty?
|
|
179
|
+
|
|
180
|
+
# Call the refiner
|
|
181
|
+
matches = @match_refiner.call(unmatched_t_nodes, unmatched_d_nodes, {
|
|
182
|
+
template_analysis: @template_analysis,
|
|
183
|
+
dest_analysis: @dest_analysis,
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
# Build result map: template node -> dest node
|
|
187
|
+
matches.each_with_object({}) do |match, h|
|
|
188
|
+
h[match.template_node] = match.dest_node
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Merge two container nodes by emitting opening, recursively merging children, then closing
|
|
193
|
+
# @param template_node [NodeWrapper] Template container node
|
|
194
|
+
# @param dest_node [NodeWrapper] Destination container node
|
|
195
|
+
# @param template_analysis [FileAnalysis] Template analysis
|
|
196
|
+
# @param dest_analysis [FileAnalysis] Destination analysis
|
|
197
|
+
# @param result [MergeResult] Result to populate
|
|
198
|
+
def merge_container_nodes(template_node, dest_node, template_analysis, dest_analysis, result)
|
|
199
|
+
# Use destination's opening line (or template if dest doesn't have one)
|
|
200
|
+
opening = dest_node.opening_line || template_node.opening_line
|
|
201
|
+
result.add_line(opening, decision: MergeResult::DECISION_MERGED, source: :merged) if opening
|
|
202
|
+
|
|
203
|
+
# Recursively merge the children
|
|
204
|
+
template_children = template_node.mergeable_children
|
|
205
|
+
dest_children = dest_node.mergeable_children
|
|
206
|
+
|
|
207
|
+
merge_node_lists(
|
|
208
|
+
template_children,
|
|
209
|
+
dest_children,
|
|
210
|
+
template_analysis,
|
|
211
|
+
dest_analysis,
|
|
212
|
+
result,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Use destination's closing line (or template if dest doesn't have one)
|
|
216
|
+
closing = dest_node.closing_line || template_node.closing_line
|
|
217
|
+
result.add_line(closing, decision: MergeResult::DECISION_MERGED, source: :merged) if closing
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Add a node to the result (non-container or leaf node)
|
|
221
|
+
# @param node [NodeWrapper] Node to add
|
|
222
|
+
# @param result [MergeResult] Result to populate
|
|
223
|
+
# @param source [Symbol] :template or :destination
|
|
224
|
+
# @param decision [String] Decision constant
|
|
225
|
+
# @param analysis [FileAnalysis] Analysis for line access
|
|
226
|
+
def add_node_to_result(node, result, source, decision, analysis)
|
|
227
|
+
if freeze_node?(node)
|
|
228
|
+
result.add_freeze_block(node)
|
|
229
|
+
elsif node.is_a?(NodeWrapper)
|
|
230
|
+
add_wrapper_to_result(node, result, source, decision, analysis)
|
|
231
|
+
else
|
|
232
|
+
DebugLogger.debug("Unknown node type", {node_type: node.class.name})
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def add_wrapper_to_result(wrapper, result, source, decision, analysis)
|
|
237
|
+
return unless wrapper.start_line && wrapper.end_line
|
|
238
|
+
|
|
239
|
+
# Add the node content line by line
|
|
240
|
+
(wrapper.start_line..wrapper.end_line).each do |line_num|
|
|
241
|
+
line = analysis.line_at(line_num)
|
|
242
|
+
next unless line
|
|
243
|
+
|
|
244
|
+
result.add_line(line.chomp, decision: decision, source: source, original_line: line_num)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Json
|
|
4
|
+
module Merge
|
|
5
|
+
# Debug logging utility for Json::Merge.
|
|
6
|
+
# Extends the base Ast::Merge::DebugLogger with Json-specific configuration.
|
|
7
|
+
#
|
|
8
|
+
# @example Enable debug logging
|
|
9
|
+
# ENV['JSON_MERGE_DEBUG'] = '1'
|
|
10
|
+
# DebugLogger.debug("Processing node", {type: "pair", line: 5})
|
|
11
|
+
#
|
|
12
|
+
# @example Disable debug logging (default)
|
|
13
|
+
# DebugLogger.debug("This won't be printed", {})
|
|
14
|
+
module DebugLogger
|
|
15
|
+
extend Ast::Merge::DebugLogger
|
|
16
|
+
|
|
17
|
+
# Json-specific configuration
|
|
18
|
+
self.env_var_name = "JSON_MERGE_DEBUG"
|
|
19
|
+
self.log_prefix = "[Json::Merge]"
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# Override log_node to handle Json-specific node types.
|
|
23
|
+
#
|
|
24
|
+
# @param node [Object] Node to log information about
|
|
25
|
+
# @param label [String] Label for the node
|
|
26
|
+
def log_node(node, label: "Node")
|
|
27
|
+
return unless enabled?
|
|
28
|
+
|
|
29
|
+
info = case node
|
|
30
|
+
when Json::Merge::NodeWrapper
|
|
31
|
+
{type: node.type.to_s, lines: "#{node.start_line}..#{node.end_line}"}
|
|
32
|
+
else
|
|
33
|
+
extract_node_info(node)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
debug(label, info)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Json
|
|
4
|
+
module Merge
|
|
5
|
+
# Custom JSON emitter that preserves comments and formatting.
|
|
6
|
+
# This class provides utilities for emitting JSON while maintaining
|
|
7
|
+
# the original structure, comments, and style choices.
|
|
8
|
+
#
|
|
9
|
+
# @example Basic usage
|
|
10
|
+
# emitter = Emitter.new
|
|
11
|
+
# emitter.emit_object_start
|
|
12
|
+
# emitter.emit_pair("key", '"value"')
|
|
13
|
+
# emitter.emit_object_end
|
|
14
|
+
class Emitter
|
|
15
|
+
# @return [Array<String>] Output lines
|
|
16
|
+
attr_reader :lines
|
|
17
|
+
|
|
18
|
+
# @return [Integer] Current indentation level
|
|
19
|
+
attr_reader :indent_level
|
|
20
|
+
|
|
21
|
+
# @return [Integer] Spaces per indent level
|
|
22
|
+
attr_reader :indent_size
|
|
23
|
+
|
|
24
|
+
# Initialize a new emitter
|
|
25
|
+
#
|
|
26
|
+
# @param indent_size [Integer] Number of spaces per indent level
|
|
27
|
+
def initialize(indent_size: 2)
|
|
28
|
+
@lines = []
|
|
29
|
+
@indent_level = 0
|
|
30
|
+
@indent_size = indent_size
|
|
31
|
+
@needs_comma = false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Emit a single-line comment
|
|
35
|
+
#
|
|
36
|
+
# @param text [String] Comment text (without //)
|
|
37
|
+
# @param inline [Boolean] Whether this is an inline comment
|
|
38
|
+
def emit_comment(text, inline: false)
|
|
39
|
+
if inline
|
|
40
|
+
# Inline comments are appended to the last line
|
|
41
|
+
return if @lines.empty?
|
|
42
|
+
|
|
43
|
+
@lines[-1] = "#{@lines[-1]} // #{text}"
|
|
44
|
+
else
|
|
45
|
+
@lines << "#{current_indent}// #{text}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Emit a block comment
|
|
50
|
+
#
|
|
51
|
+
# @param text [String] Comment text
|
|
52
|
+
def emit_block_comment(text)
|
|
53
|
+
@lines << "#{current_indent}/* #{text} */"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Emit leading comments
|
|
57
|
+
#
|
|
58
|
+
# @param comments [Array<Hash>] Comment hashes from CommentTracker
|
|
59
|
+
def emit_leading_comments(comments)
|
|
60
|
+
comments.each do |comment|
|
|
61
|
+
indent = " " * (comment[:indent] || 0)
|
|
62
|
+
@lines << if comment[:block]
|
|
63
|
+
"#{indent}/* #{comment[:text]} */"
|
|
64
|
+
else
|
|
65
|
+
"#{indent}// #{comment[:text]}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Emit a blank line
|
|
71
|
+
def emit_blank_line
|
|
72
|
+
@lines << ""
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Emit object start
|
|
76
|
+
def emit_object_start
|
|
77
|
+
add_comma_if_needed
|
|
78
|
+
@lines << "#{current_indent}{"
|
|
79
|
+
@indent_level += 1
|
|
80
|
+
@needs_comma = false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Emit object end
|
|
84
|
+
def emit_object_end
|
|
85
|
+
@indent_level -= 1 if @indent_level > 0
|
|
86
|
+
@lines << "#{current_indent}}"
|
|
87
|
+
@needs_comma = true
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Emit array start
|
|
91
|
+
#
|
|
92
|
+
# @param key [String, nil] Key name if this array is a value in an object
|
|
93
|
+
def emit_array_start(key = nil)
|
|
94
|
+
add_comma_if_needed
|
|
95
|
+
@lines << if key
|
|
96
|
+
"#{current_indent}\"#{key}\": ["
|
|
97
|
+
else
|
|
98
|
+
"#{current_indent}["
|
|
99
|
+
end
|
|
100
|
+
@indent_level += 1
|
|
101
|
+
@needs_comma = false
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Emit array end
|
|
105
|
+
def emit_array_end
|
|
106
|
+
@indent_level -= 1 if @indent_level > 0
|
|
107
|
+
@lines << "#{current_indent}]"
|
|
108
|
+
@needs_comma = true
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Emit a key-value pair
|
|
112
|
+
#
|
|
113
|
+
# @param key [String] Key name (without quotes)
|
|
114
|
+
# @param value [String] Value (already formatted, e.g., '"string"', '123', 'true')
|
|
115
|
+
# @param inline_comment [String, nil] Optional inline comment
|
|
116
|
+
def emit_pair(key, value, inline_comment: nil)
|
|
117
|
+
add_comma_if_needed
|
|
118
|
+
line = "#{current_indent}\"#{key}\": #{value}"
|
|
119
|
+
line += " // #{inline_comment}" if inline_comment
|
|
120
|
+
@lines << line
|
|
121
|
+
@needs_comma = true
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Emit an array element
|
|
125
|
+
#
|
|
126
|
+
# @param value [String] Value (already formatted)
|
|
127
|
+
# @param inline_comment [String, nil] Optional inline comment
|
|
128
|
+
def emit_array_element(value, inline_comment: nil)
|
|
129
|
+
add_comma_if_needed
|
|
130
|
+
line = "#{current_indent}#{value}"
|
|
131
|
+
line += " // #{inline_comment}" if inline_comment
|
|
132
|
+
@lines << line
|
|
133
|
+
@needs_comma = true
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Emit raw lines (for preserving existing content)
|
|
137
|
+
#
|
|
138
|
+
# @param raw_lines [Array<String>] Lines to emit as-is
|
|
139
|
+
def emit_raw_lines(raw_lines)
|
|
140
|
+
raw_lines.each { |line| @lines << line.chomp }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Get the output as a single string
|
|
144
|
+
#
|
|
145
|
+
# @return [String]
|
|
146
|
+
def to_json
|
|
147
|
+
content = @lines.join("\n")
|
|
148
|
+
content += "\n" unless content.empty? || content.end_with?("\n")
|
|
149
|
+
content
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Alias for consistency
|
|
153
|
+
# @return [String]
|
|
154
|
+
alias_method :to_s, :to_json
|
|
155
|
+
|
|
156
|
+
# Clear the output
|
|
157
|
+
def clear
|
|
158
|
+
@lines = []
|
|
159
|
+
@indent_level = 0
|
|
160
|
+
@needs_comma = false
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
def current_indent
|
|
166
|
+
" " * (@indent_level * @indent_size)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def add_comma_if_needed
|
|
170
|
+
return unless @needs_comma && @lines.any?
|
|
171
|
+
|
|
172
|
+
# Add comma to the previous line if it doesn't already have one
|
|
173
|
+
last_line = @lines.last
|
|
174
|
+
return if last_line.strip.empty?
|
|
175
|
+
return if last_line.rstrip.end_with?(",")
|
|
176
|
+
return if last_line.rstrip.end_with?("{")
|
|
177
|
+
return if last_line.rstrip.end_with?("[")
|
|
178
|
+
|
|
179
|
+
@lines[-1] = "#{last_line},"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|