🚬

Rustでシェル自作した話

2024/06/07に公開

お久しぶりです。harukunです。
この記事を書き始めたのは2024年6月1日。
うちの大学は4学期制なのですが、2年1学期はコンピュータばかり触っていたら単位を全て落としてしまいました。オワタ\(^o^)/

単位を犠牲にしながらファミコンエミュレータ作ったりセキュキャン申し込んだり(合否発表は6/3!!)したので、そのあたりもいつか記事にしたいですね。

作ったもの

さて今回はシェル自作です。
完成品はこんな感じ↓
https://github.com/noharu36/tush

シェルの名前はtushにしました。
タバコはフィンランド語でtuppakaと言うらしく、tuppaka + shell でtushです。
ノリです。

使用した外部クレート

Cargo.toml
[dependencies]
nix = {version = "0.29.0", features = ["process", "term", "signal"]}
whoami = "1.5.1"
colored = "2.1.0"
once_cell = "1.19.0"
chrono = "0.4.38"
csv = "1.3.0"

それぞれ簡単に説明すると、
nixはOSの機能にアクセスするためのクレートで、シェルを自作するために必須なクレートです。
OSの機能にアクセスするものとしてlibcクレートがありますが、nixはunsafeなlibcをいい感じにラップして安全に使うことができます。

whoamiは実行中のユーザー名・グループ名・プロセスIDなどの情報を取得するためのクレートで、今回はシェルにユーザー名を表示させるためだけに使いました。

@user: /Users/user/workspace/tush
>

これがあるだけで一気にshellっぽくなるでしょ?

coloredはターミナル出力に色をつけるためのクレートです。見た目が少しリッチになります。

once_cell, chrono, csvは組み込みコマンドを自作する時に使いました。
そういえばRust 2024 editionではonce_cellを基にLazyCell/LazyLockが標準ライブラリに追加されるみたいですね。

実装

main

主にこちらの記事を参考に実装していきました。
使用している外部クレートのバージョンが低かったので、全て2024年6月現在の最新バージョンに置き換えて実装します。
また、機能ごとにコードを切り出してディレクトリ分割しています。

main.rs
use tush::run_shell::shell_loop;
use tush::start_screen::render;

fn main() {
    render();
    shell_loop()
}

main関数はこれだけです。

render()はシェルに入った時に可愛いスタート画面を表示してくれるものです(わざわざタバコのアスキーアート探してきた)。

shell_loop

run_shell.rs
pub fn shell_loop() {
    ignore_tty_signals();
    while let Some(line) = shell_read_line() {
        let action = match shell_parse_line(&line) {
            None => continue,
            Some(action) => action,
        };

        match action {
            Action::SimpleCommand(command) => shell_exec_simple_command(command),
        }
    }
}

ループする部分です。

ignore_tty_signals()は割り込み命令(シグナル)を制御する関数です。コマンドを実行する時にOSからシェルに対してSIGTTOUシグナルが送られてきて、これを受け取ってしまうとシェルは停止してしまいます。それを回避するため、シグナルをブロックする処理がignore_tty_signals()に書かれています(あんまり理解できてない)。

次の行からは、shell_read_line()で入力を受け付け、shell_parse_line()で受け取った入力を空白で区切ってVec<String>にし、コマンドを実行する関数shell_exec_simple_command()に渡しています。
enumでAction::SimpleCommandなるものを定義していますが、現状ではただのVec<String>であり正直必要ないです...

shell_exec

run_shell.rs
fn shell_exec_simple_command(command: Vec<String>) {
    let (pipe_read, pipe_write) = pipe().unwrap();

    if BUILTIN_COMMANDS.contains(&command[0].as_str()) {
        match command[0].as_str() {
            "cd" => cd::chdir(command.clone()),
            "exit" => exit::exit(),
            "work" => time_manage::time_manage(command.clone()),
            _ => unimplemented!()
        }
    } else {

        match unsafe { fork() } {
            Ok(ForkResult::Parent { child, .. }) => {
                setpgid(child, child).unwrap();

                tcsetpgrp(unsafe { stdin_fd() }, getpgrp()).unwrap();

                close(pipe_read.into_raw_fd()).unwrap();
                close(pipe_write.into_raw_fd()).unwrap();
                waitpid(child, None).ok();

                tcsetpgrp(unsafe { stdin_fd() }, getpgrp()).unwrap();
            },
            Ok(ForkResult::Child) => {
                restore_tty_signals();

                close(pipe_write.into_raw_fd()).unwrap();

                loop {
                    let mut buf = [0];
                    match read(pipe_read.as_raw_fd(), &mut buf) {
                        Err(e) if e == Errno::EINTR => (),
                        _ => break
                    }
                }
                close(pipe_read.into_raw_fd()).unwrap();

                let args = command.into_iter().map(|c| CString::new(c).unwrap()).collect::<Vec<_>>();
                execvp(&args[0], &args).unwrap();

            },
            Err(e) => eprintln!("fork error: {}", e)
        }
    }

}

次にコマンドを実行する部分です。若干大きい関数になっているのでうまいこと切り出したい気もする。

1行目はパイプという、親プロセスと子プロセス間で通信をできるようにするためのものを設定しています。

その下のif文は自分で定義したコマンドを実行するものです。
通常のコマンドは下の方に書いてあるexecvp(&args[0], &args).unwrap();で行っているのですが、cdやexitなどの一部の組み込みコマンドと呼ばれるコマンドは実行することができないので自作する必要がありました。
本来は組み込みコマンドもフォークして子プロセス化するべきなのですが、まだ未実装です。

elseの中は組み込みコマンド以外を実行する処理を書いています。
match unsafe { fork() }の部分はnixクレート君のものです。libcクレートとは違いunsafeを使う場所がここだけで良くなります。うれしい。

matchの中では親プロセス、子プロセスに対してそれぞれ処理を書いています。
(自分もあまりわかってないので)詳しくは説明しませんが、プロセスの設定やプロセスグループの設定、シグナルの設定やらをしています。

組み込みコマンド

cd

cd.rs
use std::env;

pub fn chdir(command: Vec<String>) {
    if let Some(path) = command.get(1) {
        env::set_current_dir(path).expect("Failed to change directory.");
    } else {
        env::set_current_dir("/Users/noharu").expect("Failed to change directory.");
    }
}

自作したコマンド達です。まずはcd。
std::envだけで実装できます。
引数にpathが渡された時はそのディレクトリに移動。引数がなかったときはホームディレクトリに移動します。

exit

exit.rs
use csv::Writer;
use std::fs::OpenOptions;
use std::process;

pub fn exit() {
    let file = OpenOptions::new()
        .write(true)
        .truncate(true)
        .open("start_time_log.csv")
        .expect("Failed to open file");

    let mut wtr = Writer::from_writer(file);
    wtr.write_record(["start"]).expect("Failed to write to CSV");
    wtr.flush().ok().unwrap();

    process::exit(0)
}

exitです。
上の方でやってることは次のworkコマンドで説明します。
exitの本来の機能として必要なのはprocess::exit(0)の1行だけです。

work

time_manage.rs
use chrono::{DateTime, Duration, Local};
use colored::*;
use csv::{Reader, Writer};
use std::fs::OpenOptions;
use std::process;

pub fn time_manage(command: Vec<String>) {
    if command.len() != 2 {
        eprintln!("Usage: work <in|out>");
        process::exit(1);
    }

    let action = &command[1];

    match action.as_str() {
        "in" => work_start(),
        "out" => work_end(),
        _ => {
            eprintln!("Invalid action: {}", action);
            process::exit(1);
        }
    }
}

fn work_start() {
    let file = OpenOptions::new()
        .create(true)
        .append(true)
        .open("start_time_log.csv")
        .expect("Failed to open file");

    let mut wtr = Writer::from_writer(file);

    let now: DateTime<Local> = Local::now();
    wtr.write_record(&[now.to_rfc3339()])
        .expect("Failed to write to CSV");
    println!("Work started at {}", now.format("%Y/%m/%d %H:%M"));
}

fn work_end() {
    let work_log_file = OpenOptions::new()
        .create(true)
        .append(true)
        .open("work_log.csv")
        .expect("Failed to open file");

    let start_log_file = OpenOptions::new()
        .read(true)
        .open("start_time_log.csv")
        .expect("Failed to open file");

    let mut wtr = Writer::from_writer(work_log_file);

    let now: DateTime<Local> = Local::now();
    let mut rdr = Reader::from_reader(start_log_file);

    let record = rdr
        .records()
        .last()
        .unwrap()
        .expect("Failed to read record");
    let last_record: String = record.iter().next().unwrap().to_string();

    let start_time =
        DateTime::parse_from_rfc3339(&last_record).expect("Failed to parse start time");
    let worked_duration: Duration = now.signed_duration_since(start_time);

    wtr.write_record(&[
        last_record.clone(),
        now.to_rfc3339(),
        format!(
            "{}:{:02}",
            worked_duration.num_hours(),
            worked_duration.num_minutes() % 60
        ),
    ])
    .expect("Failed to write to CSV");

    println!(
        "
                        ╭╯         ╭╯
                        ╰╮ Good   ╭╯    {} {}
                        ╭╯ Job! ╭╯
        ▓▓██████████▒ ╭━╯               {}🚬
        ",
        "worked for".bright_cyan().bold(),
        format!(
            "{}:{:02}",
            worked_duration.num_hours(),
            worked_duration.num_minutes() % 60
        )
        .bright_purple()
        .bold(),
        "Wanna go for a smoke?".bright_cyan().bold()
    );
}

最後にworkコマンドです。
これは自分で考えたコマンドで、労働時間の管理をしたくて自作しました。
スマホアプリで簡単に管理してくれるものはあるのですが、自分は1日に2度or3度働くことがあり(例えば13時~15時と18時~21時で働くなど)そのような場合に上手いこと記録できるアプリがなかったので、自分で作ることにしました。
work inコマンドで開始時刻を記録しwork outコマンドで現在時刻ー開始時刻を計算して、労働時間をCSVファイルに記録するものになっています。

time_manage()shell_exec_simple_command()から呼び出されるコマンドです。
引数がinかoutであることを確認してそれぞれの関数を呼び出します。

work_start()はwork inコマンドを実行するときに呼び出されます。
start_time_log.csvを開いて現在時刻を記録します。

先ほどのexit関数の上の方で実行していた処理は、シェルを閉じるときにstart_time_log.csvの中身を初期化するものです。(必要かどうかはわからないけどwork outを実行する時の処理が軽くなったらいいなという気持ち)

work_end()はwork outコマンドを実行するときに呼び出されます。
start_time_log.csvの最終行、つまりwork inを実行したときに記録された開始時刻を読み取り、それを現在時刻から引いた労働時間worked_durationを求めます。
そのあと、開始時刻、終了時刻、労働時間をwork_log.csvに記録します。
(最後に可愛いタバコのイラストを表示します)

work_log.csv
start,end,worked
2024-06-02T17:00:00.567346+09:00,2024-06-02T17:36:25.145700+09:00,0:36
2024-06-03T11:57:59.883211+09:00,2024-06-03T13:32:33.341848+09:00,1:34
2024-06-03T14:11:23.018552+09:00,2024-06-03T15:40:10.375554+09:00,1:28
2024-06-06T20:03:32.735420+09:00,2024-06-06T21:24:15.786415+09:00,1:21

CSVファイルにはこんな感じでログがまとめられていきます。
月末とかに一番右側の列だけまとめて計算してあげれば月の労働時間が出せるね!

まとめ

ここまででとりあえず最低限のシェル+自作コマンドを作ることができました。

最初の方にも書いたのですが、こちらの記事を参考に実装していきました。
しかし、ひとつ悩んだこととして、外部クレートを記事に書かれているバージョンではなく最新版を使って実装する、ということがありました。

例えば、記事ではnix = "0.23.1"を使っており、shell_exec_simple_command()の中で

tcsetpgrp(0, child).unwrap();

この1行が実装されています。
しかし、この記事を執筆している時点での最新版nix = "0.29.0"ではtcsetpgrpの実装が

pub fn tcsetpgrp<F: AsFd>(fd: F, pgrp: Pid) -> Result<()>

となっており第一引数に0を入れることはできません。

なかなか代わりの実装方法が分からなかったのですが、Rustで開発されたモダンなシェルであるNushellのソースコードの中でtcsetpgrpを使っている部分を見つけ、解決することができました。
https://github.com/nushell/nushell/blob/073d8850e950cfccdab9cf15821caaa0c3b726eb/src/terminal.rs#L59-L60

unsafe fn stdin_fd() -> impl AsFd {
    unsafe { BorrowedFd::borrow_raw(nix::libc::STDIN_FILENO) }
}

こんな感じのunsafeな関数を作ってtcsetpgrpの引数に渡すことで解決できます。(unsafeも小さな範囲で済むので素晴らしい解決案)

tcsetpgrp(unsafe { stdin_fd() }, getpgrp()).unwrap();

今後はちまちま追加機能を実装していきたいと思っています。
追加したい機能としては

  • パイプライン
  • タブ補完
  • 見た目をリッチに
  • 組み込みコマンドの子プロセス化

ですかね。
改修案などあれば是非アドバイスいただけると嬉しいです。

最近はみかん本読みながらOS自作の勉強を始めたので、そのあたりもぼちぼち記事にしていけるといいな。
それではまた!

Discussion