Add AST helpers, cache resolver, translate README
Add shared AST predicates in packages/proton/src/ast/ast-helpers.ts to centralize small structural checks used by the analyzer, callsite matcher, wrapper detector, and subclass resolver. Introduce a WeakMap cache in packages/proton/src/ast/utils.ts so createImportedTypeNameResolver is memoized per SourceFile to avoid repeated import-scans during transform() work. Update packages/proton/README.md with a Chinese translation and various clarifications/documentation tweaks.
This commit is contained in:
+138
-172
@@ -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<N, T>`. At build time a Vite plugin walks the call sites of
|
||||
`protobuf_encode<T>` / `protobuf_decode<T>` and replaces them with
|
||||
type-specific, fully-inlined codec functions. No runtime schema lookup,
|
||||
no reflection, no `.proto` files, no `protoc`.
|
||||
你只需使用 `pb<N, T>` 标记的普通 TypeScript 接口来声明你的传输 Schema。在构建时,一个 Vite 插件会扫描 `protobuf_encode<T>` / `protobuf_decode<T>` 的调用位置,并将它们替换为特定于类型的、完全内联的编解码函数。无需运行时 Schema 查找,无需反射,无需 `.proto` 文件,也无需 `protoc`。
|
||||
|
||||
```ts
|
||||
import type { pb, pb_repeated, uint_32, bool } from '@snowluma/proton';
|
||||
@@ -30,71 +24,71 @@ const bytes = protobuf_encode<UserProfile>({
|
||||
id: 42, username: 'alice', active: true, tags: ['admin'],
|
||||
});
|
||||
const user = protobuf_decode<UserProfile>(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<FieldNumber, Type>` (or `pb_repeated<FieldNumber, Type>` for
|
||||
arrays). Available primitives:
|
||||
标记格式为 `pb<FieldNumber, Type>`(数组则使用 `pb_repeated<FieldNumber, Type>`)。可用的原生类型包括:
|
||||
|
||||
| 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<T> {
|
||||
@@ -102,35 +96,36 @@ interface Wrapper<T> {
|
||||
}
|
||||
|
||||
const bytes = protobuf_encode<Wrapper<uint_32>>({ value: 42 });
|
||||
// ^ instantiated at the call site
|
||||
// ^ 在调用处实例化
|
||||
|
||||
```
|
||||
|
||||
Nested instantiations work too — `Wrapper<Wrapper<string>>` mono-morphizes to
|
||||
`Wrapper__string` + `Wrapper__Wrapper__string` and both end up in the registry
|
||||
at build time.
|
||||
嵌套实例化同样有效 —— `Wrapper<Wrapper<string>>` 会被单态化(mono-morphizes)为 `Wrapper__string` + `Wrapper__Wrapper__string`,并且在构建时两者都会进入注册表。
|
||||
|
||||
Compose them in concrete interfaces:
|
||||
你可以在具体接口中组合它们:
|
||||
|
||||
```ts
|
||||
interface Container {
|
||||
wrapped?: pb<5, Wrapper<uint_32>>;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
…or in other generic templates:
|
||||
……或者在其他泛型模板中组合:
|
||||
|
||||
```ts
|
||||
interface Outer<U> {
|
||||
wrapped?: pb<5, Wrapper<U>>;
|
||||
}
|
||||
const bytes = protobuf_encode<Outer<uint_32>>({ 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<UserProfile>({ 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<T>(v: Wrapper<T>): Uint8Array {
|
||||
// usage.ts
|
||||
import { encodeWrapped } from './schema/wrap';
|
||||
const bytes = encodeWrapped<string>({ 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<R>` / `protobuf_decode<B>` inside the abstract body can't
|
||||
be replaced statically — `R` and `B` are erased at runtime. Proton handles
|
||||
this by:
|
||||
抽象体内部的 `protobuf_encode<R>` / `protobuf_decode<B>` 无法进行静态替换 —— 因为 `R` 和 `B` 在运行时会被擦除。Proton 通过以下方式处理此问题:
|
||||
|
||||
1. Detecting any class whose static method (a) takes type parameters, (b)
|
||||
calls `protobuf_encode<R>` / `protobuf_decode<R>` 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<R>` / `protobuf_decode<R>` 且其中 `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<T>, Pick<T>, …),
|
||||
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<T>`, `Pick<T>`, `Readonly<T>`, …)
|
||||
- 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<uint_32>> }` — typeName stuck at `"Wrapper"`, no mono of the inner instantiation |
|
||||
| 3 | `interface Outer<U> { x: pb<1, Wrapper<U>> }` — 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 `<X>` as the encoded type instead of `Wrapper<X>` |
|
||||
| 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<T>`,`Pick<T>`,`Readonly<T>` 等)
|
||||
* 限定类型名称(`ns.Type`)
|
||||
* 通过命名空间导入访问的包装函数(`import * as ns from`)
|
||||
|
||||
……所有这些情况都将抛出清晰的错误提示,而不会产生错误的字节。
|
||||
|
||||
## 边缘情况修复(对比上游)
|
||||
|
||||
每个修复在 `test/__tests__/analyzer.test.ts` 中都有对应的回归测试:
|
||||
|
||||
| # | 以前会损坏的情况 |
|
||||
| --- | --- |
|
||||
| 1 | `import { pb as P }` —— 别名标记曾被忽略;字段被静默丢弃 |
|
||||
| 2 | `interface Foo { x: pb<1, Wrapper<uint_32>> }` —— typeName 曾卡在 `"Wrapper"`,未对内部实例化进行单态化 |
|
||||
| 3 | `interface Outer<U> { x: pb<1, Wrapper<U>> }` —— 外部单态化曾未重新实例化内部泛型 |
|
||||
| 4 | 包装器绑定生成的虚拟 SourceFile 文本曾为空 → `getText()` 返回垃圾数据 |
|
||||
| 5 | 跨文件转发的包装器(链式 → 导入的基类)曾无法被检测 |
|
||||
| 6 | `matchForwardedKnownWrapper` 曾未传播 `typePattern` —— 链式调用曾将 `<X>` 视为编码类型,而不是 `Wrapper<X>` |
|
||||
| 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)
|
||||
```
|
||||
|
||||
@@ -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<ts.TypeNode>,
|
||||
|
||||
@@ -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<X>` 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;
|
||||
}
|
||||
@@ -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<ts.SourceFile, ProtobufImportBindings>();
|
||||
|
||||
export function collectProtobufImportBindings(sf: ts.SourceFile): ProtobufImportBindings {
|
||||
const cached = bindingsCache.get(sf);
|
||||
if (cached) return cached;
|
||||
|
||||
const identifierToCanonical = new Map<string, CanonicalProtobufFn>();
|
||||
const namespaceAliases = new Set<string>();
|
||||
const blockedLegacyCanonical = new Set<CanonicalProtobufFn>();
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, GenericProtobufTemplate>;
|
||||
importedTypeSources: Map<string, string>;
|
||||
exportAllTypeSources: Map<string, string>;
|
||||
resolveImportedTypeName: ImportedTypeNameResolver;
|
||||
exportedWrappers: Map<string, WrapperBinding>;
|
||||
filePath: string;
|
||||
concrete: ProtobufMessage[];
|
||||
templates: Map<string, GenericProtobufTemplate>;
|
||||
importedTypeSources: Map<string, string>;
|
||||
exportAllTypeSources: Map<string, string>;
|
||||
resolveImportedTypeName: ImportedTypeNameResolver;
|
||||
exportedWrappers: Map<string, WrapperBinding>;
|
||||
}
|
||||
|
||||
export interface ImportedDefinitions {
|
||||
concrete: ProtobufMessage[];
|
||||
templates: Map<string, GenericProtobufTemplate>;
|
||||
wrapperBindings: Map<string, WrapperBinding>;
|
||||
concrete: ProtobufMessage[];
|
||||
templates: Map<string, GenericProtobufTemplate>;
|
||||
wrapperBindings: Map<string, WrapperBinding>;
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, string>();
|
||||
// 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<ts.SourceFile, ImportedTypeNameResolver>();
|
||||
|
||||
export function createImportedTypeNameResolver(sf: ts.SourceFile): ImportedTypeNameResolver {
|
||||
const cached = resolverCache.get(sf);
|
||||
if (cached) return cached;
|
||||
|
||||
const localToImported = new Map<string, string>();
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user