BallAerospace/COSMOS

View on GitHub
cosmos/lib/cosmos/config/config_parser.rb

Summary

Maintainability
F
3 days
Test Coverage
# encoding: ascii-8bit

# Copyright 2022 Ball Aerospace & Technologies Corp.
# All Rights Reserved.
#
# This program is free software; you can modify and/or redistribute it
# under the terms of the GNU Affero General Public License
# as published by the Free Software Foundation; version 3 with
# attribution addendums as found in the LICENSE.txt
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# This program may also be used under the terms of a commercial or
# enterprise edition license of COSMOS if purchased from the
# copyright holder

require 'cosmos/top_level'
require 'cosmos/ext/config_parser' if RUBY_ENGINE == 'ruby' and !ENV['COSMOS_NO_EXT']
require 'erb'

module Cosmos
  # Reads COSMOS style configuration data which consists of keywords followed
  # by 0 or more comma delimited parameters. Parameters with spaces must be
  # enclosed in quotes. Quotes should also be used to indicate a parameter is a
  # string. Keywords are case-insensitive and will be returned in uppercase.
  class ConfigParser
    # @return [String] The current keyword being parsed
    attr_accessor :keyword

    # @return [Array<String>] The parameters found after the keyword
    attr_accessor :parameters

    # @return [String] The name of the configuration file being parsed. This
    #   will be an empty string if the parse_string class method is used.
    attr_accessor :filename

    # @return [String] The current line being parsed. This is the raw string
    #   which is useful when printing errors.
    attr_accessor :line

    # @return [Integer] The current line number being parsed.
    #   This will still be populated when using parse_string because lines
    #   still must be delimited by newline characters.
    attr_accessor :line_number

    # @return [String] The default URL to use in errors. The URL can still be
    #   overridden by directly passing it to the error method.
    attr_accessor :url

    # @see message_callback=
    @@message_callback = nil

    # @param message_callback [#call(String)] Callback method called with a
    #   String when various parsing events occur.
    def self.message_callback=(message_callback)
      @@message_callback = message_callback
    end

    # @see progress_callback=
    @@progress_callback = nil

    # @param progress_callback [#call(Float)] Callback method called with a
    #   Float (0.0 to 100.0) based on the amount of the io param that has
    #   currently been processed.
    def self.progress_callback=(progress_callback)
      @@progress_callback = progress_callback
    end

    # Holds the current splash screen
    @@splash = nil

    # @param splash [Splash::SplashDialogBox] Set the splash dialog box which
    #   will be updated with messages and progress.
    def self.splash=(splash)
      if splash
        @@splash = splash
        @@progress_callback = splash.progress_callback
        @@message_callback = splash.message_callback
      else
        @@splash = nil
        @@progress_callback = nil
        @@message_callback = nil
      end
    end

    # Returns the current splash screen if present
    def self.splash
      @@splash
    end

    # Regular expression used to break up an individual line into a keyword and
    # comma delimited parameters. Handles parameters in single or double quotes.
    PARSING_REGEX = %r{ (?:"(?:[^\\"]|\\.)*") | (?:'(?:[^\\']|\\.)*') | \S+ }x # "

    # Error which gets raised by ConfigParser in #verify_num_parameters. This
    # is also the error that classes using ConfigParser should raise when they
    # encounter a configuration error.
    class Error < StandardError
      attr_reader :keyword, :parameters, :filename, :line, :line_number

      # @return [String] The usage string representing how this keyword should
      #   be formatted.
      attr_reader :usage

      # @return [String] URL which points to usage documentation on the COSMOS
      #   Wiki.
      attr_reader :url

      # Create an Error with the specified Config data
      #
      # @param config_parser [ConfigParser] Instance of ConfigParser so Error
      #   has access to the ConfigParser attributes
      # @param message [String] The error message which gets passed to the
      #   StandardError constructor
      # @param usage [String] The usage string representing how this keyword should
      #   be formatted.
      # @param url [String] URL which should point to usage information. By
      #   default this gets constructed to point to the generic configuration
      #   Guide on the COSMOS Wiki.
      def initialize(config_parser, message = "Configuration Error", usage = "", url = "")
        if Error == message
          super(message.message)
        elsif Exception == message
          super("#{message.class}:#{message.message}")
        else
          super(message)
        end
        @keyword = config_parser.keyword
        @parameters = config_parser.parameters
        @filename = config_parser.filename
        @line = config_parser.line
        @line_number = config_parser.line_number
        @usage = usage
        @url = url
      end
    end

    # @param url [String] The url to link to in error messages
    def initialize(url = "https://ballaerospace.github.io/cosmos-website/docs/v5")
      @url = url
    end

    # Creates an Error
    #
    # @param message [String] The string to set the Exception message to
    # @param usage [String] The usage message
    # @param url [String] Where to get help about this error
    # @return [Error] The constructed error
    def error(message, usage = "", url = @url)
      return Error.new(self, message, usage, url)
    end

    # Called by the ERB template to render a partial
    def render(template_name, options = {})
      raise Error.new(self, "Partial name '#{template_name}' must begin with an underscore.") if File.basename(template_name)[0] != '_'

      b = binding
      if options[:locals]
        options[:locals].each { |key, value| b.local_variable_set(key, value) }
      end
      # Assume the file is there. If not we raise a pretty obvious error
      if File.expand_path(template_name) == template_name # absolute path
        path = template_name
      else # relative to the current @filename
        path = File.join(File.dirname(@filename), template_name)
      end
      Cosmos.set_working_dir(File.dirname(path)) do
        return ERB.new(File.read(path), trim_mode: "-").result(b)
      end
    end

    # Processes a file and yields |config| to the given block
    #
    # @param filename [String] The full name and path of the configuration file
    # @param yield_non_keyword_lines [Boolean] Whether to yield all lines including blank
    #   lines or comment lines.
    # @param remove_quotes [Boolean] Whether to remove beginning and ending single
    #   or double quote characters from parameters.
    # @param run_erb [Boolean] Whether or not to run ERB on the file
    # @param variables [Hash] variables to pash to ERB context
    # @param block [Block] The block to yield to
    # @yieldparam keyword [String] The keyword in the current parsed line
    # @yieldparam parameters [Array<String>] The parameters in the current parsed line
    def parse_file(filename,
                   yield_non_keyword_lines = false,
                   remove_quotes = true,
                   run_erb = true,
                   variables = {},
                   &block)
      raise Error.new(self, "Configuration file #{filename} does not exist.") unless filename && File.exist?(filename)

      @filename = filename

      # Create a temp file where we write the ERB parsed output
      file = create_parsed_output_file(filename, run_erb, variables)
      size = file.stat.size.to_f

      # Callbacks for beginning of parsing
      @@message_callback.call("Parsing #{size} bytes of #{filename}") if @@message_callback
      @@progress_callback.call(0.0) if @@progress_callback

      begin
        # Loop through each line of the data
        parse_loop(file,
                   yield_non_keyword_lines,
                   remove_quotes,
                   size,
                   PARSING_REGEX,
                   &block)
      rescue Exception => e # Catch EVERYTHING so we can re-raise with additional info
        raise e, "#{e}\n\nParsed output in #{file.path}", e.backtrace
      ensure
        file.close unless file.closed?
      end
    end

    # Verifies the parameters in the config parameter have the specified
    # number of parameter and raises an Error if not.
    #
    # @param [Integer] min_num_params The minimum number of parameters
    # @param [Integer] max_num_params The maximum number of parameters. Pass
    #   nil to indicate there is no maximum number of parameters.
    def verify_num_parameters(min_num_params, max_num_params, usage = "")
      # This syntax works with 0 because each doesn't return any values
      # for a backwards range
      (1..min_num_params).each do |index|
        # If the parameter is nil (0 based) then we have a problem
        if @parameters[index - 1].nil?
          raise Error.new(self, "Not enough parameters for #{@keyword}.", usage, @url)
        end
      end
      # If they pass nil for max_params we don't check for a maximum number
      if max_num_params && !@parameters[max_num_params].nil?
        raise Error.new(self, "Too many parameters for #{@keyword}.", usage, @url)
      end
    end

    # Verifies the indicated parameter in the config doesn't start or end
    # with an underscore, doesn't contain a double underscore, doesn't contain
    # spaces and doesn't start with a close bracket.
    #
    # @param [Integer] index The index of the parameter to check
    def verify_parameter_naming(index, usage = "")
      param = @parameters[index - 1]
      if param.end_with? '_'
        raise Error.new(self, "Parameter #{index} (#{param}) for #{@keyword} cannot end with an underscore ('_').", usage, @url)
      end
      if param.include? '__'
        raise Error.new(self, "Parameter #{index} (#{param}) for #{@keyword} cannot contain a double underscore ('__').", usage, @url)
      end
      if param.include? ' '
        raise Error.new(self, "Parameter #{index} (#{param}) for #{@keyword} cannot contain a space (' ').", usage, @url)
      end
      if param.start_with?('}')
        raise Error.new(self, "Parameter #{index} (#{param}) for #{@keyword} cannot start with a close bracket ('}').", usage, @url)
      end
    end

    # Converts a String containing '', 'NIL' or 'NULL' to nil Ruby primitive.
    # All other arguments are simply returned.
    #
    # @param value [Object]
    # @return [nil|Object]
    def self.handle_nil(value)
      if String === value
        case value.upcase
        when '', 'NIL', 'NULL'
          return nil
        end
      end
      return value
    end

    # Converts a String containing 'TRUE' or 'FALSE' to true or false Ruby
    # primitive. All other values are simply returned.
    #
    # @param value [Object]
    # @return [true|false|Object]
    def self.handle_true_false(value)
      if String === value
        case value.upcase
        when 'TRUE'
          return true
        when 'FALSE'
          return false
        end
      end
      return value
    end

    # Converts a String containing '', 'NIL', 'NULL', 'TRUE' or 'FALSE' to nil,
    # true or false Ruby primitives. All other values are simply returned.
    #
    # @param value [Object]
    # @return [true|false|nil|Object]
    def self.handle_true_false_nil(value)
      if String === value
        case value.upcase
        when 'TRUE'
          return true
        when 'FALSE'
          return false
        when '', 'NIL', 'NULL'
          return nil
        end
      end
      return value
    end

    # Converts a string representing a defined constant into its value. The
    # defined constants are the minimum and maximum values for all the
    # allowable data types. [MIN/MAX]_[U]INT[8/16/32] and
    # [MIN/MAX]_FLOAT[32/64]. Thus MIN_UINT8, MAX_INT32, and MIN_FLOAT64 are
    # all allowable values. Any other strings raise ArgumentError but all other
    # types are simply returned.
    #
    # @param value [Object] Can be anything
    # @return [Numeric] The converted value. Either a Fixnum or Float.
    def self.handle_defined_constants(value, data_type = nil, bit_size = nil)
      if value.class == String
        case value.upcase
        when 'MIN', 'MAX'
          return self.calculate_range_value(value.upcase, data_type, bit_size)
        when 'MIN_INT8'
          return -128
        when 'MAX_INT8'
          return 127
        when 'MIN_INT16'
          return -32768
        when 'MAX_INT16'
          return 32767
        when 'MIN_INT32'
          return -2147483648
        when 'MAX_INT32'
          return 2147483647
        when 'MIN_INT64'
          return -9223372036854775808
        when 'MAX_INT64'
          return 9223372036854775807
        when 'MIN_UINT8', 'MIN_UINT16', 'MIN_UINT32', 'MIN_UINT64'
          return 0
        when 'MAX_UINT8'
          return 255
        when 'MAX_UINT16'
          return 65535
        when 'MAX_UINT32'
          return 4294967295
        when 'MAX_UINT64'
          return 18446744073709551615
        when 'MIN_FLOAT64'
          return -Float::MAX
        when 'MAX_FLOAT64'
          return Float::MAX
        when 'MIN_FLOAT32'
          return -3.402823e38
        when 'MAX_FLOAT32'
          return 3.402823e38
        when 'POS_INFINITY'
          return Float::INFINITY
        when 'NEG_INFINITY'
          return -Float::INFINITY
        else
          raise ArgumentError, "Could not convert constant: #{value}"
        end
      end
      return value
    end

    protected

    # Writes the ERB parsed results
    def create_parsed_output_file(filename, run_erb, variables)
      begin
        output = nil
        if run_erb
          Cosmos.set_working_dir(File.dirname(filename)) do
            output = ERB.new(File.read(filename), trim_mode: "-").result(binding.set_variables(variables))
          end
        else
          output = File.read(filename)
        end
      rescue => e
        # The first line of the backtrace indicates the line where the ERB
        # parse failed. Grab the line number for the error message.
        match = /:(.*):/.match(e.backtrace[0])
        line_number = match.captures[0] if match
        raise e, "ERB error at #{filename}:#{line_number}\n#{e}", e.backtrace
      end
      # Make a copy of the filename since we're calling slice! which modifies it directly
      copy = filename.dup
      config_index = copy.index('config')
      if config_index
        copy = copy[config_index..-1]
      elsif copy.include?(':') # Check for Windows drive letter
        copy = copy.split(':')[1]
      end
      parsed_filename = File.join(Dir.tmpdir, 'cosmos', 'tmp', copy)
      FileUtils.mkdir_p(File.dirname(parsed_filename)) # Create the path
      file = File.open(parsed_filename, 'w+')
      file.puts output
      file.rewind # Rewind so the file is ready to read
      file
    end

    def self.calculate_range_value(type, data_type, bit_size)
      value = 0 # Default for UINT minimum

      case data_type
      when :INT
        if type == 'MIN'
          value = -2**(bit_size - 1)
        else # 'MAX'
          value = 2**(bit_size - 1) - 1
        end
      when :UINT
        # Default is 0 for 'MIN'
        if type == 'MAX'
          value = 2**bit_size - 1
        end
      when :FLOAT
        case bit_size
        when 32
          value = 3.402823e38
          value *= -1 if type == 'MIN'
        when 64
          value = Float::MAX
          value *= -1 if type == 'MIN'
        else
          raise ArgumentError, "Invalid bit size #{bit_size} for FLOAT type."
        end
      else
        raise ArgumentError, "Invalid data type #{data_type} when calculating range."
      end
      value
    end

    if RUBY_ENGINE != 'ruby' or ENV['COSMOS_NO_EXT']
      # Iterates over each line of the io object and yields the keyword and parameters
      def parse_loop(io, yield_non_keyword_lines, remove_quotes, size, rx)
        line_continuation = false

        @line_number = 0
        @keyword = nil
        @parameters = []
        @line = nil

        while true
          @line_number += 1

          if @@progress_callback && ((@line_number % 10) == 0)
            @@progress_callback.call(io.pos / size) if size > 0.0
          end

          begin
            line = io.readline
          rescue Exception
            break
          end

          line.strip!
          data = line.scan(rx)
          first_item = data[0].to_s

          if line_continuation
            @line << line
            # Carry over keyword and parameters
          else
            @line = line
            if (first_item.length == 0) || (first_item[0] == '#')
              @keyword = nil
            else
              @keyword = first_item.upcase
            end
            @parameters = []
          end

          # Ignore comments and blank lines
          if @keyword.nil?
            if (yield_non_keyword_lines) && (!line_continuation)
              yield(@keyword, @parameters)
            end
            next
          end

          if line_continuation
            if remove_quotes
              @parameters << first_item.remove_quotes
            else
              @parameters << first_item
            end
            line_continuation = false
          end

          length = data.length
          if length > 1
            (1..(length - 1)).each do |index|
              string = data[index]

              # Don't process trailing comments such as:
              # KEYWORD PARAM #This is a comment
              # But still process Ruby string interpolations such as:
              # KEYWORD PARAM #{var}
              if (string.length > 0) && (string[0] == '#')
                if !((string.length > 1) && (string[1] == '{'))
                  break
                end
              end

              # If the string is simply '&' and its the last string then its a line continuation so break the loop
              if (string.length == 1) && (string[0] == '&') && (index == (length - 1))
                line_continuation = true
                next
              end

              line_continuation = false
              if remove_quotes
                @parameters << string.remove_quotes
              else
                @parameters << string
              end
            end
          end

          # If we detected a line continuation while going through all the
          # strings on the line then we strip off the continuation character and
          # return to the top of the loop to continue processing the line.
          if line_continuation
            # Strip the continuation character
            if @line.length >= 1
              @line = @line[0..-2]
            else
              @line = ""
            end
            next
          end

          yield(@keyword, @parameters)
        end

        @@progress_callback.call(1.0) if @@progress_callback

        return nil
      end
    end
  end
end