Rand Stats

Rakuish

zef:skyter10086

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 进程:

  1. 崩溃隔离 —— 原生层是全项目唯一做裸 libc 调用的地方 (pipe/dup2/posix_spawnp/malloc…):一个野指针就是段错误 ⇒ 会带走整个 REPL。 独立进程后,引擎崩了只是这一条命令失败。
  2. 语义不互相污染 —— REPL 进程里只有 Raku 语义($ 永远是 Raku 变量); shell 的 $ 只存在于引擎那边。
  3. 准确度 —— 默认走真 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)。

  1. cell 切分不再被块内花括号破坏。q{...} / qq(...) 现在按嵌套层数配对, for 1..3 -> $i { say "v={$i}" }、%h{$i} = ... 这类不再静默终止。

  2. 跨行 else 能接上。块尾 } 属于 if / unless / with / without 时, 脚本模式会先探一行:是 else / elsif / catch 就并进同一格,不是就原样退回给下一轮 (不丢行、行号记得住)。for / while 没有 else 分支,仍然立即执行,不拖一拍。

  3. 不再静默终止。文件读完而块/引号没闭合时,报 文件:行号: 文件结束时输入还没写完 并以 2 退出(对齐 bash 的语法错误),前面的输出照常保留。

  4. 整文件一次编译执行(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 / .codestdout(不含 stderr)/ stderr / 退出码
.ok / .Bool / .Intcode == 0 / 同 / 退出码
.Strbash $(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 / .rakuREPL 摘要 / 完整可读 / 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/ 停止维护。

定案依据(实测,不是推测):

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)。两个后果:

所以所有命令都套一层:

~/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 / %helpmagic 命令(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 状态——典型教学场景:"关机后恢复工作台"。

文件格式就是一行条 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。

装的时候踩到两件事,记下来免得重踩:

  1. zef 不在 Raku 的 site 目录里,是 rakubrew 的本地源码仓库 (~/.rakubrew/repos/zef,v1.1.4)。用 wrapper 跑: ~/.local/bin/zef → raku -I<repo>/lib <repo>/bin/zef
  2. Terminal-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 的原生发行版。