Login
strings.cr at tip
Login

File src/remilib/strings.cr from the latest check-in


     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
   100
   101
   102
   103
   104
   105
   106
   107
   108
   109
   110
   111
   112
   113
   114
   115
   116
   117
   118
   119
   120
   121
   122
   123
   124
   125
   126
   127
   128
   129
   130
   131
   132
   133
   134
   135
   136
   137
   138
   139
   140
   141
   142
   143
   144
   145
   146
   147
   148
   149
   150
   151
   152
   153
   154
   155
   156
   157
   158
   159
   160
   161
   162
   163
   164
   165
   166
   167
   168
   169
   170
   171
   172
   173
   174
   175
   176
   177
   178
   179
   180
   181
   182
   183
   184
   185
   186
   187
   188
   189
   190
   191
   192
   193
   194
   195
   196
   197
   198
   199
   200
   201
   202
   203
   204
   205
   206
   207
   208
   209
   210
   211
   212
   213
   214
   215
   216
   217
   218
   219
   220
   221
   222
   223
   224
   225
   226
   227
   228
#### libremiliacr
#### Copyright(C) 2020-2024 Remilia Scarlet <remilia@posteo.jp>
####
#### This program is free software: you can redistribute it and/or modify it
#### under the terms of the GNU General Public License as published the Free
#### Software Foundation, either version 3 of the License, or (at your option)
#### any later version.
####
#### 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 General Public License for
#### more details.
####
#### You should have received a copy of the GNU General Public License along
#### with this program.If not, see<http:####www.gnu.org/licenses/.>
require "math"

module RemiLib
  extend self

  # :nodoc:
  SANITIZE_CHARS = {
    '\e' => "ESC",
    '\u{07}' => "BEL",
    '\0' => "NUL",
    '^' => "CFLEX",
    '\u{08}' => "BS",
    '\u{09}' => "HT",
    '\u{0B}' => "VT",
    '\u{0C}' => "FF",
    '\u{1A}' => "SUB",
    '\u{01}' => "SOH",
    '\u{02}' => "STX",
    '\u{03}' => "ETX",
    '\u{04}' => "EOT",
    '\u{05}' => "ENQ",
    '\u{06}' => "ACK",
    '\u{0E}' => "SO",
    '\u{0F}' => "SI",
    '\u{7F}' => "DEL"
  }

  # :nodoc:
  SANITIZE_EXTRA_CHARS = {
    '\u{0A}' => "LF",
    '\u{0D}' => "CR"
  }

  # Sanitizes *str* so that it's somewhat safer to print into a terminal.  This
  # essentially strips out meta characters (`ESC`, `SUB`, `BEL`, etc.),
  # replacing them with textual representations.
  #
  # If *leaveExtras* is `true`, then this will **NOT** remove additional
  # characters such as Carriage Return and Line Feed.
  @[AlwaysInline]
  def sanitize(str : String, leaveExtras : Bool = false) : String
    ret = str.gsub(SANITIZE_CHARS)
    ret = ret.gsub(SANITIZE_EXTRA_CHARS) unless leaveExtras
    ret
  end

  # Trims a null-padded string.
  #
  # If *fromFirst* is `false`, this removes all trailing null characters from a
  # string.  If *fromFirst* is `true`, all characters (null or not) are removed
  # starting from the first null character found.
  def trimNulls(str : String, fromFirst : Bool = true) : String
    if fromFirst
      first = str.index('\u{0}')
      if first.nil?
        str
      else
        str.[0...first]
      end
    else
      str.rstrip('\u{0}')
    end
  end

  # Returns a new `String` that contains *str*.  If the length of *str* is less
  # than *minLength*, then additional null bytes are added until the length is
  # equal to *minLength*.
  def padString(str : String, minLength : Int) : String
    String.build(minLength) do |ret|
      ret << str
      (minLength - str.size).times { |_| ret << '\u{0}' }
    end
  end

  # Writes *str* to *dest*.  If the length of *str* is less than *minLength*,
  # then additional null bytes are written to *dest* until the length is equal
  # to *minLength*.
  def writePaddedString(str : String, dest : IO, length : Int) : Nil
    if str.size >= length
      dest.write_string(str[0...length].to_slice)
    else
      dest.write_string(str.to_slice)
      (length - str.size).times do |_|
        dest.write_byte(0)
      end
    end
  end

  # Prints *text* to *str*, wrapping the text as it goes.  *indent* is the
  # number of *indentChar*s to write at the beginning of each line.  *maxWidth*
  # is the maximum length of a line of text.
  #
  # The first line can be shorter by applying an additional offset of
  # *firstLineOffset*.  Thus the first line will be at most
  # `maxWidth - indent - firstLineOffset` long.
  def buildWrapped(text : String, str : IO, *, indent : Int = 0, firstLineOffset : Int = 0,
                   maxWidth : Int = 80, indentChar : Char = ' ') : Nil
    pos : Int32 = 0
    maxLineWidth : Int32 = 0
    inc : Int32 = 0
    block : String = ""
    indentStr : String = indentChar.to_s
    firstLine : Bool = true

    while pos < text.size
      # We have extra to do if we're not on the first line (or indentFirstLine
      # is true)
      if firstLine
        maxLineWidth = maxWidth - indent - firstLineOffset
      else
        str << indentStr * indent
        maxLineWidth = maxWidth - indent
      end

      # See if we can write out the entire string on this first line or not
      if (pos + maxLineWidth) >= text.size
        str << text[pos..].strip
        return
      end

      # Get a block of text
      block = text[pos, maxLineWidth]

      # If this block doesn't contain a space, just write out the entire block.
      # If it wraps, it wraps... we don't handle hyphenation.
      if !block.includes?(' ')
        str << block.strip
        str << '\n'
        pos += maxLineWidth

      elsif block[0] == ' '
        # The only space is the very first character
        str << block[1..]
        pos += maxLineWidth

      elsif block[-1] == ' '
        # The space is the last character in the block, so just write the block minus the space
        str << block.strip
        str << '\n'
        pos += block.size

      else
        if text[pos + block.size] == ' '
          # Special case, we're at a word boundary
          str << block
          str << '\n'
          pos += block.size + 1
        else
          # The space is somewhere in the middle of `block`
          # Find the position of the final space in the block
          inc = block.rindex(' ') || raise "Space suddenly missing"
          str << block[...inc]
          str << '\n'
          pos += inc + 1
        end
      end

      firstLine = false
    end
  end

  # Prints *text* to a new string, wrapping the text as it goes, then returns
  # the new string.  *indent* is the number of *indentChar*s to write at the
  # beginning of each line.  *maxWidth* is the maximum length of a line of text.
  #
  # The first line can be shorter by applying an _additional_ offset
  # of *firstLineOffset*.  Thus the first line will be at most
  # `maxWidth - indent - firstLineOffset` long.
  @[Deprecated("Use #buildWrapped without an IO")]
  def makeWrapped(text : String, *, indent : Int = 0, maxWidth : Int = 80,
                  firstLineOffset : Int = 0, indentChar : Char = ' ') : String
    buildWrapped(text, indent: indent, maxWidth: maxWidth, firstLineOffset: firstLineOffset,
                 indentChar: indentChar)
  end

  def buildWrapped(text : String, *, indent : Int = 0, maxWidth : Int = 80,
                   firstLineOffset : Int = 0, indentChar : Char = ' ') : String
    String.build do |str|
      buildWrapped(text, str, indent: indent, maxWidth: maxWidth, firstLineOffset: firstLineOffset,
                   indentChar: indentChar)
    end
  end

  # Splits `text` at each space, taking into account quoted strings.  This is
  # similar to how most (all?) shells split a command line into individual
  # arguments.
  #
  # For example, calling `splitAsArgs(%{This "is a test" string})` would produce
  # an array with these elements:
  #
  # 1. This
  # 2. "is a test"
  # 3. string
  #
  # If *removeQuotes* is `true`, then the outer quotes are removed from all
  # returned strings.
  def splitAsArgs(text : String, *, removeQuotes : Bool = false) : Array(String)
    ret : Array(String) = text.split(Regex.new(%q{("[^"]+")|(\S+)}), remove_empty: true)
    ret.delete(" ")

    if removeQuotes
      ret.map! do |str|
        if str[0] == '"' && str[-1] == '"'
          str[1...-1]
        else
          str
        end
      end
    end

    ret
  end
end