あまブログ

ドキドキ......ドキドキ2択クイ〜〜〜〜〜〜〜ズ!!

【Ruby3.1】lsコマンドを作る

この記事では、RubyでLinuxのlsコマンドを実装する方法を解説します。

gemを使わずにRubyの標準ライブラリのみで実装します。

後半にソースコードを載せているため、FJORD BOOT CAMP(フィヨルドブートキャンプ)でlsコマンドの課題に取り組まれている方はご注意ください。

1. 実行環境

  • macOS:12.5
  • Ruby:3.1.0

2. 作成するlsコマンドの要件

今回はlsコマンドの以下の機能を実装の対象とします。

  • オプションなしのlsコマンド
    • 横に最大3列を維持して表示
    • ファイルの並び順は列ごとに辞書式順序にソートされる
  • -aオプション
  • -rオプション
  • -lオプション

ファイルの並び順

# OK
0 4 8
1 5 9
2 6 
3 7

# NG
0 1 2
3 4 5
6 7 8
9

以下の機能は実装の対象外とします。

  • 引数にファイルやディレクトリを指定可能にする
  • mac拡張属性(@マーク)の表示

3. lsコマンドの仕様

macOS標準のlsコマンドの仕様は以下の通りです。(ソースコードはこちら)

(今回の要件に必要な箇所のみ)

  • 引数なし
    • カレントディレクトリの内容を表示
  • -aオプション
    • ファイル名が.で始まるファイルを含めて表示
  • -lオプション
    • ディレクトリ内の各ファイルのブロック数の合計を1行目に表示(total <blocks>)
    • 2行目以降に各ファイルをロングフォーマットで表示
  • -rオプション
    • 逆順で表示

3-1. ロングフォーマットについて

-lオプション指定時に表示される以下のような形式をロングフォーマットと呼びます。

dr-xr-xr-x   3 root  wheel  4539  7 30 07:10 dev

ロングフォーマットに含まれるファイルの情報(属性)は以下の7つです。

  1. ファイルタイプとファイルモード
  2. ハードリンク数
  3. 所有者名
  4. グループ名
  5. ファイルサイズ
  6. タイムスタンプ
  7. ファイル名

1. ファイルタイプとファイルモード

  • ファイルタイプとファイルモードを10桁のアルファベットで表示(drwxr-xr-x)
    • 1桁目:ファイルタイプ
    • 2桁目~10桁目:ファイルモード
  • File::Stat#ftypeでファイルタイプを取得
  • File::Stat#modeでファイルモードを取得

lsコマンドの-lオプションで表示されるファイルタイプとファイルモードの詳細については以下の記事を参照ください。

amablog.tech

また、File::Stat#modeが返すファイルモードの数値と記号表記の対応については以下の記事を参照ください。

amablog.tech

2. ハードリンク数

  • ファイルのハードリンク数を表示
    • シンボリックリンクはカウントされない
    • File::Stat#nlinkでファイルのハードリンク数を取得

3. 所有者名

  • ファイルの所有者名を表示
    • File::Stat#uidでファイルの所有者のUIDを取得
    • ユーザ名とユーザID(UID)の対応は/etc/passwdで管理
    • Etc.#getpwuidでpasswdデータベースからUIDを検索、Etc::Passwd#nameでユーザ名を返す

4. グループ名

  • ファイルの所有グループ名を表示
    • File::Stat#gidでファイルの所有グループのGIDを取得
    • グループ名とグループID(GID)の対応は/etc/groupで管理
    • Etc.#getgrgidでgroupデータベースからGIDを検索、Etc::Group#nameでグループ名を返す

5. ファイルサイズ

  • ファイルサイズをバイト単位で表示
  • ファイルがキャラクタデバイスまたはブロックデバイスの場合、ファイルサイズの代わりにデバイス番号を16進数で表示

6. タイムスタンプ

  • ファイルの最終更新時刻を表示
    • 表示形式
      • デフォルト:<月> <日> <時間>
      • 最終更新時刻が6ヶ月以上前または未来の日付の場合:<月> <日> <年>
    • File::Stat#mtimeでファイルの最終更新時刻を取得

7. ファイル名

  • ファイル名を表示
  • ファイルがシンボリックリンクの場合、リンク先のパスも表示
    • 例:etc -> private/etc

4. ソースコード

  • ver1:自作→レビュー反映
  • ver2:ver1→他の人のコードを反映

4-1. ver1

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

require 'optparse'
require 'etc'

params = ARGV.getopts('alr')

def display_files(params)
  files = get_files(params)
  if params['l']
    display_long_format(files)
  else
    display_sort_by_column(files)
  end
end

def get_files(params)
  files = if params['a']
            Dir.glob('*', File::FNM_DOTMATCH, base: './')
          else
            Dir.glob('*', base: './')
          end
  files = files.reverse if params['r']
  files
end

def display_sort_by_column(files)
  number_of_elements = files.size
  max_number_of_words = files.map(&:size).max
  max_number_of_columns = 3
  number_of_rows = calc_number_of_rows(number_of_elements, max_number_of_columns)
  number_of_rows.times do |i|
    i.step(number_of_elements - 1, number_of_rows) do |n|
      print files[n].ljust(max_number_of_words + 2)
    end
    print "\n"
  end
end

def calc_number_of_rows(number_of_elements, max_number_of_columns)
  if (number_of_elements % max_number_of_columns).zero?
    number_of_elements / max_number_of_columns
  else
    (number_of_elements / max_number_of_columns) + 1
  end
end

def display_long_format(files)
  long_formats = get_long_formats(files)
  max_widths = get_max_widths(long_formats)
  number_of_blocks = get_number_of_blocks(long_formats)
  puts "total #{number_of_blocks}"
  long_formats.each do |long_format|
    print "#{long_format[:file_mode]} "
    print "#{long_format[:number_of_links].rjust(max_widths[:link])} "
    print "#{long_format[:owner_name].ljust(max_widths[:owner])}  "
    print "#{long_format[:group_name].ljust(max_widths[:group])}  "
    print "#{long_format[:file_size].rjust(max_widths[:file_size])} "
    print "#{long_format[:last_modified_time]} "
    print "#{long_format[:pathname]}\n"
  end
end

def get_long_formats(files)
  long_formats = []
  files.each do |file|
    file_stat = File.lstat(file)
    long_format = {
      file_mode: get_file_mode(file_stat),
      number_of_links: file_stat.nlink.to_s,
      owner_name: Etc.getpwuid(file_stat.uid).name,
      group_name: Etc.getgrgid(file_stat.gid).name,
      file_size: get_file_size(file_stat),
      last_modified_time: get_last_modified_time(file_stat),
      pathname: get_pathname(file),
      blocks: file_stat.blocks
    }
    long_formats << long_format
  end
  long_formats
end

def get_max_widths(long_formats)
  links = []
  owners = []
  groups = []
  file_sizes = []
  long_formats.each do |long_format|
    links << long_format[:number_of_links]
    owners << long_format[:owner_name]
    groups << long_format[:group_name]
    file_sizes << long_format[:file_size]
  end
  {
    link: links.map(&:size).max,
    owner: owners.map(&:size).max,
    group: groups.map(&:size).max,
    file_size: file_sizes.map(&:size).max
  }
end

def get_number_of_blocks(long_formats)
  blocks = []
  long_formats.each do |long_format|
    blocks << long_format[:blocks]
  end
  blocks.sum
end

def get_file_mode(file_stat)
  file_mode_numeric = file_stat.mode.to_s(8).rjust(6, '0')
  file_type_symbolic = get_file_type_symbolic(file_stat.ftype)
  file_permissions_symbolic = get_file_permissions_symbolic(file_mode_numeric)
  "#{file_type_symbolic}#{file_permissions_symbolic}"
end

def get_file_type_symbolic(file_type)
  {
    'fifo' => 'p',
    'characterSpecial' => 'c',
    'directory' => 'd',
    'blockSpecial' => 'b',
    'file' => '-',
    'link' => 'l',
    'socket' => 's'
  }[file_type]
end

def get_file_permissions_symbolic(file_mode_numeric)
  file_permissions_symbolic = []
  file_mode_numeric.slice(3, 3).each_char do |file_permission_numeric|
    file_permission_symbolic = {
      '0' => '---',
      '1' => '--x',
      '2' => '-w-',
      '3' => '-wx',
      '4' => 'r--',
      '5' => 'r-x',
      '6' => 'rw-',
      '7' => 'rwx'
    }[file_permission_numeric]
    file_permissions_symbolic << file_permission_symbolic
  end
  get_special_permissions(file_mode_numeric, file_permissions_symbolic)
  file_permissions_symbolic.join
end

def get_special_permissions(file_mode_numeric, file_permissions_symbolic)
  case file_mode_numeric.slice(2)
  when '1'
    file_permissions_symbolic[2] = if file_permissions_symbolic[2].slice(2) == 'x'
                                     file_permissions_symbolic[2].gsub(/.$/, 't')
                                   else
                                     file_permissions_symbolic[2].gsub(/.$/, 'T')
                                   end
  when '2'
    file_permissions_symbolic[1] = if file_permissions_symbolic[1].slice(2) == 'x'
                                     file_permissions_symbolic[1].gsub(/.$/, 's')
                                   else
                                     file_permissions_symbolic[1].gsub(/.$/, 'S')
                                   end
  when '4'
    file_permissions_symbolic[0] = if file_permissions_symbolic[0].slice(2) == 'x'
                                     file_permissions_symbolic[0].gsub(/.$/, 's')
                                   else
                                     file_permissions_symbolic[0].gsub(/.$/, 'S')
                                   end
  end
end

def get_file_size(file_stat)
  if file_stat.rdev != 0
    "#{file_stat.rdev_major}, #{file_stat.rdev_minor}"
  else
    file_stat.size.to_s
  end
end

def get_last_modified_time(file_stat)
  if Time.now - file_stat.mtime >= (60 * 60 * 24 * (365 / 2.0)) || (Time.now - file_stat.mtime).negative?
    file_stat.mtime.strftime('%_m %_d  %Y')
  else
    file_stat.mtime.strftime('%_m %_d %H:%M')
  end
end

def get_pathname(file)
  if File.symlink?(file)
    "#{file} -> #{File.readlink(file)}"
  else
    file
  end
end

display_files(params)

4-2. ver2

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

require 'optparse'
require 'etc'

COLUMN_NUMBER = 3

MODE_MAP = {
  '0' => '---',
  '1' => '--x',
  '2' => '-w-',
  '3' => '-wx',
  '4' => 'r--',
  '5' => 'r-x',
  '6' => 'rw-',
  '7' => 'rwx'
}.freeze

def exec
  params = ARGV.getopts('alr')

  paths = get_paths(dotmatch: params['a'])
  list_paths(paths, long_format: params['l'], reverse: params['r'])
end

def get_paths(dotmatch: false)
  dotmatch ? Dir.glob('*', File::FNM_DOTMATCH) : Dir.glob('*')
end

def list_paths(paths, long_format: false, reverse: false)
  paths = paths.reverse if reverse
  long_format ? list_long(paths) : list_short(paths)
end

def list_long(paths)
  long_formats = paths.map { |path| get_long_format(path) }
  max_length_map = get_max_length_map(long_formats)
  block_total = long_formats.map { |long_format| long_format[:blocks] }.sum

  puts "total #{block_total}"
  long_formats.each { |long_format| print_long_format(long_format, max_length_map) }
end

def get_long_format(path)
  path_stat = File.lstat(path)
  {
    type: format_type(path_stat.ftype),
    mode: format_mode(path_stat.mode),
    nlink: path_stat.nlink.to_s,
    username: Etc.getpwuid(path_stat.uid).name,
    groupname: Etc.getgrgid(path_stat.gid).name,
    bitesize: get_bitesize(path_stat),
    mtime: get_mtime(path_stat),
    pathname: get_pathname(path),
    blocks: path_stat.blocks
  }
end

def format_type(type)
  {
    'fifo' => 'p',
    'characterSpecial' => 'c',
    'directory' => 'd',
    'blockSpecial' => 'b',
    'file' => '-',
    'link' => 'l',
    'socket' => 's'
  }[type]
end

def format_mode(mode)
  mode_octal = mode.to_s(8)
  permissions_numeric = mode_octal.slice(-3..-1).split(//)
  permissions_symbolic = permissions_numeric.map { |n| MODE_MAP[n] }
  add_special_permissions(mode_octal, permissions_symbolic).join
end

def add_special_permissions(mode_octal, permissions_symbolic)
  case mode_octal.slice(-4)
  when '1'
    add_sticky_bit(permissions_symbolic)
  when '2'
    add_sgid(permissions_symbolic)
  when '4'
    add_suid(permissions_symbolic)
  end
  permissions_symbolic
end

def add_sticky_bit(permissions_symbolic)
  permissions_symbolic[2] = if permissions_symbolic[2].slice(2) == 'x'
                              permissions_symbolic[2].gsub(/.$/, 't')
                            else
                              permissions_symbolic[2].gsub(/.$/, 'T')
                            end
end

def add_sgid(permissions_symbolic)
  permissions_symbolic[1] = if permissions_symbolic[1].slice(2) == 'x'
                              permissions_symbolic[1].gsub(/.$/, 's')
                            else
                              permissions_symbolic[1].gsub(/.$/, 'S')
                            end
end

def add_suid(permissions_symbolic)
  permissions_symbolic[0] = if permissions_symbolic[0].slice(2) == 'x'
                              permissions_symbolic[0].gsub(/.$/, 's')
                            else
                              permissions_symbolic[0].gsub(/.$/, 'S')
                            end
end

def get_bitesize(path_stat)
  if path_stat.rdev != 0
    "0x#{path_stat.rdev.to_s(16)}"
  else
    path_stat.size.to_s
  end
end

def get_mtime(path_stat)
  if Time.now - path_stat.mtime >= (60 * 60 * 24 * (365 / 2.0)) || (Time.now - path_stat.mtime).negative?
    path_stat.mtime.strftime('%_m %_d  %Y')
  else
    path_stat.mtime.strftime('%_m %_d %H:%M')
  end
end

def get_pathname(path)
  if File.symlink?(path)
    "#{path} -> #{File.readlink(path)}"
  else
    path
  end
end

def get_max_length_map(long_formats)
  {
    nlink: long_formats.map { |long_format| long_format[:nlink].size }.max,
    username: long_formats.map { |long_format| long_format[:username].size }.max,
    groupname: long_formats.map { |long_format| long_format[:groupname].size }.max,
    bitesize: long_formats.map { |long_format| long_format[:bitesize].size }.max
  }
end

def print_long_format(long_format, max_length_map)
  print [
    "#{long_format[:type]}#{long_format[:mode]} ",
    "#{long_format[:nlink].rjust(max_length_map[:nlink])} ",
    "#{long_format[:username].ljust(max_length_map[:username])}  ",
    "#{long_format[:groupname].ljust(max_length_map[:groupname])}  ",
    "#{long_format[:bitesize].rjust(max_length_map[:bitesize])} ",
    "#{long_format[:mtime]} ",
    "#{long_format[:pathname]}\n"
  ].join
end

def list_short(paths)
  element_number = paths.size.to_f
  max_length = paths.map(&:size).max
  row_number = (element_number / COLUMN_NUMBER).ceil
  lines = Array.new(row_number) { [] }
  paths.each_with_index do |path, index|
    line_number = index % row_number
    lines[line_number].push(path.ljust(max_length + 2))
  end
  lines.each { |line| puts line.join }
end

exec

【参考】