magicprotorb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/DESIGN.md ADDED
@@ -0,0 +1,163 @@
1
+ # Design
2
+
3
+ `magicprotorb` is the Ruby counterpart of [magicproto](https://example.com/magicproto)
4
+ (Python). The goal is identical: make a `.proto` file importable directly, with
5
+ no `protoc` invocation, no checked-in generated code, and no build step in your
6
+ edit/run loop. Only the host-language mechanics differ.
7
+
8
+ ## The core idea
9
+
10
+ A generated protobuf file is a pure function of two things: the `.proto` source
11
+ and the path it lives at. Checking the output into your repo creates a third,
12
+ independent thing that has to be kept in sync by hand — and the failure mode
13
+ everyone has hit is the generated `require`/`import` pointing somewhere the
14
+ descriptor name doesn't actually live.
15
+
16
+ `magicprotorb` removes the third thing. The require path *is* the proto path:
17
+
18
+ ```
19
+ require "magicprotorb/greet/hello_pb" <-> greet/hello.proto
20
+ ```
21
+
22
+ There is exactly one source of truth (the `.proto`), and the mapping between the
23
+ require name, the file on disk, and the descriptor's fully-qualified name is the
24
+ identity function. They cannot drift because there is nothing to keep in sync.
25
+
26
+ ## The pipeline
27
+
28
+ ```
29
+ require "magicprotorb/greet/hello_pb"
30
+
31
+
32
+ RequireHook claim "magicprotorb/<x>_pb" / "<x>_services_pb"; strip suffix
33
+ │ -> canonical proto path "greet/hello.proto"
34
+
35
+ IncludePath resolve against include roots (MAGICPROTORB_PATH, then $LOAD_PATH)
36
+
37
+
38
+ Compiler._compile Rust ext (protox) -> serialized FileDescriptorSet [the missing piece]
39
+
40
+
41
+ Registrar decode FDS; add_serialized_file each file (dep order, idempotent);
42
+ │ assign Ruby constants exactly as a generated _pb.rb would
43
+
44
+ ServiceBuilder (only for _services_pb) synthesize GRPC::GenericService + Stub
45
+ ```
46
+
47
+ ### Why a native extension at all
48
+
49
+ The stock `google-protobuf` runtime can *consume* a serialized
50
+ `FileDescriptorSet` (`DescriptorPool#add_serialized_file`) and it ships the
51
+ descriptor.proto message types (`Google::Protobuf::FileDescriptorSet` et al.) —
52
+ but it cannot *produce* descriptors from `.proto` text. Compiling `.proto` is
53
+ precisely the job of `protoc`.
54
+
55
+ `magicprotorb` does that one step with [`protox`](https://crates.io/crates/protox),
56
+ a pure-Rust protobuf compiler, wrapped in a tiny [`magnus`](https://crates.io/crates/magnus)
57
+ extension (`ext/magicprotorb_native`). The extension exposes a single method:
58
+
59
+ ```ruby
60
+ Magicprotorb::Compiler._compile(proto_path, include_dirs) # => String (serialized FileDescriptorSet)
61
+ ```
62
+
63
+ Everything above that line is ordinary Ruby talking to the ordinary protobuf
64
+ runtime. This mirrors magicproto, which wraps the same `protox` crate.
65
+
66
+ ### Why constants are assigned by hand
67
+
68
+ `add_serialized_file` registers descriptors in the pool but does **not** create
69
+ any Ruby constants — a generated `_pb.rb` is what does
70
+ `HelloRequest = pool.lookup("greet.HelloRequest").msgclass`. So the `Registrar`
71
+ walks the `FileDescriptorProto` and performs the same assignments, byte-for-byte
72
+ compatible with `protoc`'s Ruby generator:
73
+
74
+ - `package a_b.c` → nested modules `A_b`-style camelization → `AB::C`
75
+ (each `_`-delimited part of each segment is capitalized: `my_co` → `MyCo`).
76
+ - messages → `lookup(full_name).msgclass`, nested messages under their parent
77
+ constant (`Book::Chapter`).
78
+ - enums → `lookup(full_name).enummodule`.
79
+ - synthetic `map<>` entry messages get no constant (protoc skips them too).
80
+
81
+ The result is genuinely the same class you'd get from generated code — same
82
+ descriptor object, same `#encode`/`#decode`, same `.name`.
83
+
84
+ ## Multi-package / namespacing model
85
+
86
+ Two independent axes, deliberately kept separate:
87
+
88
+ 1. **File location** is what you import and what `protoc -I` resolves. It is
89
+ *not* required to match the proto `package`. Put `foo/bar.proto` where you'd
90
+ put `foo/bar.rb` and import `magicprotorb/foo/bar_pb`.
91
+
92
+ 2. **Proto `package`** is what determines the Ruby module nesting of the
93
+ resulting constants, via the protoc naming rule above.
94
+
95
+ Because the include roots are `MAGICPROTORB_PATH` then `$LOAD_PATH`, a gem ships
96
+ its protos under its own `lib/` and the **directory name namespaces them**: gem
97
+ `acme` ships `lib/acme/widgets.proto`, you `require "magicprotorb/acme/widgets_pb"`,
98
+ and a second gem physically cannot shadow it without colliding on the same
99
+ directory. This is the `protoc -I` include model, reused verbatim.
100
+
101
+ ### Imports
102
+
103
+ Compiling `greet/hello.proto` compiles its transitive imports too; every file in
104
+ the returned `FileDescriptorSet` is registered into the pool (in dependency
105
+ order, idempotently). Constants are also assigned for imported, non-well-known
106
+ files, matching `protoc`'s behavior of emitting `require`s for each dependency.
107
+ Well-known types (`google/protobuf/*`) are owned by the runtime and are neither
108
+ re-registered nor re-named.
109
+
110
+ ## gRPC
111
+
112
+ `require "magicprotorb/greet/hello_services_pb"` first does the message load
113
+ (register + constants), then reads the service descriptors of *that file only*
114
+ (matching `protoc`'s per-file output) and builds, for each service:
115
+
116
+ ```ruby
117
+ module Greet
118
+ module Greeter
119
+ class Service
120
+ include GRPC::GenericService
121
+ self.marshal_class_method = :encode
122
+ self.unmarshal_class_method = :decode
123
+ self.service_name = "greet.Greeter"
124
+ rpc :SayHello, Greet::HelloRequest, Greet::HelloReply
125
+ rpc :SayHelloStream, Greet::HelloRequest, stream(Greet::HelloReply)
126
+ end
127
+ Stub = Service.rpc_stub_class
128
+ end
129
+ end
130
+ ```
131
+
132
+ `require "grpc"` is lazy, so message-only users never load it. Streaming sides
133
+ are wrapped with the DSL's `stream(...)`, exactly as generated code does.
134
+
135
+ ## Idempotency & concurrency
136
+
137
+ - Compilation results are memoized per canonical proto path.
138
+ - `add_serialized_file` rejects duplicate file names; the `Registrar` tracks
139
+ loaded files and also rescues that specific error, so re-requiring, shared
140
+ imports, and pre-registered well-known types are all no-ops.
141
+ - A re-`require` of an already-loaded module returns `false`, like `Kernel#require`.
142
+ - A process-wide mutex serializes the pool mutations.
143
+
144
+ ## Limitations
145
+
146
+ - **A compiler is needed at *install* time.** The Rust extension is compiled
147
+ when the gem installs (a Rust toolchain / `cargo` must be present). The point
148
+ of magicprotorb is removing `protoc` from your *edit/run* loop, not removing
149
+ all native build steps; the trade is "compile a small Rust crate once at
150
+ install" for "never run protoc again."
151
+ - **Constants appear at require time, not parse time.** Editors/static analyzers
152
+ that don't execute the require won't see `Greet::HelloRequest`. This is
153
+ inherent to runtime code generation (same caveat as magicproto).
154
+ - **The hook only claims `magicprotorb/…_pb` / `…_services_pb`.** Anything else
155
+ (including the native extension and the version file) falls straight through to
156
+ the real `require`.
157
+ - **One descriptor pool.** Everything registers into
158
+ `DescriptorPool.generated_pool`, exactly like generated code; two different
159
+ protos defining the same fully-qualified type still conflict, just as they
160
+ would with `protoc`.
161
+ - **`required_ruby_version >= 3.0`**; descriptor types are loaded via
162
+ `google/protobuf/descriptor_pb`, which is required explicitly for compatibility
163
+ with protobuf 4.x where it is not auto-loaded.
data/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # magicprotorb
2
+
3
+ Import `.proto` files directly in Ruby. No `protoc`, no generated `_pb.rb` files, no build step:
4
+
5
+ ```ruby
6
+ require "magicprotorb" # installs the import hook
7
+ require "magicprotorb/greet/hello_pb" # compiles greet/hello.proto
8
+ require "magicprotorb/greet/hello_services_pb" # + synthesizes the gRPC stub
9
+
10
+ req = Greet::HelloRequest.new(name: "world")
11
+ stub = Greet::Greeter::Stub.new("localhost:50051", :this_channel_is_insecure)
12
+ ```
13
+
14
+ `require "magicprotorb/greet/hello_pb"` compiles `greet/hello.proto` (found on
15
+ `MAGICPROTORB_PATH` / `$LOAD_PATH`) at require time and defines the message
16
+ constants. The dotted require path mirrors the canonical proto path 1:1, so the
17
+ require name, the file location, and the descriptor name can never drift apart —
18
+ the classic "the generated import points at the wrong place" problem cannot occur.
19
+
20
+ ## How it works
21
+
22
+ A `Kernel#require` hook claims names under `magicprotorb/` that end in `_pb` or
23
+ `_services_pb` (only).
24
+
25
+ - `magicprotorb/greet/hello_pb` → canonical `greet/hello.proto`, located on the
26
+ include roots (the `protoc -I` model: `MAGICPROTORB_PATH` then `$LOAD_PATH`).
27
+ - A small Rust extension (`magicprotorb_native`, built on the pure-Rust
28
+ [`protox`](https://crates.io/crates/protox) compiler) turns the `.proto` into a
29
+ serialized `FileDescriptorSet` — the one thing the stock protobuf runtime can't
30
+ do itself.
31
+ - Those descriptors are registered through the stock
32
+ `Google::Protobuf::DescriptorPool.generated_pool#add_serialized_file`, and the
33
+ message/enum constants are assigned exactly the way a generated `_pb.rb` does,
34
+ so the message classes are indistinguishable from generated ones.
35
+ - `_services_pb` modules are synthesized directly from the service descriptors as
36
+ ordinary `GRPC::GenericService` classes (`require "grpc"` happens lazily).
37
+
38
+ See [DESIGN.md](DESIGN.md) for the full rationale, the multi-package namespacing
39
+ model, and the limitations.
40
+
41
+ ## Where to put protos
42
+
43
+ Put `foo/bar.proto` where you'd want `foo/bar.rb`, and import it as
44
+ `magicprotorb/foo/bar_pb`.
45
+
46
+ A library ships its protos as data inside its own `lib/` directory (already on
47
+ `$LOAD_PATH`); the directory name namespaces them, so two installed gems can't
48
+ collide.
49
+
50
+ ### Finding your protos (include roots)
51
+
52
+ magicprotorb resolves a proto by its canonical path against the include roots —
53
+ `MAGICPROTORB_PATH` first, then `$LOAD_PATH` — exactly like `protoc -I`. Note that
54
+ Ruby does **not** put the current directory on `$LOAD_PATH`, so a proto sitting
55
+ next to your script isn't found automatically. Make its directory an include root:
56
+
57
+ ```ruby
58
+ require "magicprotorb"
59
+ $LOAD_PATH.unshift __dir__ # this script's dir is now an include root
60
+ require "magicprotorb/keyvalue_pb" # resolves ./keyvalue.proto
61
+ ```
62
+
63
+ or point `MAGICPROTORB_PATH` at it from the shell:
64
+
65
+ ```sh
66
+ MAGICPROTORB_PATH="$PWD" ruby my_script.rb
67
+ ```
68
+
69
+
70
+ ## Naming
71
+
72
+ | require | compiles | gives you |
73
+ | --- | --- | --- |
74
+ | `magicprotorb/greet/hello_pb` | `greet/hello.proto` | `Greet::HelloRequest`, ... |
75
+ | `magicprotorb/greet/hello_services_pb` | `greet/hello.proto` | `Greet::Greeter::Service` / `::Stub` |
76
+
77
+ The proto `package` becomes the Ruby module path the same way `protoc`'s Ruby
78
+ generator does it: `package my_co.sub_pkg.v1;` → `MyCo::SubPkg::V1`.
79
+
80
+ There is also a programmatic API equivalent to the requires:
81
+
82
+ ```ruby
83
+ Magicprotorb.import("greet/hello") # like require "magicprotorb/greet/hello_pb"
84
+ Magicprotorb.import_services("greet/hello") # like require "magicprotorb/greet/hello_services_pb"
85
+ Magicprotorb.include_paths # the roots currently searched
86
+ ```
87
+
88
+ ## Installation
89
+
90
+ ```ruby
91
+ # Gemfile
92
+ gem "magicprotorb"
93
+ ```
94
+
95
+ Building the gem compiles the bundled Rust extension, so a Rust toolchain
96
+ (`cargo`) is required at install time. The runtime needs only `google-protobuf`
97
+ (and `grpc`, if you import services).
98
+
99
+ ### Installing from a local checkout
100
+
101
+ ```sh
102
+ bundle exec rake install # builds the gem and installs it (native ext included)
103
+ ```
104
+
105
+ After this, `require "magicprotorb"` works from any script without `-I`. The gem
106
+ installs into whichever Ruby is active (`rbenv`/`rvm`), so install under the same
107
+ Ruby you'll run with.
108
+
109
+ > The `install` task deliberately runs `gem install` **outside** the bundle
110
+ > (`Bundler.with_unbundled_env`). A native-extension gem whose own gemspec is the
111
+ > bundle's path gem otherwise fails to build at install time, because RubyGems'
112
+ > per-extension build dir goes missing under `bundle exec`.
113
+
114
+ ## Development
115
+
116
+ After checking out the repo:
117
+
118
+ ```sh
119
+ bin/setup # install dependencies
120
+ bundle exec rake # compile the extension, then run the tests
121
+ ```
122
+
123
+ `bundle exec rake compile` builds `ext/magicprotorb_native` into
124
+ `lib/magicprotorb/`. The fixture protos live in `test/protos`.
data/Rakefile ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+ require "rb_sys/extensiontask"
6
+
7
+ GEMSPEC = Gem::Specification.load("magicprotorb.gemspec")
8
+
9
+ # Builds ext/magicprotorb_native and drops the artifact in lib/magicprotorb/.
10
+ RbSys::ExtensionTask.new("magicprotorb_native", GEMSPEC) do |ext|
11
+ ext.lib_dir = "lib/magicprotorb"
12
+ end
13
+
14
+ Rake::TestTask.new(test: :compile) do |t|
15
+ t.libs << "test" << "lib"
16
+ t.test_files = FileList["test/**/test_*.rb"]
17
+ end
18
+
19
+ require "rubocop/rake_task"
20
+ RuboCop::RakeTask.new
21
+
22
+ # bundler/gem_tasks defines `install`, but it shells out to `gem install` while
23
+ # still inside the bundle. Because this gem's own gemspec is the bundle's path
24
+ # gem, that breaks the native-extension build at install time (RubyGems' build
25
+ # staging dir, ext/.../.gem.<ts>, ends up missing). Re-run the install outside
26
+ # the bundle, where `gem install` of our compiled .gem works correctly.
27
+ Rake::Task["install"].clear
28
+ desc "Build magicprotorb and install it (native extension compiled outside the bundle)"
29
+ task install: :build do
30
+ gem_file = "pkg/#{GEMSPEC.full_name}.gem"
31
+ Bundler.with_unbundled_env do
32
+ sh "gem", "install", gem_file
33
+ end
34
+ end
35
+
36
+ task default: %i[compile test]
@@ -0,0 +1,22 @@
1
+ [package]
2
+ name = "magicprotorb_native"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ publish = false
6
+ autobins = false
7
+
8
+ # The compiled artifact is loaded by Ruby as `magicprotorb/magicprotorb_native`.
9
+ # It deliberately does NOT end in `_pb`, so the require hook never claims it.
10
+ [lib]
11
+ name = "magicprotorb_native"
12
+ crate-type = ["cdylib"]
13
+
14
+ [dependencies]
15
+ # Ruby <-> Rust bindings.
16
+ magnus = "0.7"
17
+ # Pure-Rust protobuf *compiler* — the one thing the stock protobuf runtime
18
+ # cannot do itself (turns .proto text into a FileDescriptorSet).
19
+ protox = "0.9"
20
+ # protox builds on prost's descriptor types; keep these in lockstep with it.
21
+ prost = "0.14"
22
+ prost-types = "0.14"
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mkmf"
4
+ require "rb_sys/mkmf"
5
+
6
+ # Builds the Rust crate and installs the artifact as
7
+ # lib/magicprotorb/magicprotorb_native.<dlext>.
8
+ create_rust_makefile("magicprotorb/magicprotorb_native")
@@ -0,0 +1,35 @@
1
+ //! The native half of magicprotorb.
2
+ //!
3
+ //! Exactly one job: take a canonical proto path plus a list of include roots
4
+ //! (the `protoc -I` model) and return a *serialized `FileDescriptorSet`* — the
5
+ //! same bytes a `protoc --descriptor_set_out` invocation would emit, and the
6
+ //! same bytes the stock Ruby protobuf runtime knows how to register via
7
+ //! `DescriptorPool#add_serialized_file`.
8
+ //!
9
+ //! The compiler is `protox`, a pure-Rust protobuf compiler, so there is no
10
+ //! dependency on a `protoc` binary at run time.
11
+
12
+ use magnus::{function, prelude::*, Error, RString, Ruby};
13
+ use prost::Message;
14
+
15
+ /// Compile `file` (resolved against `includes`) and return the serialized
16
+ /// `FileDescriptorSet`, transitive dependencies included, as a binary string.
17
+ fn compile(ruby: &Ruby, file: String, includes: Vec<String>) -> Result<RString, Error> {
18
+ let fds = protox::compile([file], includes).map_err(|e| {
19
+ Error::new(
20
+ ruby.exception_runtime_error(),
21
+ format!("magicprotorb: failed to compile proto: {e}"),
22
+ )
23
+ })?;
24
+ Ok(RString::from_slice(&fds.encode_to_vec()))
25
+ }
26
+
27
+ #[magnus::init]
28
+ fn init(ruby: &Ruby) -> Result<(), Error> {
29
+ let module = ruby.define_module("Magicprotorb")?;
30
+ let compiler = module.define_class("Compiler", ruby.class_object())?;
31
+ // Underscore-prefixed: the Ruby Compiler wrapper adds path resolution and
32
+ // error context on top of this raw call.
33
+ compiler.define_singleton_method("_compile", function!(compile, 2))?;
34
+ Ok(())
35
+ }
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magicprotorb
4
+ # Resolves canonical proto paths against include roots, mirroring the
5
+ # `protoc -I` model: MAGICPROTORB_PATH first, then Ruby's $LOAD_PATH.
6
+ #
7
+ # This is the Ruby analogue of magicproto's "MAGICPROTO_PATH then sys.path":
8
+ # a library ships its protos as package data under its own lib directory, that
9
+ # directory is already on $LOAD_PATH, and the directory name namespaces the
10
+ # protos so two installed gems can't collide.
11
+ module IncludePath
12
+ ENV_VAR = "MAGICPROTORB_PATH"
13
+
14
+ module_function
15
+
16
+ # Ordered, de-duplicated list of existing directories to search, highest
17
+ # priority first. These become the `-I` include roots handed to the compiler.
18
+ def roots
19
+ raw = []
20
+ if (env = ENV.fetch(ENV_VAR, nil)) && !env.empty?
21
+ raw.concat(env.split(File::PATH_SEPARATOR))
22
+ end
23
+ raw.concat($LOAD_PATH)
24
+
25
+ seen = {}
26
+ raw.each_with_object([]) do |dir, out|
27
+ next if dir.nil? || dir.to_s.empty?
28
+
29
+ path = File.expand_path(dir.to_s)
30
+ next if seen[path] || !File.directory?(path)
31
+
32
+ seen[path] = true
33
+ out << path
34
+ end
35
+ end
36
+
37
+ # The first root under which +proto_path+ exists, or nil. Used to produce a
38
+ # LoadError-shaped failure before invoking the compiler.
39
+ def resolve(proto_path)
40
+ roots.find { |root| File.file?(File.join(root, proto_path)) }
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magicprotorb
4
+ # Orchestrates a single import: resolve -> compile -> register (-> services).
5
+ # Compilation results are memoized per canonical proto path; a process-wide
6
+ # mutex serializes the descriptor-pool mutations.
7
+ module Loader
8
+ MUTEX = Mutex.new
9
+ @compiled = {}
10
+
11
+ module_function
12
+
13
+ # Register messages/enums for +proto_path+ (and its imports). Idempotent.
14
+ def load_messages(proto_path)
15
+ MUTEX.synchronize { Registrar.register(compile(proto_path)) }
16
+ end
17
+
18
+ # As load_messages, plus synthesize gRPC Service/Stub for the proto's own
19
+ # services (not those of its imports — matching protoc's per-file output).
20
+ def load_services(proto_path)
21
+ MUTEX.synchronize do
22
+ fds = compile(proto_path)
23
+ Registrar.register(fds)
24
+ primary = fds.file.find { |file| file.name == proto_path } || fds.file.last
25
+ ServiceBuilder.build(primary)
26
+ end
27
+ end
28
+
29
+ # Compile +proto_path+ to a FileDescriptorSet via the native protox-backed
30
+ # compiler, using the current include roots. Memoized.
31
+ def compile(proto_path)
32
+ @compiled[proto_path] ||= begin
33
+ if IncludePath.resolve(proto_path).nil?
34
+ raise LoadError,
35
+ "magicprotorb: cannot find #{proto_path} on #{IncludePath::ENV_VAR} or $LOAD_PATH"
36
+ end
37
+
38
+ bytes =
39
+ begin
40
+ Compiler._compile(proto_path, IncludePath.roots)
41
+ rescue StandardError => e
42
+ raise CompileError, e.message
43
+ end
44
+
45
+ Google::Protobuf::FileDescriptorSet.decode(bytes)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magicprotorb
4
+ # Translates proto identifiers into Ruby constant names exactly the way
5
+ # protoc's Ruby generator does, so the constants magicprotorb synthesizes are
6
+ # identical to those a checked-in `*_pb.rb` would define.
7
+ #
8
+ # package my_co.sub_pkg.v1 -> MyCo::SubPkg::V1
9
+ # message OuterMsg -> OuterMsg (nested under its parent)
10
+ #
11
+ # Each dot-separated package segment is split on "_" and each part is
12
+ # capitalized (my_co -> MyCo, v1 -> V1); message/enum names keep their own
13
+ # casing with only the first letter forced upper.
14
+ module Naming
15
+ module_function
16
+
17
+ # ["MyCo", "SubPkg", "V1"] for "my_co.sub_pkg.v1"; [] for an empty package.
18
+ def package_modules(package)
19
+ return [] if package.nil? || package.empty?
20
+
21
+ package.split(".").map { |segment| camelize(segment) }
22
+ end
23
+
24
+ def camelize(segment)
25
+ segment.split("_").map { |part| part.empty? ? "" : part[0].upcase + part[1..] }.join
26
+ end
27
+
28
+ # Constant name for a message/enum simple name (first letter upper).
29
+ def constant_name(simple_name)
30
+ simple_name[0].upcase + simple_name[1..]
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "google/protobuf"
4
+ require "google/protobuf/descriptor_pb"
5
+
6
+ module Magicprotorb
7
+ # Takes a compiled FileDescriptorSet and registers it through the *stock*
8
+ # protobuf machinery — DescriptorPool.generated_pool#add_serialized_file plus
9
+ # the same constant assignments a generated `*_pb.rb` performs. The resulting
10
+ # message classes are therefore indistinguishable from generated ones.
11
+ module Registrar
12
+ # The protobuf runtime already owns google/protobuf/*; never re-register or
13
+ # assign constants for those (it would clobber Google::Protobuf::Timestamp
14
+ # and friends, and add_serialized_file would reject the duplicate).
15
+ WELL_KNOWN_PREFIX = "google/protobuf/"
16
+
17
+ @loaded_files = {}
18
+
19
+ module_function
20
+
21
+ # Register every file in the set (dependency-ordered by the compiler), then
22
+ # assign Ruby constants for each non-well-known file. Idempotent.
23
+ def register(file_descriptor_set)
24
+ pool = Google::Protobuf::DescriptorPool.generated_pool
25
+ file_descriptor_set.file.each { |file| register_file(pool, file) }
26
+ end
27
+
28
+ def register_file(pool, file)
29
+ name = file.name
30
+ return if @loaded_files[name]
31
+
32
+ begin
33
+ pool.add_serialized_file(file.to_proto)
34
+ rescue Google::Protobuf::TypeError => e
35
+ # Already present (a well-known type, or a shared import registered by a
36
+ # previously imported proto). Anything else is a real error.
37
+ raise unless e.message.include?("duplicate file name")
38
+ end
39
+
40
+ @loaded_files[name] = true
41
+ assign_constants(pool, file) unless name.start_with?(WELL_KNOWN_PREFIX)
42
+ end
43
+
44
+ # --- constant assignment (mirrors protoc's Ruby generator) ----------------
45
+
46
+ def assign_constants(pool, file)
47
+ package = file.package
48
+ scope = ensure_module(Naming.package_modules(package))
49
+ file.message_type.each { |msg| assign_message(pool, scope, package, msg) }
50
+ file.enum_type.each { |enum| assign_enum(pool, scope, package, enum.name) }
51
+ end
52
+
53
+ def assign_message(pool, parent, scope_fullname, msg)
54
+ full_name = qualify(scope_fullname, msg.name)
55
+ klass = pool.lookup(full_name).msgclass
56
+ set_const(parent, Naming.constant_name(msg.name), klass)
57
+
58
+ # Synthetic map-entry messages get no constant (protoc skips them too).
59
+ msg.nested_type.each do |nested|
60
+ next if nested.options&.map_entry
61
+
62
+ assign_message(pool, klass, full_name, nested)
63
+ end
64
+ msg.enum_type.each { |enum| assign_enum(pool, klass, full_name, enum.name) }
65
+ end
66
+
67
+ def assign_enum(pool, parent, scope_fullname, enum_name)
68
+ full_name = qualify(scope_fullname, enum_name)
69
+ set_const(parent, Naming.constant_name(enum_name), pool.lookup(full_name).enummodule)
70
+ end
71
+
72
+ # --- helpers --------------------------------------------------------------
73
+
74
+ def qualify(scope, name)
75
+ scope.nil? || scope.empty? ? name : "#{scope}.#{name}"
76
+ end
77
+
78
+ # Walk/create a chain of modules (e.g. %w[MyCo SubPkg V1]) under Object,
79
+ # returning the innermost. An empty list returns Object (no-package case).
80
+ def ensure_module(segments)
81
+ segments.reduce(Object) do |parent, segment|
82
+ if parent.const_defined?(segment, false)
83
+ parent.const_get(segment, false)
84
+ else
85
+ mod = Module.new
86
+ parent.const_set(segment, mod)
87
+ mod
88
+ end
89
+ end
90
+ end
91
+
92
+ def set_const(parent, name, value)
93
+ parent.const_set(name, value) unless parent.const_defined?(name, false)
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Magicprotorb
4
+ # Prepended onto Kernel so that a bare `require "magicprotorb/<proto>_pb"`
5
+ # compiles and registers <proto>.proto at require time. This is the Ruby
6
+ # counterpart to magicproto's sys.meta_path finder.
7
+ #
8
+ # Only names under "magicprotorb/" that end in "_pb" / "_services_pb" are
9
+ # claimed; everything else (including the native extension and version file)
10
+ # falls through to the real require via super.
11
+ module RequireHook
12
+ PREFIX = "magicprotorb/"
13
+ MUTEX = Mutex.new
14
+ @required = {}
15
+
16
+ def require(name)
17
+ handled = RequireHook.dispatch(name)
18
+ handled.nil? ? super : handled
19
+ end
20
+
21
+ # Returns true (loaded now), false (already loaded), or nil (not ours).
22
+ def self.dispatch(name)
23
+ return nil unless name.is_a?(String) && name.start_with?(PREFIX)
24
+
25
+ rest = name[PREFIX.length..]
26
+ if rest.end_with?("_services_pb")
27
+ proto = "#{rest.delete_suffix("_services_pb")}.proto"
28
+ loader = :load_services
29
+ elsif rest.end_with?("_pb")
30
+ proto = "#{rest.delete_suffix("_pb")}.proto"
31
+ loader = :load_messages
32
+ else
33
+ return nil
34
+ end
35
+
36
+ MUTEX.synchronize do
37
+ return false if @required[name]
38
+
39
+ Loader.public_send(loader, proto)
40
+ @required[name] = true
41
+ end
42
+ true
43
+ end
44
+ end
45
+ end