class BiDiGenerate::Schema

def annotate_discriminator_enum!(params, selector)

Boolean discriminators (handleRequestDevicePrompt.accept) need no membership check.
hash so the check accepts the idiomatic symbol like every other enum.
That spans variants, so no single enum constant fits — emit an inline symbol=>wire
values (e.g. continueWithAuth.action = {provideCredentials} + {default, cancel}).
the const values that tag each variant plus the default variant's own enum
A discriminated union's `by` field is validated against the whole allowed set:
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 baked_discriminator?(field)

can serialize null rather than a fixed tag that can only ever emit the literal.
settable value (the literal to set, null to clear), so it stays a normal field that
`literal | null` (browsingContext.setBypassCSP, emulation.setScriptingEnabled) is a
A const field is a baked discriminator tag, unless it is also nullable: the spec's
def baked_discriminator?(field)
  field['type'].key?('const') && !field['type']['nullable']
end

def checkable_primitive(node)

def checkable_primitive(node)
  node['primitive'] if node.key?('primitive') && CHECKABLE_PRIMITIVES.include?(node['primitive'])
end

def commands_for(domain)

def commands_for(domain)
  @commands.select { |c| c['domain'] == domain }
end

def default_variant_enum_values(selector, by)

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

def discriminated_variants(selector)

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 domain_path(name)

def domain_path(name)
  name.include?('.') ? ruby_path(name) : nil
end

def domains

Domains that carry a command or event each become one generated module.
def domains
  (@commands + @events).map { |entry| entry['domain'] }.uniq
end

def enum_const(field_type)

discriminator's const vs enum); only flat record params get the outbound check.
type, else nil. Union command-params skip this (their merged superset can blur a
The allowed-values constant path when a field (or a list's element) is an enum
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

def enums_for(domain)

Enum types declared under "." 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

def envelope_synthetic?(type)

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

def events_for(domain)

def events_for(domain)
  @events.select { |e| e['domain'] == domain }
end

def field_ir(field)

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 guard_union_dispatch_keys_simple!(selector, ref)

camelCase so the outbound dispatch gets an explicit wire<->ruby mapping then.
Every current key is a single lowercase word; fail generation if a new one is
symbol, which holds only while each dispatch wire key equals its ruby kwarg.
`Union.build` matches the command's kwargs to the selector's dispatch keys by
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

def initialize(schema)

def initialize(schema)
  @types = schema['types']
  @commands = schema['commands']
  @events = schema['events']
  promote_command_params_records!
end

def merged_params(variant_fields)

when every variant declares it required; variant-specific fields become optional.
Merge variant field lists into one flat param superset. A field is required only
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

def message_envelope?(type)

No value type carries a const `method` field, so this is unambiguous.
(`{method: , params: …}`) — the wire shape of a command/event message.
A protocol message envelope is a record with a baked `method` discriminator
def message_envelope?(type)
  type['fields'].any? { |f| f['wire'] == 'method' && f['type'].key?('const') }
end

def named_type(name)

output keeps its own treatment of dotless names.
emitted as a class) and its absolute RBS class path, derived independently so each
A named structured type's serialization ref (nil for a dotless/global type, never
def named_type(name)
  {ref: domain_path(name), list: false, rbs: rbs_abs(ruby_path(name))}
end

def nilable(type, flag)

def nilable(type, flag)
  flag ? BiDiGenerate.rbs_nilable(type) : type
end

def ordered_variants(selector)

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

def params_for(params_ref)

whose variants aren't all records) so the caller forwards verbatim.
with no params, or nil when params can't be flattened (alias, or a union
records — the merged superset of variant fields. Returns [] for commands
Flat params for a command: the record's fields, or — for a union of
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

def promote_command_params_records!

`userAgentClientHints.setClientHintsOverride`.
and references it like any other params type. Today this is exactly
points straight at it. Promote it to a top-level domain record so the generator emits
synthetic params record would never be emitted even though the command's params ref
command's message envelope. That envelope is suppressed (Transport forms it), so the
command to those params, but hoists them into a synthetic record owned by the
than the usual group form referencing a named params type). The projector links the
A command written in CDDL map form carries its params as an *inline* object (rather
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

def promote_to_domain_type!(name)

domain record instead of nesting under its (suppressed) envelope.
Strip the synthetic/owner/label tags so a lifted-out type emits as a top-level
def promote_to_domain_type!(name)
  type = @types[name]
  type&.delete('synthetic')
  type&.delete('owner')
  type&.delete('label')
end

def rbs_abs(path)

def rbs_abs(path)
  "::Selenium::WebDriver::BiDi::Protocol::#{path}"
end

def rbs_const(value)

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_type(node)

def rbs_type(node)
  resolve(node)[:rbs]
end

def record_class(name, type)

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

def record_params(fields)

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 resolve(node)

apart when the schema shape changes.
serialization facts and the RBS signature from one walk keeps them from drifting
Projects a schema type node to {ref:, list:, nullable:, rbs:}. Deriving the
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

def resolve_named(name, seen = {})

here. seen guards against cyclic ref-aliases.
property of the referencing node (applied by +resolve+), so it is not threaded
(e.g. script.ListLocalValue -> [script.LocalValue]) is preserved. Nullability is a
following aliases — including alias-to-list — so an element type behind an alias
Resolves a named ref to the same {ref:, list:, rbs:} facts, transparently
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

def resolve_named_alias(name, inner, seen)

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 resolve_union(node, nullable)

any other shape (a record arm, multiple structured arms, all scalars) stays opaque.
unchanged, so the scalar siblings pass through. Carry its ref so nested entries are typed;
RemoteValue / string) parses through that arm — its from_json returns a non-Hash value
An inline union of one union-typed arm plus scalars (e.g. a MappingRemoteValue entry,
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

def ruby_path(name)

resolves to the same nested constant the type is emitted as.
Class path, nesting a synthetic type under its owner as `Owner::Label` so a ref
def ruby_path(name)
  type = @types[name]
  return BiDiGenerate.type_ruby_path(name) unless type && type['synthetic']
  "#{ruby_path(type['owner'])}::#{type['label']}"
end

def scalar_rbs(node)

own nullable is intentionally left off — only the referencing node's is applied.
The leaf of +resolve+: the bare scalar type, before any nullable wrap. An alias's
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 structured_ref(name)

it is non-structured (or a bare list, returned raw).
The Protocol-relative class path a command result parses into, or nil when
def structured_ref(name)
  resolved = resolve_named(name)
  resolved[:list] ? nil : resolved[:ref]
end

def suppressed_record?(type)

envelope, which Transport replaces — so nothing else references them.
synthetic params record lifted out of one. Both are reachable only through the
Records the generator deliberately does not emit: a message envelope, or a
def suppressed_record?(type)
  message_envelope?(type) || envelope_synthetic?(type)
end

def type_kind(ref)

def type_kind(ref)
  @types[ref]&.fetch('kind', nil)
end

def types_for(domain)

skipped — Transport forms that envelope, so nothing references them.
Command/event message envelopes (the `{method, params}` wire wrapper) are
they stay opaque hashes; only non-empty records and unions become classes.
"." Empty records are projector artifacts with nothing to carry, so
Structured value classes (records + discriminated unions) declared under
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

def union_class(name)

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

def union_from_alias(name)

non-Hash payload unchanged). So dispatch the ref arms by their const tag.
discriminator; the bare-string arms need no dispatch (Union.from_json returns a
projector leaves it an alias with no selector. Its object arm(s) carry a const
scalar-or-object union the object-payload selector model doesn't cover, so the
The sole alias-union is input.Origin ("viewport" | "pointer" | ElementOrigin): a
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 union_from_selector(name, selector)

grouping, never domain-scoped, so it is never emitted as a class.
dispatch. Unreachable here: every correlated union is a top-level result
{ correlated: true } -> resolved by request id, not the payload, so no payload
{ ordered: [{ ref, requires }] } -> presence rules in the spec's choice order.
as the fallback (it may itself be a union, which finishes the dispatch).
{ by, variants, default? } -> a discriminator table (value => ref), `default`
Map a union `selector` to dispatch variants the template renders:
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 union_params(type, ref = nil)

null-vs-absent — so no nullable allowlist is needed.
kwargs to the matching variant via `Union.build`, whose typed `as_json` handles
variant-specific fields become optional. The command body dispatches these
signature. A field is only required when every variant declares it required;
Merge a union's record variants into one flat param list for the command
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

def union_ref?(name)

scalar sibling. A record arm would raise on one, so it is not carried.
True when a ref (following aliases) is a union — the only arm whose from_json tolerates a
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