LoginSignup
18
1

More than 3 years have passed since last update.

Rubyでlsコマンド実装

Last updated at Posted at 2020-12-23

はじめに

こんにちは!
DMM WEBCAMP Advent Calendar 2020の24日目を担当するメンターの@shinoooooです。

クリスマスまであと1日ですね!
皆様は、どうお過ごしになられるでしょうか?
人それぞれ予定は異なるとは思いますが、まずは貴重なクリスマスイブの時間を割いてこの記事を読んでいただいていることに、感謝申し上げます。
ありがとうございます!

この記事では、Rubyで実装したlsコマンドの解説及び、実装を通して得られた知見を書きました。
自分自身もRubyを学習中の身ですので、記事の内容やコードに関するアドバイスをいただけましたら幸いです。

開発環境

  • macOS Catalina 10.15.7
  • Ruby 2.7.2
  • zsh 5.8

lsコマンドとは

プログラミングを学習されている方は、一度は実行したことがあるコマンドかと思います。

lsの機能は、Linux関連のドキュメントを和訳しているサイトによると、

ls, dir, vdir - ディレクトリの中身をリスト表示する

と書かれています。

実際に実行すると、

$ ls
hoge huga index.html     #カレントディレクトリ内のファイルが出力される。

このような表示が出てくるかと思います。

今回は、このlsコマンドをRubyで実装し、

$ ruby ls.rb
hoge huga index.html

と出すことが目標です。

仕様

今回実装したls.rbの仕様は、下記の通りです。

  • ls,ls -l,ls -a, ls -laと同等の機能を実装
    • 'optparse'というライブラリを使った都合上、-laのように複数のオプションをまとめて指定することができない。組み合わせる場合は、-l -aと入力する必要がある。
  • ACLと呼ばれる機能(説明は割愛します)の表示は未実装
  • 実際のlsのソースコードを参考にしていないので、アルゴリズムや関数名が違うかもしれません。

出力形式

いきなりコードを書いて模倣することはできないので、lsの出力の分析を行います。
ディレクトリとファイルを用意した方がわかりやすいので、下記のコマンドを実行します。

# directory1~directory10とfile1~file10をtestディレクトリ内に作成。
$ mkdir test/directory{1..10} && touch test/file{1..10} 

そして、ls testを実行して出力結果を確認します。

$ ls test
directory1      directory3      directory6      directory9      file2           file5           file8
directory10     directory4      directory7      file1           file3           file6           file9
directory2      directory5      directory8      file10          file4           file7

当たり前といえば、当たり前ですが綺麗に並んでいます。
闇雲に出力するだけではlsのような整った出力結果にはならなそうなので、課題を1つ1つ洗い出しながら完成を目指すことにします。

表示順

lsで出力されているファイル名は以下のような順番で昇順で並んでいます。
「ファイルの数」や「ターミナルの画面の大きさ」、「ファイル名の長さ」で、「1行に表示されるファイルの数」と「行数」は若干出力は変化しますが、表示順で確認したls testの出力は以下のようになっていました。

表示されている順番
名前が1番目に若いファイル     名前が4番目に若いファイル     名前が7番目に若いファイル   ... 名前が19番目に若いファイル
名前が2番目に若いファイル     名前が5番目に若いファイル          ︙                ... 名前が20番目に若いファイル
名前が3番目に若いファイル     名前が6番目に若いファイル          ︙

1行ずつ名前が1,4,7,10,13,16,19番目に若いファイル,名前が2,5,8,11,14,17,20番目に若いファイル,名前が3,6,9,12,15,18番目に若いファイルを順に出力すればlsの出力の再現ができそうです。

配列の処理

ディレクトリ内のファイル一覧を取得するために用いたDirクラスは、ファイル一覧を配列で渡してくれます。今回は、これをfilesという変数に代入して扱うこととします。
ですが、受け取った配列の中身が昇順ではなかったのでArrayクラスsortメソッドで昇順に並び替えます。

sortメソッド
files = ["file2", "file3", "file1"]
files.sort #=> ["file1", "file2", "file3"] 

配列の添字は0から始まります。
昇順で並び替えたことにより、files内の1番目に若いファイル名はfiles[0]とすることで取得することができます。
表示順で書いた名前が1,4,7,10,13,16,19番目に若いファイルを出力するということは、files[0],files[3],files[6],files[9],files[12],files[15],files[18]を出力することと言えます。

1行に出力できるファイル数や、行数を求め、filesをうまく出力する処理を書くと、下記のような出力になります。

$ ruby ls.rb test
directory1directory3directory6directory9file2file5file8
directory10directory4directory7file1file3file6file9
directory2directory5directory8file10file4file7

可読性は低いですが、一歩lsに近づきました。

ファイル名の間の区切り

このままだとどこからどこまでが1つのファイル名か分からないので、ファイルとファイルを区切る必要があります。
lsの出力結果のファイル名とファイル名はどう区切られているか、確認していきたいと思います。
適当にprint.rbというような名前のファイルを作成し、ls testの実行結果の

directory1      directory3

をコピーし、貼り付けます。

これを文字列としてターミナルに出力してみたいと思います。
" "で囲み、先頭にpを付け、

print.rb
p "directory1   directory13"

として保存します。

ターミナル上でruby print.rbと実行してみると以下のように出力されます。

$ ruby print.rb
"directory1\tdirectory13"

directory1とdirectory3の間に\tという文字が現れました。
これは、タブ文字を表しています。
このことから、ファイル名がタブ文字で区切られていることが確認できます。

これを模倣してファイル名の末尾に\tを追加することにします。
そのためにprintfメソッドを使用します。
ここではprintfについて詳しく解説はしないので、気になる方はドキュメントを読んでみてください。

printfの使用例
printf("%s\t", files[0]) # => %sの中にfiles[0]の中身が入り、出力される。
printf("%s\t", files[1])
# => "directory1    ""directory10   "

ls.rbにも実装すると、以下のような出力になります。

$ ruby ls.rb test
directory1  directory3  directory6  directory9  file2   file5   file8
directory10 directory4  directory7  file1   file3   file6   file9
directory2  directory5  directory8  file10  file4   file7

先ほどと比べると綺麗に見えますが、まだlsの出力とは異なります。

ファイル名の長さの統一

最後に、綺麗に見せるためにファイル名の長さを統一します。

directory10file1file2file3を使い、考えてみましょう。
現時点での出力はこのようになっています。

directory10\tfile2 
file1\tfile3

ファイル名の長さが統一されていないことで、1行毎の出力に文字数のズレが生じています。

全てのファイル名の長さが同じであれば、

ファイル名\tファイル名\t
ファイル名\tファイル名\t

のように綺麗な見た目にすることができるので空白文字を用いて、文字数が一番長いファイル名の長さに統一します。
今回の例では、directory1が一番ファイル名が長いので,directory1の長さに統一することにします。

# 空白文字を使うことで見た目を整えることができる!
directory\tfile2    \t 
file1    \tfile3    \t

文字列の長さの統一もprintfメソッドで実現することができます。
%sを少し改変することで幅指定を行うことができます。

printfの幅指定
printf("%15s", files[0])
# => "     directory1" 15文字になるよう空白文字が追加される。

printf("%-15s", files[0])
# => "directory1     " -をつけると左詰めになる。

name_len = 15

printf("%-#{name_len}s\t", files[0]) # => 式展開もできる。
# => "directory1     "

ls.rbでも幅指定をすると

$ ruby ls.rb test
directory1      directory3      directory6      directory9      file2           file5           file8
directory10     directory4      directory7      file1           file3           file6           file9
directory2      directory5      directory8      file10          file4           file7

lsと同じ出力を得ることができました!

解説

全てを解説すると、長くなってしまうので、出力形式で説明した箇所のみを解説します。
また、呼び出しているメソッドに関しては細かい説明はせず、機能の解説のみをコメント文で行っています。

ソースコードは 完成コードにあります。
全体の実装が気になる方は、こちらを参照してください。

self.display_normal

  def self.display_normal(dir) 
    files = get_files(dir) # ディレクトリ内のファイル名の配列を、filesに代入する。

    name_len = 1 # ファイル名の最大値を格納するname_lenを用意する。
    files.map { |file| name_len = file.length if name_len < file.length } # 一番長いファイル名を、name_lenに代入する。

    total_length = (name_len + 5) * files.count # 出力すべき文字数を算出。
    columns = `tput cols`.to_i # ターミナルの1行の文字数を取得。

    line_count = (total_length + (columns - 1)) / columns # 必要な出力行数を求める。
    column_count = (files.count + (line_count / 2)) / line_count # 1行に表示できるファイル数を求める。
    line_count = 1 if line_count == 0 # 最低でも1行は出力する必要があるため、line_countが0になっていた場合、1を代入する。

    (0...line_count).each do |line| 
      (0..column_count).each do |column| 
        idx = line_count * column + line 
        printf("%-#{name_len}s\t", files[idx]) if idx < files_count # 添字が配列内の大きさを超えていなければ、ターミナルに出力する
      end
      print("\n") # ここに到達すると1行分の出力が終わっているので改行する。
    end
  end

実行時間

timeコマンドを使い、実行時間を計測します。

$ time ls test                                                                    
directory1  directory3  directory6  directory9  file2       file5       file8
directory10 directory4  directory7  file1       file3       file6       file9
directory2  directory5  directory8  file10      file4       file7
ls test  0.00s user 0.00s system 78% cpu 0.008 total

#  ls testの実行時間 0.00s

$  time ruby ls.rb test                                                            
directory1  directory3  directory6  directory9  file2       file5       file8
directory10 directory4  directory7  file1       file3       file6       file9
directory2  directory5  directory8  file10      file4       file7
ruby ls.rb test  0.10s user 0.07s system 86% cpu 0.191 total 

# ruby ls.rbの実行時間 0.10s

目で見てわかるぐらいにはlsよりも遅いですね。

所感

実用性はあまりないですが、かなりlsコマンドを再現できたかと思います。
車輪の再開発と呼ぶには拙いコードですが、取り組む前よりも実装力は上がったと思います。

期日に余裕があればテストを書いたり、機能拡張をしたかったです。
また、常にドキュメント片手に作業をしていたので、ドキュメントを読み漁る力も身についたと思います。

当記事では解説はできませんでしたが、ls -lの実装も大変学びが多かったです。

おわりに

当アドベントカレンダーはWeb系の記事が多いので少し変わったことを記事にしたいと思いました。
他の方よりも、直接学びになることは少ないかもしれませんが、少しでも参考になりましたら幸いです。
いよいよ明日が最終日です。
@hide9138の記事で当アドベントカレンダーが良い締めくくりを迎えれるよう願っています。
それでは、良いクリスマスを:thumbsup:

完成コード

Github : https://github.com/shinooooo/Ruby-sh

参考

Ruby 2.7.0 リファレンスマニュアル
Man page of LS

18
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
1