学習日記

学習記録

lsコマンドを作ってみる

Rubyでlsコマンドを作ります

表題の通りlsコマンドを作ってみます とはいっても完全再現するととんでもないコード量になるので、今回はオプションを実装しません

また今回はフィヨルドブートキャンプのプラクティス内にあるlsコマンドを作るを題材として作っていきますので フィヨルドブートキャンプ生の方でプラクティスを修了前の方は閲覧を控えるようお願いします。

また筆者はフィヨルドブートキャンプ生ですが、該当プラクティス合格前に書いたコードになります。

余談になりますが、フィヨルドブートキャンプのカリキュラムではファイルやフォルダを指定するようにすることは必須ではありません。

要件

今回模倣するlsコマンドの要件です

  1. 引数無しで実行するとカレントディレクトリ内のファイルが一覧表示されること
  2. Rubyの標準ライブラリしか使わないこと
  3. 最大3列を維持して表示すること
  4. 列数の変更に対応できること
  5. ファイルやディレクトリを指定できること
  6. ファイルが指定された場合、相対パスで指定された場合相対パスを、チルダパスで指定された場合絶対パスを、絶対パスで指定された場合絶対パスを返す。
  7. 2バイト文字があっても表示が崩れないこと

実装

繰り返しになりますが、フィヨルドブートキャンプ生の方でプラクティス修了前の方は閲覧を控えるようお願い致します。

手始めに使う予定のライブラリと定数を定義します、合わせてshebangも記述しておきます。

#!/usr/bin/env ruby
# frozen_string_literal: true

require 'pathname'
DISPLAY_COLUMNS_COUNT = 3
DISPLAY_WIDTH = 18

mainメソッドを定義して、その中に動きを定義していく感じで書いていこうと思います

ついでに後で使う予定の配列も定義しておきます

def main
  argument_type = argument_parse if ARGV[0] # ARGVには引数が入っている
  directory_files = [] # 後の計算で使用
end

処理を開始する前に今回はファイルやディレクトリを指定可能にしなければならないので、引数が渡されてるのかどうか、また引数はファイルなのかフォルダなのかを判断します。

ではその判断してくれるargument_parseの中身を書いていきます

def argument_parse(argument)
  if File.directory?(argument)
    :directory_path
  elsif File.file?(argument)
    :file_path
  else
    :invalid
  end
end

こんな感じでいけるかな?

rubyではメソッドが最終的に評価した式が返り値になるので、argument_typeに引数に渡されたタイプ毎に分類されたシンボルが返ります この時文字列ではなくシンボルを返すのは内部的にシンボルのほうが処理速度が速いからです。

次にmainメソッド側にこの戻り値を利用した処理を書き加えていきます

def main
  argument_type = argument_parse if ARGV[0]
  case argument_type
  when :invalid
    error_message # 渡された引数が不正ならエラーメッセージを表示するメソッドを呼び出す
  when :file_path
    display_file_results(ARGV[0]) # 渡された引数がファイルなら結果を出力するメソッドを呼び出す
  when :directory_path
    directory_files = Dir.glob("#{ARGV[0]}/*").map(&File.method(:basename)) # 渡された引数がディレクトリなら、渡されたディレクトリの一覧を取得する
  else
    directory_files = Dir.glob('*') # 引数が渡されなかったら現在のカレントディレクトリ内の一覧を取得する
  end
end

こんな感じでいいでしょう。

実装が簡単な引数が不正な値だった場合から片付けていきます

def error_message
  puts "'#{ARGV[0]}'にアクセスできません:そのようなファイルやディレクトリはありません"
end

いい感じですね。

では次に引数がファイルだった場合を考えていきます。

前述の要件の通りファイルが指定された場合は少し面倒くさいです。

何故この仕様なのかというと、筆者の環境でlsコマンドを使うとそのように結果が返ってくるからです。

相対パス絶対パスでファイルを指定される時というのはそもそもそのままの値を返せばよく、チルダパスを指定された時のみ絶対値を計算すればよさそうなので、このように・・・

def display_file_results(file_path)
  specified_path = file_path[0] == '~' ? Pathname.new(file_path).expand_path('~') : file_path # 渡されたパスの先頭が"~"なら絶対パスを、それ以外ならそのまま返す
  puts specified_path
end

実装完了です。

pathnameは標準ライブラリですので、requireを忘れないようにしてください。

次はいよいよ本命のディレクトリに移っていきましょう。

1個のメソッドに処理を詰め込んでしまってもいいのですが、後からのメンテナンス性と可読性を考えて小分けに実装することにします

メインメソッドをこんな感じに追記して...

def main
省略

  row_size = calculate_row_size(directory_files) # 表示する行のサイズを計算するメソッド
  display_data = create_display_data(row_size,directory_files) # 計算した行のサイズを元に1行毎の配列を作る
  display_directory_results(row_size, display_data) # 1行毎の配列を列数に合わせて表示する
end

この3個の処理を経て結果が表示されるようにしていきます。

まずはcalulate_row_sizeで行数を計算します。

def calculate_row_size(directory_files)
  (directory_files.size + DISPLAY_COLUMNS_COUNT - 1) / DISPLAY_COLUMNS_COUNT - 1 #最適な行数の計算
end

表示したい列の行数をこれで row_sizeに格納したので

次はこれをもとに実際に表示するためのデータを配列に入れていきます

def create_display_data(row_size, directory_files)
  directory_files.each_slice(row_size + 1).to_a # 1行毎の配列を作る,3行目は自動的に余りの数で作ってくれる
end

これで1行当たりのデータが配列になってdirectory_filesに格納されました

次は先ほど計算したrow_sizeと今回作成した配列directory_filesを使って実際にデータを出力します

def display_directory_results(row_size, display_data)
  (row_size + 1).times do |row|
    DISPLAY_COLUMNS_COUNT.times do |col|
      print display_data[col][row].to_s.ljust(DISPLAY_WIDTH) #ファイル名を18文字として左寄せで表示
    end
    puts
  end
  puts
end

これで完成です、お疲れ様でし・・

そうです、このままだと2バイト文字に対応してません。

2バイト文字が混ざると表記がずれるのは、2バイト文字であっても1文字は1文字とカウントされるためです。

そこで今回は表示するファイルに含まれる2バイトの文字を数え、その分だけDISPLAY_WIDTHから値を引くことで表記ずれを防ごうと思います。

まずは表示する側にコードを書きます。

def display_directory_results(row_size, display_data)
  (row_size + 1).times do |row|
    DISPLAY_COLUMNS_COUNT.times do |col|
      wide_chars_count = count_characters(display_data[col][row]) || 0 # wide_chars_countに2バイト文字の数を入れる、2バイト文字がなかった場合nilが返るので対策しておく
      print display_data[col][row].to_s.ljust(DISPLAY_WIDTH - wide_chars_count)
    end
    puts
  end
  puts
end

それでは次にチェック側を書きます

def count_characters(file_name)
  file_name.each_char.count { |char| char.bytesize > 1 } if !!(file_name =~ /[^[:ascii:]]/) #2バイト文字があればカウントを1増やす
end

if !!(file_name =~ /[^[:ascii:]]/)はなくても動作しますが、むやみやたらにファイル名の全文字を解析するのもリソースの無駄なので、ascii文字だけで構成されたファイル名の場合はスルーさせています。

これで後は表示を確認してあげれば

大丈夫そうですね、お疲れ様でした。