diff --git a/MIGRATING.md b/MIGRATING.md index 2aaad31ec..a5aaafe45 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -202,6 +202,8 @@ Instead of the `new` keyword, you create a message with a function call: }); ``` +:white_check_mark: The `connect-migrate` tool will handle this. + Messages are now plain TypeScript types, which greatly improves compatibility with the ecosystem. For example, messages can be passed from a server-side component in Next.js to a client-side component without losing any data or types. @@ -220,7 +222,10 @@ import { SayRequestSchema } from "./gen/eliza_pb"; ``` The same applies to the methods `equals`, `clone`, `toJson`, and `toJsonString`, -and to the static methods `fromBinary`, `fromJson`, `fromJsonString`. +and to the static class methods. + +:white_check_mark: The `connect-migrate` tool will handle the static class methods +`fromBinary`, `fromJson`, and `fromJsonString`. > [!WARNING] > @@ -251,6 +256,8 @@ import { isMessage } from "@bufbuild/protobuf"; } ``` +:white_check_mark: The `connect-migrate` tool will handle `isMessage` calls. + #### PlainMessage removed The `PlainMessage` type was used to represent just the fields of a message, @@ -349,11 +356,13 @@ The well-known type `google.protobuf.Struct` is now generated as a more-convenie All well-known types have been moved to the subpath export `@bufbuild/protobuf/wkt`. For example, if you want to refer to `google.protobuf.Timestamp`: -```ts +```diff - import type { Timestamp } from "@bufbuild/protobuf"; + import type { Timestamp } from "@bufbuild/protobuf/wkt"; ``` +:white_check_mark: The `connect-migrate` tool will handle this. + Helpers that were previously part of the generated class are now standalone functions, also exported from `@bufbuild/protobuf/wkt`: diff --git a/packages/connect-migrate/src/lib/migrate-source-files.ts b/packages/connect-migrate/src/lib/migrate-source-files.ts index 77dbacaf1..c2bd4a615 100644 --- a/packages/connect-migrate/src/lib/migrate-source-files.ts +++ b/packages/connect-migrate/src/lib/migrate-source-files.ts @@ -23,7 +23,7 @@ interface MigrateSourceFilesResult { export function migrateSourceFiles( scanned: Scanned, - transform: j.Transform, + transform: j.Transform | j.Transform[], print: PrintFn, logger?: Logger, updateSourceFileFn: typeof updateSourceFile = updateSourceFile, @@ -68,19 +68,24 @@ interface UpdateSourceFileResult { } export function updateSourceFile( - transform: Transform, + transform: Transform | Transform[], path: string, logger?: Logger, ): UpdateSourceFileResult { logger?.log(`transform ${path}`); try { - const source = readFileSync(path, "utf8"); - const result = updateSourceFileInMemory(transform, source, path); - if (!result.modified) { + let source = readFileSync(path, "utf8"); + let modified = false; + for (const t of Array.isArray(transform) ? transform : [transform]) { + const result = updateSourceFileInMemory(t, source, path); + source = result.source; + modified = modified || result.modified; + } + if (!modified) { logger?.log(`skipped`); return { ok: true, modified: false }; } - writeFileSync(path, result.source, "utf-8"); + writeFileSync(path, source, "utf-8"); logger?.log(`modified`); return { ok: true, modified: true }; } catch (e) { diff --git a/packages/connect-migrate/src/migrations/v2.0.0-transform-class-refs.spec.ts b/packages/connect-migrate/src/migrations/v2.0.0-transform-class-refs.spec.ts new file mode 100644 index 000000000..afd62692a --- /dev/null +++ b/packages/connect-migrate/src/migrations/v2.0.0-transform-class-refs.spec.ts @@ -0,0 +1,451 @@ +// Copyright 2021-2024 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { updateSourceFileInMemory } from "../lib/migrate-source-files"; +import transform from "./v2.0.0-transform-class-refs"; + +describe("v2.0.0 transform class references", () => { + describe("new to create", () => { + it("transforms single import", () => { + const input = [`import {Foo} from "./x_pb.js";`, `new Foo();`].join("\n"); + const output = [ + `import { FooSchema } from "./x_pb.js";`, + `import { create } from "@bufbuild/protobuf";`, + `create(FooSchema);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + it("transforms multiple imports", () => { + const input = [ + `import {Foo, Bar} from "./x_pb.js";`, + `new Foo();`, + `new Bar();`, + ].join("\n"); + const output = [ + `import { FooSchema, BarSchema } from "./x_pb.js";`, + `import { create } from "@bufbuild/protobuf";`, + `create(FooSchema);`, + `create(BarSchema);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + it("transforms single import alias", () => { + const input = [ + `import {Foo as MyFoo} from "./x_pb.js";`, + `new MyFoo();`, + ].join("\n"); + const output = [ + `import { FooSchema as MyFooSchema } from "./x_pb.js";`, + `import { create } from "@bufbuild/protobuf";`, + `create(MyFooSchema);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + it("transforms multiple import aliases", () => { + const input = [ + `import {Foo as MyFoo, Bar as MyBar} from "./x_pb.js";`, + `new MyFoo();`, + `new MyBar();`, + ].join("\n"); + const output = [ + `import { FooSchema as MyFooSchema, BarSchema as MyBarSchema } from "./x_pb.js";`, + `import { create } from "@bufbuild/protobuf";`, + `create(MyFooSchema);`, + `create(MyBarSchema);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + it("transforms mix of regular and import aliases", () => { + const input = [ + `import {Foo as MyFoo, Bar} from "./x_pb.js";`, + `new MyFoo();`, + `new Bar();`, + ].join("\n"); + const output = [ + `import { FooSchema as MyFooSchema, BarSchema } from "./x_pb.js";`, + `import { create } from "@bufbuild/protobuf";`, + `create(MyFooSchema);`, + `create(BarSchema);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + it("transforms new with init object", () => { + const input = [ + `import {Foo} from "./x_pb.js";`, + `new Foo({x:123});`, + ].join("\n"); + const output = [ + `import { FooSchema } from "./x_pb.js";`, + `import { create } from "@bufbuild/protobuf";`, + `create(FooSchema, {x:123});`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toBe(output); + }); + it("transforms new with init object and import alias", () => { + const input = [ + `import {Foo as MyFoo} from "./x_pb.js";`, + `new MyFoo({x:123});`, + ].join("\n"); + const output = [ + `import { FooSchema as MyFooSchema } from "./x_pb.js";`, + `import { create } from "@bufbuild/protobuf";`, + `create(MyFooSchema, {x:123});`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toBe(output); + }); + it("adds type import for single import", () => { + const input = [ + `import {Foo} from "./x_pb.js";`, + `const foo: Foo = new Foo();`, + ].join("\n"); + const output = [ + `import { FooSchema } from "./x_pb.js";`, + `import { create } from "@bufbuild/protobuf";`, + `import type { Foo } from "./x_pb.js";`, + `const foo: Foo = create(FooSchema);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + it("adds type import for multiple imports", () => { + const input = [ + `import {Foo, Bar} from "./x_pb.js";`, + `const foo: Foo = new Foo();`, + `const bar: Bar = new Bar();`, + ].join("\n"); + const output = [ + `import { FooSchema, BarSchema } from "./x_pb.js";`, + `import { create } from "@bufbuild/protobuf";`, + `import type { Foo, Bar } from "./x_pb.js";`, + `const foo: Foo = create(FooSchema);`, + `const bar: Bar = create(BarSchema);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + it("adds type import for multiple import aliases", () => { + const input = [ + `import {Foo as MyFoo, Bar as SomeBar} from "./x_pb.js";`, + `const foo: MyFoo = new MyFoo();`, + `const bar: SomeBar = new SomeBar();`, + ].join("\n"); + const output = [ + `import { FooSchema as MyFooSchema, BarSchema as SomeBarSchema } from "./x_pb.js";`, + `import { create } from "@bufbuild/protobuf";`, + `import type { Foo as MyFoo, Bar as SomeBar } from "./x_pb.js";`, + `const foo: MyFoo = create(MyFooSchema);`, + `const bar: SomeBar = create(SomeBarSchema);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + it("adds type import for multiple imports with mix of aliases and regular", () => { + const input = [ + `import {Foo as MyFoo, Bar} from "./x_pb.js";`, + `const foo: MyFoo = new MyFoo();`, + `const bar: Bar = new Bar();`, + ].join("\n"); + const output = [ + `import { FooSchema as MyFooSchema, BarSchema } from "./x_pb.js";`, + `import { create } from "@bufbuild/protobuf";`, + `import type { Foo as MyFoo, Bar } from "./x_pb.js";`, + `const foo: MyFoo = create(MyFooSchema);`, + `const bar: Bar = create(BarSchema);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + it("does not transform when identifier is not imported from _pb", () => { + const input = `import {Foo} from "./x.js"; new Foo()`; + const output = `import {Foo} from "./x.js"; new Foo()`; + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toBe(output); + }); + it("does not add type import to .js file", () => { + const input = [ + `import {Foo} from "./x_pb.js";`, + `const foo = new Foo();`, + `foo instanceof Foo;`, + ].join("\n"); + const output = [ + `import { FooSchema } from "./x_pb.js";`, + `import { create } from "@bufbuild/protobuf";`, + `const foo = create(FooSchema);`, + `foo instanceof Foo;`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.js"); + expect(result.source).toEqual(output); + }); + it("adds create import to existing @bufbuild/protobuf import", () => { + const input = [ + `import {fake} from "@bufbuild/protobuf";`, + `import {Foo} from "./x_pb.js";`, + `new Foo();`, + ].join("\n"); + const output = [ + `import { fake, create } from "@bufbuild/protobuf";`, + `import { FooSchema } from "./x_pb.js";`, + `create(FooSchema);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + }); + + describe("isMessage()", () => { + it("transforms with schema argument", () => { + const input = [ + `import {isMessage} from "@bufbuild/protobuf";`, + `import {Foo} from "./x_pb.js";`, + `isMessage(1, Foo);`, + ].join("\n"); + const output = [ + `import {isMessage} from "@bufbuild/protobuf";`, + `import { FooSchema } from "./x_pb.js";`, + `isMessage(1, FooSchema);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + it("transforms with schema argument and import alias", () => { + const input = [ + `import {isMessage} from "@bufbuild/protobuf";`, + `import {Foo as MyFoo} from "./x_pb.js";`, + `isMessage(1, MyFoo);`, + ].join("\n"); + const output = [ + `import {isMessage} from "@bufbuild/protobuf";`, + `import { FooSchema as MyFooSchema } from "./x_pb.js";`, + `isMessage(1, MyFooSchema);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + it("does not transform without schema argument", () => { + const input = [ + `import {isMessage} from "@bufbuild/protobuf";`, + `import {Foo} from "./x_pb.js";`, + `isMessage(1);`, + ].join("\n"); + const output = [ + `import {isMessage} from "@bufbuild/protobuf";`, + `import {Foo} from "./x_pb.js";`, + `isMessage(1);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + it("adds type import", () => { + const input = [ + `import {isMessage} from "@bufbuild/protobuf";`, + `import {Foo} from "./x_pb.js";`, + `type X = Foo;`, + `isMessage(1, Foo);`, + ].join("\n"); + const output = [ + `import {isMessage} from "@bufbuild/protobuf";`, + `import { FooSchema } from "./x_pb.js";`, + `import type { Foo } from "./x_pb.js";`, + `type X = Foo;`, + `isMessage(1, FooSchema);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + it("adds type import with import alias", () => { + const input = [ + `import {isMessage} from "@bufbuild/protobuf";`, + `import {Foo as MyFoo} from "./x_pb.js";`, + `type X = MyFoo;`, + `isMessage(1, MyFoo);`, + ].join("\n"); + const output = [ + `import {isMessage} from "@bufbuild/protobuf";`, + `import { FooSchema as MyFooSchema } from "./x_pb.js";`, + `import type { Foo as MyFoo } from "./x_pb.js";`, + `type X = MyFoo;`, + `isMessage(1, MyFooSchema);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + it("does not transform isMessage when identifier is not imported from _pb", () => { + const input = `import {Foo} from "./x.js"; isMessage(1, Foo);`; + const output = `import {Foo} from "./x.js"; isMessage(1, Foo);`; + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toBe(output); + }); + it("does not add type import to .js file", () => { + const input = [ + `import {isMessage} from "@bufbuild/protobuf";`, + `import {Foo} from "./x_pb.js";`, + `isMessage(1, Foo);`, + `foo instanceof Foo;`, + ].join("\n"); + const output = [ + `import {isMessage} from "@bufbuild/protobuf";`, + `import { FooSchema } from "./x_pb.js";`, + `isMessage(1, FooSchema);`, + `foo instanceof Foo;`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.js"); + expect(result.source).toEqual(output); + }); + describe("update wkt import", () => { + it("regular import", () => { + const input = [ + `import {Timestamp} from "@bufbuild/protobuf";`, + `new Timestamp();`, + ].join("\n"); + const output = [ + `import { TimestampSchema } from "@bufbuild/protobuf/wkt";`, + `import { create } from "@bufbuild/protobuf";`, + `create(TimestampSchema);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + it("import alias", () => { + const input = [ + `import {Timestamp as TS} from "@bufbuild/protobuf";`, + `new TS();`, + ].join("\n"); + const output = [ + `import { TimestampSchema as TSSchema } from "@bufbuild/protobuf/wkt";`, + `import { create } from "@bufbuild/protobuf";`, + `create(TSSchema);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + it("adds type import", () => { + const input = [ + `import {Timestamp} from "@bufbuild/protobuf";`, + `const ts: Timestamp = new Timestamp();`, + ].join("\n"); + const output = [ + `import { TimestampSchema } from "@bufbuild/protobuf/wkt";`, + `import { create } from "@bufbuild/protobuf";`, + `import type { Timestamp } from "@bufbuild/protobuf/wkt";`, + `const ts: Timestamp = create(TimestampSchema);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + it("adds type import for multiple import aliases", () => { + const input = [ + `import {Timestamp as TS, Duration as Dur} from "@bufbuild/protobuf";`, + `const ts: TS = new TS();`, + `const duration: Dur = new Dur();`, + ].join("\n"); + const output = [ + `import { TimestampSchema as TSSchema, DurationSchema as DurSchema } from "@bufbuild/protobuf/wkt";`, + `import { create } from "@bufbuild/protobuf";`, + `import type { Timestamp as TS, Duration as Dur } from "@bufbuild/protobuf/wkt";`, + `const ts: TS = create(TSSchema);`, + `const duration: Dur = create(DurSchema);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + }); + describe("static methods", () => { + it("transforms fromBinary call", () => { + const input = [ + `import {Foo} from "./foo_pb";`, + `Foo.fromBinary(x, y);`, + ].join("\n"); + const output = [ + `import { FooSchema } from "./foo_pb";`, + `import { fromBinary } from "@bufbuild/protobuf";`, + `fromBinary(FooSchema, x, y);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + it("transforms fromBinary call for alias", () => { + const input = [ + `import {Foo as MyFoo} from "./foo_pb";`, + `MyFoo.fromBinary(x, y);`, + ].join("\n"); + const output = [ + `import { FooSchema as MyFooSchema } from "./foo_pb";`, + `import { fromBinary } from "@bufbuild/protobuf";`, + `fromBinary(MyFooSchema, x, y);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + it("transforms fromJson call", () => { + const input = [ + `import {Foo} from "./foo_pb";`, + `Foo.fromJson(x, y);`, + ].join("\n"); + const output = [ + `import { FooSchema } from "./foo_pb";`, + `import { fromJson } from "@bufbuild/protobuf";`, + `fromJson(FooSchema, x, y);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + it("transforms fromJson call for alias", () => { + const input = [ + `import {Foo as MyFoo} from "./foo_pb";`, + `MyFoo.fromJson(x, y);`, + ].join("\n"); + const output = [ + `import { FooSchema as MyFooSchema } from "./foo_pb";`, + `import { fromJson } from "@bufbuild/protobuf";`, + `fromJson(MyFooSchema, x, y);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + it("transforms fromJsonString call", () => { + const input = [ + `import {Foo} from "./foo_pb";`, + `Foo.fromJsonString(x, y);`, + ].join("\n"); + const output = [ + `import { FooSchema } from "./foo_pb";`, + `import { fromJsonString } from "@bufbuild/protobuf";`, + `fromJsonString(FooSchema, x, y);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + it("transforms fromJsonString call for alias", () => { + const input = [ + `import {Foo as MyFoo} from "./foo_pb";`, + `MyFoo.fromJsonString(x, y);`, + ].join("\n"); + const output = [ + `import { FooSchema as MyFooSchema } from "./foo_pb";`, + `import { fromJsonString } from "@bufbuild/protobuf";`, + `fromJsonString(MyFooSchema, x, y);`, + ].join("\n"); + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toEqual(output); + }); + }); + }); +}); diff --git a/packages/connect-migrate/src/migrations/v2.0.0-transform-class-refs.ts b/packages/connect-migrate/src/migrations/v2.0.0-transform-class-refs.ts new file mode 100644 index 000000000..99b6dce2e --- /dev/null +++ b/packages/connect-migrate/src/migrations/v2.0.0-transform-class-refs.ts @@ -0,0 +1,505 @@ +// Copyright 2021-2024 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import j from "jscodeshift"; + +const bufbuildProtobufPackage = "@bufbuild/protobuf"; + +/** + * Replace imports for a message class with a schema import, and update + * - "new" expressions + * - isMessage calls + * + * When the identifier is used elsewhere, add a type import for the message type. + * When the message is a well-known type, update to import from @bufbuild/protobuf/wkt. + */ +const transform: j.Transform = (file, { j }, options) => { + const root = j(file.source); + + // Identifiers imported from generated protos + const pbNames = new Set(); + + // Identifiers we'll need to import from @bufbuild/protobuf + const needBufbuildProtobufImports = new Set(); + + // Replace wkt imports from @bufbuild/protobuf to @bufbuild/protobuf/wkt + root + .find(j.ImportDeclaration, { + specifiers: [ + { + type: "ImportSpecifier", + }, + ], + source: { + type: "StringLiteral", + value: bufbuildProtobufPackage, + }, + importKind: "value", + }) + .forEach((path) => { + const specifiers = path.value.specifiers ?? []; + const specifiersWkt = specifiers.filter( + (specifier) => + specifier.type === "ImportSpecifier" && + isWktMessage(specifier.imported.name), + ); + const specifiersRest = specifiers.filter( + (specifier) => !specifiersWkt.includes(specifier), + ); + if (specifiersWkt.length > 0) { + path.insertAfter( + j.importDeclaration( + specifiersWkt, + j.stringLiteral(bufbuildProtobufPackage + "/wkt"), + ), + ); + if (specifiersRest.length > 0) { + path.replace(j.importDeclaration(specifiersRest, path.value.source)); + } else { + path.replace(); + } + } + }); + + // Replace `new Foo()` -> `create(FooSchema)` + root + .find(j.NewExpression, { + callee: { + type: "Identifier", + }, + }) + .filter((path) => { + const name = (path.node.callee as j.Identifier).name; + return findPbImports(name, root).size() > 0; + }) + .forEach((path) => { + const name = (path.node.callee as j.Identifier).name; + path.replace( + j.callExpression(j.identifier("create"), [ + j.identifier(name + "Schema"), + ...path.node.arguments, + ]), + ); + pbNames.add(name); + needBufbuildProtobufImports.add("create"); + }); + + // Replace `isMessage(foo, Foo)` -> `isMessage(foo, FooSchema)` + root + .find(j.CallExpression, { + callee: { + type: "Identifier", + }, + }) + .filter((path) => { + const fnName = (path.node.callee as j.Identifier).name; + if (fnName !== "isMessage") { + return false; + } + if (path.node.arguments.length !== 2) { + return false; + } + const ident = path.node.arguments[1]; + if (ident.type !== "Identifier") { + return false; + } + return findPbImports(ident.name, root).size() > 0; + }) + .forEach((path) => { + const ident = path.node.arguments[1] as j.Identifier; + path.replace( + j.callExpression(path.node.callee, [ + path.node.arguments[0], + j.identifier(ident.name + "Schema"), + ]), + ); + pbNames.add(ident.name); + }); + + // Replace `Foo.fromBinary(x, y)` -> `fromBinary(FooSchema, x, y)` + replaceStaticMethodCall( + "fromBinary", + pbNames, + needBufbuildProtobufImports, + root, + ); + replaceStaticMethodCall( + "fromJson", + pbNames, + needBufbuildProtobufImports, + root, + ); + replaceStaticMethodCall( + "fromJsonString", + pbNames, + needBufbuildProtobufImports, + root, + ); + + // Replace `import {Foo}` -> `import {FooSchema}` + for (const name of pbNames) { + findPbImports(name, root).forEach((path) => { + path.replace( + j.importDeclaration( + path.value.specifiers?.map((specifier) => { + if ( + specifier.type !== "ImportSpecifier" || + !namesEqual(name, specifier) + ) { + return specifier; + } + return j.importSpecifier( + j.identifier(`${specifier.imported.name}Schema`), + j.identifier(`${specifier.local?.name}Schema`), + ); + }), + path.value.source, + path.value.importKind, + ), + ); + }); + } + + // Add type import when the name was used outside of new and isMessage() + if (file.path.endsWith(".ts")) { + for (const name of pbNames) { + if (root.find(j.Identifier, { name }).size() > 0) { + const pbImports = findPbImports(name + "Schema", root); + const firstImport = pbImports.at(0); + const from = firstImport.get() as j.ASTPath; + const fromSource = from.value.source; + + // Search and see if this name is in the root yet. If it is, replace it. + // If not, insert it + // This is so we end up with: + // import type { Foo, Bar } from “./x.js”; + // instead of: + // import type { Foo } from “./x.js”; + // import type { Bar } from “./x.js”; + const typeImports = root.find(j.ImportDeclaration, { + source: { + type: "StringLiteral", + value: fromSource.value as string, + }, + importKind: "type", + }); + // If the type import for this source file isn't in the root yet, add it + const importDecl = findImportByName(name + "Schema", root); + if (importDecl !== undefined) { + const importSpecs = + importDecl.value.specifiers?.map((specifier) => { + if (specifier.type !== "ImportSpecifier") { + return specifier; + } + const localMinusSchema = removeSchemaSuffix( + specifier.local?.name, + ); + const importedMinusSchema = removeSchemaSuffix( + specifier.imported.name, + ); + + return j.importSpecifier( + j.identifier(`${importedMinusSchema}`), + j.identifier(`${localMinusSchema}`), + ); + }) ?? []; + + if (typeImports.length === 0) { + firstImport.insertAfter( + j.importDeclaration(importSpecs, fromSource, "type"), + ); + } else { + typeImports.forEach((path) => { + const specs = + path.value.specifiers?.map((specifier) => { + if (specifier.type !== "ImportSpecifier") { + return specifier; + } + return j.importSpecifier( + j.identifier(`${specifier.imported.name}`), + j.identifier(`${specifier.local?.name}`), + ); + }) ?? []; + + specs.concat(importSpecs); + + path.replace( + j.importDeclaration(specs, path.value.source, "type"), + ); + }); + } + } + } + } + } + + // Add `import {create} from "@bufbuild/protobuf"` + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- linter is wrong + if (needBufbuildProtobufImports.size > 0) { + const needSpecifiers = Array.from(needBufbuildProtobufImports).map((name) => + j.importSpecifier(j.identifier(name)), + ); + // Find existing import + const importBufbuildProtobuf = root.find(j.ImportDeclaration, { + specifiers: [ + { + type: "ImportSpecifier", + }, + ], + source: { + type: "StringLiteral", + value: bufbuildProtobufPackage, + }, + importKind: "value", + }); + if (importBufbuildProtobuf.size() > 0) { + // Add to existing import + importBufbuildProtobuf + .at(0) + .replaceWith((path) => + j.importDeclaration( + [...(path.value.specifiers ?? []), ...needSpecifiers], + path.value.source, + path.value.importKind, + ), + ); + } else { + // Add new import + root + .find(j.ImportDeclaration, { + specifiers: [ + { + type: "ImportSpecifier", + }, + ], + }) + .at(0) + .insertAfter( + j.importDeclaration( + needSpecifiers, + j.stringLiteral(bufbuildProtobufPackage), + ), + ); + } + } + + return root.toSource( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- passing the printOptions onto toSource is safe + options.printOptions ?? { + quote: determineQuoteStyle(root.find(j.ImportDeclaration)), + }, + ); +}; + +function findImportByName(name: string, root: j.Collection) { + const result = root + .find(j.ImportDeclaration, { + specifiers: [ + { + type: "ImportSpecifier", + }, + ], + }) + .filter((path) => { + return ( + path.value.specifiers?.some((specifier) => + namesEqual(name, specifier as j.ImportSpecifier), + ) ?? false + ); + }); + if (result.length === 0) { + return undefined; + } + return result.at(0).get() as j.ASTPath; +} + +function namesEqual(name: string, importSpecifier: j.ImportSpecifier) { + if (importSpecifier.local?.name !== importSpecifier.imported.name) { + // This is an import alias, so use the localName for comparison + return importSpecifier.local?.name === name; + } + + return importSpecifier.imported.name === name; +} + +function removeSchemaSuffix(name: string | undefined) { + if (name === undefined) { + return ""; + } + return name.endsWith("Schema") ? name.substring(0, name.length - 6) : name; +} + +function findPbImports(name: string, root: j.Collection) { + return root + .find(j.ImportDeclaration, { + specifiers: [ + { + type: "ImportSpecifier", + }, + ], + }) + .filter((path) => { + // First make sure that the given name is in the root doc + const found = path.value.specifiers?.find((specifier) => { + return namesEqual(name, specifier as j.ImportSpecifier); + }); + if (!found) { + return false; + } + const from = path.value.source.value; + if (typeof from !== "string") { + return false; + } + + // The given name may be an alias, so to test whether this is fromWkt, + // we need to use the imported name to compare against the list of wkt + // imports + const importedName = (found as j.ImportSpecifier).imported.name; + const importedNameMinusSchema = removeSchemaSuffix(importedName); + const fromIsWkt = + (from === bufbuildProtobufPackage || + from === bufbuildProtobufPackage + "/wkt") && + (isWktMessage(importedName) || isWktMessage(importedNameMinusSchema)); + const fromIsPb = + from.endsWith("_pb") || + from.endsWith("_pb.js") || + from.endsWith("_pb.ts"); + return fromIsPb || fromIsWkt; + }); +} + +function replaceStaticMethodCall( + methodName: string, + pbNames: Set, + needBufbuildProtobufImports: Set, + root: j.Collection, +): void { + root + .find(j.CallExpression, { + callee: { + type: "MemberExpression", + object: { + type: "Identifier", + }, + property: { + type: "Identifier", + name: methodName, + }, + }, + }) + .forEach((path) => { + const callee = path.value.callee as j.MemberExpression; + const object = callee.object as j.Identifier; + const pbImports = findPbImports(object.name, root); + if (pbImports.size() === 0) { + return; + } + path.replace( + j.callExpression(j.identifier(methodName), [ + j.identifier(object.name + "Schema"), + ...path.value.arguments, + ]), + ); + pbNames.add(object.name); + needBufbuildProtobufImports.add(methodName); + }); +} + +function determineQuoteStyle( + importPaths: j.Collection, +): "double" | "single" { + let nodePath: unknown; + if (importPaths.length > 0) { + nodePath = importPaths.get("source", "extra", "raw") as unknown; + } + if ( + typeof nodePath == "object" && + nodePath != null && + "value" in nodePath && + typeof nodePath.value == "string" + ) { + return nodePath.value.startsWith("'") ? "single" : "double"; + } + return "double"; +} + +function isWktMessage(name: string): boolean { + return [ + "Any", + "Duration", + "Timestamp", + "DoubleValue", + "FloatValue", + "Int64Value", + "UInt64Value", + "Int32Value", + "UInt32Value", + "BoolValue", + "StringValue", + "BytesValue", + "Struct", + "Value", + "ListValue", + "FieldMask", + "Empty", + "SourceContext", + "Type", + "Field", + "Enum", + "EnumValue", + "Option", + "Api", + "Method", + "Mixin", + "FileDescriptorSet", + "FileDescriptorProto", + "DescriptorProto", + "DescriptorProto_ExtensionRange", + "DescriptorProto_ReservedRange", + "ExtensionRangeOptions", + "ExtensionRangeOptions_Declaration", + "FieldDescriptorProto", + "OneofDescriptorProto", + "EnumDescriptorProto", + "EnumDescriptorProto_EnumReservedRange", + "EnumValueDescriptorProto", + "ServiceDescriptorProto", + "MethodDescriptorProto", + "FileOptions", + "MessageOptions", + "FieldOptions", + "FieldOptions_EditionDefault", + "FieldOptions_FeatureSupport", + "OneofOptions", + "EnumOptions", + "EnumValueOptions", + "ServiceOptions", + "MethodOptions", + "UninterpretedOption", + "UninterpretedOption_NamePart", + "FeatureSet", + "FeatureSetDefaults", + "FeatureSetDefaults_FeatureSetEditionDefault", + "SourceCodeInfo", + "SourceCodeInfo_Location", + "GeneratedCodeInfo", + "GeneratedCodeInfo_Annotation", + "Version", + "CodeGeneratorRequest", + "CodeGeneratorResponse", + "CodeGeneratorResponse_File", + ].includes(name); +} + +export default transform; diff --git a/packages/connect-migrate/src/migrations/v2.0.0-transform.spec.ts b/packages/connect-migrate/src/migrations/v2.0.0-transform-connect-plugin-imports.spec.ts similarity index 85% rename from packages/connect-migrate/src/migrations/v2.0.0-transform.spec.ts rename to packages/connect-migrate/src/migrations/v2.0.0-transform-connect-plugin-imports.spec.ts index 84fddd952..21d202af9 100644 --- a/packages/connect-migrate/src/migrations/v2.0.0-transform.spec.ts +++ b/packages/connect-migrate/src/migrations/v2.0.0-transform-connect-plugin-imports.spec.ts @@ -13,9 +13,9 @@ // limitations under the License. import { updateSourceFileInMemory } from "../lib/migrate-source-files"; -import transform from "./v2.0.0-transform"; +import transform from "./v2.0.0-transform-connect-plugin-imports"; -describe("v2.0.0 transform", () => { +describe("v2.0.0 transform connect plugin imports", () => { it("should modify import from *_connect.js to *_pb.js", () => { const input = `import { ElizaService } from "./gen/eliza_connect.js";`; const output = `import { ElizaService } from "./gen/eliza_pb.js";`; @@ -46,6 +46,12 @@ describe("v2.0.0 transform", () => { const result = updateSourceFileInMemory(transform, input, "foo.tsx"); expect(result.source).toBe(output); }); + it("should modify import alias", () => { + const input = `import { ElizaService as MyEliza } from "./gen/eliza_connect.js";`; + const output = `import { ElizaService as MyEliza } from "./gen/eliza_pb.js";`; + const result = updateSourceFileInMemory(transform, input, "foo.ts"); + expect(result.source).toBe(output); + }); it("should not care about existing imports", () => { const input = ` import { ElizaService } from "./gen/eliza_connect.js"; diff --git a/packages/connect-migrate/src/migrations/v2.0.0-transform.ts b/packages/connect-migrate/src/migrations/v2.0.0-transform-connect-plugin-imports.ts similarity index 94% rename from packages/connect-migrate/src/migrations/v2.0.0-transform.ts rename to packages/connect-migrate/src/migrations/v2.0.0-transform-connect-plugin-imports.ts index 6fadaf63e..4625e1195 100644 --- a/packages/connect-migrate/src/migrations/v2.0.0-transform.ts +++ b/packages/connect-migrate/src/migrations/v2.0.0-transform-connect-plugin-imports.ts @@ -20,6 +20,10 @@ const replacements = [ ["_connect.ts", "_pb.ts"], ]; +/** + * Replace imports for protoc-connect-es generated files (*_connect.js) + * with protoc-gen-es generated files (*_pb.js). + */ const transform: j.Transform = (file, { j }, options) => { const root = j(file.source); const importPaths = root.find(j.ImportDeclaration); diff --git a/packages/connect-migrate/src/migrations/v2.0.0.ts b/packages/connect-migrate/src/migrations/v2.0.0.ts index 031b64621..6f52a0c25 100644 --- a/packages/connect-migrate/src/migrations/v2.0.0.ts +++ b/packages/connect-migrate/src/migrations/v2.0.0.ts @@ -13,7 +13,8 @@ // limitations under the License. import { Scanned } from "../lib/scan"; -import modifyImports from "./v2.0.0-transform"; +import transportConnectPluginImports from "./v2.0.0-transform-connect-plugin-imports"; +import transportClassRefs from "./v2.0.0-transform-class-refs"; import { MigrateError, MigrateSuccess, Migration } from "../migration"; import { runInstall } from "../lib/run"; import { writePackageJsonFile } from "../lib/package-json"; @@ -158,7 +159,7 @@ export const v2_0_0: Migration = { const errorLines: string[] = []; const { sourceFileErrors } = migrateSourceFiles( scanned, - modifyImports, + [transportConnectPluginImports, transportClassRefs], print, logger, updateSourceFileFn,