首页
/ Starship 贡献者开发指南:架构入口、模块编写、测试模拟与新模块清单

Starship 贡献者开发指南:架构入口、模块编写、测试模拟与新模块清单

2026-09-06 16:45:51作者:钟日瑜

本文基于 Starship 仓库的 CONTRIBUTING.md 展开,系统梳理该项目面向贡献者的完整开发工作流:从项目术语、架构入口(clap 命令分发与 rayon 并行模块计算),到模块开发中环境变量读取、外部命令执行、绝对路径处理的三大约定,再到测试体系(ModuleRenderer、命令 mock 匹配机制、测试编写准则)、日志与 Lint/格式化流程,以及新增一个模块必须完成的完整检查清单。读完本文,你可以独立地在 Starship 代码库中定位功能入口、规范地编写和测试一个新模块,并通过项目的 CI 要求(clippy、rustfmt、dprint、配置 schema 更新)。

1. 术语与贡献理念

Starship 的文档和源码中反复使用两个核心概念,理解它们是阅读后续所有开发约定(尤其是测试约定)的前提:

  • Module(模块):提示符中一个基于操作系统上下文信息给出信息的组件。例如 rust 模块会在当前目录是一个 Rust 项目时,显示当前安装的 Rust 版本。
  • Segment(片段):组成模块的更小子组件。例如 rust 模块中的 symbol 片段就是版本号前显示的那个字符(默认是 🦀)。

项目的贡献理念(Philosophy)在 CONTRIBUTING.md 中明确写道:让 Starship 尽可能快速、健壮、可靠,同时保留高度可定制性。其手段包括:

  • 利用 Rust 语言内建的安全特性;
  • 彻底的跨平台测试;
  • 在显示提示符时消除不必要的开销——通过减少重复计算、善用缓存。

文档特别指出:如果你发现任何可以进一步节省时间或降低提示符渲染负载的地方,项目非常欢迎相应的 issue 或 PR。这直接决定了后文的性能取向约定(例如外部命令默认超时、路径扫描缓存等)。

2. 架构:从 main.rs 到并行模块计算

CONTRIBUTING.md 对架构的官方描述是:

项目从 main.rs 开始,根据通过 clap 传入的参数,调用相应的 print:: 方法;打印完整提示符时,使用 rayon 对模块的计算进行并行化。任何应用到模块上的样式都会被其片段继承;模块的前缀/后缀默认不应用任何样式。

对照当前仓库源码,这条架构描述可以得到逐点印证:

  • 入口 src/main.rs 中定义了 Cli 结构体(基于 clapParser 派生宏),subcommand_required = true 意味着每次调用必须给出子命令。Commands 枚举(src/main.rs#L66-L166)列出了全部子命令:promptmoduleinitpresetprint-configtogglebug-reporttimingsexplain 等。
  • main() 函数(src/main.rs#L168-L316)完成参数解析后,按子命令分派到 print::promptprint::moduleconfigure::update_configuration 等函数——这正是文档所说"根据参数调用相应的 print:: 方法"。
  • 并行化在启动阶段就已建立:main() 一开始就调用 init_global_threadpool()src/main.rs#L319-L324),通过 rayon::ThreadPoolBuilder::new().num_threads(num_rayon_threads()).build_global() 初始化全局线程池。Cargo.toml 中可见 rayon = "1.12.0"clap(4.x,含 derive 特性)均为直接依赖(Cargo.toml#L37-L40Cargo.toml#L72)。

代码库的整体分层也可以从目录结构直接看出:

  • src/configs/:每个模块的配置结构体(struct/Default 实现),如 src/configs/rust.rssrc/configs/starship_root.rs(全局 PROMPT_ORDER 定义处);
  • src/modules/:每个模块的渲染实现,pub fn module<'a>(context: &'a Context) -> Option<Module<'a>> 是统一签名;
  • src/context/Context 类型,是模块函数接收的上下文对象,封装了环境、路径、配置与命令执行;
  • src/formatter/:格式串解析器(pest 语法)与 StringFormatter,负责把 {{ }} 模板渲染成带样式的输出;
  • src/test/:测试基础设施,核心是 ModuleRenderer

3. 模块开发三大约定:环境、命令、绝对路径

CONTRIBUTING.md 为模块实现规定了三个必须使用的自定义函数/机制,目的都是"让模块在测试环境中可被 mock"。下面逐一说明其用法,并结合源码解释底层实现。

3.1 读取环境变量:context.get_env

use super::{Context, Module, RootModuleConfig};

use crate::configs::php::PhpConfig;
use crate::formatter::StringFormatter;

pub fn module<'a>(context: &'a Context) -> Option<Module<'a>> {
   // 这里 `my_env_var` 要么是变量的值,
   // 如果变量未设置则函数(通过 `?`)直接返回。
   let my_env_var = context.get_env("MY_VAR")?;

   // 然后就可以放心地使用这个值
}

实现上,Context::get_env 只是一个薄封装(src/context/mod.rs#L226-L230):

// Retrieves a environment variable from the os or from a table if in testing mode
#[inline]
pub fn get_env<K: AsRef<str>>(&self, key: K) -> Option<String> {
    self.env.get_env(key)
}

注意注释里"from the os or from a table if in testing mode"——测试模式下环境变量从一张表(即 ModuleRenderer::env 注入的 mock 表)中读取,生产模式下才真正读操作系统。这就是文档示例中"未设置就通过 ? 退出"的语义在两种模式下都成立的原因。

3.2 执行外部命令:context.exec_cmd

use super::{Context, Module, ModuleConfig};

use crate::configs::php::PhpConfig;
use crate::formatter::StringFormatter;

pub fn module<'a>(context: &'a Context) -> Option<Module<'a>> {
   // 这里 `output` 要么是所调用命令的 stdout,
   // 要么(当程序未安装或无法运行时)函数直接退出。
   let output = context.exec_cmd("my_command", &["first_arg", "second_arg"])?.stdout;

   // 然后就可以放心地使用 output
}

文档同时给出了一条硬性规定:如果无法使用 context.exec_cmd,请使用 crate::utils::create_command 代替 std::process::Command::new

从源码看,这条规定的理由很具体(src/utils/mod.rs#L165-L190):

/// Attempt to resolve `binary_name` from and creates a new `Command` pointing at it
/// This allows executing cmd files on Windows and prevents running executable from cwd on Windows
/// This function also initializes std{err,out,in} to protect against processes changing the console mode
pub fn create_command<T: AsRef<OsStr>>(binary_name: T) -> Result<Command> {

create_command 会先通过 which 解析二进制完整路径(允许在 Windows 上执行 .cmd 文件、防止从当前目录误执行可执行文件),并显式初始化 stdin/stdout/stderr 管道(stdin 置空,防止子进程篡改控制台模式)。

Context::exec_cmdsrc/context/mod.rs#L463-L489)在其内部还叠加了两层测试/性能逻辑:

  1. 测试 mock 优先#[cfg(test)] 分支下,先查 self.cmd 表(即 ModuleRenderer::cmd 注入的 per-test mock),再回退到全局 crate::utils::mock_cmd
  2. 超时控制:非测试路径下用 exec_timeout 执行,超时时间来自配置项 command_timeout(默认值见 src/utils/mod.rs#L17-L18 中的 DEFAULT_COMMAND_TIMEOUT_MS: u64 = 500)。命令超时时会记录警告并建议用户调高 command_timeoutsrc/utils/mod.rs#L751-L757)——这正是贡献理念中"减少提示符渲染负载"的具体体现。

另一个细节:CommandOutput 同时保留 stdoutstderr 两个字段(src/utils/mod.rs#L192-L196),而 get_command_string_output 会在 stdout 为空时回退到 stderr(src/utils/mod.rs#L156-L163),因此写模块时不必假定目标程序把版本信息打到 stdout。

3.3 绝对文件名:crate::utils::context_path

在模块中处理绝对路径时,文档要求使用 crate::utils::context_path() 来由绝对路径名构造 PathBuf在测试环境下,根目录会被替换为一个 Tempdir,可通过 ModuleRenderer::root_path() 获取,你可以在这个 mock 根目录中放入任意文件。

use crate::utils::context_path;

pub fn module<'a>(context: &'a Context) -> Option<Module<'a>> {
    if !context_path(context, "/run/test/testfile").exists() {
        return None
    }
    // ..
}

测试侧对应的写法:

#[test]
fn test_testfile() {
    let renderer = ModuleRenderer::new("mymodule");

    let root_path = renderer.root_path();

    // 这会在 $TEMPDIR 下创建 run/test/testfile
    let mut absolute_test_file = PathBuf::from(root_path);

    absolute_test_file.push("run");
    absolute_test_file.push("test");
    std::fs::DirBuilder::new()
        .recursive(true)
        .create(&absolute_test_file)?;

    absolute_test_file.push("testfile");
    std::fs::File::create(&absolute_test_file)?;

    // ...
}

该机制的底层实现是一个编译期切换(src/utils/mod.rs#L20-L41):非测试构建中 context_path#[inline] 的空操作(直接 PathBuf::from);#[cfg(test)] 版本则把绝对路径的首个组件替换为 context.root_dir.path()(Tempdir)。root_path()self.context.root_dir.path()src/test/mod.rs#L109-L111)。这套机制使得"读取 /proc/.../run/... 等系统绝对路径"的模块也能在任何机器上被确定性测试。

4. 测试体系:ModuleRenderer、命令 Mock 与编写准则

文档强调"测试对于确保 starship 在大小各种系统上按预期工作至关重要":Starship 在生成提示符时会与大量应用和系统 API 交互,bug 容易混入。单元测试使用 Rust 内建测试框架,与被测实现写在同一个文件里,通过 cargo test 运行,并作为 CI 的一部分在各平台上运行。

4.1 渲染输出测试一律使用 ModuleRenderer

文档给出的标准测试骨架(完整继承自 CONTRIBUTING.md):

use super::{Context, Module, ModuleConfig};

use crate::configs::php::PhpConfig;
use crate::formatter::StringFormatter;
use crate::utils;

pub fn module<'a>(context: &'a Context) -> Option<Module<'a>> {
   /* 你的模块代码写在这里 */
}

#[cfg(test)]
mod tests {
   use super::*;
   use crate::test::ModuleRenderer;
   use nu_ansi_term::Color;
   use std::fs::File;
   use std::io;

   #[test]
   fn should_render() -> io::Result<()> {
      // 搭建测试环境
      let tempdir = tempfile::tempdir()?;
      // 创建模块渲染所需的文件
      File::create(tempdir.path().join("YOUR_FILE"))?.sync_all()?;

      // 模块的输出
      let actual = ModuleRenderer::new("YOUR_MODULE_NAME")
         // 自定义路径
         .path(&tempdir.path())
         // 自定义配置
         .config(toml::toml!{
            [YOUR_MODULE_NAME]
            val = 1
         })
         // 环境变量 mock
         .env("KEY","VALUE")
         // 运行模块并收集输出
         .collect();

      // 模块应当渲染出的值
      let expected = Some(format!("{} ",Color::Black.paint("THIS SHOULD BE RENDERED")));

      // 断言实际值与期望值相同
      assert_eq!(actual, expected);

      // 关闭 tempdir
      tempdir.close()
   }
}

src/test/mod.rs 可以看到该构建器实际提供的能力,比示例更多:

4.2 外部命令输出必须注册到 mock 表

文档规定:如果一个模块依赖某个外部程序的输出,那么该输出必须添加到 src/utils/mod.rs 中的 match 语句里。match 的键必须与 utils::exec_cmd() 的调用完全一致,包括位置参数与 flag——参数数组用 " " 连接,因此 utils::exec_cmd("program", &["arg", "more_args"]) 对应的 match 是 program arg more_args

源码印证(src/utils/mod.rs#L204-L228):display_command 正是把命令与参数以空格 join 成字符串;exec_cmd#[cfg(test)] 下先查 mock_cmd(&cmd, args),命中则直接返回,否则才走真实执行 internal_exec_cmd。当前 mock_cmd 的 match 表(src/utils/mod.rs#L231-L647)覆盖了绝大多数模块依赖的探测命令,例如 "go version""ruby -v""dotnet --list-sdks""python3 --version""pijul channel" 等,每条返回固定的 CommandOutput { stdout, stderr }。因此给新模块添加 mock 时,你需要:

  1. 确定模块中 exec_cmd 调用的准确命令串;
  2. mock_cmd 的 match 中新增一条完全一致的字面量(或前缀/后缀模式,表中已有 s if s.ends_with(...) 这类写法);
  3. 填入与真实程序输出格式一致的 stdout/stderr 样例。

对于无法 mock 的程序(例如它会读写文件),文档规定:把它加入项目的 CI workflow 文件(.github/workflows/workflow.yml)预装/预配置,并在测试上标注 #[ignored](按文档原文表述)。这样任何人都能在本地不预先配置环境就运行测试套件;该属性在 CI 运行中被绕过。

4.3 测试编写准则(Test Programming Guidelines)

  • 完全隔离、可复现:单元测试只针对"给定特定输入时某函数的期望输出",必须在任何机器上可复现,不得假设运行机器处于任何特定状态——包括预装了某些应用、设置了某些环境变量等。
  • 警惕"看起来无害"的假设:文档特别强调,像"能看到目录就能读目录"或"没人会把主目录做成 git 仓库"这类想法都曾实际坑过项目。哪怕只有一个测试失败,就可能在某些平台上彻底破坏安装流程,务必小心。
  • 文件 I/O 必须 sync_all():任何涉及文件创建或写入的测试,都要调用 sync_all()(可对照 src/test/mod.rs#L29-L43 中为测试 git 显式设置 core.fsync = all 的做法,注释明确说这是为了让 Windows 上的 I/O 竞争测试结果稳定)。
  • tempfile::tempdir 用完必须 dir.close():以便目录生命周期可被推理。这同样适用于 fixture_repo()——它返回的 TempDir 也需要关闭。

5. 日志:自定义 logger 与 STARSHIP_LOG

Starship 的调试日志使用自定义 logger 实现(StarshipLogger,见 src/logger.rs)。开启调试日志的方法是设置 STARSHIP_LOG 环境变量为所需日志级别:

# 运行已安装的 starship
STARSHIP_LOG=trace starship

# 用 cargo 运行
STARSHIP_LOG=trace cargo run

从源码补充两点实现细节:日志目录由 get_log_dir() 决定,优先取 STARSHIP_CACHE,否则为 $HOME/.cache/starship(回退到系统缓存目录/临时目录)(src/logger.rs#L22-L34);main() 启动时会用 rayon::spawn 异步清理超过 24 小时的旧日志文件(src/main.rs#L175-L179src/logger.rs#L36-L80)。因此排查"某模块为何没显示"时,STARSHIP_LOG=trace 加上日志文件是最直接的诊断手段。

6. Lint 与格式化:CI 的硬性门槛

文档明确:未通过 lint/format 的代码会直接导致 CI 失败,建议本地先跑:

Clippy(Rust lint)

rustup component add clippy
cargo clippy --all-targets --all-features

rustfmt(Rust 格式化)与 dprint(Markdown、TOML 等文件格式化)

rustup component add rustfmt
cargo fmt
cargo install dprint
dprint fmt

文档还建议利用编辑器插件自动运行这些工具,避免提交导致 CI 失败的 PR。

配置 schema 更新:如果你的改动影响了配置项,必须重新生成配置 schema 文件 .github/config-schema.json

cargo run --features config-schema -- config-schema > .github/config-schema.json

对应源码依据:config-schema 是一个 cargo feature(启用 schemars 依赖,见 Cargo.toml#L31-L34),且 Commands::ConfigSchema 子命令仅在 #[cfg(feature = "config-schema")] 下存在(src/main.rs#L163-L165src/main.rs#L313-L314);Cargo.tomlinclude 列表也把 .github/config-schema.json 列为发布内容(Cargo.toml#L11-L20)。当前仓库版本为 1.26.0,MSRV 提示为 Rust 1.95Cargo.toml#L3Cargo.toml#L25-L26),本地构建请使用较新的 Rust 工具链。

7. 文档工作流:Crowdin 翻译页与本地 VitePress

7.1 翻译页面由 Crowdin 托管,禁止直接编辑

大量文档页面存在非英语版本,这些翻译页面由 Crowdin 托管生成。文档明确要求:不要直接编辑这些页面——即使是无需翻译的改动(如空格、emoji)也不要,因为这可能导致合并失败。如果要贡献翻译或更正,应到项目的 Crowdin 站点进行。仓库中可以看到这一结构的直接体现:docs/ 下按语言代码(de-DE/ja-JP/zh-CN/ 等)镜像了完整的文档子树,而唯一需要手工维护的是英文源目录 docs/ 根下的 config/guide/presets/ 等。

7.2 本地运行文档网站

PR 页面底部 CI 区域提供 "deploy preview" 可预览渲染结果;如想本地查看,文档给出基于 VitePress 的步骤(docs/package.json 定义了对应脚本):

  1. cd 进入 /docs 目录;

  2. 安装依赖:

    npm install
    
  3. 以开发模式启动:

    npm run dev
    

文档同时提示,VitePress 的具体用法可参考其官方入门指南(此处不附外部链接)。

8. Git/GitHub 协作流程

项目推荐的标准 PR 流程(完整继承自 CONTRIBUTING.md):

  1. Fork 仓库;
  2. main 切出工作分支:git checkout -b my-feature-branch
  3. 进行改动,沿途提交;
  4. 改动就绪后推送分支:git push origin my-feature-branch
  5. 创建从你的分支到 starship/main 的 pull request;
  6. 无需把 PR 指派给任何人,维护者有空时评审;
  7. 评审通过后,由维护者替你 squash and merge。

另外,文档开头约定:本项目以 贡献者行为准则 发布,参与即表示同意遵守;本文档未覆盖的问题,欢迎开 issue 或到项目 Discord 提问。

9. 新模块检查清单(New Module Checklist)

项目对新模块持开放态度,且刻意保持低门槛,但 Starship 为模块提供了大量功能,因此新增模块需要完成若干步骤。以下是文档给出的完整清单,建议逐项核对:

  • [ ] 在 docs/config/README.md 中为该模块添加说明章节,描述模块及其配置选项/变量(文档注明:更多文档通常更合适——这只是最低要求)。
  • [ ] 在文档的 "Default Prompt Format" 章节中,把该模块变量加到合适位置。
  • [ ] 为 docs/public/presets/ 目录下的每个 preset 加入合适的选项(仓库中 12 个 preset .toml 文件即位于该目录)。
  • [ ] 重新生成配置 schema:cargo run --features config-schema -- config-schema > .github/config-schema.json
  • [ ] 在 src/configs/<module>.rs 中创建配置的 struct/trait,并补上:

最后,把模块实现代码写入 src/modules/,并把测试中需要 mock 的命令加入 src/utils/mod.rs;命令输出也可以在单个测试中用 ModuleRenderer::cmd 直接 mock。

小结:贡献 Starship 的核心纪律

CONTRIBUTING.md 的所有约定归纳起来,就是四条可执行纪律:

  1. 一切经由 Context:读环境用 context.get_env,跑外部命令用 context.exec_cmd(兜底 utils::create_command),构造绝对路径用 utils::context_path——这三者共同保证了测试可 mock、根目录可替换、执行有超时保护(默认 500ms);
  2. 测试即契约:所有渲染测试走 ModuleRenderer,外部命令输出必须逐字注册进 mock_cmd 的 match 表,文件写入必须 sync_all(),TempDir 必须显式 close(),测试不得假设运行环境的任何状态;
  3. CI 零容忍:clippy、rustfmt、dprint 三项本地先跑,配置变更必须同步重新生成 .github/config-schema.json
  4. 清单不遗漏:新模块按第 9 节的 6+ 项注册点逐一登记(PROMPT_ORDERFullConfigALL_MODULESmod/handle()/description()),并补齐英文文档与 preset 配置。

按这套流程,你的模块既能复用 Starship 的样式继承、并行渲染与路径扫描等基础设施,也能保证在任意平台的 CI 上稳定通过。

登录后查看全文
热门项目推荐
相关项目推荐