为了账号安全,请及时绑定邮箱和手机立即绑定

从零开始用 Rust 构建自定义命令行工具

标签:
杂七杂八
介绍

在 Rust 中创建一个自定义的 REPL(读-评-打印循环)是加深你对系统编程、异步 I/O 和命令解析理解的好方法。这篇文章会一步步教你如何制作一个简单的命令行界面,能够运行像 echolscd 这样的基本命令。

一个 REPL(读取-评估-打印循环)是一种交互式的编程环境,它会持续读取用户输入、评估输入、打印结果,并循环返回以获取更多输入。我们的 shell 也将采取同样的结构。

  • 读取 – 从标准输入(stdin)捕获用户输入。
  • 处理 – 将输入转换并执行为 Command 类型。
  • 打印 – 通过标准输出(stdout)显示相关信息。
  • 循环 – 等待下一个命令并重复这个过程。

为了简单起见,我们的 shell 将支持以下几种命令:

  • echo – 在控制台上打印文本。
  • ls – 列出当前目录下的文件。
  • pwd – 显示当前的工作目录。
  • cd – 切换目录。
  • touch – 创建新文件(或文件夹)。
  • rm – 删除文件或目录。
  • cat – 显示文件内容。

根据这份指南,我还做了一个YouTube视频,演示了整个从头到尾的搭建过程,你可以在这里,点击这里查看:

完整的源代码可以在这里找到[here](https://github.com/max-taylor/simple-rust-shell)

……

项目启动

在动手实现之前,让我们先设置我们的 Rust 项目环境,并添加必要的库。我们将利用 Rust 的包管理器 cargo 创建一个新的 Rust 项目,做好准备工作。

运行命令:cargo new shell

切换到全屏 退出全屏

这将在 shell/ 目录中创建一个新的 Rust 项目,包括通常的 Cargo.toml 文件和 src/main.rs 文件。

依赖项

这个项目我们只需要两个依赖库。

  • Tokio – 一个强大的异步运行时,可以让我们高效地处理用户输入和 I/O 操作。
  • Anyhow(一个轻量级的错误处理库,简化了对 Result 类型的处理)

在你的《Cargo.toml》文件里加上这些东西:

[dependencies] 
anyhow = "1.0.95" 
tokio = { version = "1.43.0", features = ["full"] }

切换到全屏,切换回正常模式

……

错误应对

错误处理是任何 shell 至关重要的一部分,因为我们需要优雅地处理这些问题,比如文件缺失、无效命令和权限错误。我们不会手动定义一个复杂的错误处理系统,而是将使用 anyhow 库来简化这个过程。

为什么是 anyhow 呢?

Rust的标准错误处理需要为每个可能失败的函数定义详细的Result<T, E>类型。虽然功能强大,但这可能会变得复杂。anyhow通过允许我们返回一个通用错误类型(anyhow::Error),简化了处理过程,这种类型可以捕获各种失败情况和原因而无需额外的样板代码。

设置错误处理机制

要集成anyhow,创建一个名为src/errors.rs的新文件,并在其中添加:

    pub type CrateResult<T> = anyhow::Result<T>; // 定义了一个泛型类型 `CrateResult<T>`,它是一个 `anyhow::Result<T>`。

进入全屏,退出全屏

这创建了一个可复用的类型别名 CrateResult<T>,我们将用于可能返回错误的函数。

现在,在 main.rs 文件中导入这个模块,这样我们就可以在整个项目中用到它了。

    引入 errors 模块;

点击这里进入全屏, 点击这里退出全屏

这样一来,我们在 shell 命令执行和 I/O 操作中就能更整洁地处理错误了。

……

I/O

现在我们已经搞定错误处理了,是时候来点输入输出(I/O)操作了——这可是我们REPL循环的关键部分。

启动 REPL 循环,让我们开始吧!

我们将使用 tokio::spawn 来在一个专用的异步任务中运行我们的 REPL。虽然这并非严格要求,但它很好地展示了如何在 Rust 中管理异步任务。

main.rs 文件中加入如下代码:

    use errors::CrateResult;
    use tokio::{
        io::{AsyncBufReadExt, AsyncWriteExt},
        task::JoinHandle,
    };

    fn spawn_user_input_handler() -> JoinHandle<CrateResult<()>> {
        tokio::spawn(async {
            // 初始化 stdin 和 stdout
            let stdin = tokio::io::stdin();
            let stdout = tokio::io::stdout();

            let mut reader = tokio::io::BufReader::new(stdin).lines();
            let mut stdout = tokio::io::BufWriter::new(stdout);

            stdout.write(b"欢迎来到 shell!\n").await?;

            while let Some(line) = reader.next_line().await.ok() {
                // 当前仅记录用户输入(之后将处理命令)
                println!("用户输入: {}", line.trim());
            }

            Ok(())
        })
    }

全屏显示 退出全屏

解析一下:

  • tokio::spawn(async { ... }) → 在异步任务里启动REPL循环。
  • BufReader::new(stdin).lines() → 逐行读取用户的输入。
  • BufWriter::new(stdout) → 为输出添加缓存以提高性能。
  • 通过reader.next_line().await循环 → 持续读取用户输入。
  • 记录输入(println! → 目前,我们只是打印用户输入的内容(稍后会处理命令)。

将其连接到 main 函数

现在,打开并修改 main.rs 文件让其启动 REPL 循环, 并妥善处理可能出现的错误。

    #[tokio::main]
    async fn main() {
        let user_input_handler = spawn_user_input_handler().await;

        if let Err(e) = user_input_handler {
            println!("发生了错误: {}", e);
        }
    }

切换到全屏 退出全屏

这样就能确保如果执行过程中有任何问题,我们会记录错误并干净地退出.

三颗星 ……

命令管理

现在我们的 shell 已经能接受用户输入了,我们需要一种方式来解析并执行命令

由于我们的壳支持一系列固定的命令,我们将使用一个枚举类型来表示它们。Rust中的枚举提供了一种安全且经过类型检查的方式来处理不同的命令变体形式,而无需依赖易出错的字符串比较。

定义命令枚举值

我们来看看如何定义命令枚举值。

创建一个新文件 src/command.rs,如下所示定义我们的 Command 枚举类型:

use anyhow::anyhow;

#[derive(Clone, Debug)]
/// Command 枚举定义了所有可能的命令类型
pub enum Command {
    Exit, // 退出命令
    Echo(String), // 回显命令
    Ls, // 列出目录内容命令
    Pwd, // 显示当前工作目录命令
    Cd(String), // 更改目录命令
    Touch(String), // 创建空文件命令
    Rm(String), // 删除文件命令
    Cat(String), // 显示文件内容命令
}

全屏 [退出]

每个变体都代表一个命令,像 echocdtouchrmcat 这样的命令行需要一个参数,并将这个参数存储为一个 String

将用户输入解析为命令:

将用户的输入转换成 Command,我们将为 Command 实现 TryFrom<&str> 特性:

    impl TryFrom<&str> for Command {
        type Error = anyhow::Error;

        fn try_from(value: &str) -> Result<Self, Self::Error> {
            let split_value: Vec<&str> = value.split_whitespace().collect();

            match split_value[0] {
                "exit" => Ok(Command::Exit),
                "ls" => Ok(Command::Ls),
                "echo" => {
                    if split_value.len() < 2 {
                        Err(anyhow!("缺少 echo 命令所需的参数"))
                    } else {
                        Ok(Command::Echo(split_value[1..].join(" ")))
                    }
                }
                "pwd" => Ok(Command::Pwd),
                "cd" => {
                    if split_value.len() < 2 {
                        Err(anyhow!("缺少 cd 命令所需的参数"))
                    } else {
                        Ok(Command::Cd(split_value[1..].join(" ")))
                    }
                }
                "touch" => {
                    if split_value.len() < 2 {
                        Err(anyhow!("缺少 touch 命令所需的参数"))
                    } else {
                        Ok(Command::Touch(split_value[1..].join(" ")))
                    }
                }
                "rm" => {
                    if split_value.len() < 2 {
                        Err(anyhow!("缺少 rm 命令所需的参数"))
                    } else {
                        Ok(Command::Rm(split_value[1..].join(" ")))
                    }
                }
                "cat" => {
                    println!("{}", split_value[1..].join(" "));
                    if split_value.len() < 2 {
                        Err(anyhow!("缺少 cat 命令所需的参数"))
                    } else {
                        Ok(Command::Cat(split_value[1..].join(" ")))
                    }
                }

                _ => Err(anyhow!("未识别的命令")),
            }
        }
    }

点击这里进入全屏模式,点击这里退出全屏模式

逐个分析:

  • 拆分输入字符串 → 我们使用 .split_whitespace() 将输入拆成单词。
  • 匹配命令 → 我们检查第一个单词(split_value[0])以获取用户的命令。
  • 处理参数 → 像 echocd 这样的命令需要额外的参数,所以我们检查 split_value.len() 是否小于2。
  • 错误处理 → 如果命令未知或缺失,我们则返回 Err(anyhow!(...))

main.rs 中实现命令解析

现在,在 main.rs 文件的顶部引入 Command 模块。

    使用 command::Command;

    模块 command;

点击全屏,点击退出

让我们把这种新类型添加到 src/main.rs 文件里,以便统一处理用户输入信息。可以创建如下函数:

    async fn handle_new_line(line: &str) -> CrateResult<Command> {
        // 使用上面实现的 TryFrom trait
        let command: Command = line.try_into()?;

        match command.clone() {
            // 临时占位
            _ => {}
        }
        Ok(command)
    }

进入全屏 退出全屏

现在更新 spawn_user_input_handler 中的 REPL 循环,并在处理用户输入时调用该函数。

    while let Ok(Some(line)) = reader.next_line().await {
        let command = handle_new_line(&line).await;

        if let Ok(command) = &command {
            /* 匹配命令 */
            match command {
                _ => {}
            }
        } else {
            eprintln!("解析命令时发生了错误: {}", command.err().unwrap());
        }
    }

全屏……退出

到目前为止,我们的 shell 可以 解析用户输入并匹配命令,但是命令执行还不完善。接下来,我们将优化日志记录,然后实现每个命令的逻辑,从文件系统操作如 lspwdcd 和文件操作等入手。

……

优化日志

让我们让我们的 shell 的日志功能更友好些,让它用起来更顺手。首先,我们需要创建一个辅助函数来获取 shell 的当前目录,每次执行完命令后记录当前目录以模仿正常 shell 的表现。

创建一个名为 src/helpers.rs 的文件,并在里面添加以下方法:

    use crate::errors::CrateResult;

    pub fn pwd() -> CrateResult<String> {
        let current_dir = std::env::current_dir()?;

        Ok(current_dir.display().to_string())
    }

进入全屏 退出全屏

然后将这个新文件导入到我们的模块中。

    mod helpers;

全屏模式 退出全屏

现在让我们修改REPL循环中的日志,以便在用户输入命令前,在终端打印“>”,并在每个命令后记录用户的当前工作目录。请更新你的 spawn_user_input_handler 以符合以下要求:
注:spawn_user_input_handler 是一个函数名或变量名。REPL 是一种交互式编程环境。

fn spawn_user_input_handler() -> JoinHandle<CrateResult<()>> {
    tokio::spawn(async {
        let stdin = tokio::io::stdin();
        let stdout = tokio::io::stdout();

        let mut reader = tokio::io::BufReader::new(stdin).lines();
        let mut stdout = tokio::io::BufWriter::new(stdout);

        stdout.write(b"欢迎来到shell!\n").await?;

        stdout.write(pwd()?.as_bytes()).await?;
        stdout.write(b"\n>").await?;
        stdout.flush().await?;

        while let Ok(Some(line)) = reader.next_line().await {
            let command = handle_new_line(&line).await;

            if let Ok(command) = &command {
                // 匹配命令,不做任何操作
            } else {
                eprintln!("解析命令出错: {}", command.err().unwrap());
            }

            stdout.write(pwd()?.as_bytes()).await?;
            stdout.write(b"\n>").await?;
            stdout.flush().await?;
        }

        Ok(())
    })
}

全屏模式 全屏退出


指令

我们现在有了一个可以理解和执行命令的系统,接下来的任务是为每个命令编写对应的逻辑。

退出

让我们首先添加一个退出命令,这样用户就可以关闭终端了。为此,我们需要修改 src/main.rs 文件中的 while 循环,当检测到退出命令时就跳出循环:

    if let Ok(command) = &command {
        match command {
            Command::Exit => {
                println!("准备退出...,");
                break;
            }
            _ => {}, // 其他情况
        }
    }

全屏 退出全屏

回音

echo 命令只是打印其接收到的参数,这在显示消息或测试输出时,非常有用。处理 handle_new_line 中的 Echo 命令:

当收到Echo命令时,就输出s的值。

全屏按钮可以让你进入或退出全屏模式

Ls(待补充)

ls 命令列出这些当前目录里的文件。

src/helpers.rs 文件中添加一个帮助函数:

    pub fn ls() -> CrateResult<()> {
        // 列出当前目录的内容
        let entries = fs::read_dir(".")?;

        for entry in entries {
            let entry = entry?;
            // 打印文件名
            println!("{}", entry.file_name().to_string_lossy());
        }

        // 表示成功执行
        Ok(())
    }

点全屏模式,点退出全屏

  1. handle_new_line 中处理 Ls
Command::Ls => {
    // 列出文件和目录
    helpers::ls()?;
}

全屏 退出全屏

pwd

pwd 命令用来显示当前工作目录,只需在 handle_new_line 中添加相应的代码块。

Command::Pwd => {
    println!("{}", helpers::pwd()?); // 打印当前工作目录
}

全屏模式,退出全屏

Cd

cd 命令用来切换当前目录。

src/helpers.rs 文件中添加一个助手函数。

    pub fn cd(path: &str) -> CrateResult<()> {
        std::env::set_current_dir(path)?;
        // 切换目录
        Ok(())
    }

全屏(点击进入/退出全屏)

1、在 [handle_new_line] 中处理 Cd

    Command::Cd(s) => {
        // 切换目录命令,调用helpers::cd函数
        helpers::cd(&s)?;
    }

全屏显示 退出全屏

摸一下

touch 命令是用来创建空文件的。

src/helpers.rs 文件中添加一个 helper 函数:

    // 创建一个空文件,如果成功返回空结果。
    pub fn touch(path: &str) -> CrateResult<()> {
        // 尝试创建文件,如果失败则返回错误。
        fs::File::create(path)?;
        Ok(())
    }

切换到全屏 退出全屏

  1. handle_new_line 函数中处理 Touch 事件:
    Command::Touch(s) => {
        helpers::touch(&s)?; // 触发 touch 操作
    }

全屏 全屏退出

删除或移动

(注释:此处的 "Rm" 通常指 "删除" 或 "移动",根据上下文可作相应解释。)

根据您的要求,如果必须给出直接翻译且不加解释:

删除

rm 命令可以删掉一个文件。

src/helpers.rs 文件中添加一个帮助函数。

    pub fn rm(path: &str) -> CrateResult<()> {
        // 删除指定路径的文件,若文件不存在则返回Ok
        fs::remove_file(path)?;
        Ok(())
    }

进入全屏显示 退出全屏显示

在处理新行时,处理 Rm

Command::Rm(s) => {
    helpers::rm(&s)?;
}

这段代码表示当接收到 Command::Rm(s) 命令时,会调用 helpers::rm(&s) 函数来执行相应的操作。这里的 Command::Rm 表示删除命令,而 helpers::rm 是一个辅助函数,用于执行实际的删除操作。

进入全屏 退出全屏

那只猫

cat 命令读取并显示文件中的内容

src/helpers.rs 中写一个辅助函数,

    pub fn 读取文件内容(path: &str) -> CrateResult<String> {
        let 当前工作目录 = 当前工作目录()?;
        let joined_path = std::path::Path::new(&当前工作目录).join(path);
        let contents = fs::read_to_string(joined_path)?;
        // 读取文件内容并返回
        Ok(contents)
    }

全屏 退出全屏

  1. handle_new_line 函数中处理 Cat 对象:

当命令是 Command::Cat(s) 时,会执行以下操作:
helpers 模块中调用 cat 函数,传入字符串 s,以获取文件内容。
然后将文件内容打印到控制台。

全屏 退出全屏


最后

我们用 Rust 成功构建了一个简单的 REPL/Shell,支持如 lspwdcdtouchrmcat 这样的基本命令。该架构采用了模块化设计,使得添加额外命令变得非常简单。

这只是开始哦!你可以增加更多功能,比如:

  • 命令历史记录
  • 自动完成功能
  • 命令管道
  • 后台运行

如果你想深入了解,可以查看我们提供的 YouTube 视频,该视频会一步步带你完成构建过程。

ASMR 编程实况 - Rust 🦀 中的 REPL 命令行 (外部 - 请勿出声) - YouTube

编码愉快!🚀

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消