Nimで作る簡易自作シェル


TL;DR

  • LSHというC言語製の簡易シェルを見つけたので、Nimで書き直してみた話。
  • LSHの実装チュートリアルはこの記事を参照(英語)
  • 関数ポインタとかプロセスフォークはNimで馴染みがなかったので、そこそこ勉強になった。

はじめに

1年くらい前にC言語製のLSHというシェルをお手本に、Nimの勉強のためのシェルを作りました。
LSHではパイプラインやリダイレクトなどの高機能な構文は実装されていませんが、作りが簡単なのでNimで書き直すのもいい勉強になると思ったからです。

が、改めて読み返してみると、1年前のコードは未熟すぎて恥ずかしすぎる。。。
今日は恥ずかしいコードの供養も兼ねて振り返りをしたいと思います。

nimshのコード

Github: https://github.com/iranika/nimsh

LSHから引き継いだ関数名は、わかりやすいようにスネークケースのままにしてあります。
(後から置換しやすいので)
コードは90行もないので、サクッと読めると思いますが、以下の順で追いかけると読みやすいと思います。

  1. isMainModule
  2. main()
  3. init()
  4. runForever()
  5. lsh_read_line()
  6. lsh_split_line()
  7. lsh_execute()
  8. var BUILTIN_COMMANDS, lsh_cd(), lsh_exit()
  9. lsh_launch()
  10. poxixモジュール (execvp,fork,waitpid等)
src/nimsh.nim
import rdstdin, strutils, terminal
import posix

proc lsh_read_line(): string = 
  result = readLineFromStdin(">").strip

const LSH_TOK_SEPS: set[char] = {' ','\t','\r','\n','\a'}
proc lsh_split_line(line: string): seq[string] =
  result = line.split(LSH_TOK_SEPS)

proc lsh_cd(args: seq[string]): int =
  if args.len == 1:
    stderr.write("lsh: expected argument to \"cd\"\n")
  else:
    if chdir(args[1]) != 0:
      stderr.write("lsh: argument is invalid.\n")
  return 0

proc lsh_exit(args: seq[string]): int =
  echo "Good bye"
  return -1

type builtin_command = object
  name: string
  fn: proc (args: seq[string]): int

var
  BUILTIN_COMMANDS: seq[builtin_command] = @[
    builtin_command(name:"cd", fn:lsh_cd),
    builtin_command(name:"exit", fn:lsh_exit),
    builtin_command(name:"quit", fn:lsh_exit),
  ]

proc lsh_launch(args: seq[string]): int =
  var status :cint
  var pid = fork()
  if pid == 0:
    #child process
    if execvp(args[0], args.allocCStringArray()) == -1:
      stderr.write("command execute error.\n")
    quit(0)
  elif pid < 0:
    stderr.write("fork error.\n")
  else:
    #Parent process  
    let wpid = waitpid(pid, status, WUNTRACED)
    if WIFEXITED(status):
      return 0
  return 0

proc lsh_execute(args: seq[string]): int =
  if args.len == 1:
    if isNilOrEmpty(args[0]):
      return 0  
  for cmd in BUILTIN_COMMANDS:
    if args[0] == cmd.name:
      return cmd.fn(args)  
  return lsh_launch(args)

proc runForever() =
  var line: string
  var args: seq[string]
  var status: int
  while (status == 0):
    line = lsh_read_line()
    args = lsh_split_line(line)
    status = lsh_execute(args)

proc init() =
  echo("Welcome to nimsh! 🍣")  
  return

proc main() =
  init()
  runForever()

when isMainModule:
  #後々のヘルプ実装もしやすようにdispatch経由で呼び出しています。
  import cligen
  dispatch(main, help = {})

一年ぶりにコードを見た感想

ツッコミたいところ

  • 少し流れが読みにくいかなと。今ならlsh_*関数群はモジュールで切り離すような設計にするかも。
  • BUILTIN_COMMANDSとlsh_cd等の組み込みコマンドの処理はモジュールにして切り離したほうが、後々組み込みコマンドを拡張するのに便利なのに何故分けなかったのか。
  • プロセスのシグナル処理が甘いので、Ctrl+Cなどの割り込みで簡単に例外で落ちる。
    今でもプロトタイプでは異常系を作り込むのを先送りするが、気がついている異常系はコメントやissue等で忘れないように管理してくれ(戒め)

振り返ってみると、関数ポインタやプロセスフォークをNimでどうやって書くのか、苦労してドキュメントを漁っていた記憶があります。懐かしい。
そういえば、cligenはnimshから使い始めた気がします。とても便利なので、いい収穫でした。
あとNimに関係ないですがnimshのテストは、テキストファイルからテスト用コマンドを流し込んで、想定される出力のテキストファイル(合格データ)と差分チェックしていました。今でもユーザ視点のテストを書くときにこんな感じのシェルスクリプトを書いたりします。

tests/test.sh
#!/bin/bash
#exec_command test
../nimsh < ./test_data/exec_command.test.txt &> ./result/exec_command.result.txt
diff ./should_be/exec_command.should_be.txt ./result/exec_command.result.txt || echo "ExecuteCommandTest is Faild."

終わりに: バグ?

標準出力でechoの代わりにstdout.writeを使うと、ファイルへのリダイレクトで謎の挙動をするバグ?がありました。

以下のようにtest.txtを使って入力に対するテストをしようと思ったら、ファイルへのリダイレクトで謎の順序で出力されます。

test.txt
echo "hoge"
pwd
#hoge
uname
exit

実行結果の様子。

上記画像で
Welcome~とGood byeはstdout.writeで出力。
command execute errorはstderr.writeで出力。
その他はexecvpで外部の実行ファイルが出力していました。

stderr.writeとexecvpの出力は期待通りになっている気がしますが、stdout.writeは謎。
調べたところ、nimのechoはスレッドセーフになっているようなのでechostdout.writeの挙動の違いはそのあたりが関係してそうです。
https://nim-lang.org/docs/system.html#echo%2Cvarargs%5Btyped%2C%5D

が、イマイチよくわかってないのでこの謎がわかる人いたら教えて下さい。