不跑编译器,我把仓颉 std 的 13,906 条 API 签名从 `.cjo` 二进制里挖了出来
不跑编译器,我把仓颉 std 的 13,906 条 API 签名从 .cjo 二进制里挖了出来
本文介绍 CIDE(一款社区版仓颉桌面 IDE)里「离线 API 参考面板」的实现过程:如何在不启动编译器、不联网、没有源码的前提下,仅靠 SDK 分发的
.cjo文件还原出可用的 API 签名。
文中所有数字都能按文末的思路自行复现。
一、起因:文档的组织单位,和思考的单位不一样
学仓颉那段时间,我桌面上常驻两个窗口:写代码的编辑器,和浏览器里的官方 API 文档站(cj-docs)。
真正难受的不是"要翻文档",而是文档的组织单位和我思考的单位不一样。文档按包分页,一页里是"函数 / 接口 / 类 / 异常类"四张表;而我脑子里的问题往往是反过来的:
ArrayList到底有多少个方法?各自的参数长什么样?fold的完整签名是什么,初始值和累加函数谁在前?- 哪些函数是
Iterator<T>的 extension,哪些是ArrayList自己的?
在文档页里回答这三个问题的动作是:Ctrl+F → 跳到某张表 → 看清上下文是 ArrayList<T> 还是 ArrayDeque<T> → 再 Ctrl+F 下一个。
而这些问题在编辑器里本来就该是一键的事。
二、关键判断:.cjo 里躺着现成的符号
仓颉 SDK 分发的是编译产物,std 只有 .cjo,没有 .cj 源码。所以任何"用正则扫源码"的方案都行不通。
但 .cjo 是 ELF-ish 的容器格式(内部魔数 CJOF),里面内嵌了 Cangjie 风格的 mangled 符号串,而这些串把包名、宿主类型、成员名、参数类型、参数标签、返回类型全编码进去了。
也就是说:不需要执行它,不需要符号表,甚至不需要理解 CJOF 的 section 结构——只要把文件里的可打印 ASCII 串扫出来,再按文法解码,签名就出来了。
先看一条真符号。std.collection.cjo 里:
14std.collection9ArrayList<1>C4sizeP
它的文法是「长度前缀 + 内容」的串联:
| 片段 | 含义 |
|---|---|
14 + std.collection |
包名,14 个字符 |
9 + ArrayList |
宿主类型名,9 个字符 |
<1> |
泛型 arity(只有元数,没有实参),渲染成 ArrayList<T> |
C |
宿主种类:C=class、S=struct、I=interface、G=enum |
4 + size |
成员名 |
P |
成员种类:F=func、P=prop、V/K=field |
解码结果就是:
prop ArrayList.size
整份 std.collection.cjo(1,117 KB)里能扫出 716 条这样的符号。
三、实现:三步,外加一堆边界
3.1 扫 ASCII 串
最直接的做法,遍历字节缓冲收集连续可打印字符,过短的直接扔(噪声太多):
function extractAsciiStrings(buffer, minLength = 6) {
/* ... */
}
然后一条正则把无关内容全滤掉——只留以「长度前缀 + std./stdx.」开头的:
if (!/^\d+(std|stdx)\./.test(symbol)) continue
3.2 解码
用一个游标类顺序读,readIdentifier() 负责"读数字 → 按长度切字符串"。这里有两个坑值得记:
坑一:长度前缀会吃进定界符。 如果算出来的子串里含 $,说明前面读偏了,必须回退。尖括号 <> 也一样——但不能一刀切,因为运算符重载的符号里 <、<=、>= 是合法成员名:
// 长度前缀吃进了定界符 `$` 就不是合法名字;尖括号只允许出现在纯运算符名里
if (!value || /\$/.test(value)) return null
if (/[<>]/.test(value) && !/^[<>=!+\-*/%&|^~]+$/.test(value)) return null
一开始我把带 < 的当噪声丢了,结果 ==、<、>= 这些运算符重载全部消失——而它们恰恰是 Equatable、Comparable 最该被看到的东西。
坑二:extension 的约束段不是括号配对。 形如:
<pkg>7Extend!<Type>[<args>]<:约束(W约束)*X<member>...
约束段以 X 收尾而不是 > 配对,子句之间用 W 分隔,子句还能带 T: 前缀。被扩展的类型既可能是具名类型,也可能是单字符内置类型码。
3.3 内置类型码:反查出来的,不是查表查来的
这是整件事里最有意思的部分。返回类型 l 是什么?j 呢?Dh 呢?
我没有去猜,而是用已知答案的函数反推。SDK 里总有些函数名字把类型写明了:
Decimal.toUInt8 / toUInt16 / toUInt32 / toUInt64 -> h / t / j / m
Random.nextUInt32 -> j, nextUInt64 -> m, nextBool -> b
ast.Table.getUInt16(j): t
Decimal.toFloat32 / toFloat64 -> f / d
*.toFloat16 / *.nextFloat16 -> Dh
toIntNative -> q, toUIntNative -> r
fail / exit / throwException -> n (Nothing)
一条一条钉死之后,才敢写进映射表:
const PRIM_TYPES = {
u: 'Unit',
b: 'Bool',
c: 'Char',
n: 'Nothing',
v: 'Any',
a: 'Int8',
s: 'Int16',
i: 'Int32',
l: 'Int64',
h: 'UInt8',
t: 'UInt16',
j: 'UInt32',
m: 'UInt64',
q: 'NativeInt',
r: 'UNativeInt',
Dh: 'Float16',
f: 'Float32',
d: 'Float64',
}
规则是:没有反查证据的类型码一律不收录,让它以 ?<code> 的形式暴露在界面上。宁可显示"我不认识",也不要显示一个看起来对但可能错的类型——这类静默错误在工具里是最伤信任的。
3.4 去重与降噪
- 重载:按渲染后的签名字符串去重,同一签名只留一条。
- 噪声:只在别人的 extension 约束里露过面、自己一个成员都没解出来的类型,是别的包的类型串味进来了,既无签名也无成员可看,直接丢弃。
四、实测数据(可复现)
在 Windows + 仓颉 SDK 上跑一遍全量扫描:
| 指标 | 数值 |
|---|---|
.cjo 文件数 |
44 |
| 总体积 | 13,097,608 B(12.5 MiB) |
| 类型 | 1,253 |
| 成员 | 10,163 |
| 包级自由函数 | 2,490 |
| 签名条目合计 | 13,906 |
按规模排前 10 的包:
std.ast types= 239 members= 3515 funcs= 420
std.unittest types= 245 members= 919 funcs= 274
std.core types= 107 members= 806 funcs= 374
std.net types= 52 members= 632 funcs= 207
std.overflow types= 22 members= 562 funcs= 57
std.collection types= 45 members= 517 funcs= 58
std.reflect types= 30 members= 400 funcs= 171
std.sync types= 43 members= 348 funcs= 41
std.unittest.mock types= 70 members= 272 funcs= 33
std.database.sql types= 57 members= 257 funcs= 26
std.collection 解出 ArrayList<T> 的 60 个成员,前 8 条长这样:
func ArrayList<T>.enumerate(): ArrayList<?, _l, T>
func ArrayList<T>.zip<R>(_: ArrayList<R>): ArrayList<?, _1, ?, R>
func ArrayList.updateVersion(): Unit
prop ArrayList.size
func ArrayList.reduce(_: (T, T) => T): Option<T>
func ArrayList.fold<R>(_: R, _: (R, T) => R): R
func ArrayList.none(_: (T) => Bool): Bool
func ArrayList.any(_: (T) => Bool): Bool
有了这个,开头那三个问题在编辑器里一次翻页就答完了。
五、诚实地说:哪些做不到,哪些现在还做不到
写工具最怕把 80 分说成 100 分。已知边界:
-
没有文档注释。 符号里不含任何说明文字,所以
.cjo只能给你签名,给不了语义——这也是后面必须去做中文说明的原因。 -
不区分
public/private。 私有成员以完全相同的格式出现,所以面板里看到的成员数(比如ArrayList的 60 个)包含不可访问的成员,不能当成"公开 API 数量"来引用。 -
部分参数标签缺失。 编码器没写进去的标签只能回退成
_:,于是有fold(_, R, _:(R, T) => R)这种形态。 -
复杂泛型会降级。 比上面
enumerate()那个返回值ArrayList<?, _l, T>,迭代器协议的类型实参没完全还原。再比如:14std.collection4sizeP9ArrayList<1>C3getF$$l → prop std.collection.size: Int64这条只能还原出包名和返回类型,宿主丢了。解码器对这类情况显式标
partial并降级输出——给一个不完美的签名,也比弹一句"解析失败"有用。 -
stdx不在 SDK 安装目录里。 上面 44 个包全是std.*。stdx的.cjo要等你的项目通过 cjpm 依赖了它、构建产物落到build/bin之后才会被发现。所以"std/stdx 全量"这个说法要加个前提。
顺带说一句:如果你打算引用绝对数字,请自己跑一遍——不同 SDK 版本、不同平台 triple 结果不一样。
六、第二件事:把官方中文说明内联到每个成员上
签名解决"有没有这个东西、怎么调",语义还得看官方文档。cj-docs 是服务端渲染好的静态 HTML,抓下来解析就行。
听起来简单,实际有个坑:各包的分类页文件名极不统一。class / classes、function / funcs / functions,有的包还少了 _package_ 前缀。靠猜文件名拼 URL,迟早会在某个包上崩掉。
所以换了个思路——不猜,去反查:
- 抓该包的
_package_overview.html; - 遍历其中所有
a[href*="_package_api/"],锚文本就是干净的类型名; - 得到一张「类型名 → 真实分类页 URL」的映射;
- 再进分类页,按文档顺序遍历
h2(类型)/h3(成员)/p,取每个成员标题后的第一个「功能:」段。
h3 的匹配形如 func|prop|oper|init|var|val <成员名>,于是能定位到 ArrayList.size 这一条,抓出:
返回此 ArrayList 中的元素个数。
面板里就长这样(成员说明直接落在签名上方,并标注来源版本):
成员说明 · 来自官方文档 1.1.3
返回此 ArrayList 中的元素个数。
prop ArrayList.size
配套还有:按包惰性抓取、结果缓存、断网时降级成"查看官方中文文档 ↗"深链。抓取必须走主进程——文档站响应头没有 CORS,渲染进程直连会被拦。
七、顺手一提 Electron 侧的安全设计
一个容易忽略的点:如果开一个 IPC 通道"给你路径我读文件",渲染进程一旦被注入就变成了任意文件读取。
这里的做法是两段式授权:cangjie-cjo-catalog 只返回包清单(不读内容),cangjie-cjo-api 只接受上一次清单里出现过的路径。渲染进程无法借这个通道读任意文件。
function createCatalogGuard(packages) {
const allowed = new Set(packages.map((pkg) => path.resolve(pkg.file)))
return {
packages,
isAllowed(filePath) {
/* ... */
},
find(filePath) {
/* ... */
},
}
}
八、调试:直接用 lldb-vscode
这块没什么发明——断点、变量、调用栈走的是标准 DAP 协议对接 lldb-vscode。
有意思的是验证方式。给程序写一段能自查的循环,断点停下时看变量对不对:
var total: Int64 = 0
for (i in 1..=8) {
total += i * i
}
println("increment result = ${result}") // ← 断点
停在断点时变量面板给出 stable = 24、result = 25、total = 204。204 正好是 1²+2²+…+8²,说明这不是 mock 出来的假数据;旁边的线程列表则是 lldb 报告的进程内全部线程(仓颉运行时的调度器 / GC 线程),断点停下时它们全部可见。
工具类项目里,"能被一眼验证"本身就是可信度。
九、关于官方 IDE
仓颉社区 README 里写着「仓颉专属 IDE 也正在开发和内测中,敬请期待」。
所以 CIDE 的定位很清楚:官方 IDE 成熟之前的补位,加上一些官方短期不会覆盖的体验缝隙——学习笔记与 KaTeX 公式一体化、测试运行器与 LCOV 行级覆盖率可视化、九项代码生成矩阵、把「你正在看的 API 签名 + 官方中文说明 + 实时 LSP 诊断」注入 prompt 的 AI 上下文。
它不打算替代任何东西。
⚠️ CIDE 是社区版 / 非官方项目,由个人开发者维护,与华为、仓颉编程语言官方团队无任何隶属或背书关系。“仓颉 / Cangjie” 为各自权利人的商标。
十、项目地址与验证方式
- 仓库:https://gitcode.com/wp_upala/cide
- 技术栈:Electron 30 + Monaco Editor 0.45(与 VS Code 同源)+
node-pty+lldb-vscode - 许可:见仓库
LICENSE - Windows 安装包在仓库「发行版」页,未做代码签名(社区版没有证书),首次启动遇 SmartScreen 拦截属正常现象
复现本文数字的最小脚本思路(三段):
const { discoverCjoPackages } = require('./js/core/cjo-catalog-core')
const { indexCjoBuffer } = require('./js/core/cjo-signature')
const packages = discoverCjoPackages({
sdkRoot: process.env.CANGJIE_HOME,
extraRoots: [],
})
for (const pkg of packages) {
const { types, freeFuncs } = indexCjoBuffer(
require('fs').readFileSync(pkg.file),
)
// 累加 types.length / members / freeFuncs.length
}
核心解码器在 js/core/cjo-signature.js,是纯函数——不依赖 DOM 也不依赖 Electron,主进程、渲染进程、Node 测试脚本都能直接 require。这也是本文所有数字能被三行脚本复现的原因。
如果你也在学仓颉,或者对 Cangjie mangled name 的文法有兴趣,欢迎来仓库提 Issue。特别是上面第五节列的降级情形,如果你知道更准确的还原方式,那对我帮助很大。
更多推荐



所有评论(0)