Foundry Chisel 新特性:用$_复用上一次求值结果,让 REPL 源码重放保持可执行
【免费下载链接】foundryFoundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.项目地址: https://gitcode.com/GitHub_Trending/fo/foundry
导读
Chisel 是 Foundry 内置的 Solidity REPL,允许开发者逐行输入表达式与语句并在真实 EVM 环境中即时求值。本篇文章围绕 Foundry changelog 片段 .changelog/chisel-last-result.md 引入的minor级新特性展开:Chisel 新增$_占位符,用于复用上一次求值(last evaluated result)的结果,且!source、!edit、!save、!export四个命令会使用该结果的 ABI 解码展开形式,从而保证被保存、导出或编辑后重放的源码依然是自包含、可直接编译执行的。读完本文,你将掌握$_的完整用法、边界行为、底层预处理实现原理,以及该特性与 Chisel 会话管理命令的配合方式。
特性概述:$_是什么
该 changelog 片段的完整正文如下:
Added
$_for reusing the last evaluated Chisel result.!source,!edit,!save, and!exportuse its ABI-decoding expansion so replayed source remains executable.
拆解成三个要点:
$_是"上一次求值结果"的占位符:在 Chisel 中输入$_,会被替换为上一次成功求值表达式的值;- 替换形式是 ABI 解码展开:
$_不是被替换成普通的字面量,而是一个完整的abi.decode(hex"...", (类型))表达式; !source、!edit、!save、!export受益于这种展开:这些命令会输出或持久化当前会话源码,由于$_在进入命令/编译管线前就已经被展开成自包含的可执行表达式,重放(replay)保存的源码时不再依赖 REPL 内部状态,依然可以正常编译运行。
从版本语义看,该条目在 frontmatter 中声明了chisel: minor,意味着这是一个向后兼容的新增能力,按照 .changelog/README.md 描述的 changelog 约定,minor表示该变更会随 Chisel 下一个 minor 版本发布。
使用示例:从表达式到变量与字符串
官方集成测试 crates/chisel/tests/it/repl/mod.rs 中的last_result用例完整展示了$_的三种典型用法:
➜ type(uint256).max Type: uint256 ├ Decimal: 115792089237316195423570985008687907853269984665640564039457584007913129639935 ➜ uint256 MAX = $_ ➜ MAX Type: uint256 ├ Decimal: 115792089237316195423570985008687907853269984665640564039457584007913129639935➜ uint256 value = 1 ➜ value = 2 ➜ uint256 assigned = $_ ➜ assigned Type: uint256 ├ Decimal: 2➜ "hello" Type: string ├ UTF-8: hello ➜ string memory greeting = $_ ➜ greeting Type: string ├ UTF-8: hello从中可以归纳出$_的实用规律:
- 覆盖任意可求值表达式:无论是
type(uint256).max这样的类型常量、value = 2这样的赋值语句(其求值结果是被赋的值),还是字符串字面量"hello",都能被$_捕获; - 可出现在声明右侧:
uint256 MAX = $_、string memory greeting = $_这种"取上一次结果做初始化"是最典型的用法,$_会被展开成带类型的 ABI 解码表达式,因此类型信息得以保留; - 类型由上一次表达式推断:
$_展开后携带上次结果的具体类型(如uint256、string),后续使用不需要重新声明类型信息。
底层原理一:preprocess预处理管线
$_的替换发生在 Chisel 的输入分发环节。在 crates/chisel/src/dispatcher.rs 中:
ChiselDispatcher结构体持有last_result: Option<String>字段,用于跨输入保留上一次求值结果(见 dispatcher.rs);- 每次执行 Solidity 输入前,
dispatch_solidity会调用preprocess(input, self.last_result.as_deref())对输入做预处理(见 dispatcher.rs); preprocess使用solar::parse::Cursor对输入做词法级扫描(见 dispatcher.rs):当某个 token 恰好等于$_时,将其原地替换为format!("({last_result})"),即用一对括号包住上次结果的 ABI 解码表达式。
从实现细节可以确认三个行为边界:
- 字符串字面量与注释内的
$_不会被替换:扫描时只匹配独立的$_token,"$_"字面量或注释中的$_属于其他 token 类别,原样保留; - 十六进制地址会顺带做 checksum 处理:预处理同时会把 42 长度的十六进制字面量转为 checksummed 地址;
- 无上一次结果时直接报错:当
last_result为None时,使用$_会得到no previous result错误(见 dispatcher.rs)。
dispatcher 中的单元测试test_last_result_preprocessing(见 dispatcher.rs)精确验证了上述行为:
let result = "abi.decode(hex\"2a\", (uint256))"; let (_, input) = preprocess("uint256 answer = $_;", Some(result)).unwrap(); assert_eq!(input, format!("uint256 answer = ({result});")); let literal = r#"string memory value = "$_"; // $_"#; let (_, input) = preprocess(literal, Some(result)).unwrap(); assert_eq!(input, literal); assert_eq!(preprocess("$_", None).unwrap_err().to_string(), "no previous result");即uint256 answer = $_;会被改写为uint256 answer = (abi.decode(hex"2a", (uint256)));,而字面量与注释中的$_保持原样。
底层原理二:last_result的 ABI 解码展开如何生成
$_展开后得到的abi.decode(...)表达式是在 crates/chisel/src/executor.rs 的表达式检查(inspect)流程中生成的,其生成链路如下:
- 求值时,输入表达式会被追加进
run()函数体并包进一次abi.encode(<input>)调用(对应源码注释中的bytes memory inspectoor = abi.encode(...)模式); - 编译成功后,从 EVM 返回的栈顶取出
inspectoor在内存中的偏移,再读取其长度与原始字节数据(见 executor.rs); - 通过
expr_to_dyn推断该表达式的动态 Solidity 类型DynSolType,并执行ty.abi_decode(data)得到解码后的 token 用于格式化输出(见 executor.rs); - 最终生成的
last_result字符串形如abi.decode(hex"{data}", ({ty}))(见 executor.rs),并写入InspectResult.last_result(字段定义见 executor.rs),由 dispatcher 存回self.last_result(见 dispatcher.rs)。
这正是 changelog 中所说"ABI-decoding expansion"的来源:$_携带的是被 ABI 编码的原始字节 + 精确类型,而不是文本字符串,因此无论后续把它赋给同类型变量、传给函数还是嵌入到表达式中,类型与数值都保持严格一致,天然规避了字符串拼接导致的精度丢失或类型不匹配问题。
与!source/!edit/!save/!export的配合
changelog 特别强调$_的展开使以下四个命令导出的源码保持可执行:
!source:打印当前会话的完整 Solidity 源码;!edit:在编辑器中打开run()函数体供修改;!save [id]:将会话源码与状态缓存到磁盘(对应save_session实现,见 dispatcher.rs);!export:导出会话源码。
其核心价值在于:由于$_在输入进入编译管线之前(即preprocess阶段)就已经被展开为abi.decode(hex"...", (type))这样的自包含表达式,这四个命令读取到的是展开后的完整源码,而非带占位符的原始文本。因此当你!save一个会话后重新!load回来,或把!export得到的源码复制到独立.sol文件中编译,其中引用的"上一次结果"不需要 REPL 运行时环境提供——它已经以字节数据 + 类型的形式固化在源码里,重放时可直接编译执行。
生命周期与边界行为:何时$_会失效
last_result是会话内的易失状态,集成测试last_result_resets_with_session(见 crates/chisel/tests/it/repl/mod.rs)验证了以下重置规则:
!clear清空会话后失效:clear_source在清空源码的同时会把self.last_result置为None(见 dispatcher.rs),此后使用$_报no previous result;!load加载其他会话后失效:加载新会话同样会重置last_result(见 dispatcher.rs),防止把旧会话的结果误带入新会话,也避免加载的会话中残留陈旧的上次结果引用(对应 changelog 相关条目chisel-session-load-trusts-stale-embedded-id所关注的会话状态一致性问题);- 会话启动时不存在:新启动的 Chisel 进程没有任何"上一次结果",首次输入使用
$_会直接报错。
此外需要注意:只有成功的求值才会更新last_result。如果上一次输入编译失败、求值被 revert 或仅包含注释/空白(trivia),InspectResult.last_result会保持None,$_将沿用更早的有效结果或直接报错。
小结
$_是 Chisel REPL 中一个轻量但实用的状态复用机制:它在输入预处理阶段被展开为携带精确类型的abi.decode(...)表达式,既能在 REPL 会话内无缝复用上一次求值结果(uint256 MAX = $_这类写法),又能通过 ABI 解码展开保证!source、!edit、!save、!export输出的源码脱离 REPL 状态后依然可编译、可重放。其实现横跨 crates/chisel/src/dispatcher.rs(占位符替换与生命周期管理)与 crates/chisel/src/executor.rs(ABI 编码求值与类型推断),并由 crates/chisel/tests/it/repl/mod.rs 中的集成测试完整覆盖。对频繁在 Chisel 中做数值验证与原型实验的开发者而言,善用$_可以显著减少"复制上一次输出、手动声明类型"的重复操作。
【免费下载链接】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),仅供参考