selenium-webdriver 4.45.0 → 4.46.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGES +7 -0
  3. data/bin/linux/selenium-manager +0 -0
  4. data/bin/macos/selenium-manager +0 -0
  5. data/bin/windows/selenium-manager.exe +0 -0
  6. data/lib/selenium/webdriver/bidi/protocol/bluetooth.rb +465 -0
  7. data/lib/selenium/webdriver/bidi/protocol/browser.rb +222 -0
  8. data/lib/selenium/webdriver/bidi/protocol/browsing_context.rb +694 -0
  9. data/lib/selenium/webdriver/bidi/protocol/domain.rb +40 -0
  10. data/lib/selenium/webdriver/bidi/protocol/emulation.rb +334 -0
  11. data/lib/selenium/webdriver/bidi/protocol/input.rb +281 -0
  12. data/lib/selenium/webdriver/bidi/protocol/log.rb +104 -0
  13. data/lib/selenium/webdriver/bidi/protocol/network.rb +637 -0
  14. data/lib/selenium/webdriver/bidi/protocol/permissions.rb +73 -0
  15. data/lib/selenium/webdriver/bidi/protocol/script.rb +874 -0
  16. data/lib/selenium/webdriver/bidi/protocol/session.rb +241 -0
  17. data/lib/selenium/webdriver/bidi/protocol/speculation.rb +56 -0
  18. data/lib/selenium/webdriver/bidi/protocol/storage.rb +157 -0
  19. data/lib/selenium/webdriver/bidi/protocol/user_agent_client_hints.rb +83 -0
  20. data/lib/selenium/webdriver/bidi/protocol/web_extension.rb +93 -0
  21. data/lib/selenium/webdriver/bidi/protocol.rb +39 -0
  22. data/lib/selenium/webdriver/bidi/serialization/record.rb +226 -0
  23. data/lib/selenium/webdriver/bidi/serialization/union.rb +114 -0
  24. data/lib/selenium/webdriver/bidi/serialization.rb +78 -0
  25. data/lib/selenium/webdriver/bidi/support/bidi_generate.rb +936 -0
  26. data/lib/selenium/webdriver/bidi/support/check_generated.rb +55 -0
  27. data/lib/selenium/webdriver/bidi/transport.rb +52 -0
  28. data/lib/selenium/webdriver/bidi.rb +3 -0
  29. data/lib/selenium/webdriver/chrome/driver.rb +3 -3
  30. data/lib/selenium/webdriver/common/client_config.rb +97 -0
  31. data/lib/selenium/webdriver/common/driver.rb +2 -2
  32. data/lib/selenium/webdriver/common/local_driver.rb +15 -4
  33. data/lib/selenium/webdriver/common/proxy.rb +1 -1
  34. data/lib/selenium/webdriver/common.rb +1 -0
  35. data/lib/selenium/webdriver/edge/driver.rb +3 -3
  36. data/lib/selenium/webdriver/firefox/driver.rb +3 -3
  37. data/lib/selenium/webdriver/ie/driver.rb +3 -3
  38. data/lib/selenium/webdriver/remote/bidi_bridge.rb +6 -1
  39. data/lib/selenium/webdriver/remote/bridge.rb +8 -10
  40. data/lib/selenium/webdriver/remote/driver.rb +12 -4
  41. data/lib/selenium/webdriver/remote/http/common.rb +25 -12
  42. data/lib/selenium/webdriver/remote/http/default.rb +56 -39
  43. data/lib/selenium/webdriver/safari/driver.rb +3 -3
  44. data/lib/selenium/webdriver/version.rb +1 -1
  45. metadata +25 -2
@@ -0,0 +1,936 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Licensed to the Software Freedom Conservancy (SFC) under one
4
+ # or more contributor license agreements. See the NOTICE file
5
+ # distributed with this work for additional information
6
+ # regarding copyright ownership. The SFC licenses this file
7
+ # to you under the Apache License, Version 2.0 (the
8
+ # "License"); you may not use this file except in compliance
9
+ # with the License. You may obtain a copy of the License at
10
+ #
11
+ # http://www.apache.org/licenses/LICENSE-2.0
12
+ #
13
+ # Unless required by applicable law or agreed to in writing,
14
+ # software distributed under the License is distributed on an
15
+ # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16
+ # KIND, either express or implied. See the License for the
17
+ # specific language governing permissions and limitations
18
+ # under the License.
19
+
20
+ require 'json'
21
+ require 'erb'
22
+ require 'fileutils'
23
+
24
+ # Generates Ruby WebDriver BiDi protocol modules from the shared, binding-neutral
25
+ # BiDi schema produced by the JavaScript generator (see PR #17700):
26
+ # //javascript/selenium-webdriver:create-bidi-src_schema -> bidi-schema.json
27
+ #
28
+ # The schema is already normalized (inline enums hoisted, unions canonicalized,
29
+ # group composition flattened, wire names and nullability preserved verbatim), so
30
+ # this generator is a straight projection into Ruby with no CDDL interpretation.
31
+ #
32
+ # Invoked via `bazel run //rb/lib/selenium/webdriver:bidi-generate`. Bazel passes
33
+ # the schema path (resolved through runfiles) plus the workspace-relative output
34
+ # directory as ARGV. Can also be run directly:
35
+ # ruby bidi_generate.rb schema.json output/dir
36
+ #
37
+ # @api private
38
+ module BiDiGenerate
39
+ # Companion to the generated `@api private` tags: the page explaining why the BiDi
40
+ # implementation layer is internal and what higher-level API to use instead (see #17628).
41
+ BIDI_DOC_URL = 'https://www.selenium.dev/documentation/warnings/bidi-implementation/'
42
+
43
+ # RuboCop's Layout/LineLength max; emitted Serialization::Record.define calls wrap to stay within it.
44
+ LINE_LIMIT = 120
45
+
46
+ # Ruby keywords that cannot be used as method names unquoted.
47
+ RUBY_RESERVED = %w[begin end rescue ensure raise return yield if unless while until for do
48
+ case when then class module def].freeze
49
+
50
+ def self.camel_to_snake(str)
51
+ str
52
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
53
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
54
+ .downcase
55
+ end
56
+
57
+ def self.snake_to_class_name(snake)
58
+ snake.split('_').map(&:capitalize).join
59
+ end
60
+
61
+ # Local constant for a domain-scoped type: "script.LocalValue" -> "LocalValue".
62
+ # The first letter is capitalized so a lower-cased spec name (e.g.
63
+ # "permissions.setPermission") still yields a valid Ruby constant.
64
+ def self.type_class_name(type_name)
65
+ type_name.split('.', 2).last.sub(/\A[a-z]/, &:upcase)
66
+ end
67
+
68
+ # Protocol-relative class path: "script.LocalValue" -> "Script::LocalValue".
69
+ def self.type_ruby_path(type_name)
70
+ domain = type_name.split('.', 2).first
71
+ "#{snake_to_class_name(camel_to_snake(domain))}::#{type_class_name(type_name)}"
72
+ end
73
+
74
+ # Source literal for a discriminator/const value (string, boolean, or number).
75
+ def self.ruby_literal(value)
76
+ return 'nil' if value.nil?
77
+
78
+ value.is_a?(String) ? "'#{value}'" : value.to_s
79
+ end
80
+
81
+ # Renders `prefix(args)` on one line, or one argument per line when it would exceed
82
+ # LINE_LIMIT at the given indent. open/close default to parentheses (pass {} for a
83
+ # hash literal) so emitted calls and literals stay within RuboCop's length limit.
84
+ def self.wrap_call(prefix, args, indent, open: '(', close: ')')
85
+ one_line = "#{prefix}#{open}#{args.join(', ')}#{close}"
86
+ return one_line if args.empty? || indent + one_line.length <= LINE_LIMIT
87
+
88
+ pad = ' ' * (indent + 2)
89
+ "#{prefix}#{open}\n#{pad}#{args.join(",\n#{pad}")}\n#{' ' * indent}#{close}"
90
+ end
91
+
92
+ # Append underscore to avoid clashing with Ruby reserved keywords.
93
+ def self.safe_method_name(name)
94
+ RUBY_RESERVED.include?(name) ? "#{name}_" : name
95
+ end
96
+
97
+ # Object/Data methods a Data member name would shadow (breaking value semantics
98
+ # or reflection), e.g. a "method" field overriding Object#method.
99
+ RESERVED_FIELD_NAMES = (RUBY_RESERVED + %w[method hash class send dup clone freeze inspect
100
+ to_h to_s members with deconstruct deconstruct_keys
101
+ object_id tap itself then display
102
+ extensible extensions]).freeze
103
+
104
+ # Append underscore to a field name that would shadow a core method; the wire
105
+ # name is unaffected, only the Ruby reader is renamed.
106
+ def self.safe_field_name(name)
107
+ RESERVED_FIELD_NAMES.include?(name) ? "#{name}_" : name
108
+ end
109
+
110
+ # SCREAMING_SNAKE constant name for an enum, matching the EVENTS map style
111
+ # (ReadinessState → READINESS_STATE).
112
+ def self.screaming_snake(camel)
113
+ camel_to_snake(camel).upcase
114
+ end
115
+
116
+ # Makes an RBS type admit nil, idempotently (an already-nilable or opaque type is left
117
+ # as-is). Applied to a field whose schema type is nullable, so its value type allows nil;
118
+ # keyword-optionality is expressed separately by the `?` prefix (see rbs_part / rbs_arg).
119
+ def self.rbs_nilable(type)
120
+ return type if type == 'untyped' || type == 'nil' || type.end_with?('?')
121
+
122
+ "#{type}?"
123
+ end
124
+
125
+ # Domain-qualified path to an enum's frozen hash constant
126
+ # ("browsingContext.ReadinessState" → "BrowsingContext::READINESS_STATE"), so a
127
+ # generated command method can reference it for an outbound membership check.
128
+ def self.enum_const_path(type_name)
129
+ domain, local = type_name.split('.', 2)
130
+ "#{snake_to_class_name(camel_to_snake(domain))}::#{screaming_snake(local)}"
131
+ end
132
+
133
+ # snake_case hash key for an enum value (only a label for the wire value it maps to).
134
+ # Preserves camelCase word boundaries (beforeRequestSent → before_request_sent), maps a
135
+ # leading minus to "neg" (-0 → neg0, -Infinity → neg_infinity; no underscore before a
136
+ # digit, so the key stays normalcase), and collapses other punctuation
137
+ # (dedicated-worker → dedicated_worker).
138
+ def self.enum_key(value)
139
+ camel_to_snake(value.to_s)
140
+ .sub(/\A-(?=\d)/, 'neg')
141
+ .sub(/\A-/, 'neg_')
142
+ .gsub(/[^a-z0-9]+/, '_')
143
+ .gsub(/\A_+|_+\z/, '')
144
+ end
145
+
146
+ # ruby_name is the snake_case keyword argument; wire_name is the exact key the
147
+ # protocol expects (baked verbatim from the schema, no runtime conversion). enum is
148
+ # the allowed-values constant path for an enum-typed param (nil otherwise).
149
+ Param = Struct.new(:ruby_name, :wire_name, :required, :enum, :rbs, keyword_init: true) do
150
+ # Optionals default to UNSET (omitted), so an explicit nil can still reach a
151
+ # nullable field as wire null.
152
+ def sig_part
153
+ required ? "#{ruby_name}:" : "#{ruby_name}: Serialization::UNSET"
154
+ end
155
+
156
+ def enum_check(indent)
157
+ return unless enum
158
+
159
+ BiDiGenerate.wrap_call('Serialization.validate!', ["'#{wire_name}'", ruby_name, enum], indent)
160
+ end
161
+
162
+ # An RBS keyword parameter carrying the param's value type. The `?` prefix marks the
163
+ # keyword omittable; the value type already carries the schema's nullability, so nil is
164
+ # admitted only for a nullable field (a non-nullable one rejects nil at construction).
165
+ def rbs_part
166
+ type = rbs || 'untyped'
167
+ required ? "#{ruby_name}: #{type}" : "?#{ruby_name}: #{type}"
168
+ end
169
+ end
170
+
171
+ # params_class is the Parameters class the named args construct (nil for a no-arg
172
+ # command); union_params picks its variant via `.build` rather than `.new`. result_ref
173
+ # is the Protocol-relative result class path, or nil to return the raw hash.
174
+ Command = Struct.new(:wire_name, :method_name, :params, :result_ref, :params_class,
175
+ :union_params, keyword_init: true) do
176
+ def required_params = params.select(&:required)
177
+ def optional_params = params.reject(&:required)
178
+ def enum_checks(indent) = params.filter_map { |p| p.enum_check(indent) }
179
+
180
+ # `def name` or `def name(...)` — wrapped one argument per line when the signature
181
+ # would exceed the line limit.
182
+ def def_header(indent)
183
+ return "def #{method_name}" if params.empty?
184
+
185
+ BiDiGenerate.wrap_call("def #{method_name}", required_params.map(&:sig_part) + optional_params.map(&:sig_part),
186
+ indent)
187
+ end
188
+
189
+ # The RBS method signature `(params) -> return` — the return is the typed result class
190
+ # when the command parses one, else `untyped`.
191
+ def rbs_signature
192
+ "(#{rbs_params}) -> #{rbs_return}"
193
+ end
194
+
195
+ def rbs_params
196
+ (required_params.map(&:rbs_part) + optional_params.map(&:rbs_part)).join(', ')
197
+ end
198
+
199
+ def rbs_return
200
+ result_ref ? "::Selenium::WebDriver::BiDi::Protocol::#{result_ref}" : 'untyped'
201
+ end
202
+
203
+ # The `params = …` line built before the execute call, or nil for a no-arg command. A
204
+ # record builds its Parameters object and a union dispatches via `.build` (whose typed
205
+ # as_json emits explicit null where a flat hash through Transport could not). Wrapped
206
+ # one entry per line when long, so it (and the short execute call) stay within the limit.
207
+ def params_assignment(indent)
208
+ return nil if params.empty?
209
+
210
+ kwargs = params.map { |p| "#{p.ruby_name}: #{p.ruby_name}" }
211
+ BiDiGenerate.wrap_call("params = #{params_class}.#{union_params ? 'build' : 'new'}", kwargs, indent)
212
+ end
213
+
214
+ # `@transport.execute(cmd:[, params: params][, result:])`. The result type is
215
+ # referenced directly (resolved lazily in the method body, and unambiguous within
216
+ # Protocol). Params, when present, are the `params` local built above.
217
+ def execute_call(indent)
218
+ args = ["cmd: '#{wire_name}'"]
219
+ args << 'params: params' unless params.empty?
220
+ args << "result: #{result_ref}" if result_ref
221
+ BiDiGenerate.wrap_call('execute', args, indent)
222
+ end
223
+ end
224
+
225
+ # payload_ref is the Protocol-relative class the event's params parse into (nil when
226
+ # non-structured, dispatched raw) — the inbound counterpart to a command's result_ref.
227
+ Event = Struct.new(:wire_name, :event_name, :payload_ref, keyword_init: true) do
228
+ # An EVENT_TYPES entry mapping the wire method to the type its params parse into.
229
+ def type_entry = "'#{wire_name}' => #{payload_ref || 'nil'}"
230
+ end
231
+
232
+ # constant_name is the SCREAMING_SNAKE hash name; pairs are [symbol_key, wire_value] tuples.
233
+ Enum = Struct.new(:constant_name, :pairs, keyword_init: true)
234
+
235
+ # ref is the Protocol-relative class path for a nested structured field (nil
236
+ # for a scalar/opaque field); list wraps it in an array. wire_key is the exact
237
+ # JSON payload key (the schema's `wire` name, baked verbatim).
238
+ FieldIR = Struct.new(:ruby_name, :wire_key, :required, :nullable, :ref, :list, :enum, :primitive, :rbs,
239
+ keyword_init: true) do
240
+ # A `Serialization::Record.define` spec entry: `name: 'jsonKey'` shorthand, or
241
+ # `name: {wire_key:, …}` when the field carries JSON facts beyond its name.
242
+ # enum carries the allowed-values constant path, validated at construction.
243
+ def spec_entry(indent = 0)
244
+ meta = []
245
+ meta << 'required: false' unless required
246
+ meta << 'nullable: true' if nullable
247
+ meta << "ref: '#{ref}'" if ref
248
+ meta << 'list: true' if list
249
+ meta << "enum: '#{enum}'" if enum
250
+ meta << "primitive: '#{primitive}'" if primitive
251
+ return "#{ruby_name}: '#{wire_key}'" if meta.empty?
252
+
253
+ meta.unshift("wire_key: '#{wire_key}'")
254
+ BiDiGenerate.wrap_call("#{ruby_name}: ", meta, indent, open: '{', close: '}')
255
+ end
256
+
257
+ # The `self.new` keyword for this field — a user-supplied input carrying the field's
258
+ # value type. The `?` prefix marks the field omittable; its value type already carries
259
+ # the schema's nullability, so nil is admitted only for a nullable field.
260
+ def rbs_arg
261
+ required ? "#{ruby_name}: #{rbs}" : "?#{ruby_name}: #{rbs}"
262
+ end
263
+
264
+ # The `attr_reader` type. A present value is `rbs`; an omitted optional reads back
265
+ # the UNSET sentinel, which a value type can't capture, so optionals stay `untyped`.
266
+ def rbs_reader
267
+ "#{ruby_name}: #{required ? rbs : 'untyped'}"
268
+ end
269
+ end
270
+
271
+ # A generated immutable value type (a Serialization::Record.define(...) class). discriminator is the
272
+ # baked variant tag {ruby_name:, wire:, value:} or nil; schema_name/synthetic/owner/
273
+ # nested drive owner-nesting (see nest_synthetic).
274
+ TypeClass = Struct.new(:ruby_name, :fields, :discriminator, :extensible,
275
+ :schema_name, :synthetic, :owner, :label, :nested, keyword_init: true) do
276
+ def union? = false
277
+ def nested_types = nested || []
278
+
279
+ # Keyword arguments for `Serialization::Record.define(...)`: the fixed discriminator member
280
+ # first, then the fields, then the extensible flag.
281
+ def define_entries(entry_indent)
282
+ entries = []
283
+ entries << discriminator_entry if discriminator
284
+ entries.concat(fields.map { |f| f.spec_entry(entry_indent) })
285
+ entries << 'extensible: true' if extensible
286
+ entries
287
+ end
288
+
289
+ # `Name = Serialization::Record.define(...)` as one line when it fits within the line limit at the
290
+ # given indent, else wrapped one entry per line — so the emitted source stays inside
291
+ # RuboCop's length limit without a per-file exception. Entries render at indent + 2, the
292
+ # indent a long field hash wraps itself against.
293
+ def define_assignment(name, indent)
294
+ BiDiGenerate.wrap_call("#{name} = Serialization::Record.define", define_entries(indent + 2), indent)
295
+ end
296
+
297
+ def discriminator_entry
298
+ literal = BiDiGenerate.ruby_literal(discriminator[:value])
299
+ if discriminator[:wire] == discriminator[:ruby_name].to_s
300
+ "#{discriminator[:ruby_name]}: {fixed: #{literal}}"
301
+ else
302
+ "#{discriminator[:ruby_name]}: {wire_key: '#{discriminator[:wire]}', fixed: #{literal}}"
303
+ end
304
+ end
305
+
306
+ # Every Data member gets a typed `attr_reader`: the baked discriminator (typed to its
307
+ # const), each field (typed when required; UNSET-bearing optionals stay untyped), then
308
+ # the extensible passthrough.
309
+ def rbs_readers
310
+ readers = []
311
+ readers << "#{discriminator[:ruby_name]}: #{discriminator[:rbs]}" if discriminator
312
+ readers.concat(fields.map(&:rbs_reader))
313
+ readers << 'extensions: Hash[String, untyped]' if extensible
314
+ readers
315
+ end
316
+
317
+ # The keyword arguments `self.new` accepts: each constructable field with its value
318
+ # type, plus the optional extensions bag. The fixed discriminator is baked, so its
319
+ # value is ignored — but the lenient `**kwargs` constructor still accepts it (and a
320
+ # command method passes it through), so it is advertised as an optional keyword typed
321
+ # to its const.
322
+ def rbs_new_args
323
+ parts = []
324
+ parts << "?#{discriminator[:ruby_name]}: #{discriminator[:rbs]}" if discriminator
325
+ parts.concat(fields.map(&:rbs_arg))
326
+ parts << '?extensions: untyped' if extensible
327
+ parts.join(', ')
328
+ end
329
+ end
330
+
331
+ # mode is :value (matched by discriminator), :fallback (the no-tag variant), or
332
+ # :presence (selected when its required wire keys are all present).
333
+ VariantIR = Struct.new(:mode, :value, :ref, :requires, keyword_init: true) do
334
+ # A string tag becomes an idiomatic symbol key; a bool/number tag stays a literal.
335
+ def symbolic? = value.is_a?(::String)
336
+
337
+ # The variant table entry: `sym: 'Ref'` for a string tag, else `true => 'Ref'`.
338
+ def variant_entry
339
+ key = symbolic? ? "#{BiDiGenerate.enum_key(value)}:" : "#{BiDiGenerate.ruby_literal(value)} =>"
340
+ "#{key} '#{ref}'"
341
+ end
342
+
343
+ # `sym: 'wireToken'` feeding the union's inbound wire->symbol map (string tags only).
344
+ def discriminator_pair
345
+ "#{BiDiGenerate.enum_key(value)}: '#{value}'" if symbolic?
346
+ end
347
+ end
348
+
349
+ # A generated discriminated union (< Serialization::Union, resolved by lexical scope).
350
+ # nested holds its synthetic variant records (see nest_synthetic).
351
+ UnionClass = Struct.new(:ruby_name, :discriminator_wire, :variants, :schema_name, :nested, keyword_init: true) do
352
+ def union? = true
353
+ def value_variants = variants.select { |v| v.mode == :value }
354
+ def presence_variants = variants.select { |v| v.mode == :presence }
355
+ def fallback_variant = variants.find { |v| v.mode == :fallback }
356
+ def nested_types = nested || []
357
+
358
+ # `discriminator 'wire'`, or `discriminator 'wire', {sym: 'token', …}` (wrapped when
359
+ # long) carrying the inbound wire->symbol map for string-tagged variants.
360
+ def discriminator_decl(indent)
361
+ pairs = value_variants.filter_map(&:discriminator_pair)
362
+ head = "discriminator '#{discriminator_wire}'"
363
+ return head if pairs.empty?
364
+
365
+ BiDiGenerate.wrap_call("#{head}, ", pairs, indent, open: '{', close: '}')
366
+ end
367
+ end
368
+
369
+ Module = Struct.new(:name, :ruby_class, :filename, :commands, :events, :enums, :types, keyword_init: true)
370
+
371
+ class Schema
372
+ def initialize(schema)
373
+ @types = schema['types']
374
+ @commands = schema['commands']
375
+ @events = schema['events']
376
+ promote_command_params_records!
377
+ end
378
+
379
+ # A command written in CDDL map form carries its params as an *inline* object (rather
380
+ # than the usual group form referencing a named params type). The projector links the
381
+ # command to those params, but hoists them into a synthetic record owned by the
382
+ # command's message envelope. That envelope is suppressed (Transport forms it), so the
383
+ # synthetic params record would never be emitted even though the command's params ref
384
+ # points straight at it. Promote it to a top-level domain record so the generator emits
385
+ # and references it like any other params type. Today this is exactly
386
+ # `userAgentClientHints.setClientHintsOverride`.
387
+ def promote_command_params_records!
388
+ @commands.each do |cmd|
389
+ ref = cmd.dig('params', 'ref')
390
+ next unless ref
391
+
392
+ type = @types[ref]
393
+ promote_to_domain_type!(ref) if type && envelope_synthetic?(type)
394
+ end
395
+ end
396
+
397
+ # Strip the synthetic/owner/label tags so a lifted-out type emits as a top-level
398
+ # domain record instead of nesting under its (suppressed) envelope.
399
+ def promote_to_domain_type!(name)
400
+ type = @types[name]
401
+ type&.delete('synthetic')
402
+ type&.delete('owner')
403
+ type&.delete('label')
404
+ end
405
+
406
+ # Domains that carry a command or event each become one generated module.
407
+ def domains
408
+ (@commands + @events).map { |entry| entry['domain'] }.uniq
409
+ end
410
+
411
+ def commands_for(domain)
412
+ @commands.select { |c| c['domain'] == domain }
413
+ end
414
+
415
+ def type_kind(ref)
416
+ @types[ref]&.fetch('kind', nil)
417
+ end
418
+
419
+ def events_for(domain)
420
+ @events.select { |e| e['domain'] == domain }
421
+ end
422
+
423
+ # Flat params for a command: the record's fields, or — for a union of
424
+ # records — the merged superset of variant fields. Returns [] for commands
425
+ # with no params, or nil when params can't be flattened (alias, or a union
426
+ # whose variants aren't all records) so the caller forwards verbatim.
427
+ def params_for(params_ref)
428
+ return [] unless params_ref
429
+
430
+ type = @types[params_ref['ref']]
431
+ return nil unless type
432
+
433
+ case type['kind']
434
+ when 'record' then record_params(type['fields'])
435
+ when 'union' then union_params(type, params_ref['ref'])
436
+ end
437
+ end
438
+
439
+ # Enum types declared under "<domain>." become nested constant modules.
440
+ def enums_for(domain)
441
+ @types.filter_map do |name, type|
442
+ next unless type['kind'] == 'enum'
443
+ next unless name.start_with?("#{domain}.")
444
+
445
+ pairs = type['values'].map { |v| [BiDiGenerate.enum_key(v), v.to_s] }
446
+ Enum.new(constant_name: BiDiGenerate.screaming_snake(name.sub("#{domain}.", '')), pairs: pairs)
447
+ end
448
+ end
449
+
450
+ # Structured value classes (records + discriminated unions) declared under
451
+ # "<domain>." Empty records are projector artifacts with nothing to carry, so
452
+ # they stay opaque hashes; only non-empty records and unions become classes.
453
+ # Command/event message envelopes (the `{method, params}` wire wrapper) are
454
+ # skipped — Transport forms that envelope, so nothing references them.
455
+ def types_for(domain)
456
+ prefix = "#{domain}."
457
+ @types.filter_map do |name, type|
458
+ next unless name.start_with?(prefix)
459
+
460
+ case type['kind']
461
+ when 'record' then record_class(name, type) unless type['fields'].empty? || suppressed_record?(type)
462
+ when 'union' then union_class(name)
463
+ when 'alias' then union_class(name) if type['type'].key?('union')
464
+ end
465
+ end
466
+ end
467
+
468
+ # Records the generator deliberately does not emit: a message envelope, or a
469
+ # synthetic params record lifted out of one. Both are reachable only through the
470
+ # envelope, which Transport replaces — so nothing else references them.
471
+ def suppressed_record?(type)
472
+ message_envelope?(type) || envelope_synthetic?(type)
473
+ end
474
+
475
+ # A protocol message envelope is a record with a baked `method` discriminator
476
+ # (`{method: <const>, params: …}`) — the wire shape of a command/event message.
477
+ # No value type carries a const `method` field, so this is unambiguous.
478
+ def message_envelope?(type)
479
+ type['fields'].any? { |f| f['wire'] == 'method' && f['type'].key?('const') }
480
+ end
481
+
482
+ # A synthetic record lifted out as an envelope's params (its owner is an envelope).
483
+ def envelope_synthetic?(type)
484
+ return false unless type['synthetic']
485
+
486
+ owner = @types[type['owner']]
487
+ owner && owner['kind'] == 'record' && message_envelope?(owner)
488
+ end
489
+
490
+ # The Protocol-relative class path a command result parses into, or nil when
491
+ # it is non-structured (or a bare list, returned raw).
492
+ def structured_ref(name)
493
+ resolved = resolve_named(name)
494
+ resolved[:list] ? nil : resolved[:ref]
495
+ end
496
+
497
+ private
498
+
499
+ def domain_path(name)
500
+ name.include?('.') ? ruby_path(name) : nil
501
+ end
502
+
503
+ # Class path, nesting a synthetic type under its owner as `Owner::Label` so a ref
504
+ # resolves to the same nested constant the type is emitted as.
505
+ def ruby_path(name)
506
+ type = @types[name]
507
+ return BiDiGenerate.type_ruby_path(name) unless type && type['synthetic']
508
+
509
+ "#{ruby_path(type['owner'])}::#{type['label']}"
510
+ end
511
+
512
+ # Resolution for anything not modeled as a value type (scalar, enum, empty record).
513
+ # Frozen because it is shared across callers.
514
+ OPAQUE = {ref: nil, list: false, rbs: 'untyped'}.freeze
515
+
516
+ # Projects a schema type node to {ref:, list:, nullable:, rbs:}. Deriving the
517
+ # serialization facts and the RBS signature from one walk keeps them from drifting
518
+ # apart when the schema shape changes.
519
+ def resolve(node)
520
+ nullable = node['nullable'] ? true : false
521
+ if node.key?('list')
522
+ element = resolve(node['list'])
523
+ return {ref: element[:ref], list: true, nullable: nullable, rbs: nilable("Array[#{element[:rbs]}]", nullable)}
524
+ end
525
+ if node.key?('ref')
526
+ named = resolve_named(node['ref'])
527
+ return {ref: named[:ref], list: named[:list], nullable: nullable, rbs: nilable(named[:rbs], nullable)}
528
+ end
529
+ return resolve_union(node, nullable) if node.key?('union')
530
+
531
+ {ref: nil, list: false, nullable: nullable, primitive: checkable_primitive(node),
532
+ rbs: nilable(scalar_rbs(node), nullable)}
533
+ end
534
+
535
+ # An inline union of one union-typed arm plus scalars (e.g. a MappingRemoteValue entry,
536
+ # RemoteValue / string) parses through that arm — its from_json returns a non-Hash value
537
+ # unchanged, so the scalar siblings pass through. Carry its ref so nested entries are typed;
538
+ # any other shape (a record arm, multiple structured arms, all scalars) stays opaque.
539
+ def resolve_union(node, nullable)
540
+ refs = node['union'].select { |arm| arm.key?('ref') }
541
+ opaque = {ref: nil, list: false, nullable: nullable, rbs: nilable('untyped', nullable)}
542
+ return opaque unless refs.one? && union_ref?(refs.first['ref'])
543
+
544
+ named = resolve_named(refs.first['ref'])
545
+ {ref: named[:ref], list: named[:list], nullable: nullable, rbs: nilable('untyped', nullable)}
546
+ end
547
+
548
+ # True when a ref (following aliases) is a union — the only arm whose from_json tolerates a
549
+ # scalar sibling. A record arm would raise on one, so it is not carried.
550
+ def union_ref?(name)
551
+ type = @types[name]
552
+ return false unless type
553
+ return union_ref?(type['type']['ref']) if type['kind'] == 'alias' && type['type'].key?('ref')
554
+
555
+ type['kind'] == 'union'
556
+ end
557
+
558
+ # Resolves a named ref to the same {ref:, list:, rbs:} facts, transparently
559
+ # following aliases — including alias-to-list — so an element type behind an alias
560
+ # (e.g. script.ListLocalValue -> [script.LocalValue]) is preserved. Nullability is a
561
+ # property of the referencing node (applied by +resolve+), so it is not threaded
562
+ # here. seen guards against cyclic ref-aliases.
563
+ def resolve_named(name, seen = {})
564
+ return OPAQUE if name.nil? || seen[name]
565
+
566
+ seen[name] = true
567
+ type = @types[name]
568
+ return OPAQUE unless type
569
+
570
+ case type['kind']
571
+ when 'record' then type['fields'].empty? ? OPAQUE : named_type(name)
572
+ when 'union' then named_type(name)
573
+ when 'enum' then {ref: nil, list: false, rbs: 'Symbol'}
574
+ when 'alias' then resolve_named_alias(name, type['type'], seen)
575
+ else OPAQUE
576
+ end
577
+ end
578
+
579
+ # A named structured type's serialization ref (nil for a dotless/global type, never
580
+ # emitted as a class) and its absolute RBS class path, derived independently so each
581
+ # output keeps its own treatment of dotless names.
582
+ def named_type(name)
583
+ {ref: domain_path(name), list: false, rbs: rbs_abs(ruby_path(name))}
584
+ end
585
+
586
+ def resolve_named_alias(name, inner, seen)
587
+ return named_type(name) if inner.key?('union')
588
+ return resolve_named(inner['ref'], seen) if inner.key?('ref')
589
+
590
+ if inner.key?('list')
591
+ element = resolve(inner['list'])
592
+ return {ref: element[:ref], list: true, rbs: "Array[#{element[:rbs]}]"}
593
+ end
594
+
595
+ {ref: nil, list: false, rbs: scalar_rbs(inner)}
596
+ end
597
+
598
+ def nilable(type, flag)
599
+ flag ? BiDiGenerate.rbs_nilable(type) : type
600
+ end
601
+
602
+ def record_class(name, type)
603
+ const = type['fields'].find { |f| baked_discriminator?(f) }
604
+ discriminator = const && {ruby_name: BiDiGenerate.safe_field_name(BiDiGenerate.camel_to_snake(const['name'])),
605
+ wire: const['wire'], value: const['type']['const'],
606
+ rbs: rbs_const(const['type']['const'])}
607
+ fields = type['fields'].reject { |f| baked_discriminator?(f) }.map { |f| field_ir(f) }
608
+ TypeClass.new(ruby_name: BiDiGenerate.type_class_name(name), fields: fields,
609
+ discriminator: discriminator, extensible: type['extensible'] ? true : false,
610
+ schema_name: name, synthetic: type['synthetic'] ? true : false,
611
+ owner: type['owner'], label: type['label'])
612
+ end
613
+
614
+ # A const field is a baked discriminator tag, unless it is also nullable: the spec's
615
+ # `literal | null` (browsingContext.setBypassCSP, emulation.setScriptingEnabled) is a
616
+ # settable value (the literal to set, null to clear), so it stays a normal field that
617
+ # can serialize null rather than a fixed tag that can only ever emit the literal.
618
+ def baked_discriminator?(field)
619
+ field['type'].key?('const') && !field['type']['nullable']
620
+ end
621
+
622
+ def field_ir(field)
623
+ resolved = resolve(field['type'])
624
+ ruby_name = BiDiGenerate.safe_field_name(BiDiGenerate.camel_to_snake(field['name']))
625
+ FieldIR.new(ruby_name: ruby_name, wire_key: field['wire'],
626
+ required: field['required'], nullable: resolved[:nullable],
627
+ ref: resolved[:ref], list: resolved[:list], enum: enum_const(field['type']),
628
+ primitive: resolved[:primitive], rbs: resolved[:rbs])
629
+ end
630
+
631
+ def union_class(name)
632
+ type = @types[name]
633
+ # A first-class union carries the schema's authoritative dispatch `selector`
634
+ # (derived spec-faithfully, including null discriminators and the spec's choice
635
+ # order); consume it rather than re-deriving and silently depending on emit
636
+ # order. An alias-to-union (only input.Origin) has no selector — its const-string
637
+ # arms aren't first-class types — so it keeps the structural re-derivation.
638
+ type['kind'] == 'union' ? union_from_selector(name, type['selector']) : union_from_alias(name)
639
+ end
640
+
641
+ # Map a union `selector` to dispatch variants the template renders:
642
+ # { by, variants, default? } -> a discriminator table (value => ref), `default`
643
+ # as the fallback (it may itself be a union, which finishes the dispatch).
644
+ # { ordered: [{ ref, requires }] } -> presence rules in the spec's choice order.
645
+ # { correlated: true } -> resolved by request id, not the payload, so no payload
646
+ # dispatch. Unreachable here: every correlated union is a top-level result
647
+ # grouping, never domain-scoped, so it is never emitted as a class.
648
+ def union_from_selector(name, selector)
649
+ # A correlated union is resolved by request id, never the payload, so it carries
650
+ # no dispatch — it must never be emitted (every one is a top-level result
651
+ # grouping). Fail loudly if a future schema makes one domain-scoped rather than
652
+ # emit a Union whose every parse would raise.
653
+ if selector['correlated']
654
+ raise "correlated union #{name} must not be emitted (resolved by request id, not payload)"
655
+ end
656
+
657
+ variants = selector['by'] ? discriminated_variants(selector) : ordered_variants(selector)
658
+ raise "union #{name} selector yielded no dispatch variants" if variants.empty?
659
+
660
+ UnionClass.new(ruby_name: BiDiGenerate.type_class_name(name),
661
+ discriminator_wire: selector['by'], variants: variants, schema_name: name)
662
+ end
663
+
664
+ def discriminated_variants(selector)
665
+ variants = selector['variants'].map do |variant|
666
+ VariantIR.new(mode: :value, value: variant['value'], ref: ruby_path(variant['ref']), requires: nil)
667
+ end
668
+ return variants unless selector['default']
669
+
670
+ variants << VariantIR.new(mode: :fallback, value: nil, ref: ruby_path(selector['default']), requires: nil)
671
+ end
672
+
673
+ def ordered_variants(selector)
674
+ (selector['ordered'] || []).map do |arm|
675
+ VariantIR.new(mode: :presence, value: nil, ref: ruby_path(arm['ref']), requires: arm['requires'])
676
+ end
677
+ end
678
+
679
+ # The sole alias-union is input.Origin ("viewport" | "pointer" | ElementOrigin): a
680
+ # scalar-or-object union the object-payload selector model doesn't cover, so the
681
+ # projector leaves it an alias with no selector. Its object arm(s) carry a const
682
+ # discriminator; the bare-string arms need no dispatch (Union.from_json returns a
683
+ # non-Hash payload unchanged). So dispatch the ref arms by their const tag.
684
+ def union_from_alias(name)
685
+ consts = @types[name]['type']['union'].filter_map { |arm| arm['ref'] }.to_h do |ref|
686
+ const = @types[ref]['fields'].find { |f| f['type'].key?('const') }
687
+ const || raise("alias-union #{name} arm #{ref} has no const discriminator to dispatch on")
688
+ [ref, const]
689
+ end
690
+ variants = consts.map do |ref, const|
691
+ VariantIR.new(mode: :value, value: const['type']['const'], ref: ruby_path(ref), requires: nil)
692
+ end
693
+ UnionClass.new(ruby_name: BiDiGenerate.type_class_name(name),
694
+ discriminator_wire: consts.values.first['wire'], variants: variants, schema_name: name)
695
+ end
696
+
697
+ def record_params(fields)
698
+ fields.map do |field|
699
+ Param.new(
700
+ ruby_name: BiDiGenerate.safe_field_name(BiDiGenerate.camel_to_snake(field['name'])),
701
+ wire_name: field['wire'],
702
+ required: field['required'],
703
+ enum: enum_const(field['type']),
704
+ rbs: rbs_type(field['type'])
705
+ )
706
+ end
707
+ end
708
+
709
+ def rbs_type(node)
710
+ resolve(node)[:rbs]
711
+ end
712
+
713
+ # Every primitive the projector can emit maps to an RBS type. `unknown` is intentionally
714
+ # absent — the projector rejects it (an unhandled CDDL construct fails the build), so it
715
+ # never reaches here; any other unlisted primitive fails generation at scalar_rbs rather
716
+ # than slipping through as untyped.
717
+ PRIMITIVE_RBS = {
718
+ 'string' => 'String', 'number' => 'Numeric', 'integer' => 'Integer', 'boolean' => 'bool', 'null' => 'nil'
719
+ }.freeze
720
+
721
+ # The scalar primitives that carry an inbound type-check. A field with no primitive (a ref,
722
+ # const, or opaque value) gets no descriptor and is left unchecked — lenient, so a missed
723
+ # check fails open rather than a wrong strict default rejecting valid data.
724
+ CHECKABLE_PRIMITIVES = %w[string number integer boolean].freeze
725
+
726
+ def checkable_primitive(node)
727
+ node['primitive'] if node.key?('primitive') && CHECKABLE_PRIMITIVES.include?(node['primitive'])
728
+ end
729
+
730
+ # The leaf of +resolve+: the bare scalar type, before any nullable wrap. An alias's
731
+ # own nullable is intentionally left off — only the referencing node's is applied.
732
+ def scalar_rbs(node)
733
+ return PRIMITIVE_RBS.fetch(node['primitive']) if node.key?('primitive')
734
+ return rbs_const(node['const']) if node.key?('const')
735
+
736
+ 'untyped'
737
+ end
738
+
739
+ def rbs_const(value)
740
+ case value
741
+ when true, false then 'bool'
742
+ when ::String then 'String'
743
+ when ::Numeric then 'Numeric'
744
+ else 'untyped'
745
+ end
746
+ end
747
+
748
+ def rbs_abs(path)
749
+ "::Selenium::WebDriver::BiDi::Protocol::#{path}"
750
+ end
751
+
752
+ # The allowed-values constant path when a field (or a list's element) is an enum
753
+ # type, else nil. Union command-params skip this (their merged superset can blur a
754
+ # discriminator's const vs enum); only flat record params get the outbound check.
755
+ def enum_const(field_type)
756
+ ref = field_type['ref'] || field_type.dig('list', 'ref')
757
+ return unless ref && @types[ref] && @types[ref]['kind'] == 'enum'
758
+
759
+ BiDiGenerate.enum_const_path(ref)
760
+ end
761
+
762
+ # Merge a union's record variants into one flat param list for the command
763
+ # signature. A field is only required when every variant declares it required;
764
+ # variant-specific fields become optional. The command body dispatches these
765
+ # kwargs to the matching variant via `Union.build`, whose typed `as_json` handles
766
+ # null-vs-absent — so no nullable allowlist is needed.
767
+ def union_params(type, ref = nil)
768
+ variants = type['variants'].map { |variant_ref| @types[variant_ref] }
769
+ return nil unless variants.all? { |v| v && v['kind'] == 'record' }
770
+
771
+ selector = type['selector']
772
+ guard_union_dispatch_keys_simple!(selector, ref)
773
+ params = merged_params(variants.map { |v| v['fields'] })
774
+ annotate_discriminator_enum!(params, selector)
775
+ params
776
+ end
777
+
778
+ # A discriminated union's `by` field is validated against the whole allowed set:
779
+ # the const values that tag each variant plus the default variant's own enum
780
+ # values (e.g. continueWithAuth.action = {provideCredentials} + {default, cancel}).
781
+ # That spans variants, so no single enum constant fits — emit an inline symbol=>wire
782
+ # hash so the check accepts the idiomatic symbol like every other enum.
783
+ # Boolean discriminators (handleRequestDevicePrompt.accept) need no membership check.
784
+ def annotate_discriminator_enum!(params, selector)
785
+ by = selector['by']
786
+ tagged = by ? selector['variants'].map { |v| v['value'] } : []
787
+ return unless !tagged.empty? && tagged.all?(String)
788
+
789
+ allowed = (tagged + default_variant_enum_values(selector, by)).uniq
790
+ pairs = allowed.map { |v| "#{BiDiGenerate.enum_key(v)}: '#{v}'" }
791
+ param = params.find { |p| p.wire_name == by }
792
+ return unless param
793
+
794
+ param.enum = "{#{pairs.join(', ')}}"
795
+ param.rbs = 'Symbol'
796
+ end
797
+
798
+ def default_variant_enum_values(selector, by)
799
+ default = selector['default']
800
+ field = default && @types[default]['fields'].find { |f| f['wire'] == by }
801
+ ref = field && field['type']['ref']
802
+ ref && @types[ref] && @types[ref]['kind'] == 'enum' ? @types[ref]['values'] : []
803
+ end
804
+
805
+ # Merge variant field lists into one flat param superset. A field is required only
806
+ # when every variant declares it required; variant-specific fields become optional.
807
+ def merged_params(variant_fields)
808
+ all_fields = variant_fields.flatten
809
+ all_fields.map { |f| f['wire'] }.uniq.map do |wire|
810
+ field = all_fields.find { |f| f['wire'] == wire }
811
+ required = variant_fields.all? { |fields| fields.any? { |f| f['wire'] == wire && f['required'] } }
812
+ Param.new(ruby_name: BiDiGenerate.safe_field_name(BiDiGenerate.camel_to_snake(field['name'])),
813
+ wire_name: wire, required: required, rbs: rbs_type(field['type']))
814
+ end
815
+ end
816
+
817
+ # `Union.build` matches the command's kwargs to the selector's dispatch keys by
818
+ # symbol, which holds only while each dispatch wire key equals its ruby kwarg.
819
+ # Every current key is a single lowercase word; fail generation if a new one is
820
+ # camelCase so the outbound dispatch gets an explicit wire<->ruby mapping then.
821
+ def guard_union_dispatch_keys_simple!(selector, ref)
822
+ keys = selector['by'] ? [selector['by']] : (selector['ordered'] || []).flat_map { |arm| arm['requires'] }
823
+ camel = keys.reject { |k| BiDiGenerate.camel_to_snake(k) == k }
824
+ return if camel.empty?
825
+
826
+ raise "union command param #{ref} dispatches on non-snake wire key(s) #{camel.inspect}; " \
827
+ 'Union.build matches kwargs to dispatch keys by symbol, so give the outbound ' \
828
+ 'dispatch an explicit wire<->ruby mapping before shipping this.'
829
+ end
830
+ end
831
+
832
+ # Param kinds the named args can construct a Parameters object for (record fields,
833
+ # or a union dispatched to one of its variants); anything else forwards a raw hash.
834
+ PARAMS_CLASS_KINDS = %w[record union].freeze
835
+
836
+ def self.build_ir(schema)
837
+ schema.domains.map do |domain|
838
+ Module.new(
839
+ name: domain,
840
+ ruby_class: snake_to_class_name(camel_to_snake(domain)),
841
+ filename: camel_to_snake(domain),
842
+ commands: schema.commands_for(domain).map { |cmd| build_command(schema, cmd) },
843
+ events: schema.events_for(domain).map { |ev| build_event(schema, ev) },
844
+ enums: schema.enums_for(domain),
845
+ types: nest_synthetic(schema.types_for(domain))
846
+ )
847
+ end
848
+ end
849
+
850
+ def self.build_command(schema, cmd)
851
+ params = schema.params_for(cmd['params'])
852
+ # A param that can't flatten to a typed object (alias or non-record union) would be
853
+ # silently dropped, so fail generation and handle that shape deliberately if it appears.
854
+ if cmd['params'] && params.nil?
855
+ raise "command #{cmd['method']} has params that cannot be expressed as a typed object"
856
+ end
857
+
858
+ params_ref = cmd['params'] && cmd['params']['ref']
859
+ params_kind = schema.type_kind(params_ref)
860
+ params_class = type_class_name(params_ref) if !params.empty? && PARAMS_CLASS_KINDS.include?(params_kind)
861
+ Command.new(
862
+ wire_name: cmd['method'],
863
+ method_name: safe_method_name(camel_to_snake(cmd['name'])),
864
+ params: params,
865
+ result_ref: cmd['result'] && schema.structured_ref(cmd['result']['ref']),
866
+ params_class: params_class,
867
+ union_params: params_kind == 'union'
868
+ )
869
+ end
870
+
871
+ def self.build_event(schema, event)
872
+ params = event['params']
873
+ payload_ref = params && params['ref'] && schema.structured_ref(params['ref'])
874
+ Event.new(wire_name: event['method'], event_name: camel_to_snake(event['name']), payload_ref: payload_ref)
875
+ end
876
+
877
+ # The projector tags lifted-out types with {synthetic, owner, label}. Emit each
878
+ # synthetic record inside its owner's class body under its bare label, so
879
+ # `Owner_Label` becomes the nested `Owner::Label` (refs resolve there via
880
+ # ruby_path). Synthetic enums stay domain-level. Raises on a missing owner.
881
+ def self.nest_synthetic(types)
882
+ index = types.to_h { |t| [t.schema_name, t] }
883
+ children = types.select { |t| !t.union? && t.synthetic }
884
+ children.each do |child|
885
+ owner = index[child.owner] ||
886
+ raise("synthetic type #{child.schema_name} has no emitted owner #{child.owner}")
887
+ owner.nested = (owner.nested || []) << child
888
+ child.ruby_name = child.label
889
+ end
890
+ types - children
891
+ end
892
+
893
+ def self.render(mod, template_path)
894
+ ERB.new(File.read(template_path), trim_mode: '-').result(binding)
895
+ end
896
+
897
+ def self.call(schema_path, output_dir)
898
+ raw = load_json(schema_path)
899
+ schema = Schema.new(raw)
900
+ modules = build_ir(schema)
901
+
902
+ emit(modules, output_dir, 'module.rb.erb', 'rb')
903
+ emit(modules, sig_dir(output_dir), 'module.rbs.erb', 'rbs')
904
+ end
905
+
906
+ # Renders every module through one template and writes the result into target,
907
+ # one file per module. Used for both the Ruby source and its RBS signatures.
908
+ def self.emit(modules, output_dir, template, extension)
909
+ target = File.join(workspace_root, output_dir)
910
+ FileUtils.mkdir_p(target)
911
+
912
+ tmpl = File.join(File.dirname(__FILE__), 'templates', template)
913
+ modules.each do |mod|
914
+ path = File.join(target, "#{mod.filename}.#{extension}")
915
+ File.write(path, render(mod, tmpl))
916
+ warn "bidi-generate: wrote #{path}"
917
+ end
918
+ end
919
+
920
+ # The RBS signatures mirror the source tree under sig/ (the repo's convention),
921
+ # e.g. rb/lib/.../protocol -> rb/sig/lib/.../protocol.
922
+ def self.sig_dir(output_dir)
923
+ output_dir.sub(%r{(\A|/)lib/}, '\1sig/lib/')
924
+ end
925
+
926
+ private_class_method def self.load_json(path)
927
+ resolved = File.exist?(path) ? path : File.join(Dir.pwd, path)
928
+ JSON.parse(File.read(resolved))
929
+ end
930
+
931
+ private_class_method def self.workspace_root
932
+ ENV['BUILD_WORKSPACE_DIRECTORY'] || Dir.pwd
933
+ end
934
+ end
935
+
936
+ BiDiGenerate.call(*ARGV) if $PROGRAM_NAME == __FILE__