dotenv-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.
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,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dotenv
4
+ module Merge
5
+ # Debug logging support for dotenv-merge.
6
+ # Extends the base Ast::Merge::DebugLogger with dotenv-specific configuration.
7
+ #
8
+ # @example Enable debug logging
9
+ # ENV["DOTENV_MERGE_DEBUG"] = "true"
10
+ #
11
+ # @example Direct usage
12
+ # Dotenv::Merge::DebugLogger.debug("message", { key: "value" })
13
+ #
14
+ # @see Ast::Merge::DebugLogger
15
+ module DebugLogger
16
+ extend Ast::Merge::DebugLogger
17
+
18
+ # Configure for dotenv-merge
19
+ self.env_var_name = "DOTENV_MERGE_DEBUG"
20
+ self.log_prefix = "[dotenv-merge]"
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dotenv
4
+ module Merge
5
+ # Represents a single line in a dotenv file.
6
+ # Parses and categorizes lines as assignments, comments, blank lines, or invalid.
7
+ #
8
+ # Inherits from Ast::Merge::AstNode for a normalized API across all ast-merge
9
+ # content nodes. This provides #slice, #location, #unwrap, and other standard methods.
10
+ #
11
+ # Dotenv files follow a simple format where each line is one of:
12
+ # - `KEY=value` - Environment variable assignment
13
+ # - `export KEY=value` - Assignment with export prefix
14
+ # - `# comment` - Comment line
15
+ # - Empty/whitespace - Blank line
16
+ #
17
+ # @example Parse a simple assignment
18
+ # line = EnvLine.new("API_KEY=secret123", 1)
19
+ # line.assignment? # => true
20
+ # line.key # => "API_KEY"
21
+ # line.value # => "secret123"
22
+ #
23
+ # @example Parse an export statement
24
+ # line = EnvLine.new("export DATABASE_URL=postgres://localhost/db", 2)
25
+ # line.assignment? # => true
26
+ # line.export? # => true
27
+ # line.key # => "DATABASE_URL"
28
+ #
29
+ # @example Parse a comment
30
+ # line = EnvLine.new("# Database configuration", 3)
31
+ # line.comment? # => true
32
+ # line.comment # => "# Database configuration"
33
+ #
34
+ # @example Quoted values with escape sequences
35
+ # line = EnvLine.new('MESSAGE="Hello\nWorld"', 4)
36
+ # line.value # => "Hello\nWorld" (with actual newline)
37
+ class EnvLine < Ast::Merge::AstNode
38
+ # Prefix for exported environment variables
39
+ # @return [String]
40
+ EXPORT_PREFIX = "export "
41
+
42
+ # @return [String] The original raw line content
43
+ attr_reader :raw
44
+
45
+ # @return [Integer] The 1-indexed line number in the source file
46
+ attr_reader :line_number
47
+
48
+ # @return [Symbol, nil] The line type (:assignment, :comment, :blank, :invalid)
49
+ attr_reader :type
50
+
51
+ # @return [String, nil] The environment variable key (for assignments)
52
+ attr_reader :key
53
+
54
+ # @return [String, nil] The environment variable value (for assignments)
55
+ attr_reader :value
56
+
57
+ # @return [Boolean] Whether the line has an export prefix
58
+ attr_reader :export
59
+
60
+ # Initialize a new EnvLine by parsing the raw content
61
+ #
62
+ # @param raw [String] The raw line content from the dotenv file
63
+ # @param line_number [Integer] The 1-indexed line number
64
+ def initialize(raw, line_number)
65
+ @raw = raw
66
+ @line_number = line_number
67
+ @type = nil
68
+ @key = nil
69
+ @value = nil
70
+ @export = false
71
+ parse!
72
+
73
+ location = Ast::Merge::AstNode::Location.new(
74
+ start_line: line_number,
75
+ end_line: line_number,
76
+ start_column: 0,
77
+ end_column: @raw.length,
78
+ )
79
+
80
+ super(slice: @raw, location: location)
81
+ end
82
+
83
+ # Generate a unique signature for this line (used for merge matching)
84
+ #
85
+ # @return [Array<Symbol, String>, nil] Signature array [:env, key] for assignments, nil otherwise
86
+ def signature
87
+ return unless @type == :assignment
88
+
89
+ [:env, @key]
90
+ end
91
+
92
+ # Check if this line is an environment variable assignment
93
+ #
94
+ # @return [Boolean] true if the line is a valid KEY=value assignment
95
+ def assignment?
96
+ @type == :assignment
97
+ end
98
+
99
+ # Check if this line is a comment
100
+ #
101
+ # @return [Boolean] true if the line starts with #
102
+ def comment?
103
+ @type == :comment
104
+ end
105
+
106
+ # Check if this line is blank (empty or whitespace only)
107
+ #
108
+ # @return [Boolean] true if the line is blank
109
+ def blank?
110
+ @type == :blank
111
+ end
112
+
113
+ # Check if this line is invalid (unparseable)
114
+ #
115
+ # @return [Boolean] true if the line could not be parsed
116
+ def invalid?
117
+ @type == :invalid
118
+ end
119
+
120
+ # Check if this line has the export prefix
121
+ #
122
+ # @return [Boolean] true if the line starts with "export "
123
+ def export?
124
+ @export
125
+ end
126
+
127
+ # Get the raw comment text (for comment lines only)
128
+ #
129
+ # @return [String, nil] The raw line content if this is a comment, nil otherwise
130
+ def comment
131
+ return @raw if comment?
132
+
133
+ nil
134
+ end
135
+
136
+ # Convert to string representation (returns raw content)
137
+ #
138
+ # @return [String] The original raw line content
139
+ def to_s
140
+ @raw
141
+ end
142
+
143
+ # Inspect for debugging
144
+ #
145
+ # @return [String] A debug representation of this EnvLine
146
+ def inspect
147
+ "#<#{self.class.name} line=#{@line_number} type=#{@type} key=#{@key.inspect}>"
148
+ end
149
+
150
+ private
151
+
152
+ # Parse the raw line content and set type, key, value, and export
153
+ #
154
+ # @return [void]
155
+ def parse!
156
+ stripped = @raw.strip
157
+ if stripped.empty?
158
+ @type = :blank
159
+ elsif stripped.start_with?("#")
160
+ @type = :comment
161
+ else
162
+ parse_assignment!(stripped)
163
+ end
164
+ end
165
+
166
+ # Parse a potential assignment line
167
+ #
168
+ # @param stripped [String] The stripped line content
169
+ # @return [void]
170
+ def parse_assignment!(stripped)
171
+ line = stripped
172
+ if line.start_with?(EXPORT_PREFIX)
173
+ @export = true
174
+ line = line[EXPORT_PREFIX.length..]
175
+ end
176
+
177
+ if line.include?("=")
178
+ key_part, value_part = line.split("=", 2)
179
+ key_part = key_part.strip
180
+ if valid_key?(key_part)
181
+ @type = :assignment
182
+ @key = key_part
183
+ @value = unquote(value_part || "")
184
+ else
185
+ @type = :invalid
186
+ end
187
+ else
188
+ @type = :invalid
189
+ end
190
+ end
191
+
192
+ # Validate an environment variable key
193
+ #
194
+ # @param key [String, nil] The key to validate
195
+ # @return [Boolean] true if the key is valid (starts with letter/underscore, contains only alphanumerics/underscores)
196
+ def valid_key?(key)
197
+ return false if key.nil? || key.empty?
198
+
199
+ key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
200
+ end
201
+
202
+ # Remove quotes from a value and process escape sequences
203
+ #
204
+ # @param value [String] The raw value part after the =
205
+ # @return [String] The unquoted and processed value
206
+ def unquote(value)
207
+ value = value.strip
208
+
209
+ # Double-quoted: process escape sequences
210
+ if value.start_with?('"') && value.end_with?('"')
211
+ return process_escape_sequences(value[1..-2])
212
+ end
213
+
214
+ # Single-quoted: literal value, no escape processing
215
+ if value.start_with?("'") && value.end_with?("'")
216
+ return value[1..-2]
217
+ end
218
+
219
+ # Unquoted: strip inline comments
220
+ strip_inline_comment(value)
221
+ end
222
+
223
+ # Process escape sequences in double-quoted strings
224
+ #
225
+ # Handles: \n (newline), \t (tab), \r (carriage return), \" (quote), \\ (backslash)
226
+ #
227
+ # @param value [String] The value with escape sequences
228
+ # @return [String] The value with escape sequences converted
229
+ def process_escape_sequences(value)
230
+ value
231
+ .gsub('\n', "\n")
232
+ .gsub('\t', "\t")
233
+ .gsub('\r', "\r")
234
+ .gsub('\"', '"')
235
+ .gsub("\\\\", "\\")
236
+ end
237
+
238
+ # Strip inline comments from unquoted values
239
+ #
240
+ # @param value [String] The unquoted value
241
+ # @return [String] The value with inline comments removed
242
+ def strip_inline_comment(value)
243
+ # Find # that's preceded by whitespace and strip from there
244
+ if (match = value.match(/\s+#/))
245
+ value[0, match.begin(0)].strip
246
+ else
247
+ value
248
+ end
249
+ end
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dotenv
4
+ module Merge
5
+ # File analysis for dotenv files.
6
+ # Parses dotenv source and extracts environment variable assignments,
7
+ # comments, and freeze blocks.
8
+ #
9
+ # Dotenv files follow a simple format:
10
+ # - `KEY=value` - Environment variable assignment
11
+ # - `export KEY=value` - Assignment with export prefix
12
+ # - `# comment` - Comment line
13
+ # - Blank lines are preserved
14
+ #
15
+ # @example Basic usage
16
+ # analysis = FileAnalysis.new(dotenv_source)
17
+ # analysis.statements.each do |stmt|
18
+ # puts stmt.class
19
+ # end
20
+ #
21
+ # @example With custom freeze token
22
+ # analysis = FileAnalysis.new(source, freeze_token: "my-merge")
23
+ # # Looks for: # my-merge:freeze / # my-merge:unfreeze
24
+ class FileAnalysis
25
+ include Ast::Merge::FileAnalyzable
26
+
27
+ # Default freeze token for identifying freeze blocks
28
+ # @return [String]
29
+ DEFAULT_FREEZE_TOKEN = "dotenv-merge"
30
+
31
+ # Initialize file analysis with dotenv parser
32
+ #
33
+ # @param source [String] Dotenv source code to analyze
34
+ # @param freeze_token [String] Token for freeze block markers (default: "dotenv-merge")
35
+ # @param signature_generator [Proc, nil] Custom signature generator
36
+ def initialize(source, freeze_token: DEFAULT_FREEZE_TOKEN, signature_generator: nil)
37
+ @source = source
38
+ @freeze_token = freeze_token
39
+ @signature_generator = signature_generator
40
+
41
+ # Parse all lines
42
+ @lines = parse_lines(source)
43
+
44
+ # Extract and integrate freeze blocks
45
+ @statements = extract_and_integrate_statements
46
+
47
+ DebugLogger.debug("FileAnalysis initialized", {
48
+ signature_generator: signature_generator ? "custom" : "default",
49
+ lines_count: @lines.size,
50
+ statements_count: @statements.size,
51
+ freeze_blocks: freeze_blocks.size,
52
+ assignments: assignment_lines.size,
53
+ })
54
+ end
55
+
56
+ # Check if parse was successful (dotenv always succeeds, may have invalid lines)
57
+ # @return [Boolean]
58
+ def valid?
59
+ true
60
+ end
61
+
62
+ # Get assignment lines (not in freeze blocks)
63
+ # @return [Array<EnvLine>]
64
+ def assignment_lines
65
+ @statements.select { |stmt| stmt.is_a?(EnvLine) && stmt.assignment? }
66
+ end
67
+
68
+ # Get all assignment lines including those in freeze blocks
69
+ # @return [Array<EnvLine>]
70
+ def all_assignments
71
+ @lines.select(&:assignment?)
72
+ end
73
+
74
+ # Get a specific line (1-indexed)
75
+ # Override base to return EnvLine objects instead of raw strings
76
+ # @param line_number [Integer] Line number (1-indexed)
77
+ # @return [EnvLine, nil] The line object
78
+ def line_at(line_number)
79
+ return if line_number < 1
80
+
81
+ @lines[line_number - 1]
82
+ end
83
+
84
+ # Compute default signature for a node
85
+ # @param node [EnvLine, FreezeNode] The statement
86
+ # @return [Array, nil] Signature array
87
+ def compute_node_signature(node)
88
+ case node
89
+ when FreezeNode
90
+ node.signature
91
+ when EnvLine
92
+ node.signature
93
+ end
94
+ end
95
+
96
+ # Note: fallthrough_node? is inherited from FileAnalyzable.
97
+ # EnvLine inherits from AstNode and FreezeNode inherits from FreezeNodeBase,
98
+ # both of which are recognized by the base implementation.
99
+
100
+ # Get environment variable by key
101
+ # @param key [String] The environment variable key
102
+ # @return [EnvLine, nil] The assignment line or nil
103
+ def env_var(key)
104
+ @lines.find { |line| line.assignment? && line.key == key }
105
+ end
106
+
107
+ # Get all environment variable keys
108
+ # @return [Array<String>] List of keys
109
+ def keys
110
+ all_assignments.map(&:key)
111
+ end
112
+
113
+ private
114
+
115
+ # Parse source into EnvLine objects
116
+ # @param source [String] Source content
117
+ # @return [Array<EnvLine>]
118
+ def parse_lines(source)
119
+ source.lines.each_with_index.map do |line, index|
120
+ EnvLine.new(line.chomp, index + 1)
121
+ end
122
+ end
123
+
124
+ # Extract statements, integrating freeze blocks
125
+ # @return [Array<EnvLine, FreezeNode>]
126
+ def extract_and_integrate_statements
127
+ freeze_markers = find_freeze_markers
128
+ return @lines.dup if freeze_markers.empty?
129
+
130
+ # Build freeze blocks from markers
131
+ freeze_blocks = build_freeze_blocks(freeze_markers)
132
+
133
+ # Integrate: replace lines in freeze blocks with FreezeNode
134
+ integrate_freeze_blocks(freeze_blocks)
135
+ end
136
+
137
+ # Find all freeze markers in the source
138
+ # @return [Array<Hash>] Array of marker info hashes
139
+ def find_freeze_markers
140
+ markers = []
141
+ pattern = Ast::Merge::FreezeNodeBase.pattern_for(:hash_comment, @freeze_token)
142
+
143
+ @lines.each do |line|
144
+ next unless line.comment?
145
+
146
+ if line.raw =~ pattern
147
+ marker_type = ::Regexp.last_match(1) # 'freeze' or 'unfreeze'
148
+ reason = ::Regexp.last_match(2)&.strip
149
+ reason = nil if reason&.empty?
150
+
151
+ markers << {
152
+ type: marker_type.to_sym,
153
+ line: line.line_number,
154
+ reason: reason,
155
+ }
156
+ end
157
+ end
158
+
159
+ markers
160
+ end
161
+
162
+ # Build FreezeNode objects from markers
163
+ # @param markers [Array<Hash>] Freeze markers
164
+ # @return [Array<FreezeNode>]
165
+ def build_freeze_blocks(markers)
166
+ blocks = []
167
+ open_marker = nil
168
+
169
+ markers.each do |marker|
170
+ case marker[:type]
171
+ when :freeze
172
+ if open_marker
173
+ DebugLogger.warning("Nested freeze block at line #{marker[:line]}, ignoring")
174
+ else
175
+ open_marker = marker
176
+ end
177
+ when :unfreeze
178
+ if open_marker
179
+ blocks << FreezeNode.new(
180
+ start_line: open_marker[:line],
181
+ end_line: marker[:line],
182
+ analysis: self,
183
+ reason: open_marker[:reason],
184
+ )
185
+ open_marker = nil
186
+ else
187
+ DebugLogger.warning("Unfreeze without freeze at line #{marker[:line]}, ignoring")
188
+ end
189
+ end
190
+ end
191
+
192
+ if open_marker
193
+ DebugLogger.warning("Unclosed freeze block starting at line #{open_marker[:line]}")
194
+ end
195
+
196
+ blocks
197
+ end
198
+
199
+ # Integrate freeze blocks into statement list
200
+ # @param freeze_blocks [Array<FreezeNode>]
201
+ # @return [Array<EnvLine, FreezeNode>]
202
+ def integrate_freeze_blocks(freeze_blocks)
203
+ return @lines.dup if freeze_blocks.empty?
204
+
205
+ # Build a set of line numbers covered by freeze blocks
206
+ frozen_lines = Set.new
207
+ freeze_blocks.each do |block|
208
+ (block.start_line..block.end_line).each { |ln| frozen_lines << ln }
209
+ end
210
+
211
+ result = []
212
+ freeze_block_starts = freeze_blocks.map(&:start_line).to_set
213
+
214
+ @lines.each do |line|
215
+ if frozen_lines.include?(line.line_number)
216
+ # If this is the start of a freeze block, add the FreezeNode
217
+ if freeze_block_starts.include?(line.line_number)
218
+ block = freeze_blocks.find { |b| b.start_line == line.line_number }
219
+ result << block if block
220
+ end
221
+ # Skip individual lines in freeze blocks
222
+ else
223
+ result << line
224
+ end
225
+ end
226
+
227
+ result
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dotenv
4
+ module Merge
5
+ # Represents a freeze block in a dotenv file.
6
+ # Freeze blocks protect sections from being overwritten during merge.
7
+ #
8
+ # @example Freeze block in dotenv file
9
+ # # dotenv-merge:freeze Custom API settings
10
+ # API_KEY=my_custom_key
11
+ # API_SECRET=my_custom_secret
12
+ # # dotenv-merge:unfreeze
13
+ #
14
+ # @see Ast::Merge::FreezeNodeBase
15
+ class FreezeNode < Ast::Merge::FreezeNodeBase
16
+ # Make InvalidStructureError available as Dotenv::Merge::FreezeNode::InvalidStructureError
17
+ InvalidStructureError = Ast::Merge::FreezeNodeBase::InvalidStructureError
18
+
19
+ # Make Location available as Dotenv::Merge::FreezeNode::Location
20
+ Location = Ast::Merge::FreezeNodeBase::Location
21
+
22
+ # Initialize a new FreezeNode for dotenv
23
+ #
24
+ # @param start_line [Integer] Starting line number (1-indexed)
25
+ # @param end_line [Integer] Ending line number (1-indexed)
26
+ # @param analysis [FileAnalysis] The file analysis
27
+ # @param reason [String, nil] Optional reason from freeze marker
28
+ def initialize(start_line:, end_line:, analysis:, reason: nil)
29
+ super(
30
+ start_line: start_line,
31
+ end_line: end_line,
32
+ analysis: analysis,
33
+ reason: reason
34
+ )
35
+ end
36
+
37
+ # Get the content of this freeze block
38
+ # @return [String] The content lines joined
39
+ def content
40
+ @lines&.map { |l| l.respond_to?(:raw) ? l.raw : l.to_s }&.join("\n")
41
+ end
42
+
43
+ # Get a signature for this freeze block
44
+ # @return [Array] Signature based on normalized content
45
+ def signature
46
+ [:FreezeNode, content.gsub(/\s+/, " ").strip]
47
+ end
48
+
49
+ # Get environment variable lines within the freeze block
50
+ # @return [Array<EnvLine>] Assignment lines only
51
+ def env_lines
52
+ @lines&.select { |l| l.respond_to?(:assignment?) && l.assignment? } || []
53
+ end
54
+
55
+ # String representation for debugging
56
+ # @return [String]
57
+ def inspect
58
+ "#<#{self.class.name} lines=#{@start_line}..#{@end_line} env_vars=#{env_lines.size}>"
59
+ end
60
+ end
61
+ end
62
+ end