TL;DR
- LSHというC言語製の簡易シェルを見つけたので、Nimで書き直してみた話。
- LSHの実装チュートリアルはこの記事を参照(英語)
- 関数ポインタとかプロセスフォークはNimで馴染みがなかったので、そこそこ勉強になった。
はじめに
1年くらい前にC言語製のLSHというシェルをお手本に、Nimの勉強のためのシェルを作りました。
LSHではパイプラインやリダイレクトなどの高機能な構文は実装されていませんが、作りが簡単なのでNimで書き直すのもいい勉強になると思ったからです。
が、改めて読み返してみると、1年前のコードは未熟すぎて恥ずかしすぎる。。。
今日は恥ずかしいコードの供養も兼ねて振り返りをしたいと思います。
nimshのコード
Github: https://github.com/iranika/nimsh
LSHから引き継いだ関数名は、わかりやすいようにスネークケースのままにしてあります。
(後から置換しやすいので)
コードは90行もないので、サクッと読めると思いますが、以下の順で追いかけると読みやすいと思います。
- isMainModule
- main()
- init()
- runForever()
- lsh_read_line()
- lsh_split_line()
- lsh_execute()
- var BUILTIN_COMMANDS, lsh_cd(), lsh_exit()
- lsh_launch()
- poxixモジュール (execvp,fork,waitpid等)
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のテストは、テキストファイルからテスト用コマンドを流し込んで、想定される出力のテキストファイル(合格データ)と差分チェックしていました。今でもユーザ視点のテストを書くときにこんな感じのシェルスクリプトを書いたりします。
#!/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を使って入力に対するテストをしようと思ったら、ファイルへのリダイレクトで謎の順序で出力されます。
echo "hoge"
pwd
#hoge
uname
exit
上記画像で
Welcome~とGood byeはstdout.write
で出力。
command execute errorはstderr.write
で出力。
その他はexecvpで外部の実行ファイルが出力していました。
stderr.write
とexecvpの出力は期待通りになっている気がしますが、stdout.write
は謎。
調べたところ、nimのecho
はスレッドセーフになっているようなのでecho
とstdout.write
の挙動の違いはそのあたりが関係してそうです。
https://nim-lang.org/docs/system.html#echo%2Cvarargs%5Btyped%2C%5D
が、イマイチよくわかってないのでこの謎がわかる人いたら教えて下さい。