lib/dotenv/parser.rb



require "dotenv/substitutions/variable"
require "dotenv/substitutions/command" if RUBY_VERSION > "1.8.7"

module Dotenv
  # Error raised when encountering a syntax error while parsing a .env file.
  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
    ]

    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

    QUOTED_STRING = /\A(['"])(.*)\1\z/m

    class << self
      attr_reader :substitutions

      def call(...)
        new(...).call
      end
    end

    def initialize(string, overwrite: false)
      # Convert line breaks to same format
      @string = string.gsub(/\r\n?/, "\n")
      @hash = {}
      @overwrite = overwrite
    end

    def call
      @string.scan(LINE) do
        match = $LAST_MATCH_INFO

        if existing?(match[:key])
          # Use value from already defined variable
          @hash[match[:key]] = ENV[match[:key]]
        elsif match[:export] && !match[:value]
          # Check for exported variable with no 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

    # Determine if a variable is already defined and should not be overwritten.
    def existing?(key)
      !@overwrite && key != "DOTENV_LINEBREAK_MODE" && ENV.key?(key)
    end

    def parse_value(value)
      # Remove surrounding quotes
      value = value.strip.sub(QUOTED_STRING, '\2')
      maybe_quote = Regexp.last_match(1)

      # 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)
      value.gsub(/\\([^$])/, '\1')
    end

    def expand_newlines(value)
      if (@hash["DOTENV_LINEBREAK_MODE"] || ENV["DOTENV_LINEBREAK_MODE"]) == "legacy"
        value.gsub('\n', "\n").gsub('\r', "\r")
      else
        value.gsub('\n', "\\\\\\n").gsub('\r', "\\\\\\r")
      end
    end
  end
end