Rakuish
双语言交互环境:能跑 shell 脚本,也能执行 Raku 语句。只支持 Linux。
用 Raku 实现的 IPython 式 eval 环境 —— REPL 里默认求值 Raku 表达式(结果按 Out[n]
显示),! 前缀把整行交给 shell 引擎(独立进程,默认真 bash),
% 前缀是 magic 命令(%run 跑整个脚本、%shell 切 shell 后端等)。
两套语义分居两个进程:REPL 进程里只有 Raku 语义($ 永远是 Raku 变量);
shell 语义(展开 / 管道 / heredoc / glob / 算术)在独立的引擎进程里
(bin/rakuish-sh)⇒ 引擎崩了也带不走 REPL,语义也不互相污染。
两个维度别混:
| 维度 | 默认 | 怎么改 | 影响范围 |
|---|
| 输入求值语言 | Raku | 裸输入走 Raku;! / `cmd` / auto-shell 才走 shell | 决定一行输入交给谁 |
| shell 后端 | bash | %shell bash|native、!@bash … / !@native … | 只影响 !、反引号、auto-shell;不影响裸输入的 Raku 求值 |
当前状态
主线是 Raku 实现(lib/ + bin/rakuish)—— 2026-10-30 定案(依据见
HANDOFF.md 第 5 节)。v/ 下的 V 实现已停止维护,保留下来
只作 shell 层(lexer / parser / expand)的移植素材,详见
v/README.md。
| 组件 | 状态 |
|---|
| 交互层依赖(Prompt / Terminal::LineEditor / Commands) | ✅ 已装 |
Rakuish::Incomplete(多行输入判定) | ✅ 13 条断言,MoarVM 全绿 |
Rakuish::Platform(实现差异隔离层) | ✅ 30 条断言,MoarVM 全绿 |
Rakuish::Eval(快档求值 + Out[n] + 错误渲染) | ✅ 57 条断言,MoarVM 全绿 |
Rakuish::Loop(交互主循环:分流 / 多行 / magic) | ✅ 97 条断言,MoarVM 全绿 |
脚本模式(rakuish run x.rakuish / 按 shebang 选解释器) | ✅ t/43 16 + t/52 14 条断言,MoarVM 全绿 |
Rakuish::Result(run → 结果对象 / 链式加工 / run-pipe 真管道) | ✅ t/44 26 条断言,MoarVM 全绿 |
Rakuish::Builtins(cd/pwd/export/unset/alias/type/exit/read/source/test 归 REPL 宿主) | ✅ t/45 81 条断言,MoarVM 全绿 |
DSL 糖(run … :in('data') / Result.paths / 裸 run 直接展示 .out) | ✅ t/46 13 + t/47 15 条断言,MoarVM 全绿 |
PATH 补全(命令 + 内建 + 别名合并去重 / run 首参补成带引号 / 按 PATH 串缓存) | ✅ t/48 17 条断言,MoarVM 全绿 |
脚本模式认 shebang(rakuish run x.sh 走 bash / python3,不再当 Raku 求值) | ✅ t/52 14 条断言,MoarVM 全绿 |
| native 后端语义对齐矩阵(99 条 bash 构造:一致 / 明确不支持两档,0 静默不同;清单入对拍每次验收都回归) | ✅ tools/native-scan.sh + docs/NATIVE-PARITY.md,MoarVM 全绿 |
| 脚本模式错误输出无装饰(stderr 只有「文件:行号」+ 消息,没有 REPL 的分级头 / 出错行 / 提示) | ✅ t/43,MoarVM 全绿 |
cd -(回上一个目录;内建行不再被「未完成格」吃掉;两个实现都同步 PWD/OLDPWD) | ✅ t/19、t/04,MoarVM 全绿 |
read -p 提示语走 stderr 且只在 stdin 是终端时打印(bash 语义) | ✅ t/45、对拍 21-readp,MoarVM 全绿 |
Rakuish::Slow(慢档:真 sh 跑 shell) | ✅ 77 条断言,MoarVM 全绿 |
Rakuish::Shell::Lexer(引号感知词法) | ✅ t/31,MoarVM 全绿 |
Rakuish::Shell::Parser(Pipeline / Command / Redirect) | ✅ t/32,MoarVM 全绿 |
Rakuish::Shell::Expand(引号感知展开 + glob) | ✅ t/33,MoarVM 全绿 |
Rakuish::Shell::Exec(原生管道 / 重定向:posix_spawn + pipe + dup2) | ✅ t/34,MoarVM 全绿 |
Rakuish::Shell::Session(跨格存活的 shell 变量表) | ✅ t/35,MoarVM 全绿 |
原生 && / ` | |
原生命令替换 $(...) 与反引号 + bash 展开顺序(展开→分词→glob) | ✅ t/37,MoarVM 全绿 |
原生 heredoc(<<EOF / <<'EOF' / <<-EOF) | ✅ t/38,MoarVM 全绿 |
原生特殊参数 $? / $$ + brace 展开 {a,b} / {1..5} | ✅ t/39,MoarVM 全绿 |
原生算术展开 $((...))(含 ** 优先级、进制、赋值副作用) | ✅ t/40,MoarVM 全绿 |
原生参数展开 ${V:-w} / ${V#p} / ${V//p/r} / ${V^^} / ${V:off:len} 全套算子 | ✅ t/41,MoarVM 全绿 |
shell 引擎(独立进程;后端默认 bash,%shell native 可切自研实现) | ✅ t/42,MoarVM 全绿 |
原生 shell 层接入(! 直通 / 自动 shell / 反引号捕获) | ✅ t/34 + 既有套件无回归 |
%run 跑整脚本(汇总块 + --numbers 行号)—— 按 shebang 选解释器,.sh 默认 bash(不是 dash!) | ✅ t/07、t/08 |
%time 计时求值 | ✅ t/10 |
%load 载入文件进 workspace(-r 行范围 / -s 符号提取) | ✅ t/11 |
Ctrl-C 中断(REPL 保活 + %run 脚本真被杀) | ✅ t/12 + 真实 SIGINT 冒烟 |
会话持久化(.repl 回放 / %repl 看开关清) | ✅ t/13、t/23 |
| 报错美化(分级 + 源码高亮 + 到位提示) | ✅ t/24 |
%help 结构化命令总览 | ✅ t/25 |
~/.rakuishrc 启动配置(color / replay / history) | ✅ t/27 |
| 多行块(行尾运算符续行 / heredoc / Ctrl-D 不误杀) | ✅ t/28 |
Raku 求值失败自动转交 shell(auto-shell 可关) | ✅ t/29 |
反引号捕获(`cmd` → 当值用) | ✅ t/18 |
| TAB 补全(magic / shell 命令 + 内建 + 别名 / store 键 / 路径 / Raku 变量 / 关键字 / 例程) | ✅ t/09、t/20、t/26、t/30、t/48 |
cd 原生 chdir(相对路径归一化:cd .. 落在父目录本身,不留 /x/..) / 彩色 ls / ll(ls <文件> 打印它自己,与 bash 一致) | ✅ t/19、t/22 |
cd 报错文案两处实现统一(cd 与 %cd 逐字相同:没有这个目录 / 不是目录 / 进不去) | ✅ t/19 |
| 命令高亮 / 值渲染 / 颜色开关 / 词法作用域 | ✅ t/15、t/17、t/21、t/16 |
| 启动器冒烟(shebang / 可执行位 / 语法检查) | ✅ t/14 |
测试规模:53 个 .t、789 条断言(prove 口径),bash tools/run-all.sh 全绿
(问题总数 = 0)。
自写的多行判定不是权宜之计:Raku 实现在这件事上都靠不住 —— 它既没有
<HERE> 位置标记,也不区分"未写完"与"写错了"(1 + 和 1 +) 的消息一模一样),
所以只能自己扫。它是纯逻辑,因此行为可预期 —— 这正是
Platform 层隔离思路的第一块试金石。
⚠ 2026-11-05 起只跑 MoarVM(不再跑 rakupp):rakupp 的实现差异逼出了一堆
「为了兼容它而写」的变通,让代码偏离 idiomatic Raku、也偏离 bash 语义。
项目只在 Rakudo(MoarVM)上实现与验收 —— 详见
HANDOFF.md §1.3。
shell 引擎(2026-11-05 起:独立进程 + 可插拔)
!cmd、反引号捕获、自动回退 shell 全部交给独立的引擎进程 bin/rakuish-sh:
| shell 后端 | 说明 |
|---|
| bash(默认) | 真 bash -c —— 语义 100% 准、零维护(brace 展开、[[ ]]、数组、case、函数…全都有) |
| native | 本项目自己实现的 Raku shell 层(词法 / 语法 / 展开 / 算术 / 执行,走 posix_spawn + pipe(2) + dup2)—— 不依赖系统 bash,但只覆盖 bash 的一部分 |
%shell bash|native 随时切换(会话变量两边都互通)。
逐行委托(不切默认后端,只让这一行走另一个后端):
!@bash echo hi → 这一行走 bash 后端
!@native echo hi → 这一行走 native 后端
! echo hi → 走当前后端(%shell 设的那个,默认 bash)
⚠ @ 起头是刻意的:shell 的命令名不可能以 @ 开头 ⇒ 这个前缀不会与真实命令冲突。
不许跨后端委托:native 后端遇到自己不支持的语法 ⇒ 明确报错 + 退出码 2 +
委托提示(告诉你用 !@bash …),绝不偷偷把整行交给 sh -c ——
本机 /bin/sh 是 dash,它与 bash 的差异会静默泄漏(没有 brace 展开、
没有 [[ ]]…)⇒ 用户写 bash 语法却跑出另一种结果,比明确报错更糟。
| native 后端暂不支持 | 表现 |
|---|
cd /tmp && ls(shell 内建) | 退出码 2 + 委托提示(默认的 bash 后端正常) |
{ cmd; } 复合命令 / ( cmd ) 子 shell | 同上 |
export X=1 | cat(会话内建在管道里) | 同上 |
[[ … ]] / 数组 / case / 函数 | 同上 |
为什么把 shell 语义搬出 REPL 进程:
- 崩溃隔离 —— 原生层是全项目唯一做裸 libc 调用的地方
(
pipe/dup2/posix_spawnp/malloc…):一个野指针就是段错误 ⇒ 会带走整个 REPL。
独立进程后,引擎崩了只是这一条命令失败。 - 语义不互相污染 —— REPL 进程里只有 Raku 语义(
$ 永远是 Raku 变量);
shell 的 $ 只存在于引擎那边。 - 准确度 —— 默认走真 bash ⇒ 不再自己实现 brace 展开、数组、
case……
会话变量互通:引擎跑完把完整环境回传给 REPL,REPL 与自己的环境求差
⇒ !export FOO=bar 跨格存活、!echo $FOO 读得到、$? 也是上一条命令的退出码 ✓
终端方言:引擎进程会把 dumb / 空的 TERM 提升为 xterm-256color ——
否则 uutils ls 之类即使显式 --color=always 也不上色(本机实测)。
只影响引擎子进程;REPL 自己的 %*ENV<TERM> 不会被回写。
!ls / ! --numbers ls / !@bash ls 都会补 --color=always。
原生 shell 层(自研实现,2026-10-31 起;2026-11-05 更新)
native 后端的实现:词法(引号边界)/ 语法(管道、重定向、顺序执行)/ 展开(变量、命令替换、glob)三件套,
执行走 posix_spawn + pipe(2) + dup2(file actions)自己实现。
| 能力 | 说明 |
|---|
| 管道 | !ls | wc -l 真的用 pipe(2) 串起来,不走 sh |
| 重定向 | < > >> 2> 2>&1 >&2 &> &>> 全部落到 fd 上 |
| 引号语义 | 单引号内一切原样;双引号内可展开;裸位置反斜杠转义任意字符 |
| 变量 / glob | $VAR ${VAR} 走会话变量表(回退 %*ENV);glob 按 bash 的顺序做:展开 → 分词 → glob —— 所以 echo $VAR(VAR='*.txt')也会 glob |
| 顺序执行 | && || 原生支持:&& 是前一条成功才跑、|| 是失败才跑,同优先级左结合(与真 sh 逐条对拍) |
| 会话变量 | !export FOO=bar / !unset FOO / !FOO=bar —— 跨格存活,shell 展开、子进程、Raku 侧(%*ENV)三边都看得到 |
| glob 元字符 | * ? [abc] [a-z] [!abc] 都支持;引号内 / 被转义的元字符不生效(bash 语义) |
| 命令替换 | $(cmd) 与反引号 原生支持:只取 stdout、去掉尾随换行;裸位置的结果参与分词($(echo a b) → 两个参数),双引号内不分词 |
| heredoc | !cat <<EOF 之后接着输入正文(以 EOF 行结束);<<'EOF' 正文不展开、<<-EOF 去掉行首制表符 |
| 家目录展开 | ~ ~/ ~user(查 /etc/passwd)—— 词首裸位置才展开,引号内不展开 |
| brace 展开 | !echo {a,b} → a b;{1..5} / {a..e} 区间、{01..03} 补零、{1..10..2} 步长;引号内不展开;无逗号无 .. 的 {a} 是字面量 |
| 特殊参数 | !echo $? → 上一条命令的退出码(每条 pipeline 都更新);!echo $$ → 本 shell 的 PID(会话内恒定) |
| 算术展开 | !echo $((1+2)) → 3;位运算、比较、三元、逗号、0x/八进制/2#101 进制;变量参与($((X+1)),裸名字 X 也算变量);赋值有副作用($((X=9)) 真的把 X 改成 9)。⚠ 优先级是 bash 的不是 C 的:$((-2**2)) = 4 |
| 参数展开 | ${V:-w} ${V-w} ${V:=w} ${V=w} ${V:?msg} ${V?msg} ${V:+w} ${V+w} ${#V} ${V#p} ${V##p} ${V%p} ${V%%p} ${V/p/r} ${V//p/r} ${V/#p/r} ${V/%p/r} ${V^} ${V^^} ${V,} ${V,,} ${V:off} ${V:off:len} —— 全套算子,语义逐条与真 bash 对拍 |
| 致命诊断 | ${V:?msg} 与 $((1/0)) ⇒ 报错 + 中止这条命令 + 退出码 1(bash 语义:stdout 空、shell 活着) |
| 不支持的语法 | { cmd; } 复合命令、( cmd ) 子 shell、<<< herestring、cd 等 shell 内建、$! $# $@ 等特殊参数、${V@Q}、算术里的命令替换、管道里的 export 等 → 明确报错 + 退出码 2 + 委托提示(!@bash … / %shell bash)。⚠ 绝不偷偷交给 sh -c —— 本机 /bin/sh 是 dash,差异会静默泄漏 |
全部期望值都来自真 bash 5.3.9 的逐条对拍(对拍工具:bash tools/diff-check.sh,
用例在 tools/diff-cases/,126 条,stdout 严格比对)。
其中 8 条是「标记用例」(行尾带 #!unsupported)—— 那是被 native 后端
明确拒绝的语法(子 shell / 复合命令 / cd / 管道里的 export)⇒
不比对 stdout,改为断言「确实明确报错了」(退出码 2 + 「不支持」+ 委托提示)。
⇒ 「不许跨后端委托」这条规则本身也进了回归网。
新写 bash 语义必须做对拍 —— 这类偏差是静默的(不报错、结果错)。
⚠ 一条硬约束(实测踩出来的):不要用 fork + 在子进程里 exec。
MoarVM 是多线程运行时,fork 后其它线程持有的锁在子进程里永远解不开 ——
子进程只要触发一次 GC/调度器就会永久卡死(连「只调 libc 原生函数」也会,
因为 NativeCall 调度器自己会分配内存)。
所以进程创建交给 posix_spawn(3)(libc 内部 clone,我们的运行时不参与),
管道/重定向靠 posix_spawn_file_actions_adddup2 描述。
压测:2400 次原生执行零失败。细节见 HANDOFF.md §4.5。
运行 REPL
项目是"教学型"双语言 REPL,直接跑 bin/rakuish 即可(需要 tty / 真实尺寸的 pty):
# 方式一:显式用 raku 跑(和开发环境一致,最稳)
raku bin/rakuish
# 方式二:加可执行位后直接跑(已加好)
./bin/rakuish
模块搜索路径按脚本自身位置推算,所以从任意目录调用都能找到 lib/,不依赖当前工作目录。
依赖 Prompt 模块(提供 LineEditor 行编辑器 / TAB 补全)。若缺失,启动会给出一句人话提示:
zef install Prompt
在 WSL 里照 ## 开发 的 rakuish-dev 流程用干净 PATH 启动最稳:
~/bin/rakuish-dev raku bin/rakuish
运行脚本(0.11.0 起)
脚本模式和 REPL 共用同一个主循环,只是关掉全部交互装饰。
raku bin/rakuish run hello.rakuish # 显式 run
raku bin/rakuish hello.rakuish # 裸路径也认
chmod +x hello.rakuish && ./hello.rakuish # shebang: #!/usr/bin/env rakuish
hello.rakuish:
say 'hello';
say 1 + 1; # 3 —— 要输出就 say,裸表达式不打印
!ls -l # ! 开头交给 shell 引擎(默认真 bash)
exit 7; # 退出码透传
| REPL | 脚本 |
|---|
In[n] 回显 / Out[n] 值渲染 | 有 | 无 |
| 求值失败后猜「这行像不像外部命令」 | 有 | 关(打错变量名不会被当命令执行) |
| 出错 | 美化后继续 | 写 stderr,立即停止,退出码 1 |
| 参数 | —— | @*ARGS 可用 |
所以 raku bin/rakuish run x.rakuish > out.txt 里只有程序输出,错误全在 stderr。
设计与路线图见 docs/PLAN-C.md:真 bash 留作兼容层、native 语法层冻结、
自研预算全部投到 Raku shell DSL。进度:P0 脚本模式 ✅(0.11.0)→ P1 Result 对象化 ✅(0.12.0)→ P2 内建与会话状态归位 ✅(0.13.0)→ P3 补全与 DSL 糖 ✅(0.14.0)→ P4 脚本正确性 ✅(0.15.0)→ P5-1 整文件一次编译 ✅(0.16.0)。
脚本模式的正确性(0.15.0 起 / C 方案 P4)
三件事,都属于"错了还看不出来"的那类,所以都用真实子进程断言(t/49-p4-cells.t)。
cell 切分不再被块内花括号破坏。q{...} / qq(...) 现在按嵌套层数配对,
for 1..3 -> $i { say "v={$i}" }、%h{$i} = ... 这类不再静默终止。
跨行 else 能接上。块尾 } 属于 if / unless / with / without 时,
脚本模式会先探一行:是 else / elsif / catch 就并进同一格,不是就原样退回给下一轮
(不丢行、行号记得住)。for / while 没有 else 分支,仍然立即执行,不拖一拍。
不再静默终止。文件读完而块/引号没闭合时,报 文件:行号: 文件结束时输入还没写完
并以 2 退出(对齐 bash 的语法错误),前面的输出照常保留。
整文件一次编译执行(0.16.0 / P5-1)。脚本模式不再「读一格跑一格、每格重跑整份缓冲」:
现在把文件读满、拼成一个编译单元一次 EVAL。副作用只发生一次,耗时从 O(n²) 回到 O(n),
顺带把 use v6.d / class / sub MAIN / heredoc / END 这些文件级语义全部拿回来。
代价是编译期错误整份都不执行(对齐 raku foo.raku 与 bash);运行期错误仍保留之前的输出。
证据见 t/50-p5-script.t。REPL 维持原有的重放式累积(跨格 my / 容器状态靠它活着),不受影响。
会话内建归 REPL 宿主(0.13.0 起 / C 方案 P2)
原则:谁拥有会话,谁实现内建。cd / pwd / export / unset / alias /
unalias / type / exit / read / source(.)/ test(含 [)现在由
REPL 进程自己实现,直接改宿主 %*ENV 与 $*CWD,不再甩给引擎进程。
export FOO=1 # 宿主 %*ENV<FOO> 立刻可见
!printenv FOO # 引擎是子进程,天然继承 → 1
cd /tmp # 三种视角同步
pwd # /tmp
!pwd # /tmp
run('pwd').out # /tmp
alias ll='ls -l' # 只对 REPL 简单命令生效
!ll # `!` 行归 bash,不受 alias 影响
引擎 → 宿主方向仍走 --env-out + apply-env-delta:!export BAR=2 之后
%ENV<BAR> 也看得到。
顺带修掉 P0 遗留:脚本模式 $*PROGRAM 指向脚本本身;报错渲染带上
文件:行号(.line 经 remap-line 归算,.file 不用 MoarVM 的 EVAL_0)。
shell 结果对象化(0.12.0 起 / C 方案 P1)
!cmd 把输出甩给终端、`cmd` 只给一个字符串——都是"看得见、拿不着"。
run 把命令结果变成值,于是 shell 输出能直接进 Raku 表达式:
my $r = run 'ls -la'; # 立即执行,得到 Result
$r.code; $r.out; $r.err; # 退出码 / stdout / stderr(互不污染)
$r.ok; $r.Int; # code == 0 / 退出码
run('ls').grep(*.ends-with('.rakumod')).head; # 进程内过滤
run('git status --short').lines.elems; # 数行
run(q{printf '%s' '{"a":{"b":[1,2,3]}}'}).json<a><b>[1]; # → 2
run('echo', '-n', 'a b').out; # 多参数 = 直接 exec,不过 shell
run-pipe('ls', 'grep .raku', 'head -3'); # 真管道(大数据不进内存)
| 成员 | 说明 |
|---|
.out / .err / .code | stdout(不含 stderr)/ stderr / 退出码 |
.ok / .Bool / .Int | code == 0 / 同 / 退出码 |
.Str | bash $(cmd) 语义:剥掉尾部换行,内部换行保留 |
.lines / .words / .elems | 切行 / 分词 / 行数 |
.head(n?) / .tail(n?) / .first(m) | 取首尾(不传 n 返回单行) |
.grep(m) / .map(&f) / .sort(&by?) / .join(sep) | 在行上加工,返回 List |
.json | 解析 .out;装了 JSON::Fast 优先用它,否则走内置解析器 |
.summary / .gist / .raku | REPL 摘要 / 完整可读 / Out[n] 用的单行摘要 |
语义与 `cmd` 完全一致(都走引擎,%shell 切后端时一起变),差别只在
run 还带回了 code 与 stderr。
⚠ run 在 EVAL 里遮蔽了 Raku 内建 run(这是有意的:Rakuish 里 run('ls')
就该拿到 Result)。要原生 Proc,显式写 my &cr = &CORE::run; 再用 cr(...)。
补全与三颗小糖(0.14.0 起 / C 方案 P3)
P0–P2 解决「能不能用」,P3 全是手感。
补全
! 行首与 run 的首个参数,现在补的是「PATH 命令 + 内建 + 别名」合并去重后的结果 ——
!cd / !export / !ll 敲一半按 TAB 就有反应了(ll 是你在 .rakuishrc 里
alias ll='ls -l' 定义的)。
run 的首参补出来自带引号:
rakuish> run 'gr<TAB>
run 'grep'
因为 run ls 在 Raku 里是「调用例程 ls」,run 'ls' 才是「执行命令 ls」。
PATH 扫描结果按 PATH 串缓存(补全每敲一个字符都要算候选,几千个可执行文件逐个 stat
是主要卡顿源),export PATH=… 之后键变了自动失效,不会拿到过期候选。
WSL 特例:默认会把 Windows 的 PATH 追加进来,而 drvfs 上每个文件都报 +x ——
所以 /mnt/ 下的目录改按扩展名认命令(exe / bat / cmd / com / 无扩展名),
不会把 .dll / .json 塞进候选表。
糖
(run 'grep -c raku', :in("a\nraku\nraku\n")).out.trim; # :in 喂 stdin(shell 行形式)→ "2"
(run 'wc', '-l', :in($text)).out.trim; # 同上,argv 形式(直接 exec)
(run 'ls -1 *.rakumod').paths».basename; # .paths:输出当 IO::Path 列表
(run 'ls -1 *.rakumod').paths.grep(*.f); # 接着当文件用
run 'ls' # 裸 run:直接把 .out 展示成 Out[n]
# Out[3]:
# bin
# docs
# lib
# t
my $r = run 'ls' # 赋值 ⇒ 仍是 Result 摘要,不刷屏
# Out[4]: Result(code=0 · 12 行 · bin)
判定在源码上(bare-run-src),所以只影响「这行就是这个命令」的写法;赋值、当实参、
say run 'ls' 都不受影响。脚本模式下不自动打印,与「脚本只输出程序自己 say 的东西」一致。
stderr 有内容单列一行,退出码非 0 标 [退出码 N]:
run 'ls /nope'
# Out[5]: (无输出)
# stderr: ls: cannot access '/nope': No such file or directory
# [退出码 2]
:in 只收字符串;要传句柄或拿原生 Proc,显式 my &cr = &CORE::run; 再用 cr(...)。
安装(作为 Raku 模块)
Rakuish 是一个标准 Raku 发行版(见 META6.json)。用 zef 安装后即可获得 rakuish
命令与 Rakuish::* 模块:
# 方式一:从源码目录安装(读取 META6.json,自动拉取 Prompt / Commands 依赖)
zef install .
# 方式二:走项目自带脚本(等价于上面,并顺手给 bin/rakuish 加可执行位)
bash tools/install.sh
# 卸载
zef uninstall Rakuish
# 或:bash tools/uninstall.sh
安装后依赖 Prompt(行编辑器 / TAB 补全)与 Commands(magic 分发)会一并装上;
bin/rakuish 会被装到 Raku 的 bin 目录,直接进入 PATH。
⚠ 还有第二个可执行文件:bin/rakuish-sh —— shell 引擎
(REPL 把 shell 命令交给它跑,默认真 bash;见下面「架构」一节)。
它与 bin/rakuish 一起安装(META6.json 的 bin 字段里有两个)。
⚠ Rakuish::Shell::Engine::engine-path() 会依次尝试:RAKUISH_SH 环境变量 →
<安装前缀>/bin/rakuish-sh(从 $*REPO 的 prefix 推)→ 源码树的
<repo>/bin/rakuish-sh → launcher 同目录 → PATH。
⇒ 源码运行和 zef / raku-pm 安装后都能找到引擎,不依赖 cwd ✓
(zef 把模块源码放在 <前缀>/sources/<HASH>,不能按模块文件的父目录推算 ——
这是 0.9.3 修掉的真 bug。)
注意:本项目只支持 Linux(LineEditor 依赖真实 pty,且 %run 走真 sh)。
核心设计(摘要,详见设计文档)
| 快档 | 慢档 |
|---|
| 谁执行 | 自身进程(Raku EVAL / shell 内建 / 纯 Raku 函数) | 真 sh 子进程 |
| 输入 | 无前缀 | !cmd、%run s.sh |
| 输出 | Raku 值 → Out[n](不靠 $? 猜) | 继承 fd1 直通:流式 + 原生 ANSI 颜色原样透传 |
| 状态 | 活在 Workspace,跨 cell 保留 | 脚本自己的进程,跑完即销毁 |
关键原则:别用 Raku 模拟 shell 跑脚本。 整个 .sh 原样 spawn 出去,
local / $$ / IFS / heredoc / 子 shell / 退出码全部由真 sh 负责,
一行都不用模拟。
与前作的关系
前作 raku-shell(Windows 版 POSIX shell)
已于 2026-10-09 冻结归档,在 Windows 侧保留作为参考实现。不搬它的执行内核,按
Linux 重写 —— Linux 上 run(:in) 可用、参数原样传递、没有文本句柄 CRLF、信号投得出去,
要绕的坑根本不存在。详见设计文档第 0.1 节。
曾经的 V 实现(v/)
2026-10-13 起曾用 V 重写一版 v0.2,想用自写的 lexer/parser/expand 拿到原生管道与
重定向语义。2026-10-30 定案:主线回到 Raku 实现,v/ 停止维护。
定案依据(实测,不是推测):
- 依赖漂移时 Raku 路线优雅降级、V 路线硬崩。rakupp 5.3.0→5.5.0 只回归了 1 个
测试(正则跨后端语义,根因已定位、修复已验证);V 编译器一升级,整个构建失败,
REPL 根本跑不起来。
- V 重写把功能全丢了:TAB 补全、历史、magic 命令、
Out[n] 值渲染、报错美化、
会话回放、~/.rakuishrc、Ctrl-C 保活 —— Raku 版有的,V 版一个都没有。 - V 路线要求 pty 常驻、ANSI 剥离、提示符边界识别、
%*ENV 字符串桥,而这套复杂度
在 Raku 里全部消失(EVAL 就在同一进程内)。设计文档第 0.1 节早就论证过。
v/ 里的 shell 层(lexer / parser / expand,537 行纯逻辑)质量不错,保留下来
作为移植素材 —— 把它们移植进 Raku 实现,就能同时拿到原生管道/重定向语义和现有全部
交互打磨。坑位清单见 v/README.md。
仓库与远端
远端:Gitee — https://gitee.com/skyter10086/rakuish.git
git remote -v # origin → gitee
git push origin main # 主线在 main
本地仓库早期没有远端,历史只存在这一台机器上;2026-10-30 起已配好 Gitee 远端。
换机器前先 git bundle 备份仍然更保险。
开发
项目在 WSL 原生文件系统(~/Rakuish)—— Linux-only 项目不该放在 /mnt/c 那种
DrvFs 上:行尾、权限、文件锁语义都不对,实测慢 10-15%。
先过 rakuish-dev
WSL 默认继承 Windows 的 PATH,里面混着 /mnt/c/rakudo/bin 与
/mnt/c/rakudo/share/perl6/site;而且 WSL 裸环境下 raku 根本不在 PATH 上
(Windows 那份 MoarVM 只在 /mnt/c/rakudo)。两个后果:
- 直接
raku 可能跑到 Windows 那份 Rakudo 上,装的模块落错地方 - 忘了写绝对路径就
command not found,而 which -a raku 是空的 —— 看不出原因
所以所有命令都套一层:
~/bin/rakuish-dev <命令> # 重建干净 PATH 后执行
~/bin/rakuish-dev raku t/01-incomplete.t
它做三件事:把 RAKU_ROOT(rakubrew 2026.09 的 install/bin)放到 PATH 首位、
清空 RAKULIB/PERL5LIB/PERL6LIB、删掉全部 /mnt/c 条目。
两个 Raku 实现都用来验证
cd ~/Rakuish
bash tools/run-all.sh # 全套件 × 两实现,汇总写 tmp/all.log
任何改动都要在两个实现上各跑一遍,这是 Platform 层隔离的验收标准:
纯逻辑部分两个实现必须逐条断言一致,差异只允许留在 Platform 里。
run-all.sh 同时报退出码和 not ok 计数 —— 本项目踩过:测试崩在中途
(后面的 subtest 根本没跑)时 not ok 计数是 0 但退出码非 0,只看计数会把
"测试崩了"当"全绿"。
实现差异清单见设计文档第 0.1 节,隔离方式见「Platform 层」一节。
主循环的结构约定
「读一行」被做成可注入的 Callable(reader-from 返回一个按顺序给元素、
取完给 Nil 的闭包),主循环本体 drive(&read-line) 只依赖这个 Callable。
于是判定 / 分流 / 渲染全都能单元测试(18 组),真读终端那段由
tools/repl-smoke.raku 在 pty 里冒烟。
这个拆分不是洁癖:读一行是库的活,而"这一行该续行吗 / 该走哪档 /
Out[n] 是几"才是本项目自己的逻辑 —— 后者才是值得测的部分。
分流规则(显式前缀,不做自动判别):
| 输入 | 走向 |
|---|
1 + 1 | 快档 Raku 求值 |
%time / %help | magic 命令(Commands 承载,自带唯一前缀缩写) |
!ls -l | 慢档 shell —— 默认直通,流式与原生 ANSI 都保住 |
! --numbers cmd | 慢档 + 捕获并给行号 1 │ …(加工必然失去流式) |
没写完(如 1 +) | 续行,提示符变成 ....1> |
%repl / %STORE{...} | %repl 看/关/开/清空会话记录;%STORE{'k'} = v 是跨 cell 状态主 API(按 Raku 求值,不再误判 magic) |
会话持久化(.repl 重放)
每次启动会自动把"会改变 workspace 状态的输入"追加进 transcript 文件
(默认 ~/.rakuish/repl.raku,可用环境变量 RAKUISH_REPL 覆盖),下次启动
重放它以恢复 %STORE 等跨 cell 状态——典型教学场景:"关机后恢复工作台"。
- 记什么:普通 Raku 行、
%time、%load(都最终走 EVAL,状态会留下)。 - 不记什么:
%run(脚本重跑有副作用)、%quit/%exit/%help(无意义)、
%repl 自身(自指的元命令)。失败的 Raku cell 也不记,避免把错误行永久固化。 %repl:%repl(或 %repl status)看当前路径/条数/开关;%repl off 关记录
(本会话)、%repl on 开、%repl clear 清空文件。- 重放时打印
↻ 重放 N 条输入(%repl clear 清空 · %repl off 关闭记录)。
文件格式就是一行条 REPL 输入;多行 cell 用多行存、回放时按同样规则重新拼。
慢档为什么这样设计:两个实现上实测确认 run() 不接返回值就继承 fd1,
于是输出流式直通、\033[31m 这类原生 ANSI 原样保留 —— 这在 Linux 上是
免费的。代价是拿不到输出内容,所以要加工(行号/汇总)必须显式要求。
! 选项写前置(! --numbers cmd),与 IPython 的 magic 同一约定:
末尾的 --numbers 分不清是选项还是传给程序的参数。
交互层依赖已装齐
Prompt 0.0.11/ Terminal::LineEditor 0.0.23 / Commands 0.0.9装在 MoarVM 侧
(经 zef,源 360.zef.pm),rakupp 侧三个模块也都能 use。
装的时候踩到两件事,记下来免得重踩:
zef 不在 Raku 的 site 目录里,是 rakubrew 的本地源码仓库
(~/.rakubrew/repos/zef,v1.1.4)。用 wrapper 跑:
~/.local/bin/zef → raku -I<repo>/lib <repo>/bin/zefTerminal-API 的测试需要有真实尺寸的 pty。无 pty 时 get-config 直接抛错;
script -qec 造出的 pty 尺寸是 0×0,于是「Terminal size is sane」失败。
正确做法:script -q -c "stty rows 40 cols 120; zef install ..." /dev/null
Prompt 的一个 API 陷阱
$p.history 返回的是你传给 :history 的那个 IO::Path,不是历史条目数组:
my $p = Prompt.new('LineEditor', :history($h));
say $p.history.^name; # IO::Path
say $p.history; # /home/zby/.xxx ← 是路径,不是历史内容
于是 $p.history.elems 会返回路径字符串的长度,看起来像「历史只有 1 条」
(IO::Path 有 1 个分量)。要读历史内容得自己维护数组,别信.history。
目标平台
Linux(x86_64 / aarch64)。需要 Raku —— CI 与本地都用
rakudo-star 的原生发行版。