dotenv-merge 1.0.3 → 7.1.3

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.
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dotenv
4
+ # StructuredMerge dotenv assignment and comment merge behavior.
5
+ module Merge
6
+ BACKEND_REFERENCE = TreeHaver::BackendReference.new(id: 'dotenv-line', family: 'line')
7
+
8
+ module Backend
9
+ # TreeHaver language wrapper for dotenv files parsed through the line
10
+ # substrate supplied by plain-merge.
11
+ class Language < TreeHaver::Base::Language
12
+ def initialize(name = :dotenv)
13
+ super(name, backend: :line, options: {})
14
+ end
15
+
16
+ def self.dotenv
17
+ new(:dotenv)
18
+ end
19
+
20
+ def self.env
21
+ new(:env)
22
+ end
23
+ end
24
+
25
+ # Parser adapter that exposes Dotenv::Merge::FileAnalysis via
26
+ # TreeHaver.parser_for(:dotenv, backend_type: :line).
27
+ class Parser < TreeHaver::Base::Parser
28
+ def parse(source)
29
+ raise 'Language not set' unless language
30
+
31
+ attach_line_analysis(Dotenv::Merge::FileAnalysis.new(source), source)
32
+ end
33
+
34
+ def parse_string(_old_tree, source)
35
+ parse(source)
36
+ end
37
+
38
+ private
39
+
40
+ def attach_line_analysis(analysis, source)
41
+ analysis.instance_variable_set(:@line_analysis, plain_line_analysis(source))
42
+ analysis.define_singleton_method(:line_analysis) { @line_analysis }
43
+ analysis
44
+ end
45
+
46
+ def plain_line_analysis(source)
47
+ Plain::Merge.analyze_text(source)
48
+ end
49
+ end
50
+ end
51
+
52
+ def self.register_backend!
53
+ TreeHaver::BackendRegistry.register(BACKEND_REFERENCE)
54
+ %i[dotenv env].each do |language_name|
55
+ register_language!(language_name)
56
+ end
57
+ nil
58
+ end
59
+
60
+ def self.register_language!(language_name)
61
+ TreeHaver.register_language(
62
+ language_name,
63
+ backend_module: Backend,
64
+ backend_type: :line,
65
+ gem_name: 'dotenv-merge',
66
+ contract: :line
67
+ )
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dotenv
4
+ module Merge
5
+ # Extracts and tracks dotenv comments with their line numbers from source.
6
+ #
7
+ # Inherits shared lookup, query, region-building, and attachment API from
8
+ # +Ast::Merge::Comment::HashTrackerBase+. Only format-specific comment
9
+ # extraction and owner resolution are overridden here.
10
+ #
11
+ # Dotenv supports hash-style comments as either:
12
+ # - full-line comments (`# comment`)
13
+ # - safe inline comments on unquoted assignments (`KEY=value # comment`)
14
+ #
15
+ # This adapter intentionally stays conservative around quoted values. `#`
16
+ # inside quoted values is not treated as a comment, and quoted assignments
17
+ # with trailing comment-like text remain literal value content. That is a
18
+ # deliberate parser boundary for dotenv-merge, not a pending comment-matrix
19
+ # bug to "fix" later without an explicit product decision.
20
+ class CommentTracker < Ast::Merge::Comment::HashTrackerBase
21
+ def initialize(source_or_lines)
22
+ @line_objects = normalize_line_objects(source_or_lines)
23
+ @line_parser = Ast::Merge::Comment::QuotedHashLineParser.new
24
+ super(@line_objects.map(&:raw))
25
+ end
26
+
27
+ def augment(owners: [], **options)
28
+ Ast::Merge::Comment::Augmenter.new(
29
+ lines: @lines,
30
+ comments: @comments,
31
+ owners: owners,
32
+ style: :hash_comment,
33
+ total_comment_count: @comments.size,
34
+ inline_comment_count: @comments.count { |comment| !comment[:full_line] },
35
+ **options
36
+ )
37
+ end
38
+
39
+ private
40
+
41
+ def normalize_line_objects(source_or_lines)
42
+ case source_or_lines
43
+ when String
44
+ source_or_lines.lines.each_with_index.map do |line, index|
45
+ EnvLine.new(line.chomp, index + 1)
46
+ end
47
+ else
48
+ Array(source_or_lines)
49
+ end
50
+ end
51
+
52
+ def extract_comments
53
+ @line_objects.filter_map do |line|
54
+ if line.comment?
55
+ build_full_line_comment(line)
56
+ elsif line.assignment?
57
+ build_inline_comment(line)
58
+ end
59
+ end
60
+ end
61
+
62
+ def build_full_line_comment(line)
63
+ match = line.raw.match(FULL_LINE_COMMENT_REGEX)
64
+ return unless match
65
+
66
+ {
67
+ line: line.line_number,
68
+ indent: match[:indent].length,
69
+ text: match[:text].to_s,
70
+ full_line: true,
71
+ raw: line.raw
72
+ }
73
+ end
74
+
75
+ def build_inline_comment(line)
76
+ value_part = raw_value_part(line)
77
+ return if value_part.nil?
78
+
79
+ stripped_value = value_part.lstrip
80
+ return if stripped_value.start_with?('"', "'")
81
+
82
+ parsed = @line_parser.parse(value_part)
83
+ return unless parsed&.inline?
84
+
85
+ {
86
+ line: line.line_number,
87
+ indent: leading_indent(line.raw),
88
+ text: parsed.text,
89
+ full_line: false,
90
+ raw: parsed.raw
91
+ }
92
+ end
93
+
94
+ def raw_value_part(line)
95
+ raw = line.raw.sub(/\A\s*export\s+/, '')
96
+ _key_part, value_part = raw.split('=', 2)
97
+ value_part
98
+ end
99
+
100
+ def leading_indent(raw)
101
+ raw[/\A\s*/].to_s.length
102
+ end
103
+
104
+ def owner_line_num(owner)
105
+ return owner.start_line if owner.respond_to?(:start_line) && owner.start_line
106
+ return owner.line_number if owner.respond_to?(:line_number)
107
+
108
+ nil
109
+ end
110
+ end
111
+ end
112
+ end
@@ -16,8 +16,8 @@ module Dotenv
16
16
  extend Ast::Merge::DebugLogger
17
17
 
18
18
  # Configure for dotenv-merge
19
- self.env_var_name = "DOTENV_MERGE_DEBUG"
20
- self.log_prefix = "[dotenv-merge]"
19
+ self.env_var_name = 'DOTENV_MERGE_DEBUG'
20
+ self.log_prefix = '[dotenv-merge]'
21
21
  end
22
22
  end
23
23
  end
@@ -38,7 +38,7 @@ module Dotenv
38
38
  class EnvLine < Ast::Merge::AstNode
39
39
  # Prefix for exported environment variables
40
40
  # @return [String]
41
- EXPORT_PREFIX = "export "
41
+ EXPORT_PREFIX = 'export '
42
42
 
43
43
  # @return [String] The original raw line content
44
44
  attr_reader :raw
@@ -75,7 +75,7 @@ module Dotenv
75
75
  start_line: line_number,
76
76
  end_line: line_number,
77
77
  start_column: 0,
78
- end_column: @raw.length,
78
+ end_column: @raw.length
79
79
  )
80
80
 
81
81
  super(slice: @raw, location: location)
@@ -84,7 +84,7 @@ module Dotenv
84
84
  # TreeHaver::Node protocol: type
85
85
  # @return [String] "env_line"
86
86
  def type
87
- "env_line"
87
+ 'env_line'
88
88
  end
89
89
 
90
90
  # Generate a unique signature for this line (used for merge matching)
@@ -163,7 +163,7 @@ module Dotenv
163
163
  stripped = @raw.strip
164
164
  if stripped.empty?
165
165
  @line_type = :blank
166
- elsif stripped.start_with?("#")
166
+ elsif stripped.start_with?('#')
167
167
  @line_type = :comment
168
168
  else
169
169
  parse_assignment!(stripped)
@@ -181,13 +181,13 @@ module Dotenv
181
181
  line = line[EXPORT_PREFIX.length..]
182
182
  end
183
183
 
184
- if line.include?("=")
185
- key_part, value_part = line.split("=", 2)
184
+ if line.include?('=')
185
+ key_part, value_part = line.split('=', 2)
186
186
  key_part = key_part.strip
187
187
  if valid_key?(key_part)
188
188
  @line_type = :assignment
189
189
  @key = key_part
190
- @value = unquote(value_part || "")
190
+ @value = unquote(value_part || '')
191
191
  else
192
192
  @line_type = :invalid
193
193
  end
@@ -214,14 +214,10 @@ module Dotenv
214
214
  value = value.strip
215
215
 
216
216
  # Double-quoted: process escape sequences
217
- if value.start_with?('"') && value.end_with?('"')
218
- return process_escape_sequences(value[1..-2])
219
- end
217
+ return process_escape_sequences(value[1..-2]) if value.start_with?('"') && value.end_with?('"')
220
218
 
221
219
  # Single-quoted: literal value, no escape processing
222
- if value.start_with?("'") && value.end_with?("'")
223
- return value[1..-2]
224
- end
220
+ return value[1..-2] if value.start_with?("'") && value.end_with?("'")
225
221
 
226
222
  # Unquoted: strip inline comments
227
223
  strip_inline_comment(value)
@@ -239,7 +235,7 @@ module Dotenv
239
235
  .gsub('\t', "\t")
240
236
  .gsub('\r', "\r")
241
237
  .gsub('\"', '"')
242
- .gsub("\\\\", "\\")
238
+ .gsub('\\\\', '\\')
243
239
  end
244
240
 
245
241
  # Strip inline comments from unquoted values
@@ -26,7 +26,10 @@ module Dotenv
26
26
 
27
27
  # Default freeze token for identifying freeze blocks
28
28
  # @return [String]
29
- DEFAULT_FREEZE_TOKEN = "dotenv-merge"
29
+ DEFAULT_FREEZE_TOKEN = 'dotenv-merge'
30
+
31
+ # @return [CommentTracker] Comment tracker for this file
32
+ attr_reader :comment_tracker, :structural_diagnostics
30
33
 
31
34
  # Initialize file analysis with dotenv parser
32
35
  #
@@ -34,25 +37,29 @@ module Dotenv
34
37
  # @param freeze_token [String] Token for freeze block markers (default: "dotenv-merge")
35
38
  # @param signature_generator [Proc, nil] Custom signature generator
36
39
  # @param options [Hash] Additional options (forward compatibility - ignored by FileAnalysis)
37
- def initialize(source, freeze_token: DEFAULT_FREEZE_TOKEN, signature_generator: nil, **options)
40
+ def initialize(source, freeze_token: DEFAULT_FREEZE_TOKEN, signature_generator: nil, **_options)
38
41
  @source = source
39
42
  @freeze_token = freeze_token
40
43
  @signature_generator = signature_generator
44
+ @structural_diagnostics = []
41
45
  # **options captures any additional parameters (e.g., node_typing) for forward compatibility
42
46
 
43
47
  # Parse all lines
44
48
  @lines = parse_lines(source)
45
49
 
50
+ # Initialize comment tracking before freeze block integration
51
+ @comment_tracker = CommentTracker.new(@lines)
52
+
46
53
  # Extract and integrate freeze blocks
47
54
  @statements = extract_and_integrate_statements
48
55
 
49
- DebugLogger.debug("FileAnalysis initialized", {
50
- signature_generator: signature_generator ? "custom" : "default",
51
- lines_count: @lines.size,
52
- statements_count: @statements.size,
53
- freeze_blocks: freeze_blocks.size,
54
- assignments: assignment_lines.size,
55
- })
56
+ DebugLogger.debug('FileAnalysis initialized', {
57
+ signature_generator: signature_generator ? 'custom' : 'default',
58
+ lines_count: @lines.size,
59
+ statements_count: @statements.size,
60
+ freeze_blocks: freeze_blocks.size,
61
+ assignments: assignment_lines.size
62
+ })
56
63
  end
57
64
 
58
65
  # Check if parse was successful (dotenv always succeeds, may have invalid lines)
@@ -61,12 +68,115 @@ module Dotenv
61
68
  true
62
69
  end
63
70
 
71
+ # Get shared comment capability information for this analysis.
72
+ #
73
+ # @return [Ast::Merge::Comment::Capability]
74
+ def comment_capability
75
+ @comment_capability ||= comment_tracker.augment(owners: []).capability
76
+ end
77
+
78
+ # Describe how dotenv merges currently own and emit comments.
79
+ #
80
+ # Dotenv comment handling is source-augmented and emitted through the
81
+ # synthetic merge layer.
82
+ #
83
+ # @return [Ast::Merge::Comment::SupportStyle]
84
+ def comment_support_style
85
+ @comment_support_style ||= shared_comment_support_style(
86
+ source: :dotenv_source,
87
+ style: :hash_comment,
88
+ read_strategy: :source_augmented_portable_write
89
+ )
90
+ end
91
+
92
+ # Get all tracked comments converted to shared Ast::Merge comment nodes.
93
+ #
94
+ # @return [Array<Ast::Merge::Comment::Line>]
95
+ def comment_nodes
96
+ comment_tracker.comment_nodes
97
+ end
98
+
99
+ # Get a shared Ast::Merge comment node at a specific line.
100
+ #
101
+ # @param line_num [Integer] 1-based line number
102
+ # @return [Ast::Merge::Comment::Line, nil]
103
+ def comment_node_at(line_num)
104
+ comment_tracker.comment_node_at(line_num)
105
+ end
106
+
107
+ # Get comments in a line range converted to a shared comment region.
108
+ #
109
+ # @param range [Range] Range of 1-based line numbers
110
+ # @param kind [Symbol] Region kind (:leading, :inline, :orphan, etc.)
111
+ # @param full_line_only [Boolean] Whether to keep only full-line comments
112
+ # @return [Ast::Merge::Comment::Region]
113
+ def comment_region_for_range(range, kind:, full_line_only: false)
114
+ comment_tracker.comment_region_for_range(
115
+ range,
116
+ kind: kind,
117
+ full_line_only: full_line_only
118
+ )
119
+ end
120
+
121
+ # Build a passive shared comment augmenter for this analysis.
122
+ #
123
+ # @param owners [Array<#start_line,#end_line>, nil] Owners used for attachment inference
124
+ # @param options [Hash] Additional augmenter options
125
+ # @return [Ast::Merge::Comment::Augmenter]
126
+ def comment_augmenter(owners: nil, **options)
127
+ comment_tracker.augment(
128
+ owners: owners || comment_augmenter_default_owners,
129
+ **options
130
+ )
131
+ end
132
+
133
+ # Build a passive shared comment attachment for an owner.
134
+ #
135
+ # @param owner [Object] Structural owner for the attachment
136
+ # @param options [Hash] Additional metadata / lookup overrides
137
+ # @return [Ast::Merge::Comment::Attachment]
138
+ def comment_attachment_for(owner, **options)
139
+ shared_comment_attachment_for(
140
+ owner,
141
+ tracker_attachment: comment_augmenter(**options).attachment_for(owner),
142
+ **options
143
+ )
144
+ end
145
+
146
+ # @return [Symbol]
147
+ def comment_attachment_strategy
148
+ :tracker_layout_merge
149
+ end
150
+
151
+ def ruleset_owner_selector
152
+ :assignment_lines_plus_freeze_blocks
153
+ end
154
+
155
+ def ruleset_match_key
156
+ :env_key
157
+ end
158
+
159
+ def ruleset_render_family
160
+ :dotenv_assignments
161
+ end
162
+
64
163
  # Get assignment lines (not in freeze blocks)
65
164
  # @return [Array<EnvLine>]
66
165
  def assignment_lines
67
166
  @statements.select { |stmt| stmt.is_a?(EnvLine) && stmt.assignment? }
68
167
  end
69
168
 
169
+ # Get merge-relevant structural owners in source order.
170
+ # For dotenv this means assignment lines plus integrated freeze blocks,
171
+ # excluding standalone comments, blanks, and invalid lines.
172
+ #
173
+ # @return [Array<EnvLine, FreezeNode>]
174
+ def structural_owners
175
+ @structural_owners ||= @statements.select do |stmt|
176
+ stmt.is_a?(FreezeNode) || (stmt.is_a?(EnvLine) && stmt.assignment?)
177
+ end
178
+ end
179
+
70
180
  # Get all assignment lines including those in freeze blocks
71
181
  # @return [Array<EnvLine>]
72
182
  def all_assignments
@@ -95,7 +205,7 @@ module Dotenv
95
205
  end
96
206
  end
97
207
 
98
- # Note: fallthrough_node? is inherited from FileAnalyzable.
208
+ # NOTE: fallthrough_node? is inherited from FileAnalyzable.
99
209
  # EnvLine inherits from AstNode and FreezeNode inherits from FreezeNodeBase,
100
210
  # both of which are recognized by the base implementation.
101
211
 
@@ -114,6 +224,10 @@ module Dotenv
114
224
 
115
225
  private
116
226
 
227
+ def comment_augmenter_default_owners
228
+ structural_owners
229
+ end
230
+
117
231
  # Parse source into EnvLine objects
118
232
  # @param source [String] Source content
119
233
  # @return [Array<EnvLine>]
@@ -145,17 +259,17 @@ module Dotenv
145
259
  @lines.each do |line|
146
260
  next unless line.comment?
147
261
 
148
- if line.raw =~ pattern
149
- marker_type = ::Regexp.last_match(1) # 'freeze' or 'unfreeze'
150
- reason = ::Regexp.last_match(2)&.strip
151
- reason = nil if reason&.empty?
262
+ next unless line.raw =~ pattern
152
263
 
153
- markers << {
154
- type: marker_type.to_sym,
155
- line: line.line_number,
156
- reason: reason,
157
- }
158
- end
264
+ marker_type = ::Regexp.last_match(1) # 'freeze' or 'unfreeze'
265
+ reason = ::Regexp.last_match(2)&.strip
266
+ reason = nil if reason && reason.empty?
267
+
268
+ markers << {
269
+ type: marker_type.to_sym,
270
+ line: line.line_number,
271
+ reason: reason
272
+ }
159
273
  end
160
274
 
161
275
  markers
@@ -172,6 +286,11 @@ module Dotenv
172
286
  case marker[:type]
173
287
  when :freeze
174
288
  if open_marker
289
+ @structural_diagnostics << {
290
+ category: :freeze_ambiguity,
291
+ line: marker[:line],
292
+ message: 'Nested freeze marker'
293
+ }.freeze
175
294
  DebugLogger.warning("Nested freeze block at line #{marker[:line]}, ignoring")
176
295
  else
177
296
  open_marker = marker
@@ -182,16 +301,26 @@ module Dotenv
182
301
  start_line: open_marker[:line],
183
302
  end_line: marker[:line],
184
303
  analysis: self,
185
- reason: open_marker[:reason],
304
+ reason: open_marker[:reason]
186
305
  )
187
306
  open_marker = nil
188
307
  else
308
+ @structural_diagnostics << {
309
+ category: :freeze_ambiguity,
310
+ line: marker[:line],
311
+ message: 'Unfreeze marker without matching freeze marker'
312
+ }.freeze
189
313
  DebugLogger.warning("Unfreeze without freeze at line #{marker[:line]}, ignoring")
190
314
  end
191
315
  end
192
316
  end
193
317
 
194
318
  if open_marker
319
+ @structural_diagnostics << {
320
+ category: :freeze_ambiguity,
321
+ line: open_marker[:line],
322
+ message: 'Unclosed freeze marker'
323
+ }.freeze
195
324
  DebugLogger.warning("Unclosed freeze block starting at line #{open_marker[:line]}")
196
325
  end
197
326
 
@@ -43,7 +43,7 @@ module Dotenv
43
43
  # Get a signature for this freeze block
44
44
  # @return [Array] Signature based on normalized content
45
45
  def signature
46
- [:FreezeNode, content.gsub(/\s+/, " ").strip]
46
+ [:FreezeNode, content.gsub(/\s+/, ' ').strip]
47
47
  end
48
48
 
49
49
  # Get environment variable lines within the freeze block
@@ -50,7 +50,7 @@ module Dotenv
50
50
 
51
51
  lines = extract_lines(statement)
52
52
  @lines.concat(lines)
53
- @decisions << {decision: decision, source: :template, index: index, lines: lines.length}
53
+ @decisions << { decision: decision, source: :template, index: index, lines: lines.length }
54
54
  end
55
55
 
56
56
  # Add content from the destination at the given statement index
@@ -63,7 +63,7 @@ module Dotenv
63
63
 
64
64
  lines = extract_lines(statement)
65
65
  @lines.concat(lines)
66
- @decisions << {decision: decision, source: :destination, index: index, lines: lines.length}
66
+ @decisions << { decision: decision, source: :destination, index: index, lines: lines.length }
67
67
  end
68
68
 
69
69
  # Add content from a freeze block
@@ -77,7 +77,7 @@ module Dotenv
77
77
  source: :destination,
78
78
  start_line: freeze_node.start_line,
79
79
  end_line: freeze_node.end_line,
80
- lines: lines.length,
80
+ lines: lines.length
81
81
  }
82
82
  end
83
83
 
@@ -87,13 +87,13 @@ module Dotenv
87
87
  # @return [void]
88
88
  def add_raw(lines, decision:)
89
89
  @lines.concat(lines)
90
- @decisions << {decision: decision, source: :raw, lines: lines.length}
90
+ @decisions << { decision: decision, source: :raw, lines: lines.length }
91
91
  end
92
92
 
93
93
  # Convert the merged result to a string
94
94
  # @return [String] The merged dotenv content
95
95
  def to_s
96
- return "" if @lines.empty?
96
+ return '' if @lines.empty?
97
97
 
98
98
  # Join with newlines and ensure file ends with newline
99
99
  result = @lines.join("\n")
@@ -114,7 +114,7 @@ module Dotenv
114
114
  {
115
115
  total_decisions: @decisions.length,
116
116
  total_lines: @lines.length,
117
- by_decision: counts,
117
+ by_decision: counts
118
118
  }
119
119
  end
120
120