不跑编译器,我把仓颉 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

一开始我把带 < 的当噪声丢了,结果 ==<>= 这些运算符重载全部消失——而它们恰恰是 EquatableComparable 最该被看到的东西。

坑二: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 分。已知边界:

  1. 没有文档注释。 符号里不含任何说明文字,所以 .cjo 只能给你签名,给不了语义——这也是后面必须去做中文说明的原因。

  2. 不区分 public / private 私有成员以完全相同的格式出现,所以面板里看到的成员数(比如 ArrayList 的 60 个)包含不可访问的成员,不能当成"公开 API 数量"来引用。

  3. 部分参数标签缺失。 编码器没写进去的标签只能回退成 _:,于是有 fold(_, R, _:(R, T) => R) 这种形态。

  4. 复杂泛型会降级。 比上面 enumerate() 那个返回值 ArrayList<?, _l, T>,迭代器协议的类型实参没完全还原。再比如:

    14std.collection4sizeP9ArrayList<1>C3getF$$l
    →  prop std.collection.size: Int64
    

    这条只能还原出包名和返回类型,宿主丢了。解码器对这类情况显式标 partial降级输出——给一个不完美的签名,也比弹一句"解析失败"有用。

  5. stdx 不在 SDK 安装目录里。 上面 44 个包全是 std.*stdx.cjo 要等你的项目通过 cjpm 依赖了它、构建产物落到 build/bin 之后才会被发现。所以"std/stdx 全量"这个说法要加个前提。

顺带说一句:如果你打算引用绝对数字,请自己跑一遍——不同 SDK 版本、不同平台 triple 结果不一样

六、第二件事:把官方中文说明内联到每个成员上

签名解决"有没有这个东西、怎么调",语义还得看官方文档。cj-docs 是服务端渲染好的静态 HTML,抓下来解析就行。

听起来简单,实际有个坑:各包的分类页文件名极不统一class / classesfunction / funcs / functions,有的包还少了 _package_ 前缀。靠猜文件名拼 URL,迟早会在某个包上崩掉。

所以换了个思路——不猜,去反查

  1. 抓该包的 _package_overview.html
  2. 遍历其中所有 a[href*="_package_api/"],锚文本就是干净的类型名;
  3. 得到一张「类型名 → 真实分类页 URL」的映射;
  4. 再进分类页,按文档顺序遍历 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 = 24result = 25total = 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。特别是上面第五节列的降级情形,如果你知道更准确的还原方式,那对我帮助很大。

Logo

一站式 AI 云服务平台

更多推荐