作者: 一了 <1liao3@funlang.org>
日期: 2026-10-23
一句话总结: Fun 9.4.5 和 9.4.6 补上了包管理、项目启动器、测试运行器和单文件打包,六个 `.fun` 文件两千六百多行。这篇说说写成 Fun 代码的清单、只读的 `fun run`、按文件名找测试的 `fun test`,以及 `fun pack` 那条 FUNBUNDL 尾巴,还有几个跨平台的坑。
Fun 的工具链,自己写自己
Fun 以前只有一条命令,fun 脚本.fun。跑得起来就算完,工程上的事自己想办法。
这两天补了三样:fun package、fun test、fun pack。加上藏在后面的 fun run,四个命令,六个 .fun 文件,一共 2628 行,含注释。版本号从 9.4.5 走到 9.4.6,日子是 10 月 20 号到 22 号,前后三天。(吐槽:语言自己的工具,总算不用别的语言代劳了。)
包管理这名字听着大,其实就三件事:清单写什么,依赖放哪里,跑的时候怎么找。测试和打包也一样,规矩越少越好。
一、清单是一段 Fun 代码
fun package init myapp 会在目录里写一个 package.fun,外加 src/main.fun。加 --lib 就只写 src/lib.fun。目录不是空的,就只写清单,不动你已有的文件。
清单不是 JSON,也不是 YAML,是一段能跑的 Fun:
fun package()
result = [
name: 'myapp',
version: '0.1.0',
bin: 'src/main.fun',
lib: '',
'fun': '9.4',
dependencies: [
'hello': [source: 'github.com/acme/hello', version: 'v1.0.0'],
'local': [source: '../libs/local']
],
// >>> generated - do not edit; run 'fun package tidy' to refresh
resolved: []
// <<< generated
];
end fun;
package() 返回一个集合,就这么简单。读清单也是编译一遍再调 package(),语法错了当场报,不会带着半截清单往下跑。最底下那一段 resolved 是生成物,tidy 只改两条注释之间的字节。fun package add 和 remove 也只动 dependencies 这一段,旁边的注释、空行、单引号双引号,原样留着。改清单最怕工具自作聪明重排一遍,这里干脆不排。
没有单独的 lockfile,resolved 就是 lockfile,跟清单住在一起,少一个文件要同步。
名字有规矩,[a-z][a-z0-9_-]*,大写和点都不行。版本只收精确的 tag、分支或 commit,^1.2 这种不认。路径依赖就地使用,只有 git 依赖才要 git。
fun package tidy 把声明的版本装进 ~/.fun 缓存,FUN_HOME 可以改地方。下载完记下 commit,再算一遍 git tree hash,写进 resolved。下次 tidy 先比 hash,对不上就报缓存坏了,让你删了重来。版本冲突也在这一步报,两个依赖要同一个名字的不同版本,直接停下,不猜。写清单也不是一把梭,写完重新编译一遍,确认 resolved 读得回来,不对劲就把备份写回去。
有个细节我挺喜欢:缓存里的 tag 和 commit 不联网也能用,分支不行。分支是会动的,今天拉了明天就不知道是哪一版。这话写在了代码注释里,也写进了行为里。路径依赖还有个坑,resolved 里记的是当前 checkout 的绝对路径,那段是本地状态,别提交。
二、run 只读,tidy 才动手
fun run 跑清单里的 bin。在项目目录里,普通的 fun 某个脚本.fun 也走同一套,脚本不用改 import 路径就能拿到依赖。
这里定了一条线:运行时只读。依赖缺了、旧了,只提示一句 run 'fun package tidy',不联网,不改清单,不偷偷装东西。构建工具最让人恼火的,就是你以为它在跑测试,其实它在下载世界。Fun 这边把下载和运行分开,tidy 是唯一会动手的命令。
依赖齐了,启动器在 ~/.fun/tmp/run-<pid> 下写几个临时 shim,把目录塞到 FUN_PATH 前面,再拉起子进程。子进程带 FUN_PACKAGE_READY=1,不会再进项目模式。参数位置也保住了,1.arg() 还是脚本自己,2.arg() 往后才是用户参数。子进程跑完,shim 目录删掉,不往项目里塞东西。清单写坏了也有逃生口,FUN_PACKAGE_READY=1 fun 脚本.fun 可以绕过整套项目逻辑。
清单里还能写 'fun': '9.4',启动器按点分段比版本号。要 9.4,手里是 9.3.9,就老老实实报错。这个比较函数二十来行,没有引入 semver 库。
三、测试按文件名找
fun test 的规矩更简单:项目根下所有 *_test.fun 都是测试,放在哪个目录都行,文件名对了就算。别的 .fun 是普通模块,测试之间可以共享 helper。
use 'test.fun';
use '../src/lib.fun';
test('adds', {
assert.equal(add(1, 2), 3);
});
test('rejects division by zero', {
assert.raises({ safe_div(1, 0); }, 'division by zero');
});
断言有十二个:equal、not_equal、is_true、is_false、match、contains、greater、less、is_nil、not_nil、raises、fail,每个都能在最后带一句自己的话。test.fun 本身只有 153 行,没有宏,没有 describe 和 it 的嵌套,注册就是往一个集合里塞一对名字和代码块。
每个测试文件跑在自己的子解释器里。启动器在临时目录写一个 driver,内容就三行:导入 test.fun,导入测试文件,调用 runTests()。一个文件崩了,不连累别的文件;一个文件里的全局变量,也污染不到下一个。
输出是给人看的:
tests/calc_test.fun PASS (4)
1 file, 4 tests: all passed
失败时多几行:
not ok broken
math is broken
expected 3, got 2
1 file, 5 tests: 4 passed, 1 failed
进程退出码是 1,CI 直接能接。项目只要有 package.fun,不需要 bin,一个纯库也能测。dist/、点目录和路径依赖目录不参与发现,省得把缓存里的别人家测试也跑一遍。
test、assert、runTests 这三个名字被占了,as test、as assert 和依赖键 test、assert 也占了。第三方测试库想进来,换个别名。这话写在文档里,也写在启动器的检查里,撞上了会直接告诉你改名。
四、pack 是运行时加一条尾巴
fun pack 打一个单文件可执行程序。做法不神秘:拿 funcmd 的运行时,后面接一个 zip 包,再补 16 字节的尾巴,八个字节 FUNBUNDL,八个字节记录包的长度。程序启动先看自己的尾巴,有包就解到临时目录,chdir 进去,再跑入口脚本。
.funpack/entry 记入口路径,依赖放在 .funpack/packages/<name>/,根目录再放一层 shim。fun pack 认清单里的 bin,路径按项目根算,use 扫一遍源码,把传递依赖都收进来。use ':file' 那种编译期嵌入的模板、表、语法文件,也会自动带上。
资源要显式加:
fun pack -o dist/app -a public -a assets/logo.png
-a 的路径必须在项目根里,打包后保持根相对路径。开发时写 public/...,打包后还是 public/...,代码不用改。
拿仓库里的 package-demo 试了一遍,四个文件,663 字节的载荷,套在七百多 KB 的运行时后面。单文件省掉的是依赖目录,不是解释器本身。
运行时的工作目录就是解包后的根,相对读和开发时一样。相对写会落在临时目录,进程退出一起删掉。这点得说清楚,别把用户上传的文件写进包里,以为存住了。(吐槽:单文件程序的好处是干净,坏处是它真的什么都不给你留。)
Windows 上输出名少了 .exe 会自动补,funcmd 旁边的运行库 DLL 也会抄过去,pcre3.dll 是静态导入,getapi 还要 ffi.dll。所以 Windows 的产物是 exe 加几个 DLL,不算严格单文件,这个坑先记着。
v0 的限制也摆明了:zip 只 STORE 不压缩,CRC 字段写 0,自带的运行时不解 CRC,外面的解压工具会嘀咕两句;use 只认字符串字面量,拼出来的、条件里的路径扫不到;.git、.DS_Store、Thumbs.db 不打包。已经带尾巴的文件会跳过,同一个目录反复打包,不会把上一次的产物套娃套进去。
五、工具自己也得守规矩
这些工具没有编译进 funcmd,除了一个分发用的表。utils.pas 里写着 ('pack', 'package', 'test'),命令进来,按名字找 lib/tools/fun-<name>.fun,找不到就去开发构建的三层上级目录找。run 单独一行判断,因为它要把子命令留在 2.arg(),参数位置和直接跑脚本一样。改工具不用重编解释器,存盘就生效,写的时候这一点很舒服。
剩下的全在 .fun 里。清单扫描器是共用的,run、pack、test、package 都读同一份 fun-package-model.fun。它得跳过注释、正则、字符串,还得知道 .# 是运算符不是注释开头。这些规则跟语言本身的词法绑在一起,换个语言写,早晚对不上。
写的时候踩的坑也都留着痕迹。Windows 的 cmd /c 会吃掉一层引号,得多包一层;% 会被 cmd 展开,git 命令得躲开;Fun 里 '\\' 是两个字符,路径归一化要按 92 来;macOS x86_64 的 readdir 是老的 32 位 inode 布局,arm64 只有 64 位版本,取 API 时得先试 $INODE64。(吐槽:跨平台这事,文档里一句话,代码里三个分支。)
打包器里还留了一句注释:函数体里的集合字面量是共享实例,每次调用都在改同一个,所以不能写 [src: ..],只能 new [] 再逐个字段赋值。这种坑不写在代码里,下一个人还得再踩一遍。
测试也是自己测自己。fun/test/lib-fun-test-test.fun 四百多行,造临时项目,调 fun test,比对输出和退出码。包管理那边同样,lib-fun-package-test.fun 三百九十多行,tidy、run、pack 各跑一遍,连 CRLF 快照都存了一份。工具链的可信度,最后还得靠工具链自己验。
结语
三天时间,Fun 从一个光杆解释器,长出了包管理、测试运行器和单文件打包。东西都不大,六个文件两千六百多行,没有守护进程,没有插件系统,也没有配置文件格式之争。清单就是 Fun,测试就是 Fun,打包器也是 Fun。
当然,离成熟还远。依赖只有 git 和路径两种,没有 registry;打包不压缩,也不签校验;fun test 只跑当前项目,没有过滤、没有并行、没有覆盖率。这些等有人真用起来再说。
眼下这几样,够我把一个小工具从头写到尾,测完,打成单个文件,扔到别人的机器上跑。对我来说,这就叫工具链了。