OpenC3/cosmos

View on GitHub
openc3/lib/openc3/core_ext/string.rb

Summary

Maintainability
A
2 hrs
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.

# Modified by OpenC3, Inc.
# All changes Copyright 2022, OpenC3, Inc.
# All Rights Reserved
#
# This file may also be used under the terms of a commercial license
# if purchased from OpenC3, Inc.

require 'openc3/packets/binary_accessor'
require 'openc3/ext/string' if RUBY_ENGINE == 'ruby' and !ENV['OPENC3_NO_EXT']

# OpenC3 specific additions to the Ruby String class
class String
  # The printable range of ASCII characters
  PRINTABLE_RANGE = 32..126
  # Regular expression to identify a character that is not in the printable range
  NON_PRINTABLE_REGEX = /[^\s!-~]/
  # Regular expression to identify a String as a floating point number
  FLOAT_CHECK_REGEX = /\A\s*[-+]?\d*\.\d+\s*\z/
  # Regular expression to identify a String as a floating point number in
  # scientific notation
  SCIENTIFIC_CHECK_REGEX = /\A\s*[-+]?(\d+((\.\d+)?)|(\.\d+))[eE][-+]?\d+\s*\z/
  # Regular expression to identify a String as an integer
  INT_CHECK_REGEX = /\A\s*[-+]?\d+\s*\z/
  # Regular expression to identify a String as an integer in hexadecimal format
  HEX_CHECK_REGEX = /\A\s*0[xX][\dabcdefABCDEF]+\s*\z/
  # Regular expression to identify a String as an Array of numbers
  ARRAY_CHECK_REGEX = /\A\s*\[.*\]\s*\z/

  # Displays a String containing binary data in a human readable format by
  # converting each byte to the hex representation.
  #
  # @param word_size [Integer] How many bytes compose a word. Words are grouped
  #   together without spaces in between
  # @param words_per_line [Integer] The number of words to display on a single
  #   formatted line
  # @param word_separator [String] The string to place between words
  # @param indent [Integer] The amount of spaces to put in front of each
  #   formatted line
  # @param show_address [Boolean] Whether to show the hex address of the first
  #   byte in the formatted output
  # @param address_separator [String] The string to put after the hex address.
  #   Only used if show_address is true.
  # @param show_ascii [Boolean] Whether to interpret the binary data as ASCII
  #   characters and display the printable characters to the right of the
  #   formatted line
  # @param ascii_separator [String] The string to put between the formatted
  #   line and the ASCII characters. Only used if show_ascii is true.
  # @param unprintable_character [String] The string to output when data in the
  #   binary String does not result in a printable ASCII character. Only used if
  #   show_ascii is true.
  # @param line_separator [String] The string used to end a line. Normally newline.
  def formatted(
    word_size = 1,
    words_per_line = 16,
    word_separator = ' ',
    indent = 0,
    show_address = true,
    address_separator = ': ',
    show_ascii = true,
    ascii_separator = '  ',
    unprintable_character = ' ',
    line_separator = "\n"
  )
    string = ''
    byte_offset = 0
    bytes_per_line = word_size * words_per_line
    indent_string = ' ' * indent
    ascii_line = ''

    self.each_byte do |byte|
      if byte_offset % bytes_per_line == 0
        # Create the indentation at the beginning of each line
        string << indent_string

        # Add the address if requested
        string << sprintf("%08X%s", byte_offset, address_separator) if show_address
      end

      # Add the byte
      string << sprintf("%02X", byte)

      # Create the ASCII representation if requested
      if show_ascii
        if PRINTABLE_RANGE.include?(byte)
          ascii_line << [byte].pack('C')
        else
          ascii_line << unprintable_character
        end
      end

      # Move to next byte
      byte_offset += 1

      # If we're at the end of the line we output the ascii if requested
      if byte_offset % bytes_per_line == 0
        if show_ascii
          string << "#{ascii_separator}#{ascii_line}"
          ascii_line = ''
        end
        string << line_separator

      # If we're at a word junction then output the word_separator
      elsif (byte_offset % word_size == 0) and byte_offset != self.length
        string << word_separator
      end
    end

    # We're done printing all the bytes. Now check to see if we ended in the
    # middle of a line. If so we have to print out the final ASCII if
    # requested.
    if byte_offset % bytes_per_line != 0
      if show_ascii
        num_word_separators = ((byte_offset % bytes_per_line) - 1) / word_size
        existing_length = (num_word_separators * word_separator.length) + ((byte_offset % bytes_per_line) * 2)
        full_line_length = (bytes_per_line * 2) + ((words_per_line - 1) * word_separator.length)
        filler = ' ' * (full_line_length - existing_length)
        ascii_filler = ' ' * (bytes_per_line - ascii_line.length)
        string << "#{filler}#{ascii_separator}#{ascii_line}#{ascii_filler}"
        ascii_line = ''
      end
      string << line_separator
    end
    string
  end

  # Displays a String containing binary data in a human readable format by
  # converting each byte to the hex representation.
  # Simply formatted as a single string of bytes
  def simple_formatted
    string = ''
    self.each_byte do |byte|
      string << sprintf("%02X", byte)
    end
    string
  end

  # Uses the String each_line method to iterate through the lines and removes
  # the line specified.
  #
  # @param line_number [Integer] The line to remove from the string (1 based)
  # @param separator [String] The record separator to pass to #each_line
  #   ($/ by default is the newline character)
  # @return [String] A new string with the line removed
  def remove_line(line_number, separator = $/)
    new_string = ''
    index = 1
    self.each_line(separator) do |line|
      new_string << line unless index == line_number
      index += 1
    end
    new_string
  end

  # @return [Integer] The number of lines in the string (as split by the newline
  #   character)
  def num_lines
    value = self.count("\n")
    value += 1 if self[-1..-1] and self[-1..-1] != "\n"
    value
  end

  if RUBY_ENGINE != 'ruby' or ENV['OPENC3_NO_EXT']
    # @return [String] The string with leading and trailing quotes removed
    def remove_quotes
      return self if self.length < 2

      first_char = self[0]
      return self if (first_char != '"') && (first_char != "'")

      last_char = self[-1]
      return self if first_char != last_char

      return self[1..-2]
    end
  end

  # @return [Boolean] Whether the String represents a floating point number
  def is_float?
    if self =~ FLOAT_CHECK_REGEX or self =~ SCIENTIFIC_CHECK_REGEX then true else false end
  end

  # @return [Boolean] Whether the String represents an integer
  def is_int?
    if INT_CHECK_REGEX.match?(self) then true else false end
  end

  # @return [Boolean] Whether the String represents a hexadecimal number
  def is_hex?
    if HEX_CHECK_REGEX.match?(self) then true else false end
  end

  # @return [Boolean] Whether the String represents an Array
  def is_array?
    if ARRAY_CHECK_REGEX.match?(self) then true else false end
  end

  # @return [Boolean] Whether the string contains only printable characters
  def is_printable?
    if NON_PRINTABLE_REGEX.match?(self) then false else true end
  end

  # @return Converts the String into either a Float, Integer, or Array
  # depending on what the String represents. It can successfully convert
  # floating point numbers in both fixed and scientific notation, integers
  # in hexadecimal notation, and Arrays. If it can't be converted into
  # any of the above then the original String is returned.
  def convert_to_value
    return_value = self
    begin
      upcase_self = self.upcase
      if upcase_self == 'INFINITY'.freeze
        return_value = Float::INFINITY
      elsif upcase_self == '-INFINITY'.freeze
        return_value = -Float::INFINITY
      elsif upcase_self == 'NAN'.freeze
        return_value = Float::NAN
      elsif self.is_float?
        # Floating Point in normal or scientific notation
        return_value = self.to_f
      elsif self.is_int?
        # Integer
        return_value = self.to_i
      elsif self.is_hex?
        # Hex
        return_value = Integer(self)
      elsif self.is_array?
        # Array
        return_value = eval(self)
      end
    rescue Exception
      # Something went wrong so just return the string as is
    end
    return return_value
  end

  # Converts the String representing a hexadecimal number (i.e. "0xABCD")
  # to a binary String with the same data (i.e "\xAB\xCD")
  #
  # @return [String] Binary byte string
  def hex_to_byte_string
    string = self.dup

    # Remove leading 0x or 0X
    if string[0..1] == '0x' or string[0..1] == '0X'
      string = string[2..-1]
    end

    length = string.length
    length += 1 unless (length % 2) == 0

    array = []
    (length / 2).times do
      # Grab last two characters
      if string.length >= 2
        last_two_characters = string[-2..-1]
        string = string[0..-3]
      else
        last_two_characters = string[0..0]
        string = ''
      end

      int_value = Integer('0x' + last_two_characters)

      array.unshift(int_value)
    end

    array.pack("C*")
  end

  # Converts a String representing a class (i.e. "MyGreatClass") to a Ruby
  # filename which implements the class (i.e. "my_great_class.rb").
  #
  # @param include_extension [Boolean] Whether to add '.rb' extension
  # @return [String] Filename which implements the class name
  def class_name_to_filename(include_extension = true)
    string = self.split("::")[-1] # Remove any namespacing
    filename = ''
    length = string.length
    length.times do |index|
      filename << '_' if index != 0 and string[index..index] == string[index..index].upcase
      filename << string[index..index].downcase
    end
    filename << '.rb' if include_extension
    filename
  end

  # Converts a String representing a filename (i.e. "my_great_class.rb") to a Ruby
  # class name (i.e. "MyGreatClass").
  #
  # @return [String] Class name associated with the filename
  def filename_to_class_name
    filename = File.basename(self)
    class_name = ''
    length = filename.length
    upcase_next = true
    length.times do |index|
      break if filename[index..index] == '.'

      if filename[index..index] == '_'
        upcase_next = true
      elsif upcase_next
        class_name << filename[index..index].upcase
        upcase_next = false
      else
        class_name << filename[index..index].downcase
      end
    end
    class_name
  end

  # Converts a String representing a class (i.e. "MyGreatClass") to the actual
  # class that has been required and is present in the Ruby runtime.
  #
  # @return [Class]
  def to_class
    klass = nil
    split_self = self.split('::')
    if split_self.length > 1
      split_self.each do |class_name|
        if klass
          klass = klass.const_get(class_name)
        else
          klass = Object.const_get(class_name)
        end
      end
    else
      begin
        klass = OpenC3.const_get(self)
      rescue
        begin
          klass = Object.const_get(self)
        rescue
        end
      end
    end
    klass
  end

  # Adds quotes if the string contains whitespace
  #
  # @param quote_char [String] The quote character to add if necessary
  # @return [String] quoted string if necessary
  def quote_if_necessary(quote_char = '"')
    if /\s/.match?(self)
      return quote_char + self + quote_char
    else
      return self
    end
  end

  # Converts a string to UTF-8 and returns a new string
  # Assumes the string is Windows-1252 encoded if marked ASCII-8BIT and not UTF-8 compatible
  #
  # @return [String] UTF-8 encoded string
  def to_utf8
    self.dup.to_utf8!
  end

  # Converts a string to UTF-8 in place
  # Assumes the string is Windows-1252 encoded if marked ASCII-8BIT and not UTF-8 compatible
  #
  # @return [String] UTF-8 encoded string
  def to_utf8!
    if self.encoding == Encoding::ASCII_8BIT
      if self.force_encoding('UTF-8').valid_encoding?
        return self
      else
        # Note: this will replace any characters without a valid conversion with space (shouldn't be possible from Windows-1252)
        return self.force_encoding('Windows-1252').encode!('UTF-8', invalid: :replace, undef: :replace, replace: ' ')
      end
    else
      # Note: this will replace any characters without a valid conversion with space
      self.encode!('UTF-8', invalid: :replace, undef: :replace, replace: ' ')
    end
  end

  def comment_erb
    output = self.lines.collect! do |line|
      # If we have a commented out line that starts with #
      # but not followed by % (allows for disabling ERB comments),
      # which contains an ERB statement (<% ...)
      # then comment out the ERB statement (<%# ...).
      # We explicitly don't comment out trailing ERB statements
      # as that is not typical and is difficult to regex
      if line =~ /^\s*#[^%]*<%/
        line.gsub!('<%', '<%#')
      end
      line
    end
    return output.join("")
  end
end