#!/usr/bin/env ruby # Generates sig/raylib.rbs — RBS type signatures for the raylib + raymath Ruby # bindings. Reuses the type-mapping machinery (snake, ruby_method, rtype) from # gen_ai_reference.rb, adapted for RBS syntax. # # ruby mrbgems/raylib/tools/gen_rbs.rb require 'json' ROOT = File.expand_path('../../..', __dir__) RAYLIB = File.join(ROOT, 'vendor', 'raylib') GEN_C = File.join(__dir__, '..', 'src', 'raylib_gen.c') OUT = File.join(ROOT, 'sig', 'raylib.rbs') # raylib 6.0 relocated the parser: parser/output/ -> tools/rlparser/output/. # Tolerant loader for a known raylib 6.0 bug (LoadDirectoryFilesEx description # has unescaped quotes). Kept in sync with gen_raylib.rb / gen_ai_reference.rb. def load_api(path) raw = File.read(path) return JSON.parse(raw) rescue JSON.parse(raw .gsub('"*.*"', '\"*.*\"') .gsub('"FILES*"', '\"FILES*\"') .gsub('"DIRS*"', '\"DIRS*\"')) end API = load_api(File.join(RAYLIB, 'tools/rlparser/output/raylib_api.json')) RMATH = load_api(File.join(RAYLIB, 'tools/rlparser/output/raymath_api.json')) ALIASES = API['aliases'].to_h { |a| [a['name'], a['type']] } STRUCTS = API['structs'].map { |s| s['name'] }.to_h { |n| [n, true] } def snake(n) n.gsub(/(\d)([A-Z][a-z])/, '\1_\2').gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') .gsub(/([a-z])([A-Z])/, '\1_\2').downcase end def ruby_method(n) = n =~ /\AIs([A-Z].*)\z/ ? snake($1) + '?' : snake(n) def base_struct(t) b = t.gsub('const', '').gsub('*', '').strip b = ALIASES[b] || b STRUCTS[b] ? b : nil end def rtype(t, numeric: false) s = t.strip return nil if s == 'void' return 'Boolean' if s == 'bool' # mruby's `f` (mrb_float) mrb_get_args format auto-converts Integer -> Float, so # float *inputs* (params, struct fields) accept ints too. Use `Float | Integer` # (NOT Numeric): both members declare arithmetic so `v.x * 2` type-checks, # whereas the abstract Numeric does not declare `*`/`/`/`**`. Returns stay Float: # the C value really is a Float (pass numeric: true only for inputs). # WART: RBS's numeric tower widens `**`/some `/` (and camera-math via Math.sin) # to `Complex`, which then won't fit `Float | Integer` params — surfacing as # :information ArgumentTypeMismatch under the Steepfile's lenient config (non- # failing). mruby's auto-coercing numerics don't map cleanly to RBS's strict # tower; accepted trade-off (see .agents/knowledge/steep.md). Float would reject # int literals; Numeric breaks arithmetic — Float | Integer is the best fit. return (numeric ? 'Float | Integer' : 'Float') if s == 'float' || s == 'double' return 'String' if s == 'const char *' || s == 'char *' if (b = base_struct(s)) then return "Rl::#{b}" end return 'Integer' unless s.include?('*') nil end def rbs_type(t, numeric: false) r = rtype(t, numeric: numeric) return 'nil' if r.nil? && t.strip == 'void' return 'untyped' if r.nil? r == 'Boolean' ? 'bool' : r end skip_reason = {} if File.exist?(GEN_C) && File.read(GEN_C, 4096) =~ /Skipped \(\d+\):\s*(.*?)\*\//m $1.split(';').each do |e| e = e.strip if e =~ /\A([A-Za-z_]\w*)\s*\((.*)\)/ then skip_reason[$1] = $2 end end end skip_reason.delete('SetShaderValue'); skip_reason.delete('SetShaderValueV') SPECIAL_RBS = { 'SetShaderValue' => ' def self.set_shader_value: (Rl::Shader shader, Integer loc_index, untyped value, Integer uniform_type) -> nil', 'SetShaderValueV' => ' def self.set_shader_value_v: (Rl::Shader shader, Integer loc_index, untyped value, Integer uniform_type, Integer count) -> nil', } # Functions emitted only as hand-written sugar below (skip their JSON form): the # sugar accepts a Symbol key (resolve_key) where the JSON form would be Integer. SUGAR_OVERRIDE = %w[DrawText DrawTexturePro IsKeyDown IsKeyPressed IsKeyReleased IsKeyUp] def rbs_sig(fn) return SPECIAL_RBS[fn['name']] if SPECIAL_RBS[fn['name']] name = ruby_method(fn['name']) params = (fn['params'] || []).map { |p| "#{rbs_type(p['type'], numeric: true)} #{snake(p['name'])}" } ret = rbs_type(fn['returnType']) # numeric: false -> Float for float returns ret = 'nil' if ret.nil? " def self.#{name}: (#{params.join(', ')}) -> #{ret}" end def rbs_struct(st) fields = st['fields'].map { |f| [f['name'], rbs_type(f['type'], numeric: true)] }.select { |_, t| t && t != 'nil' } lines = [] lines << "class Rl::#{st['name']}" fields.each do |name, t| lines << " attr_accessor #{name}: #{t}" end init_params = fields.map { |n, t| "?#{t} #{n}" } lines << " def initialize: (#{init_params.join(', ')}) -> void" lines << "end" lines.join("\n") end o = +"" o << "# Generated by gen_rbs.rb — DO NOT EDIT.\n" o << "# Regenerate: ruby mrbgems/raylib/tools/gen_rbs.rb\n\n" # --- module Rl: functions --- o << "module Rl\n" fns = (API['functions'] + RMATH['functions']).reject { |f| skip_reason[f['name']] || SUGAR_OVERRIDE.include?(f['name']) } fns.each { |f| o << rbs_sig(f) << "\n" } # --- Ruby sugar (hand-written, not from JSON) --- o << "\n # --- Ruby sugar (mrblib/raylib.rb) ---\n" o << " def self.while_window_open: () { -> void } -> void\n" o << " def self.window_should_close?: () -> bool\n" o << " def self.draw: (?clear_color: Rl::Color) { -> void } -> void\n" o << " def self.mode_2d: (Rl::Camera2D camera) { -> void } -> void\n" o << " def self.mode_3d: (Rl::Camera3D camera) { -> void } -> void\n" o << " def self.texture_mode: (Rl::RenderTexture target) { -> void } -> void\n" o << " def self.blend_mode: (Integer mode) { -> void } -> void\n" o << " def self.shader_mode: (Rl::Shader shader) { -> void } -> void\n" o << " def self.scissor_mode: (Integer x, Integer y, Integer width, Integer height) { -> void } -> void\n" o << " def self.draw_text: (text: String, x: Integer, y: Integer, font_size: Integer, color: Rl::Color) -> nil\n" o << " def self.draw_texture_pro: (texture: Rl::Texture, source: Rl::Rectangle, dest: Rl::Rectangle, ?origin: Rl::Vector2, ?rotation: Float, ?tint: Rl::Color) -> nil\n" o << " def self.target_fps=: (Integer) -> Integer\n" o << " def self.master_volume=: (Float) -> Float\n" o << " def self.frame_time: () -> Float\n" o << " def self.time: () -> Float\n" o << " def self.fps: () -> Integer\n" o << " def self.screen_width: () -> Integer\n" o << " def self.screen_height: () -> Integer\n" o << " def self.mouse_x: () -> Integer\n" o << " def self.mouse_y: () -> Integer\n" o << " def self.mouse_position: () -> Rl::Vector2\n" o << " def self.mouse_wheel: () -> Float\n" o << " def self.platform: () -> Symbol\n" o << " def self.web?: () -> bool\n" o << " def self.desktop?: () -> bool\n" o << " def self.key_down?: (untyped key) -> bool\n" o << " def self.key_pressed?: (untyped key) -> bool\n" o << " def self.key_released?: (untyped key) -> bool\n" o << " def self.key_up?: (untyped key) -> bool\n" # --- constants (enums + defines) --- o << "\n # --- Constants ---\n" API['enums'].each do |e| e['values'].each { |v| o << " #{v['name']}: Integer\n" } end colors = []; ints = []; floats = []; strings = [] API['defines'].each do |d| case d['type'] when 'COLOR' then colors << d['name'] when 'INT' then ints << d['name'] when 'FLOAT' then floats << d['name'] when 'STRING' then strings << d['name'] end end colors.each { |c| o << " #{c}: Rl::Color\n" } (ints + floats).each { |c| o << " #{c}: #{floats.include?(c) ? 'Float' : 'Integer'}\n" } strings.each { |c| o << " #{c}: String\n" } # --- aliases (Texture2D = Texture, etc.) --- # raylib 6.0 adds a pointer typedef `typedef Transform *ModelAnimPose;` whose # alias name comes through as "*ModelAnimPose" — strip a leading '*' so the RBS # identifier stays valid. API['aliases'].each do |a| name = a['name'].to_s.sub(/\A\*/, '') o << " #{name}: untyped # alias for Rl::#{a['type']}\n" end o << "end\n\n" # --- structs as classes --- API['structs'].each do |st| o << rbs_struct(st) << "\n\n" end File.write(OUT, o) warn "wrote #{OUT}: #{fns.size} functions, #{API['structs'].size} structs, " \ "#{API['enums'].sum { |e| e['values'].size } + colors.size + ints.size + floats.size + strings.size} constants"