diff --git a/packages/proton/README.md b/packages/proton/README.md index d4d7243e..2aa414de 100644 --- a/packages/proton/README.md +++ b/packages/proton/README.md @@ -1,19 +1,13 @@ # @snowluma/proton -Compile-time protobuf encoder/decoder generator for SnowLuma. TypeScript -interfaces in, fully-inlined zero-runtime codec functions out. +用于 SnowLuma 的编译时 protobuf 编码器/解码器生成器。输入 TypeScript 接口,输出完全内联、零运行时的编解码函数。 -> Vendored from [SnowLuma/protobuf-dsl](https://github.com/SnowLuma/protobuf-dsl) -> with edge-case fixes applied. The unmodified upstream is kept under -> `dev/protobuf-dsl/` as a reference copy. +> 该项目衍生自(Vendored自)[SnowLuma/protobuf-dsl](https://github.com/SnowLuma/protobuf-dsl) +> 并修复了若干边缘情况。未做修改的上游源码保留在 `dev/protobuf-dsl/` 下作为参考副本。 -## What it does +## 功能特性 -You declare your wire schema as plain TypeScript interfaces marked up with -`pb`. At build time a Vite plugin walks the call sites of -`protobuf_encode` / `protobuf_decode` and replaces them with -type-specific, fully-inlined codec functions. No runtime schema lookup, -no reflection, no `.proto` files, no `protoc`. +你只需使用 `pb` 标记的普通 TypeScript 接口来声明你的传输 Schema。在构建时,一个 Vite 插件会扫描 `protobuf_encode` / `protobuf_decode` 的调用位置,并将它们替换为特定于类型的、完全内联的编解码函数。无需运行时 Schema 查找,无需反射,无需 `.proto` 文件,也无需 `protoc`。 ```ts import type { pb, pb_repeated, uint_32, bool } from '@snowluma/proton'; @@ -30,71 +24,71 @@ const bytes = protobuf_encode({ id: 42, username: 'alice', active: true, tags: ['admin'], }); const user = protobuf_decode(bytes); + ``` -After the plugin transform, this becomes: +经过插件转换后,上述代码会变成: ```js -// pre-computed tag literals, inlined varint loops, no function-call overhead +// 预计算的 Tag 字面量、内联的 varint 循环、无函数调用开销 const bytes = protobuf_encode_UserProfile({ id: 42, ... }); const user = protobuf_decode_UserProfile(bytes); + ``` -If the plugin **isn't** installed, calling `protobuf_encode` / `protobuf_decode` -throws a loud error rather than silently producing wrong bytes. +如果**未安装**该插件,调用 `protobuf_encode` / `protobuf_decode` 将直接抛出明显的错误,而不是静默生成错误的字节数据。 -## Setup in SnowLuma +## 在 SnowLuma 中配置 -The package is already wired into `@snowluma/core`'s `vitest.config.ts`. To use -it elsewhere in the monorepo: +该包已经内置到 `@snowluma/core` 的 `vitest.config.ts` 中。若要在 Monorepo 的其他地方使用: ```ts -// vite.config.ts (or vitest.config.ts) +// vite.config.ts (或 vitest.config.ts) import { defineConfig } from 'vite'; import protobufVitePlugin from '@snowluma/proton/vite'; export default defineConfig({ plugins: [protobufVitePlugin()], }); + ``` -Add the workspace dep: +添加工作区依赖: ```json { "dependencies": { "@snowluma/proton": "workspace:*" } } + ``` -## Primitive types +## 原生类型 -The marker is `pb` (or `pb_repeated` for -arrays). Available primitives: +标记格式为 `pb`(数组则使用 `pb_repeated`)。可用的原生类型包括: -| Marker | TS type | Wire | -| ----------- | ------------ | ---------------- | -| `bool` | `boolean` | varint | -| `uint_32` | `number` | varint | -| `int_32` | `number` | varint | -| `sint_32` | `number` | varint (zigzag) | -| `uint_64` | `bigint` | varint | -| `int_64` | `bigint` | varint | -| `sint_64` | `bigint` | varint (zigzag) | -| `fixed_32` | `number` | 32-bit | -| `fixed_64` | `bigint` | 64-bit | -| `sfixed_32` | `number` | 32-bit | -| `sfixed_64` | `bigint` | 64-bit | -| `float` | `number` | 32-bit | -| `double` | `number` | 64-bit | -| `string` | `string` | length-delimited | -| `bytes` | `Uint8Array` | length-delimited | +| 标记 | TS 类型 | 传输格式(Wire) | +| --- | --- | --- | +| `bool` | `boolean` | varint | +| `uint_32` | `number` | varint | +| `int_32` | `number` | varint | +| `sint_32` | `number` | varint (zigzag) | +| `uint_64` | `bigint` | varint | +| `int_64` | `bigint` | varint | +| `sint_64` | `bigint` | varint (zigzag) | +| `fixed_32` | `number` | 32-bit | +| `fixed_64` | `bigint` | 64-bit | +| `sfixed_32` | `number` | 32-bit | +| `sfixed_64` | `bigint` | 64-bit | +| `float` | `number` | 32-bit | +| `double` | `number` | 64-bit | +| `string` | `string` | length-delimited | +| `bytes` | `Uint8Array` | length-delimited | -Plus message types (any other interface marked with `pb<>` fields) used as -the second type-arg to `pb<>` / `pb_repeated<>`. +此外,还支持消息类型(任何其他带有 `pb<>` 标记字段的接口),作为 `pb<>` / `pb_repeated<>` 的第二个类型参数。 -## Generic templates +## 泛型模板 -You can declare reusable wrappers as generic interfaces: +你可以将可复用的包装器声明为泛型接口: ```ts interface Wrapper { @@ -102,35 +96,36 @@ interface Wrapper { } const bytes = protobuf_encode>({ value: 42 }); -// ^ instantiated at the call site +// ^ 在调用处实例化 + ``` -Nested instantiations work too — `Wrapper>` mono-morphizes to -`Wrapper__string` + `Wrapper__Wrapper__string` and both end up in the registry -at build time. +嵌套实例化同样有效 —— `Wrapper>` 会被单态化(mono-morphizes)为 `Wrapper__string` + `Wrapper__Wrapper__string`,并且在构建时两者都会进入注册表。 -Compose them in concrete interfaces: +你可以在具体接口中组合它们: ```ts interface Container { wrapped?: pb<5, Wrapper>; } + ``` -…or in other generic templates: +……或者在其他泛型模板中组合: ```ts interface Outer { wrapped?: pb<5, Wrapper>; } const bytes = protobuf_encode>({ wrapped: { value: 42 } }); + ``` -Both forms are covered by regression tests. +这两种形式均已通过回归测试。 -## Cross-file & wrapper-binding +## 跨文件与包装器绑定 -Schemas can live in one file and call sites in another: +Schema 和调用位置可以位于不同的文件中: ```ts // schema/user.ts @@ -140,10 +135,10 @@ export interface UserProfile { id?: pb<1, uint_32>; } import type { UserProfile } from './schema/user'; import { protobuf_encode } from '@snowluma/proton'; const bytes = protobuf_encode({ id: 42 }); + ``` -You can also build thin generic wrappers around `protobuf_encode` / `_decode` -and the plugin will trace through them, including across file boundaries: +你也可以围绕 `protobuf_encode` / `_decode` 构建轻量级的泛型包装函数,插件会对其进行追踪,即使跨越文件边界也适用: ```ts // schema/wrap.ts @@ -154,17 +149,15 @@ export function encodeWrapped(v: Wrapper): Uint8Array { // usage.ts import { encodeWrapped } from './schema/wrap'; const bytes = encodeWrapped({ value: 'hi' }); -// ^ plugin rewrites to protobuf_encode_Wrapper__string(...) +// ^ 插件会将其重写为 protobuf_encode_Wrapper__string(...) + ``` -Chained forwarders also work (`A` forwards to `B` which is imported from -another module, etc.). +链式转发同样有效(例如:`A` 转发给 `B`,而 `B` 是从另一个模块导入的,依此类推)。 -## Static method wrappers (per-subclass overrides) +## 静态方法包装器(每个子类的重写) -Sometimes you want an abstract base class to hold the encode / decode -pipeline once, with each subclass supplying only the request-/response- -shaped helpers: +有时,你可能希望抽象基类只保留一次编码/解码管道,而让每个子类仅提供对应请求/响应结构的辅助方法: ```ts import { protobuf_encode, protobuf_decode } from '@snowluma/proton'; @@ -192,75 +185,47 @@ class DemoTransformer extends PacketTransformer { static deserialize(_ctx: unknown, b: DemoResPb): DemoRes { /* … */ } } -// Just call the inherited methods naturally: +// 自然地调用继承的方法: const bytes = DemoTransformer.encode(undefined, { /* … */ }); const result = DemoTransformer.decode(undefined, bytes); + ``` -`protobuf_encode` / `protobuf_decode` inside the abstract body can't -be replaced statically — `R` and `B` are erased at runtime. Proton handles -this by: +抽象体内部的 `protobuf_encode` / `protobuf_decode` 无法进行静态替换 —— 因为 `R` 和 `B` 在运行时会被擦除。Proton 通过以下方式处理此问题: -1. Detecting any class whose static method (a) takes type parameters, (b) - calls `protobuf_encode` / `protobuf_decode` where `R` is one of - those parameters, and (c) constrains `this: T extends { … }` with the - parameter appearing inside the constraint. -2. Using TypeScript's TypeChecker — built lazily from your project's - `tsconfig.json` — to walk the `extends` chain for every subclass in the - file (cross-file edges and path aliases included) and resolve the - wrapper's type parameter against the subclass's sibling method - signature (`serialize`'s return type for `R`, `deserialize`'s body - parameter for `B`, in this example). -3. Appending a per-subclass override after the class declaration: +1. **检测**满足以下条件的类:其静态方法 (a) 接收类型参数,(b) 调用 `protobuf_encode` / `protobuf_decode` 且其中 `R` 是这些参数之一,以及 (c) 使用 `this: T extends { … }` 进行约束,且该参数出现在约束内部。 +2. **利用 TypeScript 的 TypeChecker** —— 基于项目的 `tsconfig.json` 延迟构建 —— 遍历文件中每个子类的 `extends` 链(包含跨文件边和路径别名),并将包装器的类型参数与子类的同级方法签名进行解析(在此示例中,`R` 对应 `serialize` 的返回值类型,`B` 对应 `deserialize` 的主体参数类型)。 +3. 在类声明后**追加**一个针对每个子类的重写(Override): +```js +DemoTransformer.encode = function (ctx, params) { + return protobuf_encode_DemoReqPb(this.serialize(ctx, params)); +}; - ```js - DemoTransformer.encode = function (ctx, params) { - return protobuf_encode_DemoReqPb(this.serialize(ctx, params)); - }; - ``` +``` -The original call sites stay unmodified — runtime dispatch finds the -override naturally because static properties live on the constructor -function. The abstract base body is left intact too; nothing ever calls -it. -Naming is structural, not configured: -- The wrapper method can be named anything (`encode`, `enc`, `request`, …) - — proton only looks at the body and constraint shape. -- The sibling method's name is read out of the `this: T extends { … }` - constraint, so `serialize`/`deserialize`, `build`/`parse`, or any other - pair works. -- Multi-level inheritance (`Leaf → MidBase → AbstractBase`) is followed - end-to-end by the TypeChecker. +原始的调用位置保持不变 —— 运行时调度会自然地找到重写方法,因为静态属性存在于构造函数上。抽象基类的主体也会保持原样,且永远不会被调用。 -If the sibling method lacks an explicit return / parameter annotation the -checker's inferred type is used as a fallback. The override is only -emitted when every codec call in the wrapper body resolves successfully — -partial resolution would produce dead identifier references at runtime, -so the all-or-nothing rule is intentional. +这种命名方式是结构化的,而非配置出来的: -## proto3 default-value semantics +* 包装器方法可以命名为任何名称(`encode`、`enc`、`request` 等)—— Proton 只关注主体和约束的形状。 +* 同级方法的名称是从 `this: T extends { … }` 约束中读取的,因此 `serialize`/`deserialize`、`build`/`parse` 或任何其他组合都可以正常工作。 +* 多层继承(`Leaf → MidBase → AbstractBase`)会被 TypeChecker 全程追踪。 -**Important behavioural detail.** Proton follows the proto3 wire format spec: -fields whose value equals the type's default (`0` / `false` / `""` / -empty bytes) are **not emitted** on the wire. This is byte-different from -SnowLuma's legacy `protoEncode` runtime, which always emits every field. +如果同级方法缺少显式的返回值/参数类型注解,将使用检查器推导出的类型作为兜底。只有当包装器主体中的每个编解码器调用都成功解析时,才会输出重写代码 —— 部分解析会在运行时产生死标识符引用,因此“全有或全无(all-or-nothing)”的规则是故意为之的。 -Both forms are wire-compatible with any conforming proto3 reader (missing -fields decode as the default value). When migrating legacy schemas to proton, -do **not** treat byte-level inequality on default values as a regression — see -[`packages/core/tests/proton-parity.test.ts`](../../packages/core/tests/proton-parity.test.ts) -for the test that pins this divergence. +## proto3 默认值语义 -If you have a wire consumer that depends on byte-level identity (e.g. for -hashing or signing), prefer to set non-default values explicitly rather than -relying on the encoder. +**重要的行为细节。** Proton 遵循 proto3 传输格式规范:如果字段的值等于该类型的默认值(`0` / `false` / `""` / 空字节),则**不会**在网络传输中输出。这在字节层面上与 SnowLuma 旧版始终输出每个字段的 `protoEncode` 运行时不同。 -## Diagnostics +这两种形式与任何符合规范的 proto3 读取器都是网络兼容的(缺失的字段会解码为默认值)。在将旧版 Schema 迁移到 Proton 时,**请勿**将默认值上的字节级不一致视为回归故障 —— 参见 [`packages/core/tests/proton-parity.test.ts`](https://www.google.com/search?q=../../packages/core/tests/proton-parity.test.ts) 中固定此差异的测试。 -Anything that doesn't resolve to a primitive or a registered message **throws -at build time** rather than silently leaking a wrong wire type: +如果你的网络数据消费者依赖于字节级的完全一致(例如用于哈希或签名),建议显式设置非默认值,而不是依赖编码器。 + +## 诊断机制 + +任何无法解析为原生类型或已注册消息的内容都会**在构建时抛出错误**,而不是静默泄露错误的传输类型: ``` Cannot resolve protobuf field type "Partial__UserProfile" on message "Foo" @@ -268,68 +233,69 @@ Cannot resolve protobuf field type "Partial__UserProfile" on message "Foo" registered message for this type. Common causes: union / intersection / mapped / conditional types, TypeScript utility types (Partial, Pick, …), qualified names (ns.Type), or a missing import. -``` - -This is the safety net for TypeScript constructs the analyzer doesn't model. -Currently unsupported: - -- Union / intersection types (`A | B`, `A & B`) -- Conditional / mapped types (`T extends X ? A : B`, `{ [K in keyof T]: ... }`) -- TS utility types (`Partial`, `Pick`, `Readonly`, …) -- Qualified type names (`ns.Type`) -- Wrapper functions reached via namespace import (`import * as ns from`) - -…all of these will throw with a clear message instead of producing bad bytes. - -## Edge cases fixed on top of upstream - -Each has a regression test in `test/__tests__/analyzer.test.ts`: - -| # | What used to break | -| - | --------------------- | -| 1 | `import { pb as P }` — aliased markers were ignored; fields dropped silently | -| 2 | `interface Foo { x: pb<1, Wrapper> }` — typeName stuck at `"Wrapper"`, no mono of the inner instantiation | -| 3 | `interface Outer { x: pb<1, Wrapper> }` — outer mono didn't re-instantiate the inner generic | -| 4 | Wrapper-binding synthetic SourceFile had empty text → `getText()` returned garbage | -| 5 | Cross-file forwarded wrappers (chain → imported base) not detected | -| 6 | `matchForwardedKnownWrapper` didn't propagate `typePattern` — chain treated `` as the encoded type instead of `Wrapper` | -| 7 | Synthetic-SF WeakMap was private to analyzer.ts → replacer used the wrong SF | - -Plus the build-time guard (above) that turns future analyzer gaps into hard -errors instead of silent miscodes. - -## Scripts -``` -pnpm --filter @snowluma/proton build # bundle src/index.ts → dist/index.js -pnpm --filter @snowluma/proton test # vitest run (95 tests) -pnpm --filter @snowluma/proton typecheck # tsc --noEmit ``` -## Layout +这是针对分析器无法建模的 TypeScript 结构的的安全网。目前不支持的特性包括: + +* 联合/交叉类型(`A | B`,`A & B`) +* 条件/映射类型(`T extends X ? A : B`,`{ [K in keyof T]: ... }`) +* TS 工具类型(`Partial`,`Pick`,`Readonly` 等) +* 限定类型名称(`ns.Type`) +* 通过命名空间导入访问的包装函数(`import * as ns from`) + +……所有这些情况都将抛出清晰的错误提示,而不会产生错误的字节。 + +## 边缘情况修复(对比上游) + +每个修复在 `test/__tests__/analyzer.test.ts` 中都有对应的回归测试: + +| # | 以前会损坏的情况 | +| --- | --- | +| 1 | `import { pb as P }` —— 别名标记曾被忽略;字段被静默丢弃 | +| 2 | `interface Foo { x: pb<1, Wrapper> }` —— typeName 曾卡在 `"Wrapper"`,未对内部实例化进行单态化 | +| 3 | `interface Outer { x: pb<1, Wrapper> }` —— 外部单态化曾未重新实例化内部泛型 | +| 4 | 包装器绑定生成的虚拟 SourceFile 文本曾为空 → `getText()` 返回垃圾数据 | +| 5 | 跨文件转发的包装器(链式 → 导入的基类)曾无法被检测 | +| 6 | `matchForwardedKnownWrapper` 曾未传播 `typePattern` —— 链式调用曾将 `` 视为编码类型,而不是 `Wrapper` | +| 7 | 虚拟 SF 的 WeakMap 曾对 `analyzer.ts` 私有 → 替换器使用了错误的 SF | + +此外,前述的构建时防线可确保未来的分析器漏洞会直接触发硬错误,而不是产生静默的编码错误。 + +## 脚本命令 + +```bash +pnpm --filter @snowluma/proton build # 打包 src/index.ts → dist/index.js +pnpm --filter @snowluma/proton test # 运行 vitest (95 个测试) +pnpm --filter @snowluma/proton typecheck # 运行 tsc --noEmit ``` + +## 目录结构 + +```text src/ - ast/ analyzer + type-tracking pipeline - analyzer.ts single-walk message + call-site collection - collector.ts interface → ProtobufField/GenericFieldTemplate - monomorphizer.ts generic instantiation → concrete ProtobufMessage - import-resolver.ts cross-file definition + wrapper resolution - callsite.ts matches protobuf_encode/decode invocations - static-wrapper.ts static-method wrapper detection (AST) - utils.ts name resolution + synthetic-SF tracking - dependency-graph.ts topo sort + reachability - types.ts shared schema types - codegen/ inlined wire-format emitters - subclass-override.ts per-subclass override codegen - typecheck/ TypeChecker-backed pipeline (per-subclass overrides) - program-cache.ts lazy ts.Program, keyed by tsconfig - subclass-wrapper.ts extends-chain walking + type-param resolution - file-pipeline.ts per-file glue used by the plugin's transform() - transform/ Vite plugin string edits - index.ts plugin entry (./vite) - runtime.ts runtime stubs (.) + ast/ 分析器 + 类型追踪管道 + analyzer.ts 单次遍历的消息 + 调用位置收集 + collector.ts interface → ProtobufField/GenericFieldTemplate + monomorphizer.ts 泛型实例化 → 具体 ProtobufMessage + import-resolver.ts 跨文件定义 + 包装器解析 + callsite.ts 匹配 protobuf_encode/decode 调用 + static-wrapper.ts 静态方法包装器检测 (AST) + utils.ts 名称解析 + 虚拟 SF 追踪 + dependency-graph.ts 拓扑排序 + 可达性 + types.ts 共享的 Schema 类型 + codegen/ 内联传输格式发射器 + subclass-override.ts 每个子类重写的代码生成 + typecheck/ 基于 TypeChecker 的管道(用于子类重写) + program-cache.ts 延迟加载的 ts.Program,以 tsconfig 为键 + subclass-wrapper.ts extends 链遍历 + 类型参数解析 + file-pipeline.ts 插件 transform() 使用的单文件胶水层 + transform/ Vite 插件字符串编辑 + index.ts 插件入口 (./vite) + runtime.ts 运行时桩代码 (.) + +protobuf.d.ts 公共类型 (pb<>, pb_repeated<>, 原生别名) +test/ vitest 测试套件 (包含分析器 + 跨文件 + 插件 + 子类包装器) -protobuf.d.ts public types (pb<>, pb_repeated<>, primitive aliases) -test/ vitest suite (analyzer + cross-file + plugin + subclass-wrapper) ``` diff --git a/packages/proton/src/ast/analyzer.ts b/packages/proton/src/ast/analyzer.ts index 3d2a340a..987d8890 100644 --- a/packages/proton/src/ast/analyzer.ts +++ b/packages/proton/src/ast/analyzer.ts @@ -1,4 +1,5 @@ import ts from 'typescript'; +import { getCallableName } from './ast-helpers.js'; import { collectProtobufImportBindings, matchProtobufCallSite, @@ -18,12 +19,6 @@ import { export { typeNodeToMangledName } from './utils.js'; -function getCallableName(expr: ts.Expression): string | null { - if (ts.isIdentifier(expr)) return expr.text; - if (ts.isPropertyAccessExpression(expr)) return expr.name.text; - return null; -} - function instantiateWrapperTypePattern( binding: WrapperBinding, callerTypeArgs: ts.NodeArray, diff --git a/packages/proton/src/ast/ast-helpers.ts b/packages/proton/src/ast/ast-helpers.ts new file mode 100644 index 00000000..c8cd9bbf --- /dev/null +++ b/packages/proton/src/ast/ast-helpers.ts @@ -0,0 +1,64 @@ +import ts from 'typescript'; + +/** + * Tiny AST predicates shared across the analyzer, callsite matcher, + * wrapper detector, and subclass resolver. + * + * All of these are pure structural checks on individual nodes — they + * don't walk children, don't read file content, and don't touch the + * TypeChecker. Collecting them in one module avoids the four+ private + * re-definitions that accreted while the feature stack grew. + */ + +/** Identifier text behind a call's callee, normalising `foo(...)` and + * `obj.foo(...)` to just `foo`. Returns null for any other shape + * (element access, parenthesised expression, etc.) so callers don't + * accidentally treat dynamic dispatch as a known identifier. */ +export function getCallableName(expr: ts.Expression): string | null { + if (ts.isIdentifier(expr)) return expr.text; + if (ts.isPropertyAccessExpression(expr)) return expr.name.text; + return null; +} + +export function isStaticMethod(m: ts.MethodDeclaration): boolean { + const mods = ts.getModifiers(m); + return mods?.some(mod => mod.kind === ts.SyntaxKind.StaticKeyword) ?? false; +} + +export function getMethodName(m: ts.MethodDeclaration): string | null { + if (ts.isIdentifier(m.name)) return m.name.text; + if (ts.isStringLiteral(m.name)) return m.name.text; + return null; +} + +export function getPropertyNameText(name: ts.PropertyName): string | null { + if (ts.isIdentifier(name)) return name.text; + if (ts.isStringLiteral(name)) return name.text; + return null; +} + +/** Match a `TypeNode` that's just a bare reference to `name` with no + * type-args (`Foo`, NOT `Foo` and NOT `ns.Foo`). Used by the + * subclass resolver when looking for a method's type parameter inside + * a `this: T extends {…}` constraint. */ +export function isBareTypeRef(node: ts.TypeNode, name: string): boolean { + if (!ts.isTypeReferenceNode(node)) return false; + if (!ts.isIdentifier(node.typeName)) return false; + return node.typeName.text === name; +} + +/** True if the file has at least one top-level class declaration whose + * heritage clause includes `extends X`. Cheap O(top-level) scan used + * as a precondition guard before doing TypeChecker-heavy work — most + * files in a typical project have no extending classes and this lets + * us short-circuit the whole subclass-wrapper pipeline. */ +export function fileHasExtendingClass(sf: ts.SourceFile): boolean { + for (const stmt of sf.statements) { + if (!ts.isClassDeclaration(stmt)) continue; + if (!stmt.heritageClauses?.length) continue; + for (const h of stmt.heritageClauses) { + if (h.token === ts.SyntaxKind.ExtendsKeyword && h.types.length > 0) return true; + } + } + return false; +} diff --git a/packages/proton/src/ast/callsite.ts b/packages/proton/src/ast/callsite.ts index 75fa7422..35858336 100644 --- a/packages/proton/src/ast/callsite.ts +++ b/packages/proton/src/ast/callsite.ts @@ -23,7 +23,15 @@ export interface ProtobufCallSite { column: number; } +// Same shape as utils.ts' `resolverCache`: bindings are deterministic per +// SourceFile and queried from analyzer + replacer + wrapper detector + +// import resolver, so memoise by node identity. +const bindingsCache = new WeakMap(); + export function collectProtobufImportBindings(sf: ts.SourceFile): ProtobufImportBindings { + const cached = bindingsCache.get(sf); + if (cached) return cached; + const identifierToCanonical = new Map(); const namespaceAliases = new Set(); const blockedLegacyCanonical = new Set(); @@ -65,7 +73,9 @@ export function collectProtobufImportBindings(sf: ts.SourceFile): ProtobufImport } } - return { identifierToCanonical, namespaceAliases, blockedLegacyCanonical, hasRuntimeImport }; + const result: ProtobufImportBindings = { identifierToCanonical, namespaceAliases, blockedLegacyCanonical, hasRuntimeImport }; + bindingsCache.set(sf, result); + return result; } interface MatchOptions { diff --git a/packages/proton/src/ast/import-resolver.ts b/packages/proton/src/ast/import-resolver.ts index c8c4509c..2d03551f 100644 --- a/packages/proton/src/ast/import-resolver.ts +++ b/packages/proton/src/ast/import-resolver.ts @@ -1,46 +1,42 @@ -import ts from 'typescript'; -import { readFileSync, existsSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import { dirname, resolve } from 'path'; -import { collectInterface, collectGenericInterface } from './collector.js'; -import { PRIMITIVE_TYPE_MAP, type ProtobufMessage, type GenericProtobufTemplate } from './types.js'; -import { createImportedTypeNameResolver, isKeywordTypeNode, type ImportedTypeNameResolver } from './utils.js'; +import ts from 'typescript'; import { collectProtobufImportBindings, matchProtobufCallSite, type CanonicalProtobufFn } from './callsite.js'; +import { collectGenericInterface, collectInterface } from './collector.js'; +import { PRIMITIVE_TYPE_MAP, type GenericProtobufTemplate, type ProtobufMessage } from './types.js'; +import { createImportedTypeNameResolver, isKeywordTypeNode, type ImportedTypeNameResolver } from './utils.js'; export interface WrapperBinding { - fnName: CanonicalProtobufFn; - typeArgIndex: number; - typePattern?: string; - typeParamNames?: string[]; - sourceFilePath?: string; + fnName: CanonicalProtobufFn; + typeArgIndex: number; + typePattern?: string; + typeParamNames?: string[]; + sourceFilePath?: string; } export interface ParsedFileEntry { - filePath: string; - concrete: ProtobufMessage[]; - templates: Map; - importedTypeSources: Map; - exportAllTypeSources: Map; - resolveImportedTypeName: ImportedTypeNameResolver; - exportedWrappers: Map; + filePath: string; + concrete: ProtobufMessage[]; + templates: Map; + importedTypeSources: Map; + exportAllTypeSources: Map; + resolveImportedTypeName: ImportedTypeNameResolver; + exportedWrappers: Map; } export interface ImportedDefinitions { - concrete: ProtobufMessage[]; - templates: Map; - wrapperBindings: Map; + concrete: ProtobufMessage[]; + templates: Map; + wrapperBindings: Map; } interface ImportClause { - importedName: string; - localName: string; - specifier: string; + importedName: string; + localName: string; + specifier: string; } -function getCallableName(expr: ts.Expression): string | null { - if (ts.isIdentifier(expr)) return expr.text; - if (ts.isPropertyAccessExpression(expr)) return expr.name.text; - return null; -} +import { getCallableName } from './ast-helpers.js'; /** Extract all named imports from the source file (both type and value imports). */ function extractImports(sf: ts.SourceFile): ImportClause[] { @@ -220,9 +216,9 @@ function matchForwardedProtobufFn( }); if ( cs && - ts.isTypeReferenceNode(cs.firstTypeArg) && - ts.isIdentifier(cs.firstTypeArg.typeName) && - typeParamIndexes.has(cs.firstTypeArg.typeName.text) + ts.isTypeReferenceNode(cs.firstTypeArg) && + ts.isIdentifier(cs.firstTypeArg.typeName) && + typeParamIndexes.has(cs.firstTypeArg.typeName.text) ) { found = { fnName: cs.fnName, @@ -316,11 +312,11 @@ function matchForwardedKnownWrapper( const forwardedTypeArg = wrapper ? node.typeArguments[wrapper.typeArgIndex] : undefined; if ( wrapper && - forwardedTypeArg && - ts.isTypeReferenceNode(forwardedTypeArg) && - ts.isIdentifier(forwardedTypeArg.typeName) && - !forwardedTypeArg.typeArguments?.length && - typeParamIndexes.has(forwardedTypeArg.typeName.text) + forwardedTypeArg && + ts.isTypeReferenceNode(forwardedTypeArg) && + ts.isIdentifier(forwardedTypeArg.typeName) && + !forwardedTypeArg.typeArguments?.length && + typeParamIndexes.has(forwardedTypeArg.typeName.text) ) { const chainTypeArgIndex = typeParamIndexes.get(forwardedTypeArg.typeName.text)!; const binding: WrapperBinding = { @@ -582,8 +578,8 @@ function collectCallRootTypeNodes(sf: ts.SourceFile): ts.TypeNode[] { } interface RootTypeNode { - typeNode: ts.TypeNode; - from: ParsedFileEntry; + typeNode: ts.TypeNode; + from: ParsedFileEntry; } /** @@ -724,16 +720,16 @@ export function resolveImports( ts.forEachChild(entrySourceFile, function visit(node) { if ( ts.isCallExpression(node) && - node.typeArguments?.length && - getCallableName(node.expression) && - ( - wrapperBindings.has(getCallableName(node.expression)!) || - ( - ts.isPropertyAccessExpression(node.expression) && - ts.isIdentifier(node.expression.expression) && - importedObjectWrapperMembers.get(node.expression.expression.text)?.has(node.expression.name.text) - ) - ) + node.typeArguments?.length && + getCallableName(node.expression) && + ( + wrapperBindings.has(getCallableName(node.expression)!) || + ( + ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + importedObjectWrapperMembers.get(node.expression.expression.text)?.has(node.expression.name.text) + ) + ) ) { const binding = wrapperBindings.get(getCallableName(node.expression)!) ?? ( ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.expression) diff --git a/packages/proton/src/ast/static-wrapper.ts b/packages/proton/src/ast/static-wrapper.ts index dc6d2e99..dfc6cb91 100644 --- a/packages/proton/src/ast/static-wrapper.ts +++ b/packages/proton/src/ast/static-wrapper.ts @@ -1,4 +1,5 @@ import ts from 'typescript'; +import { getMethodName, isStaticMethod } from './ast-helpers.js'; import { collectProtobufImportBindings, matchProtobufCallSite, type CanonicalProtobufFn, type ProtobufImportBindings } from './callsite.js'; /** @@ -127,17 +128,6 @@ export function detectClassWrappersInFile( return out; } -function isStaticMethod(m: ts.MethodDeclaration): boolean { - const mods = ts.getModifiers(m); - return mods?.some(mod => mod.kind === ts.SyntaxKind.StaticKeyword) ?? false; -} - -function getMethodName(m: ts.MethodDeclaration): string | null { - if (ts.isIdentifier(m.name)) return m.name.text; - if (ts.isStringLiteral(m.name)) return m.name.text; - return null; -} - /** Walk method body, collecting every protobuf_encode/decode call whose * type-arg is a bare reference to one of the method's type parameters. */ function collectCodecCallsInBody( diff --git a/packages/proton/src/ast/utils.ts b/packages/proton/src/ast/utils.ts index 68370772..5c62a20a 100644 --- a/packages/proton/src/ast/utils.ts +++ b/packages/proton/src/ast/utils.ts @@ -40,9 +40,17 @@ export function isKeywordTypeNode(node: ts.Node): boolean { return KEYWORD_TYPE_KINDS.has(node.kind); } -export function createImportedTypeNameResolver(sf: ts.SourceFile): ImportedTypeNameResolver { - const localToImported = new Map(); +// Imported-name resolvers are deterministic for a given SourceFile but get +// asked for in a half-dozen places per `transform()` call (analyzer, +// monomorphizer, replacer, subclass resolver, …). Cache by node identity +// so the per-SF statement walk runs once instead of N times. +const resolverCache = new WeakMap(); +export function createImportedTypeNameResolver(sf: ts.SourceFile): ImportedTypeNameResolver { + const cached = resolverCache.get(sf); + if (cached) return cached; + + const localToImported = new Map(); for (const stmt of sf.statements) { if (!ts.isImportDeclaration(stmt) || !stmt.importClause) continue; const bindings = stmt.importClause.namedBindings; @@ -53,7 +61,9 @@ export function createImportedTypeNameResolver(sf: ts.SourceFile): ImportedTypeN } } - return (name: string) => localToImported.get(name) ?? name; + const resolver: ImportedTypeNameResolver = (name) => localToImported.get(name) ?? name; + resolverCache.set(sf, resolver); + return resolver; } /** diff --git a/packages/proton/src/typecheck/subclass-wrapper.ts b/packages/proton/src/typecheck/subclass-wrapper.ts index 06f3ee8b..b4f6e127 100644 --- a/packages/proton/src/typecheck/subclass-wrapper.ts +++ b/packages/proton/src/typecheck/subclass-wrapper.ts @@ -1,4 +1,5 @@ import ts from 'typescript'; +import { getPropertyNameText, isBareTypeRef } from '../ast/ast-helpers.js'; import type { ClassWrapperInfo, WrapperCodecCall, WrapperMethodInfo } from '../ast/static-wrapper.js'; import { detectClassWrappers } from '../ast/static-wrapper.js'; import { @@ -188,18 +189,6 @@ function findTypeParamInConstraint(constraint: ts.TypeNode, typeParamName: strin return null; } -function isBareTypeRef(node: ts.TypeNode, name: string): boolean { - if (!ts.isTypeReferenceNode(node)) return false; - if (!ts.isIdentifier(node.typeName)) return false; - return node.typeName.text === name; -} - -function getPropertyNameText(name: ts.PropertyName): string | null { - if (ts.isIdentifier(name)) return name.text; - if (ts.isStringLiteral(name)) return name.text; - return null; -} - /** Pull a type out of a subclass member's signature at the requested * position. Prefers the explicit annotation when present; falls back to * the TypeChecker's inferred type otherwise. */ @@ -212,8 +201,7 @@ function extractSubclassMemberTypeNode( ): ts.TypeNode | null { for (const member of subclass.members) { if (!ts.isMethodDeclaration(member)) continue; - const mods = ts.getModifiers(member); - if (!mods?.some(m => m.kind === ts.SyntaxKind.StaticKeyword)) continue; + if (!isStaticMethod(member)) continue; if (!ts.isIdentifier(member.name) || member.name.text !== memberName) continue; if (position.kind === 'returnType') {