dotenv-merge 1.0.2 → 7.1.1

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.
@@ -25,6 +25,11 @@ module Dotenv
25
25
  # node_typing: { "EnvLine" => ->(n) { NodeTyping.with_merge_type(n, :secret) } },
26
26
  # preference: { default: :destination, secret: :template })
27
27
  class SmartMerger < ::Ast::Merge::SmartMergerBase
28
+ include Ast::Merge::TrailingGroups::DestIterate
29
+ include Ast::Merge::CommentLayoutEmissionSupport
30
+
31
+ attr_reader :corruption_handling
32
+
28
33
  # Initialize a new SmartMerger
29
34
  #
30
35
  # @param template_content [String] Content of the template dotenv file
@@ -33,6 +38,10 @@ module Dotenv
33
38
  # @param preference [Symbol, Hash] :destination, :template, or per-type Hash
34
39
  # @param add_template_only_nodes [Boolean] Whether to add template-only env vars
35
40
  # (default: false)
41
+ # @param remove_template_missing_nodes [Boolean] Whether to remove destination-only
42
+ # env vars that do not exist in the template (default: false)
43
+ # @param corruption_handling [Symbol] How to handle detected historical
44
+ # duplicate-prefix corruption (:heal, :warn, :error, :skip)
36
45
  # @param freeze_token [String] Token for freeze block markers
37
46
  # (default: "dotenv-merge")
38
47
  # @param match_refiner [#call, nil] Match refiner for fuzzy matching
@@ -47,6 +56,8 @@ module Dotenv
47
56
  signature_generator: nil,
48
57
  preference: :destination,
49
58
  add_template_only_nodes: false,
59
+ remove_template_missing_nodes: false,
60
+ corruption_handling: :heal,
50
61
  freeze_token: nil,
51
62
  match_refiner: nil,
52
63
  regions: nil,
@@ -54,12 +65,16 @@ module Dotenv
54
65
  node_typing: nil,
55
66
  **options
56
67
  )
68
+ @remove_template_missing_nodes = remove_template_missing_nodes
69
+ @corruption_handling = ::Ast::Merge::Healer.normalize_mode(corruption_handling)
70
+
57
71
  super(
58
72
  template_content,
59
73
  dest_content,
60
74
  signature_generator: signature_generator,
61
75
  preference: preference,
62
76
  add_template_only_nodes: add_template_only_nodes,
77
+ remove_template_missing_nodes: remove_template_missing_nodes,
63
78
  freeze_token: freeze_token,
64
79
  match_refiner: match_refiner,
65
80
  regions: regions,
@@ -78,7 +93,7 @@ module Dotenv
78
93
 
79
94
  # @return [String] The default freeze token
80
95
  def default_freeze_token
81
- "dotenv-merge"
96
+ 'dotenv-merge'
82
97
  end
83
98
 
84
99
  # @return [Class, nil] No separate resolver class for dotenv
@@ -106,151 +121,112 @@ module Dotenv
106
121
  ParseError
107
122
  end
108
123
 
109
- # Perform the dotenv-specific merge with custom alignment logic
124
+ # Perform the dotenv-specific merge with structural-owner alignment.
110
125
  #
111
126
  # @return [MergeResult] The merge result
112
127
  def perform_merge
113
- alignment = align_statements
128
+ template_nodes = @template_analysis.structural_owners
129
+ dest_nodes = @dest_analysis.structural_owners
130
+
131
+ emit_root_boundary(:preamble)
132
+
133
+ template_index = build_match_index(template_nodes, @template_analysis)
134
+ dest_sigs = destination_signature_set(dest_nodes, @dest_analysis)
135
+ trailing_groups, matched_indices = build_dest_iterate_trailing_groups(
136
+ template_nodes: template_nodes,
137
+ dest_sigs: dest_sigs,
138
+ signature_for: ->(node) { freeze_node?(node) ? nil : @template_analysis.generate_signature(node) },
139
+ add_template_only_nodes: @add_template_only_nodes
140
+ )
114
141
 
115
- DebugLogger.debug("Alignment complete", {
116
- total_entries: alignment.size,
117
- matches: alignment.count { |e| e[:type] == :match },
118
- template_only: alignment.count { |e| e[:type] == :template_only },
119
- dest_only: alignment.count { |e| e[:type] == :dest_only },
120
- })
142
+ matched_template_indices = Set.new
143
+ consumed_indices = Set.new
121
144
 
122
- process_alignment(alignment)
123
- @result
124
- end
145
+ emit_prefix_trailing_group(trailing_groups, consumed_indices) do |info|
146
+ add_template_only_node(info[:node], template_nodes.index(info[:node]))
147
+ end
125
148
 
126
- private
149
+ dest_nodes.each do |dest_node|
150
+ if freeze_node?(dest_node)
151
+ @result.add_freeze_block(dest_node)
152
+ next
153
+ end
127
154
 
128
- # Align statements between template and destination
129
- # @return [Array<Hash>] Alignment entries
130
- def align_statements
131
- template_stmts = @template_analysis.statements
132
- dest_stmts = @dest_analysis.statements
133
-
134
- # Build signature maps
135
- _template_sigs = build_signature_map(template_stmts, @template_analysis)
136
- dest_sigs = build_signature_map(dest_stmts, @dest_analysis)
137
-
138
- alignment = []
139
- matched_dest_indices = Set.new
140
-
141
- # First pass: find matches for template statements
142
- template_stmts.each_with_index do |stmt, t_idx|
143
- sig = @template_analysis.generate_signature(stmt)
144
-
145
- if sig && dest_sigs.key?(sig)
146
- d_idx = dest_sigs[sig]
147
- alignment << {
148
- type: :match,
149
- template_stmt: stmt,
150
- dest_stmt: dest_stmts[d_idx],
151
- template_index: t_idx,
152
- dest_index: d_idx,
153
- signature: sig,
154
- }
155
- matched_dest_indices << d_idx
155
+ match_key = @dest_analysis.generate_signature(dest_node)
156
+ template_match = find_unmatched(template_index[match_key], matched_template_indices)
157
+
158
+ if template_match
159
+ matched_template_indices << template_match[:index]
160
+ consumed_indices << template_match[:index]
161
+ process_match(template_match[:node], dest_node)
162
+ flush_ready_trailing_groups(
163
+ trailing_groups: trailing_groups,
164
+ matched_indices: matched_indices,
165
+ consumed_indices: consumed_indices
166
+ ) do |info|
167
+ add_template_only_node(info[:node], info[:index])
168
+ end
156
169
  else
157
- alignment << {
158
- type: :template_only,
159
- template_stmt: stmt,
160
- template_index: t_idx,
161
- signature: sig,
162
- }
170
+ process_dest_only(dest_node, dest_nodes.index(dest_node))
163
171
  end
164
172
  end
165
173
 
166
- # Second pass: add destination-only statements
167
- dest_stmts.each_with_index do |stmt, d_idx|
168
- next if matched_dest_indices.include?(d_idx)
169
-
170
- alignment << {
171
- type: :dest_only,
172
- dest_stmt: stmt,
173
- dest_index: d_idx,
174
- signature: @dest_analysis.generate_signature(stmt),
175
- }
174
+ emit_remaining_trailing_groups(
175
+ trailing_groups: trailing_groups,
176
+ consumed_indices: consumed_indices
177
+ ) do |info|
178
+ add_template_only_node(info[:node], info[:index])
176
179
  end
177
180
 
178
- # Sort by destination order (preserve dest structure), then template order for additions
179
- sort_alignment(alignment, dest_stmts.size)
181
+ emit_root_boundary(:postlude)
182
+
183
+ merged_content = @result.to_s
184
+ healed_content = collapse_cross_source_preamble_prefixes(merged_content)
185
+ update_result_content(@result, healed_content) if healed_content != merged_content
186
+
187
+ @result
180
188
  end
181
189
 
182
- # Build a map of signature => statement index
183
- # @param statements [Array] Statements
184
- # @param analysis [FileAnalysis] Analysis for signature generation
185
- # @return [Hash]
186
- def build_signature_map(statements, analysis)
187
- map = {}
188
- statements.each_with_index do |stmt, idx|
189
- sig = analysis.generate_signature(stmt)
190
- # First occurrence wins
191
- map[sig] ||= idx if sig
192
- end
193
- map
194
- end
195
-
196
- # Sort alignment entries for output
197
- # @param alignment [Array<Hash>] Alignment entries
198
- # @param dest_size [Integer] Number of destination statements
199
- # @return [Array<Hash>]
200
- def sort_alignment(alignment, dest_size)
201
- alignment.sort_by do |entry|
202
- case entry[:type]
203
- when :match
204
- # Matches: use destination position
205
- [entry[:dest_index], 0]
206
- when :dest_only
207
- # Destination-only: use destination position
208
- [entry[:dest_index], 0]
209
- when :template_only
210
- # Template-only: add at end, in template order
211
- [dest_size + entry[:template_index], 1]
212
- end
190
+ private
191
+
192
+ def build_match_index(nodes, analysis)
193
+ index = Hash.new { |h, k| h[k] = [] }
194
+ nodes.each_with_index do |node, idx|
195
+ next if freeze_node?(node)
196
+
197
+ key = analysis.generate_signature(node)
198
+ index[key] << { node: node, index: idx }
213
199
  end
200
+ index
214
201
  end
215
202
 
216
- # Process alignment entries and build result
217
- # @param alignment [Array<Hash>] Alignment entries
218
- # @return [void]
219
- def process_alignment(alignment)
220
- alignment.each do |entry|
221
- case entry[:type]
222
- when :match
223
- process_match(entry)
224
- when :template_only
225
- process_template_only(entry)
226
- when :dest_only
227
- process_dest_only(entry)
228
- end
203
+ def destination_signature_set(nodes, analysis)
204
+ nodes.each_with_object(Set.new) do |node, signatures|
205
+ next if freeze_node?(node)
206
+
207
+ signatures << analysis.generate_signature(node)
229
208
  end
230
209
  end
231
210
 
232
- # Process a matched entry
233
- # @param entry [Hash] Alignment entry
234
- # @return [void]
235
- def process_match(entry)
236
- dest_stmt = entry[:dest_stmt]
211
+ def find_unmatched(entries, matched_indices)
212
+ return unless entries
237
213
 
238
- # Freeze blocks always win
239
- if dest_stmt.is_a?(FreezeNode)
240
- @result.add_freeze_block(dest_stmt)
241
- return
242
- end
214
+ entries.find { |entry| !matched_indices.include?(entry[:index]) }
215
+ end
216
+
217
+ def trailing_group_node_matched?(node, _signature)
218
+ freeze_node?(node)
219
+ end
243
220
 
244
- # Resolve preference (handles both Symbol and Hash preferences)
245
- resolved_pref = resolve_preference(entry[:template_stmt], entry[:dest_stmt])
221
+ def process_match(template_stmt, dest_stmt)
222
+ resolved_pref = resolve_preference(template_stmt, dest_stmt)
246
223
 
247
224
  case resolved_pref
248
225
  when :template
249
- @result.add_from_template(entry[:template_index], decision: MergeResult::DECISION_TEMPLATE)
250
- when :destination
251
- @result.add_from_destination(entry[:dest_index], decision: MergeResult::DECISION_DESTINATION)
226
+ emit_template_preferred_match(template_stmt, dest_stmt)
252
227
  else
253
- @result.add_from_destination(entry[:dest_index], decision: MergeResult::DECISION_DESTINATION)
228
+ add_retained_leading_gap(dest_stmt, @dest_analysis, decision: MergeResult::DECISION_DESTINATION)
229
+ @result.add_raw(node_lines_for(dest_stmt, @dest_analysis), decision: MergeResult::DECISION_DESTINATION)
254
230
  end
255
231
  end
256
232
 
@@ -288,37 +264,284 @@ module Dotenv
288
264
  return stmt unless stmt
289
265
 
290
266
  # Check by class name
291
- type_key = stmt.class.name&.split("::")&.last
267
+ type_key = stmt.class.name&.split('::')&.last
292
268
  callable = @node_typing[type_key] || @node_typing[type_key&.to_sym]
293
269
  return callable.call(stmt) if callable
294
270
 
295
271
  stmt
296
272
  end
297
273
 
298
- # Process a template-only entry
299
- # @param entry [Hash] Alignment entry
300
- # @return [void]
301
- def process_template_only(entry)
274
+ def add_template_only_node(stmt, _index)
302
275
  return unless @add_template_only_nodes
276
+ return if freeze_node?(stmt)
277
+
278
+ # Intentional product behavior: template-only dotenv additions do not
279
+ # import surrounding template comment context. The matrix marks those
280
+ # comment-bearing template-only cases as out of scope unless this policy
281
+ # is changed deliberately.
282
+ @result.add_raw(
283
+ node_lines_for(
284
+ stmt,
285
+ @template_analysis,
286
+ include_leading_segment: false,
287
+ include_interstitial_trailing_segment: false
288
+ ),
289
+ decision: MergeResult::DECISION_ADDED
290
+ )
291
+ end
303
292
 
304
- # Skip comments and blank lines from template
305
- stmt = entry[:template_stmt]
306
- return if stmt.is_a?(EnvLine) && (stmt.comment? || stmt.blank?)
293
+ def process_dest_only(dest_stmt, _dest_index)
294
+ if remove_destination_only_assignment?(dest_stmt)
295
+ lines = removed_destination_comment_lines_for(dest_stmt)
296
+ @result.add_raw(lines, decision: MergeResult::DECISION_DESTINATION) if lines.any?
297
+ else
298
+ @result.add_raw(node_lines_for(dest_stmt, @dest_analysis), decision: MergeResult::DECISION_DESTINATION)
299
+ end
300
+ end
307
301
 
308
- @result.add_from_template(entry[:template_index], decision: MergeResult::DECISION_ADDED)
302
+ def emit_template_preferred_match(template_stmt, dest_stmt)
303
+ comment_source_node, comment_source_analysis = preferred_comment_source_for(template_stmt, dest_stmt)
304
+ inline_comment = preferred_inline_comment_for(template_stmt, dest_stmt)
305
+ add_retained_leading_gap(dest_stmt, @dest_analysis, decision: MergeResult::DECISION_DESTINATION)
306
+ @result.add_raw(
307
+ node_lines_for(
308
+ template_stmt,
309
+ @template_analysis,
310
+ comment_source_node: comment_source_node,
311
+ comment_source_analysis: comment_source_analysis,
312
+ inline_comment: inline_comment
313
+ ),
314
+ decision: MergeResult::DECISION_TEMPLATE
315
+ )
309
316
  end
310
317
 
311
- # Process a destination-only entry
312
- # @param entry [Hash] Alignment entry
313
- # @return [void]
314
- def process_dest_only(entry)
315
- dest_stmt = entry[:dest_stmt]
318
+ def preferred_comment_source_for(template_stmt, dest_stmt)
319
+ return [dest_stmt, @dest_analysis] if node_has_leading_comments?(dest_stmt, @dest_analysis)
320
+ return [template_stmt, @template_analysis] if node_has_leading_comments?(template_stmt, @template_analysis)
316
321
 
317
- if dest_stmt.is_a?(FreezeNode)
318
- @result.add_freeze_block(dest_stmt)
319
- else
320
- @result.add_from_destination(entry[:dest_index], decision: MergeResult::DECISION_DESTINATION)
322
+ [template_stmt, @template_analysis]
323
+ end
324
+
325
+ def node_has_leading_comments?(node, analysis)
326
+ leading_segment_lines_for(node, analysis).any? { |line| !line.to_s.strip.empty? }
327
+ end
328
+
329
+ def preferred_inline_comment_for(template_stmt, dest_stmt)
330
+ return unless preserve_destination_inline_comment_for_template_match?(template_stmt, dest_stmt)
331
+
332
+ destination_inline_comment_for(dest_stmt)
333
+ end
334
+
335
+ def preserve_destination_inline_comment_for_template_match?(template_stmt, dest_stmt)
336
+ return false unless template_stmt.is_a?(EnvLine) && dest_stmt.is_a?(EnvLine)
337
+ return false unless template_stmt.assignment? && dest_stmt.assignment?
338
+ return false unless destination_inline_comment_for(dest_stmt)
339
+
340
+ template_inline_comment_for(template_stmt).nil?
341
+ end
342
+
343
+ def remove_destination_only_assignment?(stmt)
344
+ @remove_template_missing_nodes && stmt.is_a?(EnvLine) && stmt.assignment?
345
+ end
346
+
347
+ def destination_inline_comment_for(stmt)
348
+ @dest_analysis.comment_tracker.inline_comment_at(stmt.line_number)
349
+ end
350
+
351
+ def template_inline_comment_for(stmt)
352
+ @template_analysis.comment_tracker.inline_comment_at(stmt.line_number)
353
+ end
354
+
355
+ def freeze_node?(node)
356
+ node.is_a?(FreezeNode) || (node.respond_to?(:is_a?) && node.is_a?(Ast::Merge::Freezable))
357
+ end
358
+
359
+ def emit_root_boundary(kind)
360
+ lines = root_boundary_lines_for(kind, @dest_analysis)
361
+ return if lines.empty?
362
+
363
+ decision = MergeResult::DECISION_DESTINATION
364
+ @result.add_raw(lines, decision: decision)
365
+ end
366
+
367
+ def root_boundary_lines_for(kind, analysis)
368
+ super(kind, analysis, owners: analysis.structural_owners, fallback_to_owner_bounds: true)
369
+ end
370
+
371
+ def emission_start_line_for(node, analysis)
372
+ region_start = region_start_line(leading_region_for(node, analysis))
373
+ preamble_start = analysis.comment_augmenter.preamble_region&.start_line if first_structural_owner?(node,
374
+ analysis)
375
+ preceding_blank_line_start(region_start || preamble_start || node.start_line, analysis)
376
+ end
377
+
378
+ def root_boundary_owner_start_line_for(node, analysis)
379
+ emission_start_line_for(node, analysis)
380
+ end
381
+
382
+ def first_structural_owner?(node, analysis)
383
+ Array(analysis.structural_owners).first.equal?(node)
384
+ end
385
+
386
+ def node_lines_for(
387
+ node,
388
+ analysis,
389
+ comment_source_node: node,
390
+ comment_source_analysis: analysis,
391
+ inline_comment: nil,
392
+ include_leading_segment: true,
393
+ include_interstitial_trailing_segment: true
394
+ )
395
+ return freeze_block_lines_for(node) if freeze_node?(node)
396
+
397
+ leading_lines = if include_leading_segment
398
+ leading_segment_lines_for(comment_source_node,
399
+ comment_source_analysis)
400
+ else
401
+ []
402
+ end
403
+ node_lines = (node.start_line..node.end_line).filter_map { |line_number| raw_line_at(analysis, line_number) }
404
+ trailing_lines = if include_interstitial_trailing_segment
405
+ interstitial_trailing_segment_lines_for(node,
406
+ analysis)
407
+ else
408
+ []
409
+ end
410
+ leading_lines + apply_inline_comment(node_lines, inline_comment) + trailing_lines
411
+ end
412
+
413
+ def add_retained_leading_gap(node, analysis, decision:)
414
+ lines = retained_owner_leading_gap_lines_for(node, analysis, owners: analysis.structural_owners)
415
+ return if lines.empty?
416
+
417
+ @result.add_raw(lines, decision: decision)
418
+ end
419
+
420
+ def removed_destination_comment_lines_for(node)
421
+ inline_lines = []
422
+
423
+ if (inline_comment = destination_inline_comment_for(node))
424
+ inline_lines << promoted_inline_comment_line_for(node, @dest_analysis, inline_comment)
321
425
  end
426
+
427
+ removed_owner_preserved_lines_for(
428
+ node,
429
+ @dest_analysis,
430
+ owners: @dest_analysis.structural_owners,
431
+ inline_lines: inline_lines
432
+ )
433
+ end
434
+
435
+ def interstitial_trailing_segment_lines_for(node, analysis)
436
+ interstitial_trailing_region_lines_for(node, analysis, owners: analysis.structural_owners)
437
+ end
438
+
439
+ def next_structural_owner_for(node, analysis)
440
+ owners = Array(analysis.structural_owners)
441
+ index = owners.index(node)
442
+ return unless index
443
+
444
+ owners[index + 1]
445
+ end
446
+
447
+ def apply_inline_comment(lines, inline_comment)
448
+ return lines if inline_comment.nil? || lines.empty?
449
+
450
+ updated_lines = lines.dup
451
+ updated_lines[-1] = "#{updated_lines[-1].rstrip} #{inline_comment[:raw].sub(/\A\s+/, '')}"
452
+ updated_lines
453
+ end
454
+
455
+ def promoted_inline_comment_line_for(node, analysis, inline_comment)
456
+ raw_line = raw_line_at(analysis, node.start_line)
457
+ return unless raw_line
458
+
459
+ "#{raw_line[/\A\s*/]}#{inline_comment[:raw].sub(/\A\s+/, '')}"
460
+ end
461
+
462
+ def freeze_block_lines_for(node)
463
+ node.lines.map { |line| line.respond_to?(:raw) ? line.raw : line.to_s }
464
+ end
465
+
466
+ STANDALONE_DOTENV_COMMENT_LINE_RE = /\A\s*#.*\z/
467
+ private_constant :STANDALONE_DOTENV_COMMENT_LINE_RE
468
+
469
+ def collapse_cross_source_preamble_prefixes(content)
470
+ template_comments, = leading_standalone_comment_run(@template_content.to_s)
471
+ return content if template_comments.empty?
472
+
473
+ merged_comments, remainder = leading_standalone_comment_run(content)
474
+ return content if merged_comments.empty?
475
+
476
+ template_tally = template_comments.tally
477
+ merged_tally = merged_comments.tally
478
+ duplicated_template_prefix = template_tally.any? do |line, count|
479
+ merged_tally.fetch(line, 0) > count
480
+ end
481
+ return content unless duplicated_template_prefix
482
+
483
+ destination_specific_comments = merged_comments.reject { |line| template_comments.include?(line) }
484
+ return content if destination_specific_comments.empty?
485
+
486
+ should_heal = ::Ast::Merge::Healer.handle(
487
+ mode: @corruption_handling,
488
+ kind: :duplicate_template_preamble_prefix,
489
+ message: 'merged dotenv preamble begins with duplicated template-owned comment lines',
490
+ prefix: '[dotenv-merge]',
491
+ error_class: Dotenv::Merge::CorruptionDetectedError,
492
+ warner: lambda { |formatted|
493
+ DebugLogger.debug_warning(formatted, {
494
+ template_comment_lines: template_comments.length,
495
+ merged_comment_lines: merged_comments.length,
496
+ destination_specific_comment_lines: destination_specific_comments.length
497
+ })
498
+ }
499
+ )
500
+ return content unless should_heal
501
+
502
+ remainder = remainder.sub(/\A(?:\s*\n)+/, '')
503
+ rebuilt = destination_specific_comments.join("\n")
504
+ return rebuilt if remainder.empty?
505
+
506
+ "#{rebuilt}\n\n#{remainder}"
507
+ end
508
+
509
+ def leading_standalone_comment_run(text)
510
+ lines = text.to_s.split("\n", -1)
511
+ comment_lines = []
512
+ index = 0
513
+
514
+ while index < lines.length
515
+ line = lines[index]
516
+ if line.strip.empty?
517
+ comment_lines << line if comment_lines.any?
518
+ index += 1
519
+ next
520
+ end
521
+
522
+ break unless STANDALONE_DOTENV_COMMENT_LINE_RE.match?(line)
523
+
524
+ comment_lines << line
525
+ index += 1
526
+ end
527
+
528
+ [comment_lines, lines.drop(index).join("\n")]
529
+ end
530
+
531
+ def raw_line_at(analysis, line_number)
532
+ line = analysis.line_at(line_number)
533
+ line.respond_to?(:raw) ? line.raw : line
534
+ end
535
+
536
+ def leading_segment_anchor_line_for(node, analysis, owners: nil, leading_region: nil)
537
+ region_start = super(node, analysis, owners: owners || analysis.structural_owners, leading_region: leading_region)
538
+ preamble_start = analysis.comment_augmenter.preamble_region&.start_line if first_structural_owner?(node,
539
+ analysis)
540
+ region_start || preamble_start
541
+ end
542
+
543
+ def source_line_at(analysis, line_number)
544
+ raw_line_at(analysis, line_number)
322
545
  end
323
546
  end
324
547
  end
@@ -2,11 +2,12 @@
2
2
 
3
3
  module Dotenv
4
4
  module Merge
5
- # Version information for Dotenv::Merge
5
+ # Version namespace for this gem.
6
6
  module Version
7
- # Current version of the dotenv-merge gem
8
- VERSION = "1.0.2"
7
+ # Current gem version.
8
+ VERSION = '7.1.1'
9
9
  end
10
- VERSION = Version::VERSION # traditional location
10
+ # Current gem version exposed at the traditional constant location.
11
+ VERSION = Version::VERSION # Traditional Constant Location
11
12
  end
12
13
  end
data/lib/dotenv/merge.rb CHANGED
@@ -1,14 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # External gems
4
- require "version_gem"
5
- require "set"
6
4
 
7
5
  # Shared merge infrastructure
8
- require "ast/merge"
6
+ require 'ast/merge'
7
+ require 'plain/merge'
8
+ require 'tree_haver'
9
+ require_relative 'merge/version'
9
10
 
10
11
  # This gem
11
- require_relative "merge/version"
12
12
 
13
13
  module Dotenv
14
14
  module Merge
@@ -59,27 +59,31 @@ module Dotenv
59
59
  # end
60
60
  class DestinationParseError < ParseError; end
61
61
 
62
- autoload :DebugLogger, "dotenv/merge/debug_logger"
63
- autoload :EnvLine, "dotenv/merge/env_line"
64
- autoload :FreezeNode, "dotenv/merge/freeze_node"
65
- autoload :FileAnalysis, "dotenv/merge/file_analysis"
66
- autoload :MergeResult, "dotenv/merge/merge_result"
67
- autoload :SmartMerger, "dotenv/merge/smart_merger"
62
+ # Raised when merge-time corruption detection is configured to error.
63
+ class CorruptionDetectedError < Error; end
64
+
65
+ autoload :DebugLogger, 'dotenv/merge/debug_logger'
66
+ autoload :Backend, 'dotenv/merge/backend'
67
+ autoload :CommentTracker, 'dotenv/merge/comment_tracker'
68
+ autoload :EnvLine, 'dotenv/merge/env_line'
69
+ autoload :FreezeNode, 'dotenv/merge/freeze_node'
70
+ autoload :FileAnalysis, 'dotenv/merge/file_analysis'
71
+ autoload :MergeResult, 'dotenv/merge/merge_result'
72
+ autoload :SmartMerger, 'dotenv/merge/smart_merger'
68
73
  end
69
74
  end
70
75
 
76
+ require_relative 'merge/backend'
77
+ Dotenv::Merge.register_backend!
78
+
71
79
  # Register with ast-merge's MergeGemRegistry for RSpec dependency tags
72
80
  # Only register if MergeGemRegistry is loaded (i.e., in test environment)
73
81
  if defined?(Ast::Merge::RSpec::MergeGemRegistry)
74
82
  Ast::Merge::RSpec::MergeGemRegistry.register(
75
83
  :dotenv_merge,
76
- require_path: "dotenv/merge",
77
- merger_class: "Dotenv::Merge::SmartMerger",
78
- test_source: "KEY=value",
79
- category: :config,
84
+ require_path: 'dotenv/merge',
85
+ merger_class: 'Dotenv::Merge::SmartMerger',
86
+ test_source: 'KEY=value',
87
+ category: :config
80
88
  )
81
89
  end
82
-
83
- Dotenv::Merge::Version.class_eval do
84
- extend VersionGem::Basic
85
- end