見出し画像

ポンコツ・キャンプ -文字列の中に含まれる複数の引数を操作するためのクラス

DOS画面からコマンドを入力した際、様々な引数を夫々のメソッド内で検査していく内にコードが煩雑になってしまったので、基本的なチェックを行なうためのクラスを作成してみました。


引数のチェックはいつも煩雑です。

一例を挙げると、linked_fileと言うコマンドには次のバリエーションを持たせています。
linked_file uid
linked_file uid フルファイル名/フルファイル名/...
linked_file uid :delete

次の引数の検査をする必要が出てきました。

  1. 引数には、1つの場合と2つの場合がある。

  2. 1つ目の引数は整数

  3. UIDは、メールボックスの個々のメール指し示す都合上、サーバー側に存在するのか確認する必要がある。

  4. また、2つ目の引数は、複数のファイル名が記されている。

  5. デリミター'/'で区切られているものの、画面入力のため' /', '/ ', ' / 'などの使用が想定される。

  6. 2つ目の引数の位置に:deleteが指定される場合がある。

#partitionを使えば、コマンドと引数の塊りとに簡単に分離可能ですが、引数に含まれる個々の検査の多いこと。

主に次の事が出来るようにしました。

  1. 文字列に含まれる引数の個数の提供

  2. 引数の数と指定範囲との照合

  3. 個々の引数は、Stringから各々引数の特徴に応じて、Integer, Range, Time, Symbolのインスタンスに変換

  4. 個々の引数のクラス名を配列で提供

  5. クラス名の配列を想定されているクラス名の配列と照合

このお蔭で、検査項目の1, 2, 6は、解消されました。
4もオプションを設けることで回避することが出来ました。

コード量は、それほど減りませんが精神的な安心感とコードの見通しの良さが得られたと思います。

以下は、commander.rbの各メソッドの冒頭で、arg_hash.rbがこのように処理をしていますと言うサンプルです。
main.rb, commander.rb, arg_hash.rb, gadget.rbを同じディレクトリに入れて、main.rbを起動すればサンプルが動きます。

main.rb

# frozen_string_literal: true

require_relative 'commander'
require_relative 'arg_hash'

####################
### Main routine ###
####################

commander    = Commander.new
command_list = [:linked_file, :end]
loop do
  print "\nコマンドを入力して下さい。 => "
  message = gets.chomp

  command, _, parameters = message.partition(' ')
  command = command.to_sym
  params  = ArgHash[parameters, split: 2]
  command_list.include?(command) ? commander.method(command).call(params) : (print "\n認識できないコマンドです。")

  break if command == :end && params.size?(0)
end

commander.rb (サンプルのために用意したコマンド; linked_fileとendのみ用意)

# frozen_string_literal: true

class Commander
  def linked_file(options)
    return print "\nパラメータの数が違います。" unless options.size?(1..2)

    combination = [Integer, [Integer, String], [Integer, Symbol]]
    return print "\n認識出来ないパラメータです。" unless options.classes_match?(*combination)

    para_list = [:delete]
    case [*options.convert.classes, para_list.include?(options[1])]

    when [Integer, false]
      print "\nPass: linked UID"
      print "\n  UID: #{options[0]}"

    when [Integer, String, false]
      print "\nPass: linked UID filenames"
      print "\n  UID: #{options[0]}"
      print "\n  filenames: #{options[1]}"

    when [Integer, Symbol, true]
      print "\nPass: linked UID :delete"
      print "\n  UID: #{options[0]}"
      print "\n  Symbol: #{options[1]}"

    else
      print "\nUnknow parameters!!"
      print "\n  UID: #{options[0]}"
      print "\n  option: #{options[1]}"
    end
  end

  def end(options)
    return print "\nパラメータの数が違います。" unless options.size?(0)

    print "\n終了します。"
    sleep 1
  end
end

arg_hash.rb (これが該当のクラス)

# frozen_string_literal: true

require_relative 'gadget'

# for checking the method arguments
class ArgHash < Hash
  using Gadget

  class << self
    # [], combination are Singleton class
    def [](string_to_hash, transform_keys: [], delimiter: ' ', split: -1)
      # generate ArgHash instance from a string with some mixed argument keywords
      # eg. ArgHash["apple, 320, 2024-02-13", delimiter: ',']                                                   ;delimiter => ','
      #       => {0 => "apple", 1 => "320", 2 => "2024-02-13"}
      #     ArgHash["apple 320 2024-02-13"]                                                                     ;no option
      #       => {0 => "apple", 1 => "320", 2 => "2024-02-13"}
      #     ArgHash["apple 320 2024-02-13", transform_keys: [:fruit, :weight, :harvest_date]]                   ;transform_keys => specifying
      #       => {:fruit => "apple", :weight => "320", :harvest_date => "2024-02-13"}
      #     ArgHash["apple 320 2024-02-13 2024-02-15", transform_keys: [:fruit, :weight, :harvest_date]]        ;values => 4, keys => 3
      #       => {:fruit => "apple", :weight => "320", :harvest_date => "2024-02-13", 3 => "2024-02-15"}
      #     ArgHash["apple 320 2024-02-13", transform_keys: [:fruit, :weight, :harvest_date, :delivery_date]]   ;values => 3, keys => 4
      #       => {:fruit => "apple", :weight => "320", :harvest_date => "2024-02-13"}
      #     ArgHash["apple 320 2024-02-13", transform_keys: [:fruit, :weight, :harvest_date], split: 3]         ;values => 2, keys => 3, split => 3
      #       => {:fruit => "apple", :weight => "320"}
      #     ArgHash["apple, 320,", transform_keys: [:fruit, :weight, :harvest_date], delimiter: ',', split: 3]  ;values => 2, keys => 3, delimiter => ','
      #       => {:fruit => "apple", :weight => "320", :harvest_date => ""}
      #     ArgHash["apple 15 10 20", transform_keys: [:fruit, :purchase_order], split: 2]                      ;values => 3, keys => 2, delimiter => ' ', split => 2
      #       => {:fruit => "apple", :purchase_order => "15 10 20"}                                             ;  note: It can behave like String#partition
      #     ArgHash["apple, 320,", transform_keys: [:fruit, :weight, :harvest_date], delimiter: ',', split: 2]  ;values => 2, keys => 3, delimiter => ',', split => 2
      #       => {:fruit => "apple", :weight => "320,"}                                                         ;  note: cases to keep in mind
      return super() if string_to_hash.empty?

      if string_to_hash.is_a?(String)
        string_to_hash = string_to_hash.split(delimiter, split).map(&:strip)
        # if transform_keys is [], set the default keys
        # in case of anything else, it uses transform_keys (fill with the alternative keys if transform_keys is shorter than string_to_hash)
        num            = string_to_hash.size - 1
        transform_keys = transform_keys.empty? ? (0..num).to_a : (0..num).map { |n| transform_keys[n] || n }
        string_to_hash = transform_keys.zip(string_to_hash)
      elsif string_to_hash.is_a?(Hash)
        string_to_hash
      else
        return string_to_hash
      end

      super(string_to_hash)
    end

    def combination(combination)
      # all possible class combinations
      # combination = {
      #   fruit:        [String,  NilClass],
      #   weight:       [Integer, NilClass],
      #   harvest_date: [Time,    NilClass]
      # }
      # ArgHash.combination(combination)
      #   => [[String,   Integer,  Time],
      #       [String,   Integer,  NilClass],
      #       [String,   NilClass, Time],
      #       [String,   NilClass, NilClass],
      #       [NilClass, Integer,  Time],
      #       [NilClass, Integer,  NilClass],
      #       [NilClass, NilClass, Time],
      #       [NilClass, NilClass, NilClass]]
      #   note:
      #     next case is pass
      #       combination = {fruit: String, weight: [Integer, NilClass]}
      #       ArgHash.combination(combination)
      #         => [[String, Integer], [String, NilClass]]
      ini, *comb = combination.values.map { |v| v.is_a?(Array) ? v : [v] }
      comb.inject(ini) { |result, c| result.product(c) }.map(&:flatten)
    end
  end

  def convert
    # convert the values of string_to_hash
    # eg. arg_hash = ArgHash["apple, 320,", transform_keys: [:fruit, :weight, :harvest_date], delimiter: ',', split: 3]
    #       => {fruit: "apple", weight: "320", harvest_date: ""}    note: harvest_date: ""
    #     arg_hash.convert
    #       => {fruit: "apple", weight: 320, harvest_date: nil}     note: harvest_date: nil
    return self if empty?

    each do |(k, v)|
      if v.is_a?(String)
        self[k] = v.empty? ? nil : v.convert
      end
    end
    self
  end

  def classes
    # all classes of string_to_hash
    # eg. ArgHash[{fruit: "apple", weight: 320, harvest_date: Time.parse("2024-02-10")}].classes
    #       => [String, Integer, Time]
    #     ArgHash[{fruit: "apple", weight: "320", harvest_date: "2024-02-10"}].convert.classes
    #       => [String, Integer, Time]
    return [NilClass] if empty?

    map { |(_, v)| v.class }
  end

  def classes_match?(*pattern)
    # check if all classes match
    #   note: self is not destroyed
    #         "" in value is evaluated as NilClass
    #         each variable of a String class instance is attempted to be converted to other classes
    # eg. ArgHash[{fruit: "apple"}].classes_match?(String)             => true
    #     ArgHash[{fruit: "apple"}].classes_match?(String, NilClass)   => true
    #     ArgHash[{fruit: nil}].classes_match?(String, NilClass)       => true
    #     note:
    #       bad case!!
    #         ArgHash[{fruit: "apple"}].classes_match?([String], [String, Range]); this case dosen't match because pattern => [String]
    #       pass case
    #         ArgHash[{fruit: "apple"}].classes_match?(String, [String, Range]); this case match because pattern => String
    #     ArgHash[{fruit: "apple", weight: 320, harvest_date: Time.parse("2024-02-10")}].classes_match?([String, Integer, Time])
    #       => true
    #     ArgHash[{fruit: "apple", weight: "320", harvest_date: "2024-02-10"}].classes_match?([String, Integer, Time])
    #       => true
    #     ArgHash[{fruit: "apple", weight: "320", harvest_date: ""}].classes_match?([String, Integer, Time], [String, Integer, NilClass])
    #       => true
    #     pattern = [[String, Integer, Time], [String, Integer, NilClass]]
    #     ArgHash[{fruit: "apple", weight: "320", harvest_date: ""}].classes_match?(*pattern)
    #       => true
    arg_hash    = Marshal.load(Marshal.dump(self))
    arg_hash[0] = nil if arg_hash.empty?
    all_string  = arg_hash.classes.map { |c| c == String }.all?
    target      = all_string ? arg_hash.convert.classes : arg_hash.classes
    target      = target.first if target.size == 1
    pattern.include?(target)
  end

  def size(ignore_blank: true)
    # the size of hash 'values'
    #   note: not count nil (and more doesn't count blank if ignore_blank: false)
    # arg_hash = ArgHash[{fruit: "apple", weight: 320, harvest_date: ""}]
    # arg_hash.size                      => 2
    # arg_hash.size(ignore_blank: false) => 3
    vals = values
    vals = vals.map { |v| (v == '') ? nil : v } if ignore_blank
    vals.compact.size
  end

  def size?(scope, ignore_blank: true)
    # ArgHash[{0 => "arg 1", 1 => "arg 2"}].size?(2)                                => true   ; evaluation => 2
    # ArgHash[{0 => "arg 1", 1 => "arg 2", 2 => "arg 3"}].size?(2)                  => false  ; evaluation => 3
    # ArgHash[{}].size?(0..2)                                                       => true   ; evaluation => 0
    # ArgHash[{}].size?(1..2)                                                       => false  ; evaluation => 0
    # ArgHash[{0 => nil, 1 => nil, 2 => nil}].size?(1..2)                           => false  ; evaluation => 0
    # ArgHash[{0 => nil, 1 => "arg_2", 2 => nil}].size?(1..2)                       => true   ; evaluation => 1
    # note: in case of ignore_blank = true;
    #   ArgHash[{0 => "", 1 => "arg_2", 2 => ""}].size?(1..2)                       => true   ; evaluation => 1
    # note: in case of ignore_blank = false;
    #   ArgHash[{0 => "", 1 => "arg_2", 2 => ""}].size?(1..2, ignore_blank: false)  => false  ; evaluation => 3
    case scope
    when Integer
      scope == size(ignore_blank: ignore_blank)
    when Range
      scope.cover?(size(ignore_blank: ignore_blank))
    end
  end
end

gadget.rb (ArgHashをサポートするmodule)

# frozen_string_literal: true

require 'date'
require 'time'

# small useful tool
#   note: the effect only within Class
module Gadget
  refine String do
    def int_convertable?
      # check if string can be converted to integer
      #   eg. str = 'I400'  => false
      #       str = '400.0' => false
      #       str = '400'   => true
      #       str = ''      => false
      to_i.to_s == delete_prefix('+')                 # delete '+' if prefix is '+'
    end
  end

  refine String do
    def time_convertable?
      # check if string can be converted to Time
      #   eg. str = 'I400'  => false
      #       str = '400.0' => false
      #       str = ''      => false
      #       str = '400'   => true
      #       str = '0'     => true
      raise Date::Error if /[^0-9\-\/+:\s]/.match?(self)
      Date.parse(self)
      true
    rescue Date::Error
      false
    end
  end

  refine String do
    def to_inum
      # eg. '+1,234,567' => 1234567
      str = delete_prefix('+')                        # delete '+' if prefix is '+'
      str = str.delete(',')                           # delete ','
      str.int_convertable? ? str.to_i : self
    end
  end

  refine String do
    def to_time
      # eg. '2023-1-1' => 2023-01-01 00:00:00 +0900
      #   note: require 'date' and 'time'
      return self unless time_convertable?

      Time.parse(self)
    end
  end

  refine String do
    def to_range
      # eg. '123..456' => 123..456
      #     '123...456' => 123...456
      #     '456..123' => 123..456
      #     '123.456' => '123.456' (nop)
      return self unless include?('..') || include?('...')

      r1, sep, r2 = partition(/\.{3}|\.{2}/)
      return self if r1.start_with?('+', '-')
      return self if r2.start_with?('+', '-')
      return self if include?(',')
      return self unless r1.int_convertable?
      return self unless r2.int_convertable?

      r1 = r1.to_i
      r2 = r2.to_i
      r1, r2 = r2, r1 if r1 > r2
      (sep == '..') ? r1..r2 : r1...r2
    end
  end

  refine String do
    def to_range_time
      # eg. '2023-1-1..2023-12-31' => 2023-1-1..2023-12-31
      #     '2023-1-1...20023-12-31' => 2023-1-1...2023-12-31
      #     '20023-12-31..2023-1-1' => 2023-1-1..2023-12-31
      #     '2023-1-1.2023-12-31' => '2023-1-1.2023-12-31' (nop)
      return self unless include?('..') || include?('...')

      r1, sep, r2 = partition(/\.{3}|\.{2}/)
      return self unless r1.time_convertable?
      return self unless r2.time_convertable?

      r1 = r1.to_time
      r2 = r2.to_time
      r1, r2 = r2, r1 if r1 > r2
      (sep == '..') ? r1...(r2 + 86400) : r1...r2
    end
  end

  refine String do
    def to_symbol(force: true)
      # eg. 'symbol' => :symbol
      #     ':symbol' => :symbol
      case [force, start_with?(':'), end_with?(':')]
      when [true, true, false], [false, true, false]  # ':symbol' => :symbol
        delete_prefix(':').to_sym
      when [true, false, true], [false, false, true]  # 'symbol:' => :symbol
        delete_suffix(':').to_sym
      when [true, false, false]                       # 'symbol'  => :symbol ; force == true
        to_sym
      else                                            # 'symbol'  => 'symbol'; force == false
        self
      end
    end
  end

  refine String do
    def convert
      # after parsing str, change to Integer, Range, Time and Symbol
      # return str if fail
      retval = to_inum
      retval = to_range                if retval.is_a?(String)
      retval = to_range_time           if retval.is_a?(String)
      retval = to_time                 if retval.is_a?(String)
      retval = to_symbol(force: false) if retval.is_a?(String)

      retval
    end
  end
end
# ------------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
  include CommonModule
  using Gadget

  p 'I400'.int_convertable?               # => false
  p '400.0'.int_convertable?              # => false
  p '400'.int_convertable?                # => true
  p '+400'.int_convertable?               # => true
  p '-400'.int_convertable?               # => true
  p '0'.int_convertable?                  # => true
  p ''.int_convertable?                   # => false

  p '1234567'.to_amount                   # => '1,234,567'

  p '1,234,567'.to_inum                   # => 1234567
  p '+1,234,567'.to_inum                  # => 1234567
  p '-1,234,567'.to_inum                  # => -1234567

  p '123..456'.to_range                   # => 123..456
  p '123...456'.to_range                  # => 123...456
  p '456..123'.to_range                   # => 123..456

  p '2023-12-31'.time_convertable?        # => true
  p '2023-12-32'.time_convertable?        # => false
  p '2023-13-01'.time_convertable?        # => false

  p '2023-12-31'.to_time                  # => 2023-12-31 00:00:00 +0900
  p '2023-12-32'.to_time                  # => '2023-12-32'
  p '2023-13-01'.to_time                  # => '2023-12-32'

  p '2023-1-1..2023-12-31'.to_range_time
  #                                       notice: the return value is NOT wrong
  #                                         => 2023-1-1 00:00:00 +0900...2024-01-01 00:00:00 +0900
  p '2023-1-1...2023-12-31'.to_range_time # => 2023-1-1 00:00:00 +0900...2023-12-31 00:00:00 +0900
  p '2023-12-31..2023-1-1'.to_range_time  # => 2023-1-1 00:00:00 +0900...2024-01-01 00:00:00 +0900

  p 'symbol'.to_symbol                    # => :symbol
  p ':symbol'.to_symbol                   # => :symbol
  p 'symbol:'.to_symbol                   # => :symbol
  p 'symbol'.to_symbol(force: false)      # => 'symbol'
  p ':symbol'.to_symbol(force: false)     # => :symbol
  p 'symbol:'.to_symbol(force: false)     # => :symbol

  p 'I400'.convert                        # => 'I400'
  p '400'.convert                         # => 400
  p '1,234,567'.convert                   # => 1234567
  p '123..456'.convert                    # => 123..456
  p '2023-12-31'.convert                  # => 2023-12-31 00:00:00 +0900
  p '2023-1-1..2023-12-31'.convert        # => 2023-1-1 00:00:00 +0900...2024-01-01 00:00:00 +0900
  p ':symbol'.convert                     # => :symbol
  p 'symbol:'.convert                     # => :symbol
  p 'symbol'.convert                      # => 'symbol'
end

動作内容

夫々の動作内容は、
main.rbで、コマンドを受け付け、
commander.rbで各コマンドを実行しています。
各メソッド内の引数のチェックを統一的に解決するために
arg_hash.rbとgadget.rbで処理しています。

arg_hash.rbとgadget.rbは、サンプルの動作以外の挙動もするようにしてありますが、それは個々のコード内のコメントを参照願います。

お試しにいかがでしょう?
何かもっと良いアイデアがあれば、お待ちしています。

この記事が気に入ったらサポートをしてみませんか?