Foundry 输出通道契约(Output Channels):stdout/stderr 分离规范与 sh_* 宏实践指南
【免费下载链接】foundryFoundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.项目地址: https://gitcode.com/GitHub_Trending/fo/foundry
Foundry 作为一个由forge、cast、anvil、chisel、script、verify等多个 CLI 工具组成的 Rust 工作区,其命令行输出能否被脚本可靠解析,直接决定了自动化流水线的可行性。本文档围绕 docs/dev/output-channels.md 定义的输出通道契约展开:它规定 stdout 只承载命令的机器可读主结果、stderr 承载一切诊断信息,并配套提供sh_*宏体系与 clippy 强制 lint 来保证实现不偏离契约。读完本文,你将掌握 Foundry 各命令 stdout 的规范格式(含 per-command 对照表)、sh_*宏的选型规则、--quiet/--json/-vvv与通道的交互语义,以及如何在编写代码时通过源码级机制守住这条契约。
契约总述:stdout 是结果,stderr 是其余一切
契约的核心只有两句话:
- stdout是命令的主要结果(primary result),除此之外什么都不应该出现。文本模式下,它是一行规范化的值(多列输出时按制表符
\t分隔);--json模式下,它是单个 JSON 文档。 - stderr承载命令发出的所有其他字节:警告、错误、进度指示、状态文案、提示语、横幅(banner)、ABI 转储、验证过程中的各种闲聊输出(verification chatter)。
这套契约的验收标准非常直白:任何 Agent 或 shell 脚本都可以运行任意命令、丢弃 stderr,并信任 stdout 只包含文档规定的机器可读结果。典型的用法示例:
forge create … 2>/dev/null # → 只得到合约地址 forge test --json 2>/dev/null | jq … # → 永远是合法 JSON cast call … 2>/dev/null | xargs … # → 只得到返回值注意最后一条示例的用途:cast call的返回值被直接喂给xargs,如果 stdout 混入了任何状态文案,管道解析就会失败——这正是该契约存在的意义。
由契约推导出的推论(Corollaries)
契约原文给出了五条推论,它们界定了各输出选项与通道之间的边界:
--json只改变 stdout 的格式,绝不改变通道的纯净度——它不会把诊断信息挪进 stdout,也不会让 stdout 变脏。--quiet抑制 stderr 诊断与进度输出。目标契约要求它永远不改变 stdout 内容;但当前实现中sh_print!/sh_println!仍会被--quiet抑制(见 macros.rs 中print_out的 TODO 注释),这一"旁路"(bypass)将在forge/script中主要的散文式 stdout 调用点迁移到sh_status!之后翻转。sh_err!是文档明确列出的例外:致命错误永远输出到 stderr,且不受--quiet抑制。-vvv增加 stderr 的详细程度,但它绝不能改变 stdout 内容。- 没有主结果的命令(例如
forge install)默认不向 stdout 写入任何内容。 - 提示语(Prompt)属于诊断信息:问题写到 stderr,答案从 stdin 读取。
如何编写符合契约的代码:sh_* 宏体系
要写符合契约的代码,规则只有一条:使用foundry_common::io提供的sh_*宏,不要直接调用println!/eprintln!。
为了从工程上强制这一点,仓库在根目录 clippy.toml 中配置了工作区级的disallowed-macroslint,直接禁用四个标准库宏:
disallowed-macros = [ # See `foundry_common::shell`. { path = "std::print", reason = "use `sh_print` or similar macros instead" }, { path = "std::eprint", reason = "use `sh_eprint` or similar macros instead" }, { path = "std::println", reason = "use `sh_println` or similar macros instead" }, { path = "std::eprintln", reason = "use `sh_eprintln` or similar macros instead" }, ]而sh_*宏之所以不受该 lint 影响,是因为它们展开为对全局Shell实例(crates/common/src/io/shell.rs中的GLOBAL_SHELL: OnceLock<Mutex<Shell>>)的write!调用,而不是std::print*系列。
宏速查表
下表是文档给出的完整宏清单,逐一说明了通道、--quiet行为与用途:
| 宏 | 通道 | 被--quiet抑制 | 用途 |
|---|---|---|---|
sh_println! | stdout | 是(目标:否,见上文) | 命令的主要机器可读结果。 |
sh_print! | stdout | 是(目标:否,见上文) | 同sh_println!,不带尾部换行。 |
sh_status! | stderr | 是 | 状态文案("Compiling…"、"Deploying contract…")。 |
sh_progress! | stderr | 是(stderr 非 tty 时也是 no-op) | 旋转指示器/进度条式的瞬时更新。 |
sh_warn! | stderr | 是 | 可恢复的问题。添加 "Warning:" 前缀。 |
sh_err! | stderr | 否 | 错误。添加 "Error:" 前缀。 |
sh_eprintln! | stderr | 是 | 原始 stderr 文本的逃生舱(escape hatch)。 |
sh_eprint! | stderr | 是 | 同sh_eprintln!,不带尾部换行。 |
prompt! | stderr(问题)+ stdin(答案) | 是(问题经sh_eprint!输出) | 交互式问答。 |
源码中的宏实现细节
在 crates/common/src/io/macros.rs 中可以印证这些宏的真实路由:
sh_err!/sh_warn!/sh_print!/sh_eprint!全部经由隐藏宏__sh_dispatch!分发到Shell::error/Shell::warn/print_out/print_err;分发时通过Shell::get()获取全局 shell,并刻意将全局锁的持有时间压到最短以避免嵌套调用死锁(源码注释明确说明这一点)。sh_status!就是sh_eprintln!的别名($crate::sh_eprintln!($($args)*)),用于输出人类可读的诊断散文。sh_progress!自带门控逻辑:只有当is_err_tty()且!is_quiet()时才真正打印,并且始终返回Ok(())——进度输出是"尽力而为"的,永远不会让调用方失败:
#[macro_export] macro_rules! sh_progress { ($($args:tt)*) => {{ if $crate::shell::is_err_tty() && !$crate::shell::is_quiet() { let _ = $crate::sh_eprintln!($($args)*); } ::core::result::Result::<(), ::eyre::Report>::Ok(()) }}; }prompt!的实现印证了"问题走 stderr、答案走 stdin"的推论:它先用sh_eprint!写出问题、flushstderr,再调用parse_line()从 stdin 读取(读取逻辑在 crates/common/src/io/stdin.rs,支持按行或按整段读取、自动剔除尾部\n/\r)。
Shell 包装器与输出模式
sh_*宏背后是 crates/common/src/io/shell.rs 中的Shell结构体,它记住三项全局偏好:
OutputMode:Normal(默认)与Quiet两个取值;Quiet模式下warn、print_out、print_err、print全部变成 no-op,唯独error无条件输出。OutputFormat:Text(默认)、Json、Markdown,提供is_json()/is_markdown()供代码分支。Verbosity:u8级别的详细程度,对应 CLI 的-v/-vvv等。
此外Shell还通过ShellOut枚举支持三种底层写目标:Stream(带颜色的真实 stdout/stderr)、Empty(丢弃一切输出)、Captured(把 stdout/stderr 捕获到内存缓冲区,专供测试断言用)。color_choice()、is_err_tty()等辅助函数则分别用于颜色与 tty 门控判断。
决策规则:每个sh_println!调用点都要回答的问题
文档为每一位编写或评审代码的开发者提供了一条可操作的决策规则。遇到任何sh_println!调用,依次问自己两个问题:
- 这是不是命令的规范主结果(canonical primary result)?
- 是 → 保留
sh_println!(stdout)。 - 否 → 改用
sh_status!、sh_warn!、sh_err!或sh_eprintln!(stderr)。
- 是 → 保留
- 这一行是否把标签和数据混在一起(例如
"Deployer: 0x…")?- 标签属于散文 → 把整行挪到
sh_status!(stderr)。 - 只有数据属于 stdout → 只输出值本身(
sh_println!)。 --json模式下 → 两者都应放进 stdout 上的同一个 JSON 文档内。
- 标签属于散文 → 把整行挪到
这条规则保证了 stdout 上永远不会出现 "标签 + 值" 的混排文本——那正是管道解析最容易踩的坑。
Per-command stdout 契约(目标状态)
下表是目标契约(target contract):后续的迁移 PR 会逐命令把当前行为对齐到这张表。它是"每个命令的 stdout 在迁移完成之后将包含什么"的权威依据,不一定是今天的真实行为。每行状态取值:
migrated—— 当前行为已符合该契约。todo—— 当前行为尚未符合,需要后续 PR 跟进。
cast系列命令
| 命令 | 文本模式 stdout | --jsonstdout | 状态 |
|---|---|---|---|
cast call | 返回值(hex / 解码后) | 返回值的 JSON | migrated |
cast send | 收据(--async时为 tx hash) | JSON 收据(--async时为 hex tx hash) | migrated |
cast estimate | 燃料估算(十进制) | JSON{ "gas": "…" } | migrated |
cast rpc | RPC 结果(JSON) | JSON | migrated |
cast storage | 单个槽位的值 | 布局的 JSON | migrated |
cast logs | 美化打印的日志 | JSON 数组 | migrated |
cast run | Trace / 解码后的输出 | JSON | migrated |
cast trace | Trace | JSON trace | migrated |
cast wallet new | 每个钱包一行记录:address(keystore 模式)或address\tprivate_key(无 keystore 模式);当 stdout 是 tty 时省略输出(此时 stderr 散文已展示这些值) | keystore 模式为{ address, public_key, path }数组;无 keystore 模式为{ address, public_key, private_key }数组 | migrated |
cast wallet sign | 签名 | JSON | migrated |
cast wallet sign-auth | 签名后的授权 RLP | JSON | migrated |
cast erc20 balance | 余额(十进制) | JSON 字符串 | migrated |
cast create2 | address\tsalt(制表符分隔);挖矿模式下,stdout 是 tty 时省略输出(stderr 散文已展示这些值) | 不适用 | migrated |
cast access-list | 访问列表 | JSON | migrated |
cast interface | Solidity 接口源码 | JSON ABI 数组 | migrated |
cast artifact | JSON artifact | 不适用 | migrated |
cast creation-code | hex 字节码(--disassemble时为反汇编) | 不适用 | migrated |
cast constructor-args | 每个构造参数一行 | 不适用 | migrated |
cast b2e-payload | JSON 执行负载 | 不适用 | migrated |
cast tx-pool | JSON | JSON | migrated |
cast da-estimate | 燃料估算 | JSON | migrated |
cast find-block | 区块号 | JSON | migrated |
cast mktx | 签名 RLP | JSON | migrated |
cast batch-mktx | 签名 RLP(--raw-unsigned时为未签名 RLP) | 不适用 | migrated |
cast batch-send | 收据(--async时为 tx hash) | JSON 收据(--async时为 hex tx hash) | migrated |
值得注意的细节:cast wallet new与cast create2引入了基于 tty 的门控——当 stdout 是终端(交互式场景)时,为避免与 stderr 上的散文重复,这两条命令会省略 stdout 记录;只有当 stdout 被重定向/管道化(非 tty)时才输出机器可读行。这正是 shell.rs 中is_out_tty()辅助函数的用途(注释明确指出"用于门控那些会与交互会话中 stderr 状态散文重复的机器可读 stdout 记录")。
forge系列命令
| 命令 | 文本模式 stdout | --jsonstdout | 状态 |
|---|---|---|---|
forge build | (空) | JSON 构建输出 | todo |
forge test | (空;退出码 = 通过/失败) | JSON 测试结果;--junit时为 JUnit XML | todo |
forge create | 部署时输出Deployer:/Deployed to:/Transaction hash:行;dry-run 时输出Contract:/Transaction:/ABI:行。编译器输出可能先行出现(归入forge build跟踪) | JSON{ deployer, deployedTo, transactionHash }(dry-run 为 JSON{ contract, transaction, abi }) | todo |
forge inspect <field> | 仅该字段的值(artifact/output打印合约 artifact JSON) | 该字段的 JSON | migrated |
forge install | (空) | (空) | migrated |
forge init | (空) | (空) | migrated |
forge update | (空) | (空) | migrated |
forge remove | (空) | (空) | migrated |
forge clone | (空) | (空) | migrated |
forge bind | (空) | (空) | migrated |
forge bind-json | (空)或生成的路径 | JSON | migrated |
forge flatten | 展平后的源码 | 不适用 | migrated |
forge fmt | (空)或--check时的格式化源码 | 不适用 | migrated |
forge tree | 依赖树 | JSON | migrated |
forge config | 配置 TOML | JSON 配置 | migrated |
forge selectors | 选择器输出 | JSON | migrated |
forge eip712 | (空) | 类型 JSON | migrated |
forge geiger | 发现结果 | JSON | migrated |
forge lint | (空;发现结果走 stderr/退出码) | JSON 发现结果 | migrated |
forge snapshot | --diff时为每测试差异行;--format table时为表格;否则(空) | 不适用 | migrated |
forge coverage | 覆盖率表格或报告 | 通过--report输出 JSON / LCOV 等 | todo |
forge cache | (空)或路径 | JSON | migrated |
forge clean | (空) | 不适用 | migrated |
forge completions | 生成的 shell 补全脚本 | 不适用 | migrated |
forge doc | (空) | 不适用 | migrated |
forge soldeer | 直通到soldeercrate;foundry 不添加任何包装散文 | 不适用 | migrated |
forge remappings | 每个 remapping 一行 | 不适用 | migrated |
forge compiler | 编译器信息 | JSON | migrated |
forge verify-contract | 提交时输出<guid-or-job-id>\t<url>;已验证则为空 | 不适用 | migrated |
forge verify-bytecode | <type> code matched with status <kind>行 | { bytecode_type, match_type, message }的 JSON 数组 | migrated |
anvil、chisel、script
| 命令 | 文本模式 stdout | --jsonstdout | 状态 |
|---|---|---|---|
anvil | Banner、账户、RPC URL 输出到 stderr | 不适用 | todo |
chisel | REPL 输出 | 不适用 | todo |
forge script | 模拟/广播结果 | JSON | todo |
注意anvil一行明确把Banner、账户列表与 RPC URL 全部归入 stderr,这正是"stderr 承载一切诊断与散文"的典型体现。而表中未列出的命令目前尚未分类——文档明确要求:在依赖某个命令的 stdout 格式之前,先提交 issue 或 PR 对其进行分类。
编译报告器与进度输出的通道归属
契约特别点名了编译流程中的进度输出。foundry_common::compile(crates/common/src/compile.rs)在 TTY 模式下使用SpinnerReporter(定义于 crates/common/src/term.rs),其旋转指示器通过sh_eprint!写到stderr——源码注释直接写明:"Progress is a diagnostic, not data: write to stderr so stdout stays clean for machine-readable output."(进度是诊断信息而非数据:写入 stderr 以保持 stdout 对机器可读输出的纯净)。
同时,Spinner::tick只在stderr是终端时才工作(TermSettings::from_env依据std::io::stderr().is_terminal()决定indicate_progress),非 TTY 时自动变为 no-op。编译完成时报告器还会在 stderr 上补一个换行,避免后续消息覆盖之前的 tick。
不过文档也如实指出了当前的一个未完成项:非 TTY 场景下,编译报告器的回退实现(BasicStdoutReporter)目前仍然写到 stdout。把它翻转到 stderr 会一次性改变大量既有 snapshot 测试,因此被列入 per-command 迁移积压(backlog)。在 crates/common/src/compile.rs 的with_compilation_reporter_and_settings中可以看到这条回退路径的选择逻辑:quiet || is_json()时用NoReporter,否则 stderr 是 tty 用SpinnerReporter、不是 tty 用BasicStdoutReporter。对于一次性进度行,直接调用sh_progress!也是被允许的。
测试如何守护契约
契约不是纸面承诺,crates/common/src/io/macros.rs 内的单元测试把它固化为可执行断言:
routing_contract测试:使用Shell::captured()捕获输出,断言 stdout 只包含sh_print!/sh_println!产生的内容,而sh_eprint!/warn/error的内容全部落在 stderr,且断言"stdout 内容没有泄漏到 stderr"(assert!(!stderr.contains("out-print"), ...))。测试注释明确写着它断言"每个宏都路由到docs/dev/output-channels.md文档记录的通道"。quiet_contract测试:把OutputMode设为Quiet后,断言 stdout 被抑制(stdout.is_empty())、sh_eprintln!/warn被抑制,但sh_err!(error)必然可见(assert!(stderr.contains("boom"), ...))。测试特意把"当前 stdout 会被--quiet抑制"这一过渡行为钉死(pinned),迫使未来翻转旁路的迁移必须同步更新该测试——这是工程上防止契约漂移的经典手法。
此外,forge命令层面对sh_*宏的广泛使用(在 crates/forge/src/cmd 下,build、create、inspect、selectors、snapshot、bind、config、remappings等命令均有调用)也从调用侧印证了上表各命令的 stdout 形态。
总结与迁移路线
Foundry 的输出通道契约可以浓缩为一句话:stdout 只给机器,stderr 只给人。它通过三层机制落地:
- 规范层:本文档定义的契约、推论与 per-command stdout 对照表;
- 工具层:
foundry_common::io的sh_*宏 + 全局Shell路由,以及 clippy.toml 中禁绝std::print*的工作区级 lint; - 验证层:
Shell::captured()捕获式单元测试,把通道路由与--quiet语义固化为可执行断言。
当前cast全系列命令已全部migrated,forge大部分命令已迁移,而forge build/forge test/forge create/forge coverage以及anvil/chisel/forge script仍处于todo状态——它们正是后续迁移 PR 的工作对象。对下游使用者而言,最稳妥的做法是:只依赖表中标为migrated的命令的 stdout 格式;对todo或未列出的命令,在自动化脚本中始终丢弃 stderr 并以--json作为主解析通道,同时留意--quiet当前仍会抑制 stdout 的过渡期行为。
参考链接
- 契约文档:docs/dev/output-channels.md
- 宏实现:crates/common/src/io/macros.rs
- Shell 包装器:crates/common/src/io/shell.rs
- 旋转指示器 / 进度:crates/common/src/term.rs
- 编译报告器:crates/common/src/compile.rs
- stdin 读取工具:crates/common/src/io/stdin.rs
- 禁用宏 lint 配置:clippy.toml
- 开发者文档索引:docs/dev/README.md
【免费下载链接】foundryFoundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.项目地址: https://gitcode.com/GitHub_Trending/fo/foundry
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考