Rubyでシェルもどきを作る

Ruby でシェルのようなものを作ってみると、Ruby とシェルやシステムコールの理解ができて、研修の課題とかにいいんじゃないかと10年くらい前に思ってたのを、ふと思い出したので書いてみます。

基本

シェルの動作を簡単に説明すると次のような感じです。

  1. プロンプトを出力
  2. 標準入力からコマンドラインを読み込む
  3. 読み込んだコマンドを実行する
  4. コマンドの終了を待つ
  5. 1 に戻る

これをそのまま Ruby で書いてみます。

while true
  print '-> '                  # プロンプト表示
  cmd = gets or break          # コマンド入力
  cmd.chomp!                   # 末尾の改行削除
  pid = Process.fork do        # 子プロセス生成
    Process.exec [cmd, cmd]    # コマンド実行
  end
  Process.waitall              # 子プロセスの終了待ち
end

Process.exec の引数に cmd ではなく [cmd, cmd] を渡しています。引数が一つだけで文字列の場合は Process.exec はシェルを経由する可能性があるためです。これからシェルを作ろうとしているのにシェル経由でコマンドを実行してしまっては面白くもなんともないので、シェルを経由しないようにしています。

実行してみます。作成したスクリプトは sheru というファイル名にしてあります。

% ruby sheru
-> date
2013年 10月  8日 火曜日 23:26:03 JST
> ls
sheru  text.md
-> ← 終了は Ctrl-D

うまく動いているようです。

EOF

ちなみに Ctrl-D で標準入力が終了するのは、コマンドが Ctrl-D のコードを読み込んで特別扱いしているのではなく、端末で Ctrl-D が EOF(End Of File)に割り当てられているからです。

stty コマンドで割り当てを確認することができます。

% stty -a
speed 38400 baud; rows 24; columns 80; line = 0;
intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^D; eol = M-^?; eol2 = M-^?;
swtch = M-^?; start = ^Q; stop = ^S; susp = ^Z; rprnt = ^R; werase = ^W;
lnext = ^V; flush = ^O; min = 1; time = 0;
-parenb -parodd cs8 hupcl -cstopb cread -clocal -crtscts
-ignbrk brkint -ignpar -parmrk -inpck -istrip -inlcr -igncr icrnl ixon -ixoff
-iuclc ixany imaxbel iutf8
opost -olcuc -ocrnl onlcr -onocr -onlret -ofill -ofdel nl0 cr0 tab0 bs0 vt0 ff0
isig icanon iexten echo echoe echok -echonl -noflsh -xcase -tostop -echoprt
echoctl echoke

stty コマンドの出力の2行目の eof = ^D というのがそうです。ちなみに Cntrl-C でコマンドが中断するのも intr = ^C が設定されているためです。

Ctrl-D 以外のキーを割り当ててみましょう。

% stty eof ^X
% stty -a
speed 38400 baud; rows 24; columns 80; line = 0;
intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^X; eol = M-^?; eol2 = M-^?;
...

Ctrl-X を割り当ててみました。これ以降この端末では Ctrl-X で入力が終了するようになります。他の端末には影響しません。

% ruby sheru
-> ^D ← Ctrl-D を入力しても普通の文字として扱われる
->    ← 終了するには Ctrl-X を入力

ややこしいので元に戻しておきます。

% stty eof ^D

コマンドラインのパース

入力を元にコマンドをちゃんと動かすことができました。ところがコマンドに引数を指定するとエラーになってしまいます。

% ruby sheru
-> ls -l
./sheru:7:in `exec': No such file or directory - ls -l (Errno::ENOENT)
    from ./sheru:7:in `block in <main>'
    from ./sheru:6:in `fork'
    from ./sheru:6:in `<main>'
-> 

引数つきの ls コマンドではなく、ls -l というコマンドを探して、そのようなコマンドは無いのでエラーになっています。

入力文字列をコマンドと引数に分解して exec に渡す必要があります。 シェルの行のパースは実はいろいろ複雑なのですが、ここでは単純に空白で区切るだけにしておきます。

while true
  print '-> '                       # プロンプト表示
  line = gets or break              # コマンド入力
  line.chomp!                       # 末尾の改行削除
  cmd, *args = line.split(/\s+/)    # 空白で入力を区切る
  pid = Process.fork do             # 子プロセス生成
    Process.exec [cmd, cmd], *args  # コマンド実行
  end
  Process.waitall                   # 子プロセスの終了待ち
end
% ruby sheru
-> ls -l
合計 8
-rwxr-xr-x 1 tommy tommy  472 10月  8 23:36 sheru
-rwxr-xr-x 1 tommy tommy  472 10月  8 23:36 text.md
-> 

ちゃんと引数を扱うことができました。

ワイルドカード

ファイル名にワイルドカードを使ってみましょう。

% ruby sheru
-> ls *
ls: * にアクセスできません: そのようなファイルやディレクトリはありません
-> 

ls コマンドが * をそのままファイル名として扱ってしまって、ファイルがないと言ってます。 ワイルドカードをファイル名に展開しているのは各コマンドではなく、シェルの役割なのです。

シェルのワイルドカードもいろいろありますが、簡単のためにここでは * の一文字だけをワイルドカードとして扱うことにします。

def parse_line(line)
  line.split(/\s+/).map{|arg|
    if arg == '*'           # 引数が '*' の場合は
      Dir.glob('*')         # ファイル名に展開する
    else
      arg
    end
  }.flatten                 # '*' の展開が配列になっているのでフラット化
end

while true
  print '-> '                       # プロンプト表示
  line = gets or break              # コマンド入力
  line.chomp!                       # 末尾の改行削除
  cmd, *args = parse_line line      # 入力のパース
  pid = Process.fork do             # 子プロセス生成
    Process.exec [cmd, cmd], *args  # コマンド実行
  end
  Process.waitall                   # 子プロセスの終了待ち
end

コマンドラインのパース処理が複雑になってきたのでメソッドを分けました。

% ruby sheru
-> ls *
sheru  text.md
-> 

ダイレクト

標準入力、標準出力、標準エラー出力は、それぞれプロセスのファイル記述子 0, 1, 2 につけられた名前です。 端末から実行された場合は通常はそれぞれ端末に結び付けられています。 なので、端末から入力された文字がコマンドの標準入力から読み込まれ、コマンドの標準出力に書かれたデータは端末に出力されるのです。

一般的なシェルは > で標準出力をファイルに書き出すことができます。それも実装してみましょう。

簡単のために >filename の形式だけをサポートします。> の後に空白文字は要りません。

def parse_line(line)
  @stdout = nil             # 標準出力リダイレクト先
  line.split(/\s+/).map{|arg|
    if arg == '*'           # 引数が '*' の場合は
      Dir.glob('*')         # ファイル名に展開する
    elsif arg =~ /\A>(.*)/  # 引数が '>' で始まる場合は
      @stdout = $1          # 続く文字列をリダイレクトファイル名とする
      nil                   # この引数は無視
    else
      arg
    end
  }.compact.flatten         # '*' の展開が配列になっているのでフラット化
end

while true
  print '-> '                       # プロンプト表示
  line = gets or break              # コマンド入力
  line.chomp!                       # 末尾の改行削除
  cmd, *args = parse_line line      # 入力のパース
  pid = Process.fork do             # 子プロセス生成
    if @stdout                      # リダイレクト先が指定されていた場合は
      STDOUT.reopen(@stdout, 'w')   # 標準出力をファイルに変更
    end
    Process.exec [cmd, cmd], *args  # コマンド実行
  end
  Process.waitall                   # 子プロセスの終了待ち
end

ダイレクトには $stdout ではなく STDOUT を使用します。 $stdoutRuby のメソッドでしか有効ではありません。標準出力(=ファイル記述子1番)のファイルを変更するには STDOUT を変更する必要があります。

なお、Rubyexec にはこれを簡単に行う方法が用意されていて、次のように記述できます。

    Process.exec [cmd, cmd], *args, :out=>@stdout

実行結果

% ruby sheru
-> ls >/tmp/xxx
-> cat /tmp/xxx
sheru
text.md
-> 

パイプ

一般的なシェルは、コマンドライン中に | があると、その前後に書かれた二つのプロセスを同時に動かして、前のプロセスの標準出力と後ろのプロセスの標準入力を接続します。

pipe システムコールを使用すると2つのファイル記述子が返されます。これがパイプです。 このファイル記述子はファイルシステム上のファイルには関連づいていないのでファイル名はありません。 名前無しパイプとも呼ばれます。

パイプは一方に書き込むともう片方から読み込むことができます。なので、一つを前のプロセスの標準出力に設定して、もう一つを後ろのプロセスの標準入力に設定することで、パイプ処理が実現できます。

以下のプログラムでは、ややこしくなるので、先に実装したリダイレクト処理は削除しています。

# [[cmd1, arg, ...], [cmd2, arg, ...], ...] を返す
def parse_line(line)
  cmd = []
  cmds = [cmd]
  line.split(/\s+/).each do |arg|
    if arg == '*'                # 引数が '*' の場合は
      cmd.concat Dir.glob('*')   # ファイル名に展開する
    elsif arg == '|'             # パイプの場合は
      cmd = []                   # 次から新しいコマンド
      cmds.push cmd
    else
      cmd.push arg
    end
  end
  cmds
end

while true
  print '-> '                       # プロンプト表示
  line = gets or break              # コマンド入力
  line.chomp!                       # 末尾の改行削除
  cmds = parse_line line
  pipes = Array.new(cmds.count-1){IO.pipe}  # コマンド接続分のパイプを用意
  pipes = [nil, pipes.flatten.reverse, nil] # [nil, W, R, W, R, ..., nil] に並び替え
    .flatten
  cmds.each do |cmd, *args|
    r, w = pipes.shift 2                # パイプを2つ取り出し
    pid = Process.fork do               # 子プロセス生成
      STDIN.reopen r if r               # 読み込みパイプにリダイレクト
      STDOUT.reopen w if w              # 書き込みパイプにリダイレクト
      Process.exec [cmd, cmd], *args    # コマンド実行
    end
    r.close if r                        # 親プロセスでは不要なので
    w.close if w                        # パイプをクローズ
  end
  Process.waitall                       # 子プロセスの終了待ち
end

実行結果

% ruby sheru
-> seq 100 | grep 0 | head -3
10
20
30
-> 

おわりに

シェルと呼ぶにはプアすぎる実装ですが、シェルがどのようにしてコマンドを実行しているのかの例くらいにはなったのではないかと思います。

システムコールは C で記述すると結構面倒なことが多いのですが、Ruby を使うととても簡単に記述することができます。Ruby は素晴らしいですね。

システムコールRuby から使う方法についてもっと詳しく知りたい人は、「なるほどUnixプロセス」という書籍がオススメです。

なるほどUnixプロセス ― Rubyで学ぶUnixの基礎
Jesse Storimer, 島田浩二(翻訳), 角谷信太郎(翻訳)
達人出版会
発行日: 2013-04-25
対応フォーマット: EPUB, PDF, ZIP