Skip to content

Commit

Permalink
Merge pull request #517 from bkeepers/refactor
Browse files Browse the repository at this point in the history
Refactor parser to avoid duplicate scan/gsub
  • Loading branch information
bkeepers authored Dec 12, 2024
2 parents b396779 + 25bc9d6 commit 5e776d2
Show file tree
Hide file tree
Showing 3 changed files with 64 additions and 76 deletions.
117 changes: 55 additions & 62 deletions lib/dotenv/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,29 @@ class FormatError < SyntaxError; end
# Parses the `.env` file format into key/value pairs.
# It allows for variable substitutions, command substitutions, and exporting of variables.
class Parser
@substitutions =
[Dotenv::Substitutions::Variable, Dotenv::Substitutions::Command]
@substitutions = [
Dotenv::Substitutions::Variable,
Dotenv::Substitutions::Command
]

LINE = /
(?:^|\A) # beginning of line
\s* # leading whitespace
(?:export\s+)? # optional export
([\w.]+) # key
(?:\s*=\s*?|:\s+?) # separator
( # optional value begin
\s*'(?:\\'|[^'])*' # single quoted value
| # or
\s*"(?:\\"|[^"])*" # double quoted value
| # or
[^\#\r\n]+ # unquoted value
)? # value end
\s* # trailing whitespace
(?:\#.*)? # optional comment
(?:$|\z) # end of line
(?:^|\A) # beginning of line
\s* # leading whitespace
(?<export>export\s+)? # optional export
(?<key>[\w.]+) # key
(?: # optional separator and value
(?:\s*=\s*?|:\s+?) # separator
(?<value> # optional value begin
\s*'(?:\\'|[^'])*' # single quoted value
| # or
\s*"(?:\\"|[^"])*" # double quoted value
| # or
[^\#\n]+ # unquoted value
)? # value end
)? # separator and value end
\s* # trailing whitespace
(?:\#.*)? # optional comment
(?:$|\z) # end of line
/x

class << self
Expand All @@ -38,43 +42,55 @@ def call(...)
end

def initialize(string, overwrite: false)
@string = string
# Convert line breaks to same format
@string = string.gsub(/[\n\r]+/, "\n")
@hash = {}
@variables_to_ignore = overwrite ? nil : ENV.except("DOTENV_LINEBREAK_MODE")
@overwrite = overwrite
end

def call
# Convert line breaks to same format
lines = @string.gsub(/\r\n?/, "\n")
# Process matches
lines.scan(LINE).each do |key, value|
next if @variables_to_ignore&.include?(key)

@hash[key] = parse_value(value || "")
end
# Process non-matches
lines.gsub(LINE, "").split(/[\n\r]+/).each do |line|
parse_line(line)
@string.scan(LINE) do
match = $LAST_MATCH_INFO

# Skip parsing values that will be ignored
next if ignore?(match[:key])

# Check for exported variable with no value
if match[:export] && !match[:value]
if !@hash.member?(match[:key])
raise FormatError, "Line #{match.to_s.inspect} has an unset variable"
end
else
@hash[match[:key]] = parse_value(match[:value] || "")
end
end

@hash
end

private

def parse_line(line)
if line.split.first == "export"
if variable_not_set?(line)
raise FormatError, "Line #{line.inspect} has an unset variable"
end
end
# Determine if the key can be ignored.
def ignore?(key)
!@overwrite && key != "DOTENV_LINEBREAK_MODE" && ENV.key?(key)
end

QUOTED_STRING = /\A(['"])(.*)\1\z/m
def parse_value(value)
# Remove surrounding quotes
value = value.strip.sub(/\A(['"])(.*)\1\z/m, '\2')
value = value.strip.sub(QUOTED_STRING, '\2')
maybe_quote = Regexp.last_match(1)
value = unescape_value(value, maybe_quote)
perform_substitutions(value, maybe_quote)

# Expand new lines in double quoted values
value = expand_newlines(value) if maybe_quote == '"'

# Unescape characters and performs substitutions unless value is single quoted
if maybe_quote != "'"
value = unescape_characters(value)
self.class.substitutions.each { |proc| value = proc.call(value, @hash) }
end

value
end

def unescape_characters(value)
Expand All @@ -88,28 +104,5 @@ def expand_newlines(value)
value.gsub('\n', "\\\\\\n").gsub('\r', "\\\\\\r")
end
end

def variable_not_set?(line)
!line.split[1..].all? { |var| @hash.member?(var) }
end

def unescape_value(value, maybe_quote)
if maybe_quote == '"'
unescape_characters(expand_newlines(value))
elsif maybe_quote.nil?
unescape_characters(value)
else
value
end
end

def perform_substitutions(value, maybe_quote)
if maybe_quote != "'"
self.class.substitutions.each do |proc|
value = proc.call(value, @hash)
end
end
value
end
end
end
21 changes: 8 additions & 13 deletions lib/dotenv/substitutions/variable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class << self
VARIABLE = /
(\\)? # is it escaped with a backslash?
(\$) # literal $
(?!\() # shouldnt be followed by paranthesis
(?!\() # shouldn't be followed by parenthesis
\{? # allow brace wrapping
([A-Z0-9_]+)? # optional alpha nums
\}? # closing brace
Expand All @@ -21,19 +21,14 @@ class << self
def call(value, env)
value.gsub(VARIABLE) do |variable|
match = $LAST_MATCH_INFO
substitute(match, variable, env)
end
end

private

def substitute(match, variable, env)
if match[1] == "\\"
variable[1..]
elsif match[3]
env[match[3]] || ENV[match[3]] || ""
else
variable
if match[1] == "\\"
variable[1..]
elsif match[3]
env[match[3]] || ENV[match[3]] || ""
else
variable
end
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/dotenv/template.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def is_comment?(line)

def var_defined?(line)
match = Dotenv::Parser::LINE.match(line)
match && match[1]
match && match[:key]
end

def line_blank?(line)
Expand Down

0 comments on commit 5e776d2

Please sign in to comment.