🐚

Rustで自作シェル書いてみた

2024/01/27に公開

はじめに

自作シェルを書いてみた記事はよく見かけるのですが、そのほとんどが fork -> exec という流れです。仕組みを学ぶという点で非常に有効なのでそれはそれで良いとして、
Rust では標準ライブラリの std::process::Command を使用してもっと簡単にコマンド実行ができるよ!
という記事があまりないので書いてみました。

https://github.com/kumavale/kumash

コマンドの実行

REPL

パース処理はできるだけシンプルに書きます。
標準入力から1行読み取り、Command::spawn() で実行します。
それを繰り返すのが REPL (Read-Eval-Print Loop)です。
これだけでシェルと言い張っても良いと思っています🤔

use std::io::{self, Write};
use std::process::Command;

fn main() {
    loop {
        // プロンプト
        print!("$ ");
        io::stdout().flush().unwrap();

        // 1行読み込む
        let mut input = String::new();
        io::stdin().read_line(&mut input).unwrap();

        // 入力をパース
        let command = input.trim();

        // コマンドを実行
        let mut child = Command::new(command)
            .spawn()
            .unwrap();
        child.wait().unwrap();
    }
}

引数を渡す

次に、コマンドにオプション(例: ls -l)を渡せるようにします。
入力を空白区切りのトークンにし、1つ目をコマンド、それ以降は引数として渡します。

 use std::io::{self, Write};
 use std::process::Command;

 fn main() {
     loop {
         // プロンプト
         print!("$ ");
         io::stdout().flush().unwrap();

         // 1行読み込む
         let mut input = String::new();
         io::stdin().read_line(&mut input).unwrap();

         // 入力をパース
-        let command = input.trim();
+        let mut tokens = input.split_whitespace();
+        let Some(command) = tokens.next() else {
+            continue;
+        };

         // コマンドを実行
         let mut child = Command::new(command)
+            .args(tokens)
             .spawn()
             .unwrap();
         child.wait().unwrap();
     }
 }

組み込みコマンド

代表的なシェルには exitcd のような 組み込みコマンド が予め用意されています。これはシェル自身の状態を変更させるときや子プロセスを生成しない高速な動作のために存在します。(詳しくは man bash-builtins など参照)
ここではシェルを終了させる exit コマンドを実装します。

 use std::io::{self, Write};
 use std::process::Command;

 fn main() {
     loop {
         // プロンプト
         print!("$ ");
         io::stdout().flush().unwrap();

         // 1行読み込む
         let mut input = String::new();
         io::stdin().read_line(&mut input).unwrap();

         // 入力をパース
         let mut tokens = input.split_whitespace();
         let Some(command) = tokens.next() else {
             continue;
         };

         // コマンドを実行
+        match command {
+            "exit" => {
+                println!("(^-^)/~~");
+                std::process::exit(0);
+            }
+            _ => {
                 let mut child = Command::new(command)
                     .args(tokens)
                     .spawn()
                     .unwrap();
                 child.wait().unwrap();
+            }
+        }
     }
 }

パイプ

コマンドを実行するからには、cat src/main.rs | less のようにパイプで繋ぎたいですね!!
これは子プロセスの入出力に 標準入出力パイプか を指定するだけで実現できます。

1つ目のコマンドを実行するとき、次に実行されるコマンドがある場合は出力をパイプにします。この子プロセスを一時的に保持しておき、次に実行するコマンドの入力に保持しておいた子プロセスの出力を指定します。

 use std::io::{self, Write};
-use std::process::Command;
+use std::process::{Child, Command, Stdio};

 fn main() {
     loop {
         // プロンプト
         print!("$ ");
         io::stdout().flush().unwrap();

         // 1行読み込む
         let mut input = String::new();
         io::stdin().read_line(&mut input).unwrap();

         // 入力をパース
+        let mut commands = input.split(" | ").peekable();
+        let mut previous_process = None;

+        while let Some(command) = commands.next() {
+            let output = commands.peek().map_or(Stdio::inherit(), |_| Stdio::piped());
             let mut tokens = command.split_whitespace();
             let Some(command) = tokens.next() else {
                 continue;
             };
	     // コマンドを実行
             match command {
                 "exit" => {
                     println!("(^-^)/~~");
                     std::process::exit(0);
                 }
                 _ => {
+                    let input = previous_process.map_or(Stdio::inherit(), |child: Child| {
+                        Stdio::from(child.stdout.unwrap())
+                    });
                     let child = Command::new(command)
                         .args(tokens)
+                        .stdin(input)
+                        .stdout(output)
                         .spawn()
                         .unwrap();
+                    previous_process = Some(child);
                 }
             }
+        }

-        child.wait().unwrap();
+        if let Some(mut final_process) = previous_process {
+            final_process.wait().unwrap();
+        }
     }
 }

タブ補完

最後にシェルらしい機能として、タブ補完 を実装します。
Bash のタブ補完は、ls ならファイル名、cd ならディレクトリ名、というようにコマンドに応じたものが設定できる bash-completion という仕組みがあるのですが、ここでは簡単にするために、どんなコマンドにおいてもカレントディレクトリのファイルやディレクトリを補完できるようにします。

コマンド入力の途中で Tab キー(\t)が押下されたことを検知したいので、 getch-rs を使用。
また補完する文字を特定するために、 regex を使用します。

$ cargo add regex getch-rs

入力処理は複雑になるので、関数に分割しておく。

 use std::io::{self, Write};
 use std::process::{Child, Command, Stdio};

 fn main() {
     loop {
         // プロンプト
         print!("$ ");
         io::stdout().flush().unwrap();

         // 1行読み込む
-        let mut input = String::new();
-        io::stdin().read_line(&mut input).unwrap();
+        let input = read_input();

         // コマンドとして解釈する
         // --- 省略 ---
 }

Tab キーが入力されたとき、その時点での最後のトークンを取得します。
このトークンに部分一致するファイルが1つあれば、残りの文字を補完します。
それ以外の場合、マッチしたファイルを全て表示します。

fn read_input() -> String {
    use getch_rs::{Getch, Key};
    use regex::Regex;

    let mut input = String::new();
    let g = Getch::new();

    while let Ok(key) = g.getch() {
        match key {
            Key::Char('\t') => {
                let last_token = input.split_whitespace().last().unwrap_or("");
                let re = Regex::new(&format!(r"\b{last_token}([\w.]*)\b")).unwrap();
                let output = Command::new("ls").output().unwrap();
                let output = String::from_utf8_lossy(&output.stdout);
                let matches = re.captures_iter(&output).map(|c| c.extract()).collect::<Vec<_>>();

                if matches.len() == 1 {
                    // マッチしたものが1つなら補完
                    let (_, [complement]) = matches[0];
                    input += complement;
                    print!("{complement}");
                } else {
                    // マッチした全てを表示
                    println!("\n{}", matches.iter().map(|(a, _)| *a).collect::<Vec<_>>().join(" "));
                    print!("$ {input}");
                }
            }
            Key::Char(ch) => {
                print!("{ch}");
                if ch == '\r' {
                    // 改行で入力終了
                    println!();
                    break;
                }
                input.push(ch);
            }
            _ => continue,
        }
        io::stdout().flush().unwrap();
    }
    input
}

おわりに

シェルにはまだまだ機能が盛りだくさんです。
最近だと fish が Rust に置き換わったり、シェルも日々進化しています。
気が向いた時に機能を増やしたいなー

参考

Discussion