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.
@@ -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
30
33
 
31
34
  # Initialize file analysis with dotenv parser
32
35
  #
@@ -34,7 +37,7 @@ 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
@@ -43,16 +46,19 @@ module Dotenv
43
46
  # Parse all lines
44
47
  @lines = parse_lines(source)
45
48
 
49
+ # Initialize comment tracking before freeze block integration
50
+ @comment_tracker = CommentTracker.new(@lines)
51
+
46
52
  # Extract and integrate freeze blocks
47
53
  @statements = extract_and_integrate_statements
48
54
 
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
- })
55
+ DebugLogger.debug('FileAnalysis initialized', {
56
+ signature_generator: signature_generator ? 'custom' : 'default',
57
+ lines_count: @lines.size,
58
+ statements_count: @statements.size,
59
+ freeze_blocks: freeze_blocks.size,
60
+ assignments: assignment_lines.size
61
+ })
56
62
  end
57
63
 
58
64
  # Check if parse was successful (dotenv always succeeds, may have invalid lines)
@@ -61,12 +67,115 @@ module Dotenv
61
67
  true
62
68
  end
63
69
 
70
+ # Get shared comment capability information for this analysis.
71
+ #
72
+ # @return [Ast::Merge::Comment::Capability]
73
+ def comment_capability
74
+ @comment_capability ||= comment_tracker.augment(owners: []).capability
75
+ end
76
+
77
+ # Describe how dotenv merges currently own and emit comments.
78
+ #
79
+ # Dotenv comment handling is source-augmented and emitted through the
80
+ # synthetic merge layer.
81
+ #
82
+ # @return [Ast::Merge::Comment::SupportStyle]
83
+ def comment_support_style
84
+ @comment_support_style ||= shared_comment_support_style(
85
+ source: :dotenv_source,
86
+ style: :hash_comment,
87
+ read_strategy: :source_augmented_portable_write
88
+ )
89
+ end
90
+
91
+ # Get all tracked comments converted to shared Ast::Merge comment nodes.
92
+ #
93
+ # @return [Array<Ast::Merge::Comment::Line>]
94
+ def comment_nodes
95
+ comment_tracker.comment_nodes
96
+ end
97
+
98
+ # Get a shared Ast::Merge comment node at a specific line.
99
+ #
100
+ # @param line_num [Integer] 1-based line number
101
+ # @return [Ast::Merge::Comment::Line, nil]
102
+ def comment_node_at(line_num)
103
+ comment_tracker.comment_node_at(line_num)
104
+ end
105
+
106
+ # Get comments in a line range converted to a shared comment region.
107
+ #
108
+ # @param range [Range] Range of 1-based line numbers
109
+ # @param kind [Symbol] Region kind (:leading, :inline, :orphan, etc.)
110
+ # @param full_line_only [Boolean] Whether to keep only full-line comments
111
+ # @return [Ast::Merge::Comment::Region]
112
+ def comment_region_for_range(range, kind:, full_line_only: false)
113
+ comment_tracker.comment_region_for_range(
114
+ range,
115
+ kind: kind,
116
+ full_line_only: full_line_only
117
+ )
118
+ end
119
+
120
+ # Build a passive shared comment augmenter for this analysis.
121
+ #
122
+ # @param owners [Array<#start_line,#end_line>, nil] Owners used for attachment inference
123
+ # @param options [Hash] Additional augmenter options
124
+ # @return [Ast::Merge::Comment::Augmenter]
125
+ def comment_augmenter(owners: nil, **options)
126
+ comment_tracker.augment(
127
+ owners: owners || comment_augmenter_default_owners,
128
+ **options
129
+ )
130
+ end
131
+
132
+ # Build a passive shared comment attachment for an owner.
133
+ #
134
+ # @param owner [Object] Structural owner for the attachment
135
+ # @param options [Hash] Additional metadata / lookup overrides
136
+ # @return [Ast::Merge::Comment::Attachment]
137
+ def comment_attachment_for(owner, **options)
138
+ shared_comment_attachment_for(
139
+ owner,
140
+ tracker_attachment: comment_augmenter(**options).attachment_for(owner),
141
+ **options
142
+ )
143
+ end
144
+
145
+ # @return [Symbol]
146
+ def comment_attachment_strategy
147
+ :tracker_layout_merge
148
+ end
149
+
150
+ def ruleset_owner_selector
151
+ :assignment_lines_plus_freeze_blocks
152
+ end
153
+
154
+ def ruleset_match_key
155
+ :env_key
156
+ end
157
+
158
+ def ruleset_render_family
159
+ :dotenv_assignments
160
+ end
161
+
64
162
  # Get assignment lines (not in freeze blocks)
65
163
  # @return [Array<EnvLine>]
66
164
  def assignment_lines
67
165
  @statements.select { |stmt| stmt.is_a?(EnvLine) && stmt.assignment? }
68
166
  end
69
167
 
168
+ # Get merge-relevant structural owners in source order.
169
+ # For dotenv this means assignment lines plus integrated freeze blocks,
170
+ # excluding standalone comments, blanks, and invalid lines.
171
+ #
172
+ # @return [Array<EnvLine, FreezeNode>]
173
+ def structural_owners
174
+ @structural_owners ||= @statements.select do |stmt|
175
+ stmt.is_a?(FreezeNode) || (stmt.is_a?(EnvLine) && stmt.assignment?)
176
+ end
177
+ end
178
+
70
179
  # Get all assignment lines including those in freeze blocks
71
180
  # @return [Array<EnvLine>]
72
181
  def all_assignments
@@ -95,7 +204,7 @@ module Dotenv
95
204
  end
96
205
  end
97
206
 
98
- # Note: fallthrough_node? is inherited from FileAnalyzable.
207
+ # NOTE: fallthrough_node? is inherited from FileAnalyzable.
99
208
  # EnvLine inherits from AstNode and FreezeNode inherits from FreezeNodeBase,
100
209
  # both of which are recognized by the base implementation.
101
210
 
@@ -114,6 +223,10 @@ module Dotenv
114
223
 
115
224
  private
116
225
 
226
+ def comment_augmenter_default_owners
227
+ structural_owners
228
+ end
229
+
117
230
  # Parse source into EnvLine objects
118
231
  # @param source [String] Source content
119
232
  # @return [Array<EnvLine>]
@@ -145,17 +258,17 @@ module Dotenv
145
258
  @lines.each do |line|
146
259
  next unless line.comment?
147
260
 
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?
261
+ next unless line.raw =~ pattern
152
262
 
153
- markers << {
154
- type: marker_type.to_sym,
155
- line: line.line_number,
156
- reason: reason,
157
- }
158
- end
263
+ marker_type = ::Regexp.last_match(1) # 'freeze' or 'unfreeze'
264
+ reason = ::Regexp.last_match(2)&.strip
265
+ reason = nil if reason && reason.empty?
266
+
267
+ markers << {
268
+ type: marker_type.to_sym,
269
+ line: line.line_number,
270
+ reason: reason
271
+ }
159
272
  end
160
273
 
161
274
  markers
@@ -182,7 +295,7 @@ module Dotenv
182
295
  start_line: open_marker[:line],
183
296
  end_line: marker[:line],
184
297
  analysis: self,
185
- reason: open_marker[:reason],
298
+ reason: open_marker[:reason]
186
299
  )
187
300
  open_marker = nil
188
301
  else
@@ -191,9 +304,7 @@ module Dotenv
191
304
  end
192
305
  end
193
306
 
194
- if open_marker
195
- DebugLogger.warning("Unclosed freeze block starting at line #{open_marker[:line]}")
196
- end
307
+ DebugLogger.warning("Unclosed freeze block starting at line #{open_marker[:line]}") if open_marker
197
308
 
198
309
  blocks
199
310
  end
@@ -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