# frozen_string_literal: true
# Licensed to the Software Freedom Conservancy (SFC) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The SFC licenses this file
# to you 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.
require 'json'
require 'erb'
require 'fileutils'
# Generates Ruby WebDriver BiDi protocol modules from the shared, binding-neutral
# BiDi schema produced by the JavaScript generator (see PR #17700):
# //javascript/selenium-webdriver:create-bidi-src_schema -> bidi-schema.json
#
# The schema is already normalized (inline enums hoisted, unions canonicalized,
# group composition flattened, wire names and nullability preserved verbatim), so
# this generator is a straight projection into Ruby with no CDDL interpretation.
#
# Invoked via `bazel run //rb/lib/selenium/webdriver:bidi-generate`. Bazel passes
# the schema path (resolved through runfiles) plus the workspace-relative output
# directory as ARGV. Can also be run directly:
# ruby bidi_generate.rb schema.json output/dir
#
# @api private
module BiDiGenerate
# Companion to the generated `@api private` tags: the page explaining why the BiDi
# implementation layer is internal and what higher-level API to use instead (see #17628).
BIDI_DOC_URL = 'https://www.selenium.dev/documentation/warnings/bidi-implementation/'
# RuboCop's Layout/LineLength max; emitted Serialization::Record.define calls wrap to stay within it.
LINE_LIMIT = 120
# Ruby keywords that cannot be used as method names unquoted.
RUBY_RESERVED = %w[begin end rescue ensure raise return yield if unless while until for do
case when then class module def].freeze
def self.camel_to_snake(str)
str
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
.downcase
end
def self.snake_to_class_name(snake)
snake.split('_').map(&:capitalize).join
end
# Local constant for a domain-scoped type: "script.LocalValue" -> "LocalValue".
# The first letter is capitalized so a lower-cased spec name (e.g.
# "permissions.setPermission") still yields a valid Ruby constant.
def self.type_class_name(type_name)
type_name.split('.', 2).last.sub(/\A[a-z]/, &:upcase)
end
# Protocol-relative class path: "script.LocalValue" -> "Script::LocalValue".
def self.type_ruby_path(type_name)
domain = type_name.split('.', 2).first
"#{snake_to_class_name(camel_to_snake(domain))}::#{type_class_name(type_name)}"
end
# Source literal for a discriminator/const value (string, boolean, or number).
def self.ruby_literal(value)
return 'nil' if value.nil?
value.is_a?(String) ? "'#{value}'" : value.to_s
end
# Renders `prefix(args)` on one line, or one argument per line when it would exceed
# LINE_LIMIT at the given indent. open/close default to parentheses (pass {} for a
# hash literal) so emitted calls and literals stay within RuboCop's length limit.
def self.wrap_call(prefix, args, indent, open: '(', close: ')')
one_line = "#{prefix}#{open}#{args.join(', ')}#{close}"
return one_line if args.empty? || indent + one_line.length <= LINE_LIMIT
pad = ' ' * (indent + 2)
"#{prefix}#{open}\n#{pad}#{args.join(",\n#{pad}")}\n#{' ' * indent}#{close}"
end
# Append underscore to avoid clashing with Ruby reserved keywords.
def self.safe_method_name(name)
RUBY_RESERVED.include?(name) ? "#{name}_" : name
end
# Object/Data methods a Data member name would shadow (breaking value semantics
# or reflection), e.g. a "method" field overriding Object#method.
RESERVED_FIELD_NAMES = (RUBY_RESERVED + %w[method hash class send dup clone freeze inspect
to_h to_s members with deconstruct deconstruct_keys
object_id tap itself then display
extensible extensions]).freeze
# Append underscore to a field name that would shadow a core method; the wire
# name is unaffected, only the Ruby reader is renamed.
def self.safe_field_name(name)
RESERVED_FIELD_NAMES.include?(name) ? "#{name}_" : name
end
# SCREAMING_SNAKE constant name for an enum, matching the EVENTS map style
# (ReadinessState → READINESS_STATE).
def self.screaming_snake(camel)
camel_to_snake(camel).upcase
end
# Makes an RBS type admit nil, idempotently (an already-nilable or opaque type is left
# as-is). Applied to a field whose schema type is nullable, so its value type allows nil;
# keyword-optionality is expressed separately by the `?` prefix (see rbs_part / rbs_arg).
def self.rbs_nilable(type)
return type if type == 'untyped' || type == 'nil' || type.end_with?('?')
"#{type}?"
end
# Domain-qualified path to an enum's frozen hash constant
# ("browsingContext.ReadinessState" → "BrowsingContext::READINESS_STATE"), so a
# generated command method can reference it for an outbound membership check.
def self.enum_const_path(type_name)
domain, local = type_name.split('.', 2)
"#{snake_to_class_name(camel_to_snake(domain))}::#{screaming_snake(local)}"
end
# snake_case hash key for an enum value (only a label for the wire value it maps to).
# Preserves camelCase word boundaries (beforeRequestSent → before_request_sent), maps a
# leading minus to "neg" (-0 → neg0, -Infinity → neg_infinity; no underscore before a
# digit, so the key stays normalcase), and collapses other punctuation
# (dedicated-worker → dedicated_worker).
def self.enum_key(value)
camel_to_snake(value.to_s)
.sub(/\A-(?=\d)/, 'neg')
.sub(/\A-/, 'neg_')
.gsub(/[^a-z0-9]+/, '_')
.gsub(/\A_+|_+\z/, '')
end
# ruby_name is the snake_case keyword argument; wire_name is the exact key the
# protocol expects (baked verbatim from the schema, no runtime conversion). enum is
# the allowed-values constant path for an enum-typed param (nil otherwise).
Param = Struct.new(:ruby_name, :wire_name, :required, :enum, :rbs, keyword_init: true) do
# Optionals default to UNSET (omitted), so an explicit nil can still reach a
# nullable field as wire null.
def sig_part
required ? "#{ruby_name}:" : "#{ruby_name}: Serialization::UNSET"
end
def enum_check(indent)
return unless enum
BiDiGenerate.wrap_call('Serialization.validate!', ["'#{wire_name}'", ruby_name, enum], indent)
end
# An RBS keyword parameter carrying the param's value type. The `?` prefix marks the
# keyword omittable; the value type already carries the schema's nullability, so nil is
# admitted only for a nullable field (a non-nullable one rejects nil at construction).
def rbs_part
type = rbs || 'untyped'
required ? "#{ruby_name}: #{type}" : "?#{ruby_name}: #{type}"
end
end
# params_class is the Parameters class the named args construct (nil for a no-arg
# command); union_params picks its variant via `.build` rather than `.new`. result_ref
# is the Protocol-relative result class path, or nil to return the raw hash.
Command = Struct.new(:wire_name, :method_name, :params, :result_ref, :params_class,
:union_params, keyword_init: true) do
def required_params = params.select(&:required)
def optional_params = params.reject(&:required)
def enum_checks(indent) = params.filter_map { |p| p.enum_check(indent) }
# `def name` or `def name(...)` — wrapped one argument per line when the signature
# would exceed the line limit.
def def_header(indent)
return "def #{method_name}" if params.empty?
BiDiGenerate.wrap_call("def #{method_name}", required_params.map(&:sig_part) + optional_params.map(&:sig_part),
indent)
end
# The RBS method signature `(params) -> return` — the return is the typed result class
# when the command parses one, else `untyped`.
def rbs_signature
"(#{rbs_params}) -> #{rbs_return}"
end
def rbs_params
(required_params.map(&:rbs_part) + optional_params.map(&:rbs_part)).join(', ')
end
def rbs_return
result_ref ? "::Selenium::WebDriver::BiDi::Protocol::#{result_ref}" : 'untyped'
end
# The `params = …` line built before the execute call, or nil for a no-arg command. A
# record builds its Parameters object and a union dispatches via `.build` (whose typed
# as_json emits explicit null where a flat hash through Transport could not). Wrapped
# one entry per line when long, so it (and the short execute call) stay within the limit.
def params_assignment(indent)
return nil if params.empty?
kwargs = params.map { |p| "#{p.ruby_name}: #{p.ruby_name}" }
BiDiGenerate.wrap_call("params = #{params_class}.#{union_params ? 'build' : 'new'}", kwargs, indent)
end
# `@transport.execute(cmd:[, params: params][, result:])`. The result type is
# referenced directly (resolved lazily in the method body, and unambiguous within
# Protocol). Params, when present, are the `params` local built above.
def execute_call(indent)
args = ["cmd: '#{wire_name}'"]
args << 'params: params' unless params.empty?
args << "result: #{result_ref}" if result_ref
BiDiGenerate.wrap_call('execute', args, indent)
end
end
# payload_ref is the Protocol-relative class the event's params parse into (nil when
# non-structured, dispatched raw) — the inbound counterpart to a command's result_ref.
Event = Struct.new(:wire_name, :event_name, :payload_ref, keyword_init: true) do
# An EVENT_TYPES entry mapping the wire method to the type its params parse into.
def type_entry = "'#{wire_name}' => #{payload_ref || 'nil'}"
end
# constant_name is the SCREAMING_SNAKE hash name; pairs are [symbol_key, wire_value] tuples.
Enum = Struct.new(:constant_name, :pairs, keyword_init: true)
# ref is the Protocol-relative class path for a nested structured field (nil
# for a scalar/opaque field); list wraps it in an array. wire_key is the exact
# JSON payload key (the schema's `wire` name, baked verbatim).
FieldIR = Struct.new(:ruby_name, :wire_key, :required, :nullable, :ref, :list, :enum, :primitive, :rbs,
keyword_init: true) do
# A `Serialization::Record.define` spec entry: `name: 'jsonKey'` shorthand, or
# `name: {wire_key:, …}` when the field carries JSON facts beyond its name.
# enum carries the allowed-values constant path, validated at construction.
def spec_entry(indent = 0)
meta = []
meta << 'required: false' unless required
meta << 'nullable: true' if nullable
meta << "ref: '#{ref}'" if ref
meta << 'list: true' if list
meta << "enum: '#{enum}'" if enum
meta << "primitive: '#{primitive}'" if primitive
return "#{ruby_name}: '#{wire_key}'" if meta.empty?
meta.unshift("wire_key: '#{wire_key}'")
BiDiGenerate.wrap_call("#{ruby_name}: ", meta, indent, open: '{', close: '}')
end
# The `self.new` keyword for this field — a user-supplied input carrying the field's
# value type. The `?` prefix marks the field omittable; its value type already carries
# the schema's nullability, so nil is admitted only for a nullable field.
def rbs_arg
required ? "#{ruby_name}: #{rbs}" : "?#{ruby_name}: #{rbs}"
end
# The `attr_reader` type. A present value is `rbs`; an omitted optional reads back
# the UNSET sentinel, which a value type can't capture, so optionals stay `untyped`.
def rbs_reader
"#{ruby_name}: #{required ? rbs : 'untyped'}"
end
end
# A generated immutable value type (a Serialization::Record.define(...) class). discriminator is the
# baked variant tag {ruby_name:, wire:, value:} or nil; schema_name/synthetic/owner/
# nested drive owner-nesting (see nest_synthetic).
TypeClass = Struct.new(:ruby_name, :fields, :discriminator, :extensible,
:schema_name, :synthetic, :owner, :label, :nested, keyword_init: true) do
def union? = false
def nested_types = nested || []
# Keyword arguments for `Serialization::Record.define(...)`: the fixed discriminator member
# first, then the fields, then the extensible flag.
def define_entries(entry_indent)
entries = []
entries << discriminator_entry if discriminator
entries.concat(fields.map { |f| f.spec_entry(entry_indent) })
entries << 'extensible: true' if extensible
entries
end
# `Name = Serialization::Record.define(...)` as one line when it fits within the line limit at the
# given indent, else wrapped one entry per line — so the emitted source stays inside
# RuboCop's length limit without a per-file exception. Entries render at indent + 2, the
# indent a long field hash wraps itself against.
def define_assignment(name, indent)
BiDiGenerate.wrap_call("#{name} = Serialization::Record.define", define_entries(indent + 2), indent)
end
def discriminator_entry
literal = BiDiGenerate.ruby_literal(discriminator[:value])
if discriminator[:wire] == discriminator[:ruby_name].to_s
"#{discriminator[:ruby_name]}: {fixed: #{literal}}"
else
"#{discriminator[:ruby_name]}: {wire_key: '#{discriminator[:wire]}', fixed: #{literal}}"
end
end
# Every Data member gets a typed `attr_reader`: the baked discriminator (typed to its
# const), each field (typed when required; UNSET-bearing optionals stay untyped), then
# the extensible passthrough.
def rbs_readers
readers = []
readers << "#{discriminator[:ruby_name]}: #{discriminator[:rbs]}" if discriminator
readers.concat(fields.map(&:rbs_reader))
readers << 'extensions: Hash[String, untyped]' if extensible
readers
end
# The keyword arguments `self.new` accepts: each constructable field with its value
# type, plus the optional extensions bag. The fixed discriminator is baked, so its
# value is ignored — but the lenient `**kwargs` constructor still accepts it (and a
# command method passes it through), so it is advertised as an optional keyword typed
# to its const.
def rbs_new_args
parts = []
parts << "?#{discriminator[:ruby_name]}: #{discriminator[:rbs]}" if discriminator
parts.concat(fields.map(&:rbs_arg))
parts << '?extensions: untyped' if extensible
parts.join(', ')
end
end
# mode is :value (matched by discriminator), :fallback (the no-tag variant), or
# :presence (selected when its required wire keys are all present).
VariantIR = Struct.new(:mode, :value, :ref, :requires, keyword_init: true) do
# A string tag becomes an idiomatic symbol key; a bool/number tag stays a literal.
def symbolic? = value.is_a?(::String)
# The variant table entry: `sym: 'Ref'` for a string tag, else `true => 'Ref'`.
def variant_entry
key = symbolic? ? "#{BiDiGenerate.enum_key(value)}:" : "#{BiDiGenerate.ruby_literal(value)} =>"
"#{key} '#{ref}'"
end
# `sym: 'wireToken'` feeding the union's inbound wire->symbol map (string tags only).
def discriminator_pair
"#{BiDiGenerate.enum_key(value)}: '#{value}'" if symbolic?
end
end
# A generated discriminated union (< Serialization::Union, resolved by lexical scope).
# nested holds its synthetic variant records (see nest_synthetic).
UnionClass = Struct.new(:ruby_name, :discriminator_wire, :variants, :schema_name, :nested, keyword_init: true) do
def union? = true
def value_variants = variants.select { |v| v.mode == :value }
def presence_variants = variants.select { |v| v.mode == :presence }
def fallback_variant = variants.find { |v| v.mode == :fallback }
def nested_types = nested || []
# `discriminator 'wire'`, or `discriminator 'wire', {sym: 'token', …}` (wrapped when
# long) carrying the inbound wire->symbol map for string-tagged variants.
def discriminator_decl(indent)
pairs = value_variants.filter_map(&:discriminator_pair)
head = "discriminator '#{discriminator_wire}'"
return head if pairs.empty?
BiDiGenerate.wrap_call("#{head}, ", pairs, indent, open: '{', close: '}')
end
end
Module = Struct.new(:name, :ruby_class, :filename, :commands, :events, :enums, :types, keyword_init: true)
class Schema
def initialize(schema)
@types = schema['types']
@commands = schema['commands']
@events = schema['events']
promote_command_params_records!
end
# A command written in CDDL map form carries its params as an *inline* object (rather
# than the usual group form referencing a named params type). The projector links the
# command to those params, but hoists them into a synthetic record owned by the
# command's message envelope. That envelope is suppressed (Transport forms it), so the
# synthetic params record would never be emitted even though the command's params ref
# points straight at it. Promote it to a top-level domain record so the generator emits
# and references it like any other params type. Today this is exactly
# `userAgentClientHints.setClientHintsOverride`.
def promote_command_params_records!
@commands.each do |cmd|
ref = cmd.dig('params', 'ref')
next unless ref
type = @types[ref]
promote_to_domain_type!(ref) if type && envelope_synthetic?(type)
end
end
# Strip the synthetic/owner/label tags so a lifted-out type emits as a top-level
# domain record instead of nesting under its (suppressed) envelope.
def promote_to_domain_type!(name)
type = @types[name]
type&.delete('synthetic')
type&.delete('owner')
type&.delete('label')
end
# Domains that carry a command or event each become one generated module.
def domains
(@commands + @events).map { |entry| entry['domain'] }.uniq
end
def commands_for(domain)
@commands.select { |c| c['domain'] == domain }
end
def type_kind(ref)
@types[ref]&.fetch('kind', nil)
end
def events_for(domain)
@events.select { |e| e['domain'] == domain }
end
# Flat params for a command: the record's fields, or — for a union of
# records — the merged superset of variant fields. Returns [] for commands
# with no params, or nil when params can't be flattened (alias, or a union
# whose variants aren't all records) so the caller forwards verbatim.
def params_for(params_ref)
return [] unless params_ref
type = @types[params_ref['ref']]
return nil unless type
case type['kind']
when 'record' then record_params(type['fields'])
when 'union' then union_params(type, params_ref['ref'])
end
end
# Enum types declared under "<domain>." become nested constant modules.
def enums_for(domain)
@types.filter_map do |name, type|
next unless type['kind'] == 'enum'
next unless name.start_with?("#{domain}.")
pairs = type['values'].map { |v| [BiDiGenerate.enum_key(v), v.to_s] }
Enum.new(constant_name: BiDiGenerate.screaming_snake(name.sub("#{domain}.", '')), pairs: pairs)
end
end
# Structured value classes (records + discriminated unions) declared under
# "<domain>." Empty records are projector artifacts with nothing to carry, so
# they stay opaque hashes; only non-empty records and unions become classes.
# Command/event message envelopes (the `{method, params}` wire wrapper) are
# skipped — Transport forms that envelope, so nothing references them.
def types_for(domain)
prefix = "#{domain}."
@types.filter_map do |name, type|
next unless name.start_with?(prefix)
case type['kind']
when 'record' then record_class(name, type) unless type['fields'].empty? || suppressed_record?(type)
when 'union' then union_class(name)
when 'alias' then union_class(name) if type['type'].key?('union')
end
end
end
# Records the generator deliberately does not emit: a message envelope, or a
# synthetic params record lifted out of one. Both are reachable only through the
# envelope, which Transport replaces — so nothing else references them.
def suppressed_record?(type)
message_envelope?(type) || envelope_synthetic?(type)
end
# A protocol message envelope is a record with a baked `method` discriminator
# (`{method: <const>, params: …}`) — the wire shape of a command/event message.
# No value type carries a const `method` field, so this is unambiguous.
def message_envelope?(type)
type['fields'].any? { |f| f['wire'] == 'method' && f['type'].key?('const') }
end
# A synthetic record lifted out as an envelope's params (its owner is an envelope).
def envelope_synthetic?(type)
return false unless type['synthetic']
owner = @types[type['owner']]
owner && owner['kind'] == 'record' && message_envelope?(owner)
end
# The Protocol-relative class path a command result parses into, or nil when
# it is non-structured (or a bare list, returned raw).
def structured_ref(name)
resolved = resolve_named(name)
resolved[:list] ? nil : resolved[:ref]
end
private
def domain_path(name)
name.include?('.') ? ruby_path(name) : nil
end
# Class path, nesting a synthetic type under its owner as `Owner::Label` so a ref
# resolves to the same nested constant the type is emitted as.
def ruby_path(name)
type = @types[name]
return BiDiGenerate.type_ruby_path(name) unless type && type['synthetic']
"#{ruby_path(type['owner'])}::#{type['label']}"
end
# Resolution for anything not modeled as a value type (scalar, enum, empty record).
# Frozen because it is shared across callers.
OPAQUE = {ref: nil, list: false, rbs: 'untyped'}.freeze
# Projects a schema type node to {ref:, list:, nullable:, rbs:}. Deriving the
# serialization facts and the RBS signature from one walk keeps them from drifting
# apart when the schema shape changes.
def resolve(node)
nullable = node['nullable'] ? true : false
if node.key?('list')
element = resolve(node['list'])
return {ref: element[:ref], list: true, nullable: nullable, rbs: nilable("Array[#{element[:rbs]}]", nullable)}
end
if node.key?('ref')
named = resolve_named(node['ref'])
return {ref: named[:ref], list: named[:list], nullable: nullable, rbs: nilable(named[:rbs], nullable)}
end
return resolve_union(node, nullable) if node.key?('union')
{ref: nil, list: false, nullable: nullable, primitive: checkable_primitive(node),
rbs: nilable(scalar_rbs(node), nullable)}
end
# An inline union of one union-typed arm plus scalars (e.g. a MappingRemoteValue entry,
# RemoteValue / string) parses through that arm — its from_json returns a non-Hash value
# unchanged, so the scalar siblings pass through. Carry its ref so nested entries are typed;
# any other shape (a record arm, multiple structured arms, all scalars) stays opaque.
def resolve_union(node, nullable)
refs = node['union'].select { |arm| arm.key?('ref') }
opaque = {ref: nil, list: false, nullable: nullable, rbs: nilable('untyped', nullable)}
return opaque unless refs.one? && union_ref?(refs.first['ref'])
named = resolve_named(refs.first['ref'])
{ref: named[:ref], list: named[:list], nullable: nullable, rbs: nilable('untyped', nullable)}
end
# True when a ref (following aliases) is a union — the only arm whose from_json tolerates a
# scalar sibling. A record arm would raise on one, so it is not carried.
def union_ref?(name)
type = @types[name]
return false unless type
return union_ref?(type['type']['ref']) if type['kind'] == 'alias' && type['type'].key?('ref')
type['kind'] == 'union'
end
# Resolves a named ref to the same {ref:, list:, rbs:} facts, transparently
# following aliases — including alias-to-list — so an element type behind an alias
# (e.g. script.ListLocalValue -> [script.LocalValue]) is preserved. Nullability is a
# property of the referencing node (applied by +resolve+), so it is not threaded
# here. seen guards against cyclic ref-aliases.
def resolve_named(name, seen = {})
return OPAQUE if name.nil? || seen[name]
seen[name] = true
type = @types[name]
return OPAQUE unless type
case type['kind']
when 'record' then type['fields'].empty? ? OPAQUE : named_type(name)
when 'union' then named_type(name)
when 'enum' then {ref: nil, list: false, rbs: 'Symbol'}
when 'alias' then resolve_named_alias(name, type['type'], seen)
else OPAQUE
end
end
# A named structured type's serialization ref (nil for a dotless/global type, never
# emitted as a class) and its absolute RBS class path, derived independently so each
# output keeps its own treatment of dotless names.
def named_type(name)
{ref: domain_path(name), list: false, rbs: rbs_abs(ruby_path(name))}
end
def resolve_named_alias(name, inner, seen)
return named_type(name) if inner.key?('union')
return resolve_named(inner['ref'], seen) if inner.key?('ref')
if inner.key?('list')
element = resolve(inner['list'])
return {ref: element[:ref], list: true, rbs: "Array[#{element[:rbs]}]"}
end
{ref: nil, list: false, rbs: scalar_rbs(inner)}
end
def nilable(type, flag)
flag ? BiDiGenerate.rbs_nilable(type) : type
end
def record_class(name, type)
const = type['fields'].find { |f| baked_discriminator?(f) }
discriminator = const && {ruby_name: BiDiGenerate.safe_field_name(BiDiGenerate.camel_to_snake(const['name'])),
wire: const['wire'], value: const['type']['const'],
rbs: rbs_const(const['type']['const'])}
fields = type['fields'].reject { |f| baked_discriminator?(f) }.map { |f| field_ir(f) }
TypeClass.new(ruby_name: BiDiGenerate.type_class_name(name), fields: fields,
discriminator: discriminator, extensible: type['extensible'] ? true : false,
schema_name: name, synthetic: type['synthetic'] ? true : false,
owner: type['owner'], label: type['label'])
end
# A const field is a baked discriminator tag, unless it is also nullable: the spec's
# `literal | null` (browsingContext.setBypassCSP, emulation.setScriptingEnabled) is a
# settable value (the literal to set, null to clear), so it stays a normal field that
# can serialize null rather than a fixed tag that can only ever emit the literal.
def baked_discriminator?(field)
field['type'].key?('const') && !field['type']['nullable']
end
def field_ir(field)
resolved = resolve(field['type'])
ruby_name = BiDiGenerate.safe_field_name(BiDiGenerate.camel_to_snake(field['name']))
FieldIR.new(ruby_name: ruby_name, wire_key: field['wire'],
required: field['required'], nullable: resolved[:nullable],
ref: resolved[:ref], list: resolved[:list], enum: enum_const(field['type']),
primitive: resolved[:primitive], rbs: resolved[:rbs])
end
def union_class(name)
type = @types[name]
# A first-class union carries the schema's authoritative dispatch `selector`
# (derived spec-faithfully, including null discriminators and the spec's choice
# order); consume it rather than re-deriving and silently depending on emit
# order. An alias-to-union (only input.Origin) has no selector — its const-string
# arms aren't first-class types — so it keeps the structural re-derivation.
type['kind'] == 'union' ? union_from_selector(name, type['selector']) : union_from_alias(name)
end
# Map a union `selector` to dispatch variants the template renders:
# { by, variants, default? } -> a discriminator table (value => ref), `default`
# as the fallback (it may itself be a union, which finishes the dispatch).
# { ordered: [{ ref, requires }] } -> presence rules in the spec's choice order.
# { correlated: true } -> resolved by request id, not the payload, so no payload
# dispatch. Unreachable here: every correlated union is a top-level result
# grouping, never domain-scoped, so it is never emitted as a class.
def union_from_selector(name, selector)
# A correlated union is resolved by request id, never the payload, so it carries
# no dispatch — it must never be emitted (every one is a top-level result
# grouping). Fail loudly if a future schema makes one domain-scoped rather than
# emit a Union whose every parse would raise.
if selector['correlated']
raise "correlated union #{name} must not be emitted (resolved by request id, not payload)"
end
variants = selector['by'] ? discriminated_variants(selector) : ordered_variants(selector)
raise "union #{name} selector yielded no dispatch variants" if variants.empty?
UnionClass.new(ruby_name: BiDiGenerate.type_class_name(name),
discriminator_wire: selector['by'], variants: variants, schema_name: name)
end
def discriminated_variants(selector)
variants = selector['variants'].map do |variant|
VariantIR.new(mode: :value, value: variant['value'], ref: ruby_path(variant['ref']), requires: nil)
end
return variants unless selector['default']
variants << VariantIR.new(mode: :fallback, value: nil, ref: ruby_path(selector['default']), requires: nil)
end
def ordered_variants(selector)
(selector['ordered'] || []).map do |arm|
VariantIR.new(mode: :presence, value: nil, ref: ruby_path(arm['ref']), requires: arm['requires'])
end
end
# The sole alias-union is input.Origin ("viewport" | "pointer" | ElementOrigin): a
# scalar-or-object union the object-payload selector model doesn't cover, so the
# projector leaves it an alias with no selector. Its object arm(s) carry a const
# discriminator; the bare-string arms need no dispatch (Union.from_json returns a
# non-Hash payload unchanged). So dispatch the ref arms by their const tag.
def union_from_alias(name)
consts = @types[name]['type']['union'].filter_map { |arm| arm['ref'] }.to_h do |ref|
const = @types[ref]['fields'].find { |f| f['type'].key?('const') }
const || raise("alias-union #{name} arm #{ref} has no const discriminator to dispatch on")
[ref, const]
end
variants = consts.map do |ref, const|
VariantIR.new(mode: :value, value: const['type']['const'], ref: ruby_path(ref), requires: nil)
end
UnionClass.new(ruby_name: BiDiGenerate.type_class_name(name),
discriminator_wire: consts.values.first['wire'], variants: variants, schema_name: name)
end
def record_params(fields)
fields.map do |field|
Param.new(
ruby_name: BiDiGenerate.safe_field_name(BiDiGenerate.camel_to_snake(field['name'])),
wire_name: field['wire'],
required: field['required'],
enum: enum_const(field['type']),
rbs: rbs_type(field['type'])
)
end
end
def rbs_type(node)
resolve(node)[:rbs]
end
# Every primitive the projector can emit maps to an RBS type. `unknown` is intentionally
# absent — the projector rejects it (an unhandled CDDL construct fails the build), so it
# never reaches here; any other unlisted primitive fails generation at scalar_rbs rather
# than slipping through as untyped.
PRIMITIVE_RBS = {
'string' => 'String', 'number' => 'Numeric', 'integer' => 'Integer', 'boolean' => 'bool', 'null' => 'nil'
}.freeze
# The scalar primitives that carry an inbound type-check. A field with no primitive (a ref,
# const, or opaque value) gets no descriptor and is left unchecked — lenient, so a missed
# check fails open rather than a wrong strict default rejecting valid data.
CHECKABLE_PRIMITIVES = %w[string number integer boolean].freeze
def checkable_primitive(node)
node['primitive'] if node.key?('primitive') && CHECKABLE_PRIMITIVES.include?(node['primitive'])
end
# The leaf of +resolve+: the bare scalar type, before any nullable wrap. An alias's
# own nullable is intentionally left off — only the referencing node's is applied.
def scalar_rbs(node)
return PRIMITIVE_RBS.fetch(node['primitive']) if node.key?('primitive')
return rbs_const(node['const']) if node.key?('const')
'untyped'
end
def rbs_const(value)
case value
when true, false then 'bool'
when ::String then 'String'
when ::Numeric then 'Numeric'
else 'untyped'
end
end
def rbs_abs(path)
"::Selenium::WebDriver::BiDi::Protocol::#{path}"
end
# The allowed-values constant path when a field (or a list's element) is an enum
# type, else nil. Union command-params skip this (their merged superset can blur a
# discriminator's const vs enum); only flat record params get the outbound check.
def enum_const(field_type)
ref = field_type['ref'] || field_type.dig('list', 'ref')
return unless ref && @types[ref] && @types[ref]['kind'] == 'enum'
BiDiGenerate.enum_const_path(ref)
end
# Merge a union's record variants into one flat param list for the command
# signature. A field is only required when every variant declares it required;
# variant-specific fields become optional. The command body dispatches these
# kwargs to the matching variant via `Union.build`, whose typed `as_json` handles
# null-vs-absent — so no nullable allowlist is needed.
def union_params(type, ref = nil)
variants = type['variants'].map { |variant_ref| @types[variant_ref] }
return nil unless variants.all? { |v| v && v['kind'] == 'record' }
selector = type['selector']
guard_union_dispatch_keys_simple!(selector, ref)
params = merged_params(variants.map { |v| v['fields'] })
annotate_discriminator_enum!(params, selector)
params
end
# A discriminated union's `by` field is validated against the whole allowed set:
# the const values that tag each variant plus the default variant's own enum
# values (e.g. continueWithAuth.action = {provideCredentials} + {default, cancel}).
# That spans variants, so no single enum constant fits — emit an inline symbol=>wire
# hash so the check accepts the idiomatic symbol like every other enum.
# Boolean discriminators (handleRequestDevicePrompt.accept) need no membership check.
def annotate_discriminator_enum!(params, selector)
by = selector['by']
tagged = by ? selector['variants'].map { |v| v['value'] } : []
return unless !tagged.empty? && tagged.all?(String)
allowed = (tagged + default_variant_enum_values(selector, by)).uniq
pairs = allowed.map { |v| "#{BiDiGenerate.enum_key(v)}: '#{v}'" }
param = params.find { |p| p.wire_name == by }
return unless param
param.enum = "{#{pairs.join(', ')}}"
param.rbs = 'Symbol'
end
def default_variant_enum_values(selector, by)
default = selector['default']
field = default && @types[default]['fields'].find { |f| f['wire'] == by }
ref = field && field['type']['ref']
ref && @types[ref] && @types[ref]['kind'] == 'enum' ? @types[ref]['values'] : []
end
# Merge variant field lists into one flat param superset. A field is required only
# when every variant declares it required; variant-specific fields become optional.
def merged_params(variant_fields)
all_fields = variant_fields.flatten
all_fields.map { |f| f['wire'] }.uniq.map do |wire|
field = all_fields.find { |f| f['wire'] == wire }
required = variant_fields.all? { |fields| fields.any? { |f| f['wire'] == wire && f['required'] } }
Param.new(ruby_name: BiDiGenerate.safe_field_name(BiDiGenerate.camel_to_snake(field['name'])),
wire_name: wire, required: required, rbs: rbs_type(field['type']))
end
end
# `Union.build` matches the command's kwargs to the selector's dispatch keys by
# symbol, which holds only while each dispatch wire key equals its ruby kwarg.
# Every current key is a single lowercase word; fail generation if a new one is
# camelCase so the outbound dispatch gets an explicit wire<->ruby mapping then.
def guard_union_dispatch_keys_simple!(selector, ref)
keys = selector['by'] ? [selector['by']] : (selector['ordered'] || []).flat_map { |arm| arm['requires'] }
camel = keys.reject { |k| BiDiGenerate.camel_to_snake(k) == k }
return if camel.empty?
raise "union command param #{ref} dispatches on non-snake wire key(s) #{camel.inspect}; " \
'Union.build matches kwargs to dispatch keys by symbol, so give the outbound ' \
'dispatch an explicit wire<->ruby mapping before shipping this.'
end
end
# Param kinds the named args can construct a Parameters object for (record fields,
# or a union dispatched to one of its variants); anything else forwards a raw hash.
PARAMS_CLASS_KINDS = %w[record union].freeze
def self.build_ir(schema)
schema.domains.map do |domain|
Module.new(
name: domain,
ruby_class: snake_to_class_name(camel_to_snake(domain)),
filename: camel_to_snake(domain),
commands: schema.commands_for(domain).map { |cmd| build_command(schema, cmd) },
events: schema.events_for(domain).map { |ev| build_event(schema, ev) },
enums: schema.enums_for(domain),
types: nest_synthetic(schema.types_for(domain))
)
end
end
def self.build_command(schema, cmd)
params = schema.params_for(cmd['params'])
# A param that can't flatten to a typed object (alias or non-record union) would be
# silently dropped, so fail generation and handle that shape deliberately if it appears.
if cmd['params'] && params.nil?
raise "command #{cmd['method']} has params that cannot be expressed as a typed object"
end
params_ref = cmd['params'] && cmd['params']['ref']
params_kind = schema.type_kind(params_ref)
params_class = type_class_name(params_ref) if !params.empty? && PARAMS_CLASS_KINDS.include?(params_kind)
Command.new(
wire_name: cmd['method'],
method_name: safe_method_name(camel_to_snake(cmd['name'])),
params: params,
result_ref: cmd['result'] && schema.structured_ref(cmd['result']['ref']),
params_class: params_class,
union_params: params_kind == 'union'
)
end
def self.build_event(schema, event)
params = event['params']
payload_ref = params && params['ref'] && schema.structured_ref(params['ref'])
Event.new(wire_name: event['method'], event_name: camel_to_snake(event['name']), payload_ref: payload_ref)
end
# The projector tags lifted-out types with {synthetic, owner, label}. Emit each
# synthetic record inside its owner's class body under its bare label, so
# `Owner_Label` becomes the nested `Owner::Label` (refs resolve there via
# ruby_path). Synthetic enums stay domain-level. Raises on a missing owner.
def self.nest_synthetic(types)
index = types.to_h { |t| [t.schema_name, t] }
children = types.select { |t| !t.union? && t.synthetic }
children.each do |child|
owner = index[child.owner] ||
raise("synthetic type #{child.schema_name} has no emitted owner #{child.owner}")
owner.nested = (owner.nested || []) << child
child.ruby_name = child.label
end
types - children
end
def self.render(mod, template_path)
ERB.new(File.read(template_path), trim_mode: '-').result(binding)
end
def self.call(schema_path, output_dir)
raw = load_json(schema_path)
schema = Schema.new(raw)
modules = build_ir(schema)
emit(modules, output_dir, 'module.rb.erb', 'rb')
emit(modules, sig_dir(output_dir), 'module.rbs.erb', 'rbs')
end
# Renders every module through one template and writes the result into target,
# one file per module. Used for both the Ruby source and its RBS signatures.
def self.emit(modules, output_dir, template, extension)
target = File.join(workspace_root, output_dir)
FileUtils.mkdir_p(target)
tmpl = File.join(File.dirname(__FILE__), 'templates', template)
modules.each do |mod|
path = File.join(target, "#{mod.filename}.#{extension}")
File.write(path, render(mod, tmpl))
warn "bidi-generate: wrote #{path}"
end
end
# The RBS signatures mirror the source tree under sig/ (the repo's convention),
# e.g. rb/lib/.../protocol -> rb/sig/lib/.../protocol.
def self.sig_dir(output_dir)
output_dir.sub(%r{(\A|/)lib/}, '\1sig/lib/')
end
private_class_method def self.load_json(path)
resolved = File.exist?(path) ? path : File.join(Dir.pwd, path)
JSON.parse(File.read(resolved))
end
private_class_method def self.workspace_root
ENV['BUILD_WORKSPACE_DIRECTORY'] || Dir.pwd
end
end
BiDiGenerate.call(*ARGV) if $PROGRAM_NAME == __FILE__