0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Ruby】CLIツールを自作して、コマンドライン操作を学ぶ

0
Last updated at Posted at 2026-02-08

はじめに

プログラミング能力を高めるため、CLI操作を学びます。
普段はGUIでの操作に慣れているため、コマンドラインは視覚的でないという理由で常に一抹の不安を感じながら使っています。また、基本的な操作しか理解していないため、CLI本来のメリットを十分に享受できていません。
そこで、コマンドライン上で動作するツールを自作し、その開発過程を通してCLI操作に慣れていこうと思います。

内容
本記事では、ファイル操作を扱うCLIツールを作成していきます。

テスト
RSpecを利用してテストコードを実装しましたが、記事が長くなるため私のruby-cli-toolsリポジトリにまとめています。

filetool

filetoolは、テキストファイルとディレクトリを操作・確認するためのCLIツールです。
以下のコマンドを備えています。

Command Description
show ファイルの内容を表示
stats 行数・単語数・文字数を表示
add テキストをファイル末尾に追記
search 文字列を検索し、該当する行を表示
replace ファイル内の文字列を別の文字列に置換
create 新規ファイルを作成
delete ファイルを削除
rename ファイル名を変更
mkdir ディレクトリを作成
rmdir ディレクトリを削除
ls ディレクトリ内を一覧表示
pwd カレントディレクトリの絶対パスを表示
cd カレントディレクトリを変更
seqrename ディレクトリ内のファイルを連番でリネーム

使用例

% filetool
Usage:
 filetool show <file>
 filetool stats <file>
 filetool add <file> "text"
 filetool search <file> "text"
 filetool replace <file> "from" "to"
 filetool create <file>
 filetool delete <file>
 filetool rename <old_name> <new_name>
 filetool mkdir dirname
 filetool rmdir dirname
 filetool ls
 filetool pwd
 filetool cd <path>
 filetool seqrename <prefix>

% filetool add tempfile.txt "hello world"
Added text to tempfile.txt

% filetool show tempfile.txt
hello world

% filetool stats tempfile.txt
Lines: 1
Words: 2
Chars: 12

% filetool search tempfile.txt "hello"
1: hello

% filetool search tempfile.txt "HELLO" -ic
1

% filetool replace tempfile.txt "hello" "country"
1: country world

% filetool create new_file.txt
Created new_file.txt

% filetool delete tempfile.txt
Deleted tempfile.txt

% filetool rename old_file new_file
Rename old_file to new_file

% filetool mkdir dirname
Directory created

% filetool rmdir dirname
Directory deleted

% filetool ls dirname
Directory:  directory1
            file1.txt

# filetool pwd
/Users/user/test/temp

# filetool cd /Users/user/test
/Users/user/test

% filetool seqrename "file"
File renamed

コード

追記を実装していく過程で大きくコードの様相が変化しました。そのため、下記のコードは初期段階のもので、完成版は私のリポジトリにあります。

bin/filetool
require_relative "../lib/filetool.rb"

if __FILE__ == $0
  
  command = ARGV[0]
  filename = ARGV[1]
  text = ARGV[2]

  if command.nil? || filename.nil?
    puts "Usage:"
    puts " ruby filetool.rb show <file>"
    puts " ruby filetool.rb stats <file>"
    puts " ruby filetool.rb add <file> \"text\""
    exit
  end

    unless File.exist?(filename)
    puts "File not found: #{filename}"
    exit
  end

  case command
  when "show"
    puts Filetool.show(filename)

  when "stats"
    result = Filetool.stats(filename)
    puts "Lines: #{result[:lines]}"
    puts "Words: #{result[:words]}"
    puts "Chars: #{result[:chars]}"

  when "add"
    if text.nil?
      puts "Text to add is missing"
      exit
    end
    Filetool.add(filename, text)
    puts "Added text to #{filename}"

  else
    puts "unknown command: #{command}"
  end
end

学び1 : 「__FILE__ == $0」

__FILE__は擬似変数1で$0はグローバル変数です。
$0には、Rubyが最初に実行したプログラム(エントリーポイント)のパスが格納されます。
__FILE__は、そのプログラムの中で読み込まれるときどきのファイルが格納されます。

exit回避
RSpecテストファイルからrequire_relativeでこのファイルを読み込むと、トップレベルのコードが実行されます。その中にexitが含まれているため、RSpec実行時にもexitが呼ばれてしまいテストが途中で終了します。
このコードがあると、RSpec実行時には$0がspec/filetool_spec.rb(rspecファイル)になり、__FILE__はbin/filetoolとなるため条件式がfalseになります。
それによって、require_relative段階でのexitが実行されず、テスト終了を回避することができます。

学び2 : 「ARGV」

コマンドラインでRubyスクリプトに渡された引数は、ARGVに格納されます。
ARGV(Argument Vector)は組み込み変数で、配列形式で引数が格納されています。

lib/filetool.rb
module Filetool
  class << self
    def show(filename)
      File.read(filename)
    end
  
    def stats(filename)
      lines = 0
      words = 0
      chars = 0

      File.foreach(filename) do |line|
        lines += 1
        words += line.split.size
        chars += line.length
      end

      { lines: lines, words: words, chars: chars }
    end

    def add(filename, text)
      File.open(filename, 'a') { |f| f.puts text }
    end
  end
end

学び3 : 「Fileクラス」

Fileは、ファイルを読み書きするためのクラスです。入出力の基本機能を持つIOクラスを継承しています。

Fileオブジェクト
ファイルアクセスのためのメタ情報と操作をまとめたオブジェクトです。具体的には、パス・モード・ファイルポインタ・ファイルディスクリプタなどの情報を保持しています。

ファイルポインタは、ファイルのどこから読み書きするかを示すために使用します。
ファイルディスクリプタは、OSが管理するファイル番号で、指定したファイルに割り当てられます。

「openメソッド」

指定されたファイルをオープンし、Fileオブジェクトを生成して返します。
ブロックを指定して呼び出した場合は、Fileオブジェクトを引数としてブロックを実行します。ブロックの実行が終了すると、ファイルは自動的にcloseされます。

closeの役割
ファイルをopenすると、OSによってファイルディスクリプタが割り当てられ、closeするとそれが解放されます。closeし忘れるとファイルディスクリプタが解放されず、数に限りがあるため多数のファイルを扱うとエラーの原因になる可能性があります。そのため、ファイル操作後はcloseが推奨されます。

「ファイル読み込みメソッド」

ファイルを読み込むためのメソッドがいくつかあります。

read
readは、ファイル全体を一度に読み込み1つの文字列として取得します。

readlines
readlinesは、ファイルを行ごとに分割しそれを1つの要素とした配列として取得します。

File.foreach
File.foreachは、Fileクラスのクラスメソッドで、指定したファイルを1行ずつ読み込みます。ファイルのopen/closeは自動で行われます。

each_line
each_lineは、Fileオブジェクトのインスタンスメソッドで、open済みのファイルを1行ずつ読み込みます。

readとreadlinesはファイル全体をメモリに読み込むため、大きなファイルには向きません。

学び6 : 「RSpec関連」

Tempfile
Tempfileは、Rubyの標準ライブラリに含まれているクラスで、OSの一時ディレクトリ(temp)に一時的なファイルを作成します。
一時的なデータを保存したりテストデータの生成などに使用され、プログラムを終了すると自動的にクリーンナップされます。

本記事では、RSpecテストコード内で使用しました(下記コードはテストコードを一部抜粋したものです)。
引数には、作成されるファイル名のプレフィックスを指定します。

require "tempfile"

    it "shows file content" do
      file = Tempfile.new('test')  #=> "/tmp/test20080518-6961-5fnk19-0"
      file.write("hello\nworld\n")
      file.close

      output = `ruby bin/filetool show #{file.path}`

      expect(output).to include('hello')
      expect(output).to include('world')
    end

バッククォート
Rubyリテラルの一種で、バッククォートで囲われた文字列をコマンドとして実行します。
返り値は、その標準出力の文字列です。

追記1 : 「PATHを通す」

現状では、% ruby bin/filetoolといったようにコマンドを実行していましたが、% filetoolだけで実行できるようにしていきます。

手順1 : 「言語指定の省略」

ファイルの先頭行にshebangを記述します。

#!/usr/bin/env ruby

shebangとは、Unix系OSのソースファイルの1行目に記述する特殊な文字列です。
役割は、そのファイルをどのインタプリタ言語で実行するのかをOSに指定することです。

手順2 : 「実行権限の付与」

ファイルに実行権限を付与します。

chmod +x bin/filetool

手順1・2の作業によってコマンド実行時に言語名(ruby)を省略することができます。

% bin/filetool

手順3 : 「PATHが通っている場所に置く」

PATHに新しいディレクトリを追加したくないのと、filetool本体をプロジェクト内に置ておきたいので、今回はPATHが通っているディレクトリにfiletool本体ではなくシンボリックリンクを作成します。

% echo $PATH | tr ':' '\n'
/usr/local/bin
/usr/bin
/bin

% ln -s ~/path/to/your/project/bin/filetool /usr/local/bin/filetool

追記2 : 「searchコマンド追加」

指定した文字列を含む行がファイル中にあるか検索し、行番号付きで表示するコマンドです。

bin/filetool
when "search"
  if arg1.nil?
    puts "string to search is missing"
    exit
  end
  puts Filetool.search(filename, arg1)
lib/filetool.rb
def search(filename, string)
  results = []
  File.foreach(filename).with_index(1) do |line, no|
    if line.include?(string)
      results << "#{no}: #{line}"
    end
  end
  results
end

追記3 : 「replaceコマンド追加」

ファイル内の文字列を指定した文字列に置換し、行番号付きで表示するコマンドです。

bin/filetool
when "replace"
  if arg1.nil? || arg2.nil?
    puts "string to replace is missing"
    exit
  end
  puts Filetool.replace!(filename, arg1, arg2)
lib/filetool.rb
def replace!(filename, from, to)
  results = []

  Tempfile.create do |tmp|
    File.foreach(filename).with_index(1) do |line, no|
      if line.include?(from)
        replaced = line.gsub(from, to)
        results << "#{no}: #{replaced.chomp}"
        tmp.write(replaced)
      else
        tmp.write(line)
      end
    end

    tmp.flush
    File.write(filename, File.read(tmp.path))
  end
  results
end

「Tempfile.create」

Tempfile.createは、Tempfile.openと同様にテンポラリファイルを生成します。
各メソッドの違いは以下です。

ブロックなし
メソッドにブロックを指定しない場合、openはテンポラリファイルを自動的にcloseしません。また、プログラム終了後テンポラリファイルは自動的に削除されます。
一方、createでは自動的にcloseされませんし、プログラム終了後に自動的に削除もされません。

ブロックあり
ブロックを指定した場合、openではブロック処理の後で自動的にcloseされます。また、プログラム終了後に自動的に削除されます。
一方、createではブロック処理後にcloseと削除が自動で行われます。

追記4 : 「例外処理」

CLI側に例外処理を実装しました。

bin/filetool
begin
  case command
  # ...(省略)
rescue Errno::ENOENT
  puts "File not found: #{filename}"
  exit 1
rescue Errno::EACCES
  puts "Permission denied: #{filename}"
  exit 1
rescue => e
  puts "Unexpected error: #{e.message}"
  exit 1
end

rubyでの例外処理は、begin・rescue節を使用して記述します。
begin節の実行中に例外オブジェクトが投げられると、rescue節で指定した例外クラスと例外オブジェクトの一致判定が行われ、一致すればrescue節の処理が実行されます。
rescue節でエラータイプ(例外クラス)を省略すると、StandardErrorクラスとそのサブクラスである全ての例外を捕捉します。
また、=> eのように変数を指定することもでき、投げられた例外オブジェクトが格納されます。この変数は、発生した例外オブジェクトが格納される$!と同じ内容を参照しています。

終了ステータス
今回の例外処理と、既存のエラー回避コードに終了ステータスを実装しました。
これによって、コマンドの成功と失敗を明示的に区分できるようになり、CLIとして正しい振る舞いになりました。

追記5 : 「createコマンド追加」

新規ファイルを作成するコマンドです。

bin/filetool
when "create"
  Filetool.create(filename)
  puts "Created #{filename}"
end
lib/filetool.rb
def create(filename)
  if File.exist?(filename)
    raise "File already exists"
  end
  File.write(filename, "")
end

File.writeはFileクラスのクラスメソッドで、内部で"w"モードでファイルを開き、書き込み後に自動でcloseします。

追記6 :  「deleteコマンド追加」

ファイルを削除するコマンドです。

bin/filetool
when "delete"
  filename = ARGV.shift or abort("File is missing")
  Filetool.delete(filename)
  puts "Deleted #{filename}"
lib/filetool.rb
def delete(filename)
  raise "File not found" unless File.exist?(filename)
  File.delete(filename)
end

追記7 : 「コマンドオプション追加」

searchコマンドに-i(大文字小文字を無視)や-c(一致件数のみ表示)などのオプションを指定できるようにしました。

bin/filetool
require "optparse"

  options = {}

  opt = OptionParser.new do |opts|
    opts.on("-i", "--ignore-case", "Ignore case") do
      options[:ignore_case] = true
    end

    opts.on("-c", "--count", "Count matched only") do
      options[:count] = true
    end
  end

  opt.parse!
  
  command = ARGV.shift
  filename = ARGV.shift
  arg1 = ARGV.shift
  arg2 = ARGV.shift

when "search"
  if arg1.nil?
    puts "string to search is missing"
    exit 1
  end
  puts Filetool.search(filename, arg1, options)
lib/filetool.rb
def search(filename, string, options = {})
  results = []
      
  pattern = options[:ignore_case] ? /#{Regexp.escape(string)}/i : string

  File.foreach(filename).with_index(1) do |line, no|
    if line.match?(pattern)
      if options[:count]
        results << no
      else
        results << "#{no}: #{line}"
      end
    end
  end

  if options[:count]
    return results.size
  end

  results
end

「OptionParser」

OptionParserクラスは、コマンドラインのオプションを扱うクラスです。
まず、OptionParserオブジェクトにブロックを渡し、その中でonメソッドを使ってオプションを定義します。最後にparse!メソッドを呼ぶと、ARGVからオプションを解析し対応する処理を実行します。

「Regexp」

正規表現のクラスです。
文字列のパターンマッチや検索・置換などに使用します。

追記8 : 「renameコマンド追加」

ファイル名を変更するコマンドです。

lib/filetool.rb
def rename(old_name, new_name)
  raise "File not found #{old_name}" unless File.exist?(old_name)
  raise "File already exist: #{new_name}" if File.exist?(new_name)

  File.rename(old_name, new_name)
end
bin/filetool
when "rename"
  Filetool.rename(filename, arg1)
  puts "Renamed #{filename} to #{arg1}"

追記9 : 「mkdirコマンド追加」

ディレクトリを作成するコマンドです。

lib/filetool.rb
def mkdir(dirname)
  raise "Directory exist: #{dirname}" if Dir.exist?(dirname)

  Dir.mkdir(dirname)
end
bin/filetool
when "rename"
  Filetool.rename(filename, arg1)
  puts "Renamed #{filename} to #{arg1}"

「Dirクラス」

ディレクトリの操作はDirクラスを使って行います。

「tmpdir」

テンポラリディレクトリを扱うための標準ライブラリです。
mkdirコマンドのテストを記述する際に使用しました。
mktmpdirは、一時ディレクトリを作成します。
ブロックを指定した場合、ブロックを抜けると自動的にディレクトリは削除されます。
ブロックを指定しない場合はディレクトリのパスを返しますが、プログラム終了時にも自動で削除されることはありません。

追記10 : 「rmdirコマンド追加」

ディレクトリを削除するコマンドです。

lib/filetool.rb
def rmdir(dirname)
  raise "No such directory: #{dirname}" unless Dir.exist?(dirname)
  raise "Directory is not empty: #{dirname}" unless Dir.empty?(dirname)

  Dir.rmdir(dirname)
end
bin/filetool
when "rmdir"
  Filetool.rmdir(arg0)
  puts "Directory deleted"

追記11 : 「lsコマンド追加」

ディレクトリ内を一覧表示するコマンドです。

lib/filetool.rb
def ls(dirname)
  raise "No such directory: #{dirname}" unless Dir.exist?(dirname)

  Dir.children(dirname).map do |name|
    path = File.join(dirname, name)
    format("%-12s%s", File.directory?(path) ? "Directory:" : "", name)
  end
end
bin/filetool
when "ls"
  puts Filetool.ls(arg0)

追記12 : 「pwd、cdコマンド追加」

pwdコマンドは、カレントディレクトリの絶対パスを表示するコマンドです。

lib/filetool.rb
def pwd
  Dir.pwd
end
bin/filetool
when "pwd"
  puts Filetool.pwd

cdコマンドは、カレントディレクトリを変更するコマンドです。

lib/filetool.rb
def cd(path)
  Dir.chdir(path)
  Dir.pwd
end
bin/filetool
when "cd"
  Filetool.cd(arg0)

追記13 : 「seqrenameコマンド追加」

lib/filetool.rb
def seqrename(prefix = "file")
  count = 1

  files = Dir.glob("*")
             .select { |f| File.file?(f) }
             .reject { |f| f.match?(/^#{Regexp.escape(prefix)}_\d{3}/) }
             .sort
    
  files.each do |file|
    ext = File.extname(file)

    loop do
      new_name = "#{prefix}_#{format("%03d", count)}#{ext}"
      if File.exist?(new_name)
        count += 1
        next
      end

      File.rename(file, new_name)
      count += 1
      break
    end
  end
end
bin/filetool
when "seqrename"
  prefix = ARGV.shift or abort("File is missing")
  Filetool.seqrename(prefix)
  puts "File renamed"

追記14 : 「TOCTTOU問題」

TOCTTOUとは、ファイルの安全性をチェックした時点と実際にファイルを使用する時点の間に時間差があり、その間に対象ファイルが変更されることで不正アクセスやデータ改ざんが発生する問題です。
いくつかのfiletoolコマンドにこの問題が存在していたため修正しました。

create

lib/filetool.rb
def create(filename)
  File.open(filename, "wx") {}
rescue Errno::EEXIST
  raise "File already exists: #{filename}"
end

File.openの第2引数で、文字列("r", "w", "a")か整数(Fileモジュール定数)でファイルのオープンモードを指定します。
"x"は排他的作成モードを表し、ファイルがすでに存在する場合はErrno::EEXISTが発生します。"wx"は「書き込み+排他的作成」を意味し、整数指定ではFile::CREATE | File::EXCL | File::WRONLYと同等の動作になります。

delete

lib/filetool.rb
def delete(filename)
  File.delete(filename)
rescue Errno::ENOENT
  raise "File not found: #{filename}"
end

rename

lib/filetool.rb
def rename(old_name, new_name)
  File.link(old_name, new_name)
  File.unlink(old_name)
rescue Errno::ENOENT
  raise "File not found: #{old_name}"
rescue Errno::EEXIST
  raise "File already exist: #{new_name}"
end

renameメソッドでは、new_nameの存在チェック時のTOCTTOU問題を避けるためにFile.renameではなく、File.linkと File.unlinkを組み合わせて実装しています。
File.link(old_name, new_name)は新しいファイルが既に存在する場合に Errno::EEXISTを発生させるため、別プロセスが途中で同名ファイルを作成しても競合を検出できます。

replace

lib/filetool
def replace!(filename, from, to)
  results = []

  Tempfile.create(File.basename(filename), File.dirname(filename)) do |tmp|
    File.foreach(filename).with_index(1) do |line, no|
      replaced = line.gsub(from, to)

      if replaced != line
      results << "#{no}: #{replaced.chomp}"
      end
      tmp.write(replaced)
    end

    tmp.flush
    tmp.fsync

    File.rename(tmp.path, filename)
  end
  results
rescue Errno::ENOENT
  raise "File not found: #{filename}"
end

flashはRubyのユーザ空間バッファの内容をOSのバッファへ書き出します。
fsyncはOSのカーネルバッファの内容を物理ストレージへ強制的に同期します。

File.write(filename, File.read(tmp.path))だと、tmpファイルを読み込むこととfilenameに上書きすることのラグがあり、別プロセスがその間にfimenameを書き換えてしまう可能性がありました。renameに変更することで1つの処理で済むため、途中状態が存在せずこの問題を防げます。

seqrename

lib/filetool.rb
def seqrename(prefix = "file")
  count = 1

  files = Dir.glob("*")
             .select { |f| File.file?(f) }
             .reject { |f| f.match?(/^#{Regexp.escape(prefix)}_\d{3}/) }
             .sort
    
  files.each do |file|
    ext = File.extname(file)

    begin
      new_name = "#{prefix}_#{format("%03d", count)}#{ext}"
      File.link(file, new_name)
      File.unlink(file)
      count += 1
    rescue Errno::EEXIST
      count += 1
      retry
    end
  end
end

mkdir

lib/filetool.rb
def mkdir(dirname)
  Dir.mkdir(dirname)
rescue  Errno::EEXIST
  raise "Directory exist: #{dirname}"
end

rmdir

filetool.rb
def rmdir(dirname)
  Dir.rmdir(dirname)
rescue Errno::ENOENT
  raise "Directory not found: #{dirname}"
rescue Errno::ENOTEMPTY
  raise "Directory is not empty: #{dirname}"
end

おわりに

コマンドライン上でファイル操作を行うツールを作成しました。基本的な機能ではありますが、FileクラスやRSpecのTempfileクラスを使うことでファイルの扱いを学ぶことができました。
今後も本記事で学んだ内容を活かしながら、別のCLIツールを作成していきたいと思います。

  1. Rubyが用意している識別子で、変数のように参照できるが代入できない。いくつか種類があり、 __FILE__ にはそのコードが書かれているファイルのパスが入る。 ↩

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?