シェルの作り方
シェルとは何か
こういうやつ
https://gyazo.com/360e7ab667de62f966ff396f45025acb
CUI (Character User Interface)
端末エミュレーター → 上画像の黒いウィンドウそのもの
具体的にはiTerm2というソフトウェア
ほかにも端末エミュレーターは色々ある
ターミナル.app, Alacritty, xterm, GNOME terminal などなど
歴史的には物理的なデバイスである「端末」が使われていた
大昔、コンピュータが高価であった時代は全員にPCを与えるなどということはできず、1台の大型コンピュータを共用するのが普通だった。もし1台のコンピュータにディスプレイとキーボードが1個しかついていなかったら、同時に1人しか操作することができない。そこで、ディスプレイとキーボードの機能だけを持たせた機械を何台もコンピュータにつなげた。これが端末である。
端末はキーが押されるとそれをコンピュータに伝送し、コンピュータでの処理結果を受け取って画面(や紙)に出力する機能しか持たない。それ自身の中でプログラムを実行することはできない(のちにそれが可能になる端末も現れた)。コンピュータと端末は通信している形になるが、端末とコンピュータはケーブルなどで直接接続されており、通信というよりもむしろ、端末が1つのデバイスとして接続されているという認識の方が近い(だから今日でも Unix で端末はデバイス扱い)。
https://gyazo.com/2cf273ca2f38aa80079fde607b85e391
↑端末の1つ VT100
入力された文字列をシェルに送信し,シェルから文字列を受信して表示する
シェルが持つ代表的な機能
コマンド呼び出し
ls, mkdir, cd ...
パイプ
ls | grep CAMPHOR-
リダイレクト
grep < inputfile > outputifle 2> errormessages
制御構造
&&
wget https://www.google.co.jp && cat index.html
||
cd hoge || mkdir hoge && cd hoge
;
cp hoge fuga; cat fuga
if, while, for, function ...
各コマンドを小さく作り,これをパイプで組み合わせて大きな機能を実現する
シェルは何をしているのか
入力のパース
fork & exec (コマンド呼び出し)
ファイルポインタの差し替え(パイプ,リダイレクト)
ビルトインコマンドの実装(cdなど)
制御構造の実現(||, &&, ;, if, while, for, functionなど)
この記事はなんの話をするのか?
主に*NIXのプロセスとかファイルとかの話をする
付随して,インタプリタの作り方にも若干触れる?TODO
シェル作りは実質インタプリタ作りという説
シェルの実装
Haskellでやる→Haskellで実装されたshell→Hash
Step 1: コマンド呼び出し
まずは普通のコマンド呼び出しができるようにしよう.ls srcとかね.
メインループ
プロンプトを出して,標準入力から一行読み取り,パースして,実行する.
code: Main.hs
main :: IO ()
main = do
-- プロンプトを表示
promptString <- fromMaybe "Hash> " <$> lookupEnv "PROMPT"
putStr promptString
hFlush stdout
-- 入力行を受け取る
line <- catch getLine $ \(_ :: IOException) -> exitWith ExitSuccess
-- パース & 実行
case parseLine line of
Left err -> print err
Right expr -> do
evalAST expr
hFlush stdout
-- 始めに戻る(再帰)
main
ASTの定義
ASTとはプログラムから余分な情報を取り除いた構造体のこと(要出典).
以下は「シングルコマンド」のみからなるAST.
code: Hash/ShellAST.hs
data ShellAST = Single Cmd Args deriving Show
type Cmd = String
パーサーの定義
パーサーというのは,文字列を受けとってASTを生成するプログラム.
parseSingleは,0個以上のスペースによって区切られた「普通の文字列」の繰り返しからなる文字列を受け取り,「普通の文字列」のリストを返す.
頭の「普通の文字列」をcmd,残りの「普通の文字列」のリストをargsとして,Single cmd argsを返す.
code: Hash/Parser.hs
parseLine :: String -> Either ParseError ShellAST
parseLine = parse parser ""
parser = parseSingle
parseSingle = do
spaces
cmd:args <- many1 $ do
tk <- try $ many1 (noneOf " |&;><")
spaces
return tk
return $ Single cmd args
評価器の定義
本記事でいちばん重要なのがこの評価器.
プロセスをforkして,コマンドをexecし,プロセスの終了をwaitしている.
TODO: 概念の説明,システムコールの説明
forkしてからexecを呼び出すのが重要.forkせずにexecしちゃうと,シェルのプロセスが呼び出し先のコマンド(lsとか)に成り代わられてしまう.つまり,コマンドが終了するとシェルごと終了してしまう.
なので,一度forkによってシェルのプロセスを複製し,そのプロセスでexecを行う.forkすると非同期にプログラムが走ることになるので,コマンドのプロセスの終了を待って,そのexitcodeを返すようにする.
code: Hash/Evaluator.hs
evalAST :: ShellAST -> IO ExitCode
evalAST (Single cmd args) = do
let searchPath = not ('/' elem cmd)
let env = Nothing
-- フォークして実行.ExitCodeを返す
exitcode <- forkWait $ executeFile cmd searchPath args env
return exitcode
テスト
シェルコマンドを実行して,その結果がzshでの実行結果と一致するかを検証する.
code: shell
Success: "ls src"
Success: " ls src "
Failed: "ls | grep R"
普通のコマンドは動いている
パイプがFailしている
Step2: パイプ
パイプ:コマンドの出力を,次のコマンドの入力に繋げる演算子
例:ls | grep yaml
code: console
$ ls
LICENSE Setup.hs package.yaml stack.yaml tmp
README.md hash2.cabal src test.sh
$ ls | grep yaml
package.yaml
stack.yaml
lsがファイル一覧を出力し,grepの入力に渡している.
grepはlsからの入力を受け取り,yamlが含まれるファイル名だけを出力する
パイプはどのように実現されているのか?
各コマンドの標準出力と標準入力を パイプ に繋ぎ変えている
ここでの パイプ はOSの機能を指しており,シェルのパイプではない
TODO:
ファイルポインタとパイプの説明をする.図を使う.
プロセス立ち上げのときに0と1と2のファイルポインタを開いて,標準入出力と標準エラー出力をopenするということを言う
パイプの実現方法はここで全部しゃべる
ASTの定義
Singleに加えて,Pipedが増えている.
Pipedは左のASTと右のASTを持っている.ASTはSingleのこともあれば,入れ子になったPipedのこともある.
code: Hash/ShellAST.hs
data ShellAST = Single Cmd Args | Piped ShellAST ShellAST deriving Show
パーサの定義
parseSingleに加えてparsePipeが追加された.これは|という文字列にマッチするパーサである.
さらにparserの定義が拡張されており,「parsePipeで区切られたparseSingle」にマッチするパーサーになっている.
code: Hash/Parser.hs
parseLine :: String -> Either ParseError ShellAST
parseLine = parse parser ""
parser = parseSingle chainl1 parsePipe
parsePipe = try $ string "|" >> (lookAhead . try $ noneOf "|") >> return Piped
parseSingle = (略)
評価器の定義
まず,evalASTの引数に2つのファイルハンドラが追加されている.今まではプロセスが持っていたstdinとstdoutを暗黙的に入出力先として使っていたが,パイプを実現する上では外から入出力先を指定できる必要がある.
ASTとしてSingleが渡された時は,そのプロセスのstdinとstdoutをそれぞれinputとoutputに差し替えた上で,以前と同じ処理を行う.「トップレベル」からevalASTを呼び出す際はinputとoutputにはそれぞれstdinとstdoutが指定されるので,以前と全く同じ動作となる.
code: Hash/Evaluator.hs
evalAST :: (Handle, Handle) -> ShellAST -> IO ExitCode
evalAST (input, output) (Single cmd args) = do
-- stdinとstdoutを差し替える
hDuplicateTo' input stdin
hDuplicateTo' output stdout
(さっきと同様に,forkしてexec,waitする)
ASTとしてPipedが渡されたときの動作は以下のようになる.
まず,システムコールを発行し,パイプを作成する.
プロセスをフォークする
入力元としてinput,出力先として書き込み側のパイプを指定して左のASTを実行する
呼び出し先でstdinとstdoutが書き換えられるが,ここはフォークしたプロセスなので,元のプロセスに影響はない
元のプロセスで,入力元としてreadPipe,出力先としてoutputを指定して,右のASTを実行する
code: Hash/Evaluator.hs
evalAST (input, output) (Piped leftAST rightAST) = do
-- パイプを作成
(readPipe, writePipe) <- createPipe
-- フォークして左のASTを実行.
forkProcess $ do
hClose readPipe -- こちらのプロセスでは不要なので閉じる
evalAST (input, writePipe) leftAST
return ()
hClose writePipe -- こちらのプロセスでは不要なので閉じる
evalAST (readPipe, output) rightAST
テスト
code: console
Success: ls src
Success: ls src
Success: ls | grep R
Success: ls|grep R
LICENSE Setup.hs package.yaml stack.yaml testtmp
README.md hash2.cabal src test.sh tmp
Failed: ls > testtmp/hoge
パイプが動いている
リダイレクトがFailしている(ls > testtmp/hoge)
リダイレクトをキャッチできておらず,標準出力にlsの結果が表示されている.
Step3: リダイレクト
リダイレクト:標準入力,標準出力,標準エラー出力を,他のファイルにつなぐ機能
grep R < LICENSE > hoge:LICENSEからファイルを読み取ってRを含む行を取り出し,hogeに書き出す
パイプと同様に,標準入力,標準出力,標準エラー出力を,他のファイルポインタに差し替えてやれば実現できる
ASTの定義
SingleのプロパティにStdin/Stdout/Stderrが増えている.
grep R < LICENSE > hoge が Single grep [] (Just "LICENSE") (Just "hoge") Nothing になる.
code: Hash/ShellAST.hs
type Stdin = Maybe String
type Stdout = Maybe String
type Stderr = Maybe String
data ShellAST = Single Cmd Args Stdin Stdout Stderr | Piped ShellAST ShellAST deriving Show
パーサーの定義
(略)
評価器の定義
fStdinが存在すればそのファイルをOpenしてstdinを差し替える.fStdoutとstdoutにも同じことをしている.stderrも同じ.
code: Hash/Evaluator.hs
evalAST (input, output) (Single cmd args fStdin fStdout fStderr) = do
-- stdinとstdoutとstderrを差し替える
input' <- fromMaybe (return input) (flip openFile ReadMode <$> fStdin)
hDuplicateTo' input' stdin
output' <- fromMaybe (return output) (flip openFile WriteMode <$> fStdout)
hDuplicateTo' output' stdout
err' <- fromMaybe (return stderr) (flip openFile WriteMode <$> fStderr)
hDuplicateTo' err' stderr
テスト
code: console
Success: ls src
Success: ls src
Success: ls | grep R
Success: ls|grep R
Success: ls > testtmp/hoge
Success: grep H < LICENSE
Success: cat notexistfile 2> testtmp/hoge
Failed: ls && echo fuga
リダイレクトは成功している
論理演算(&&)が失敗している
Step4: 論理演算/複数文
&&:論理演算のANDと同じ.
両隣のコマンドのExitCodeについて論理和を取っている.0をtrue,それ以外をfalseと見ている.
左のコマンドの返り値がfalseだったら右のコマンドは実行しない
右がtrueでも式全体はfalseになるため
これを利用して,複数のコマンドを連結することがよくある
例:mkdir hoge && cd hoge && touch fuga
どこかでコマンドがコケたらその先は実行されない
||:論理演算のORと同じ
左のコマンドの返り値がtrueだったら右のコマンドは実行しない
右がfalseでも式全体はtrueになるため
これを利用して,エラー時の処理を書くことができる
例:cd hoge || mkdir hoge && cd
hogeディレクトリが存在しない場合はmkdirしてcdし直す
;:文の区切り
左のコマンドを実行した後に右のコマンドを実行する
左の返り値がtrueでもfalseでも右を実行する
ASTの定義
And/Or/Blockが増えた.
code: Hash/ShellAST.hs
data ShellAST =
Single Cmd Args Stdin Stdout Stderr |
Piped ShellAST ShellAST |
And ShellAST ShellAST |
Or ShellAST ShellAST |
Block ShellAST ShellAST deriving Show
パーサの定義
(略)
評価器
&&の実装は以下.左を実行してExitCodeを見て,Success(= 0)だったら右を実行し,そうでなければ何もしない.
||はほとんど同じなので割愛.
code: Hash/Evaluator.hs
evalAST handles (And leftAST rightAST) = do
exitcode <- evalAST handles leftAST
if exitcode == ExitSuccess
then
evalAST handles rightAST
else
return exitcode
;の実装は以下.左を実行し,次に右を実行する.
code: Hash/Evaluator.hs
evalAST handles (Block leftAST rightAST) = do
evalAST handles leftAST
evalAST handles rightAST
テスト
code: console
Success: ls src
Success: ls src
Success: ls | grep R
Success: ls|grep R
Success: grep H < LICENSE
Success: ls > testtmp/hoge
Success: ls | grep R > testtmp/hoge
Success: cat notexistfile 2> testtmp/hoge
Success: ls && echo fuga
Success: ls&&echo fuga
Success: ls || echo hoge
Success: ls||echo hoge
Success: ls; echo hoge
Success: ls;echo hoge
Failed: cd src; ls; cd ..
&& / || / ; が動いている.
cd src; ls; cd ..がFailしている
これはコマンド自体は動いているが,出力結果が異なる
つまり,cdできていない
Step6: ビルトインコマンド
cd,which,pwdあたりはzshではビルトインコマンド
それとは別に実行ファイルも存在している場合がある
特にcdは他のコマンドと同様には動かせない
カレントディレクトリはプロセスに紐付いている
通常のコマンド発行フロー:
プロセスをfork
fork先でcdする
元のプロセスでwait
→元のプロセスのカレントディレクトリは変化しない
forkせずにcdコマンドを実行するとどうなるか?
execするとそのプロセスはコマンドに成り代わられてしまうので,cdが終わるとシェルが終了する
→シェル自身がシステムコール(chdir)を呼ぶ必要がある
ビルトインコマンド
評価器
cmdがcdのときに分岐して,カレントディレクトリを変更する処理を行う.HashMapをlookupして関数を取得するなどすれば拡張性が生まれる.
code: Hash/Evaluator.hs
evalAST (input, output) (Single cmd args fStdin fStdout fStderr) = do
(略:stdinの差し替えなど)
if cmd == "cd"
then do
home <- getEnv "HOME"
let targetDir = fromMaybe home $ headMay args
let cd = setCurrentDirectory targetDir >> return ExitSuccess
let failed = hPutStrLn stderr ("cd: no such file or directory: " ++ targetDir) >> return (ExitFailure 1)
catch cd (\(_ :: IOException) -> failed)
else
forkWait $ executeFile cmd searchPath args env
テスト
code: console
Success: ls src
Success: ls src
Success: ls | grep R
Success: ls|grep R
Success: grep H < LICENSE
Success: ls > testtmp/hoge
Success: ls | grep R > testtmp/hoge
Success: cat notexistfile 2> testtmp/hoge
Success: ls && echo fuga
Success: ls&&echo fuga
Success: ls || echo hoge
Success: ls||echo hoge
Success: ls; echo hoge
Success: ls;echo hoge
Success: cd src; ls; cd ..
clean
All test passed.
All test passed! 🎉
Step7: ヒストリ,タブ補完
シェルを開いて十字キーを入力すると^[[Aみたいな謎文字が表示される
コマンドの履歴が表示されてほしい
ファイル名を入力中にTABを入力すると,タブ文字が入力される
ファイル名を補完してほしい
haskelineというライブラリがある
普通はGNU Readlineを使うっぽい
REPL
getLineを使っていたところをhaskelineのgetInputLineを使うように書き換えただけ.
code: Hash/Main.hs
prompt = fromMaybe "Hash> " <$> lookupEnv "PROMPT"
inputSettings = Settings { complete = completeFilename, historyFile = Nothing, autoAddHistory = True }
main :: IO ()
main = runInputT inputSettings repl
where
repl :: InputT IO ()
repl = do
-- プロンプトを表示 & 入力行を取得
minput <- liftIO prompt >>= getInputLine
-- 行を構文解析 & 評価
case minput of
Nothing -> return () -- EOF
Just "" -> repl
Just line -> do
liftIO $ do
case parseLine line of
Left err -> print err
Right ast -> do
originalStdin <- hDuplicate stdin
originalStdout <- hDuplicate stdout
evalAST (stdin, stdout) ast
hFlush stdout
hDuplicateTo originalStdin stdin
hDuplicateTo originalStdout stdout
return ()
repl
ヒストリが辿れるようになり,ファイル名のタブ補完が効くようになった.
Future works
環境変数の設定
autosuggestion
ヒストリからコマンドを補完してくれるやつ
ファイルパス展開
~
*/*
Haskellの構文を援用してフィルタを作るみたいなやつ