Skip to content

Commit 4596f04

Browse files
committed
feat: add tapioca dsl compiler and rbs batch generation
1 parent b2f60dc commit 4596f04

16 files changed

Lines changed: 585 additions & 0 deletions

File tree

.rubocop.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@ inherit_gem:
77
AllCops:
88
NewCops: enable
99
TargetRubyVersion: 3.2
10+
Exclude:
11+
- "**/*.rbi"

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ This project adheres to [Semantic Versioning](http://semver.org/).
55

66
## [Unreleased]
77

8+
### Added
9+
10+
- Add Tapioca DSL compiler for Sorbet users (`lib/tapioca/dsl/compilers/structure.rb`)
11+
- Add `Structure::RBS.write_all` for batch RBS generation from arrays or modules
12+
813
## [4.3.0] - 2025-12-02
914

1015
### Added

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ gem "rubocop-minitest", require: false
1212
gem "rubocop-rake", require: false
1313
gem "rubocop-shopify", require: false
1414
gem "steep", require: false
15+
gem "tapioca", require: false
1516
gem "yard"

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,31 @@ The generated signatures work for code that uses your Structure classes, but Ste
581581

582582
See also: [RBS Data/Struct documentation](https://github.com/ruby/rbs/blob/master/docs/data_and_struct.md), [RBS issue #654](https://github.com/ruby/rbs/issues/654), [RBS issue #1077](https://github.com/ruby/rbs/issues/1077)
583583

584+
### Sorbet Support (Tapioca)
585+
586+
For Sorbet users, Structure includes a Tapioca DSL compiler that automatically generates RBI files:
587+
588+
```bash
589+
# In your project using Structure
590+
bundle exec tapioca dsl
591+
```
592+
593+
This will generate RBI files for all Structure classes in your project, giving you full type checking with Sorbet.
594+
595+
### Batch RBS Generation
596+
597+
Generate RBS files for multiple classes at once:
598+
599+
```ruby
600+
require 'structure/rbs'
601+
602+
# From an array of classes
603+
Structure::RBS.write_all([User, Order, Product], dir: "sig")
604+
605+
# From a module namespace
606+
Structure::RBS.write_all(MyApp::Models, dir: "sig")
607+
```
608+
584609
## Development
585610

586611
```bash

Steepfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
target :lib do
44
signature "sig"
55
check "lib"
6+
ignore "lib/tapioca"
67

78
library "fileutils"
89
library "pathname"

lib/structure/rbs.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,32 @@ def write(klass, dir: "sig")
5959
file_path
6060
end
6161

62+
# Write RBS files for multiple classes or all Structure classes in a module
63+
#
64+
# @param classes [Array<Class>, Module] Classes to generate RBS for, or a module to scan
65+
# @param dir [String] Output directory (default: "sig")
66+
# @return [Array<String>] Paths of written RBS files
67+
#
68+
# @example With array of classes
69+
# Structure::RBS.write_all([Person, Address, Order])
70+
#
71+
# @example With module namespace
72+
# Structure::RBS.write_all(Peddler::Models)
73+
def write_all(classes, dir: "sig")
74+
classes = structure_classes_in(classes) if classes.is_a?(Module)
75+
76+
classes.filter_map { |klass| write(klass, dir: dir) }
77+
end
78+
6279
private
6380

81+
def structure_classes_in(mod)
82+
mod.constants.filter_map do |const_name|
83+
const = mod.const_get(const_name)
84+
const if const.is_a?(Class) && const < Data && const.respond_to?(:__structure_meta__)
85+
end
86+
end
87+
6488
def emit_rbs_content(class_name:, attributes:, types:, required:, has_structure_modules:, custom_methods:)
6589
# @type var lines: Array[String]
6690
lines = []
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
return unless defined?(Tapioca::Dsl::Compiler)
5+
6+
module Tapioca
7+
module Dsl
8+
module Compilers
9+
# Generates RBI files for Structure-based Data classes.
10+
#
11+
# For example, given:
12+
#
13+
# Person = Structure.new do
14+
# attribute :name, String
15+
# attribute? :age, Integer
16+
# end
17+
#
18+
# This compiler will generate:
19+
#
20+
# class Person < Data
21+
# sig { params(name: String, age: T.nilable(Integer)).returns(Person) }
22+
# sig { params(name: String, age: T.nilable(Integer)).void }
23+
# def initialize(name:, age: nil); end
24+
#
25+
# sig { returns(String) }
26+
# def name; end
27+
#
28+
# sig { returns(T.nilable(Integer)) }
29+
# def age; end
30+
#
31+
# sig { params(data: T::Hash[T.any(String, Symbol), T.untyped]).returns(Person) }
32+
# def self.parse(data = {}); end
33+
# end
34+
class Structure < Compiler
35+
extend T::Sig
36+
37+
ConstantType = type_member { { fixed: T.class_of(::Data) } }
38+
39+
class << self
40+
extend T::Sig
41+
42+
sig { override.returns(T::Enumerable[Module]) }
43+
def gather_constants
44+
all_classes.select do |klass|
45+
klass < ::Data && klass.respond_to?(:__structure_meta__)
46+
end
47+
end
48+
end
49+
50+
sig { override.void }
51+
def decorate
52+
meta = constant.__structure_meta__
53+
return unless meta
54+
55+
attributes = meta[:mappings]&.keys || constant.members
56+
types = meta.fetch(:types, {})
57+
required = meta.fetch(:required, attributes)
58+
59+
root.create_path(constant) do |klass|
60+
generate_new_and_brackets(klass, attributes, types, required)
61+
generate_parse(klass)
62+
generate_load_dump(klass)
63+
generate_members(klass, attributes)
64+
generate_attr_readers(klass, attributes, types)
65+
generate_to_h(klass, attributes, types)
66+
generate_boolean_predicates(klass, types)
67+
end
68+
end
69+
70+
private
71+
72+
sig { params(klass: RBI::Scope, attributes: T::Array[Symbol], types: T::Hash[Symbol, T.untyped], required: T::Array[Symbol]).void }
73+
def generate_new_and_brackets(klass, attributes, types, required)
74+
params = attributes.map do |attr|
75+
type = map_type_to_sorbet(types[attr])
76+
is_required = required.include?(attr)
77+
if is_required
78+
create_kw_param(attr.to_s, type: type)
79+
else
80+
create_kw_opt_param(attr.to_s, type: type, default: "nil")
81+
end
82+
end
83+
84+
klass.create_method("initialize", parameters: params, return_type: "void")
85+
klass.create_method("new", parameters: params, return_type: constant.name.to_s, class_method: true)
86+
klass.create_method("[]", parameters: params, return_type: constant.name.to_s, class_method: true)
87+
end
88+
89+
sig { params(klass: RBI::Scope).void }
90+
def generate_parse(klass)
91+
klass.create_method(
92+
"parse",
93+
parameters: [
94+
create_opt_param("data", type: "T::Hash[T.any(String, Symbol), T.untyped]", default: "{}"),
95+
create_opt_param("overrides", type: "T.nilable(T::Hash[Symbol, T.untyped])", default: "nil"),
96+
],
97+
return_type: constant.name.to_s,
98+
class_method: true,
99+
)
100+
end
101+
102+
sig { params(klass: RBI::Scope).void }
103+
def generate_load_dump(klass)
104+
klass.create_method(
105+
"load",
106+
parameters: [create_param("data", type: "T.nilable(T::Hash[T.any(String, Symbol), T.untyped])")],
107+
return_type: "T.nilable(#{constant.name})",
108+
class_method: true,
109+
)
110+
klass.create_method(
111+
"dump",
112+
parameters: [create_param("value", type: "T.nilable(#{constant.name})")],
113+
return_type: "T.nilable(T::Hash[Symbol, T.untyped])",
114+
class_method: true,
115+
)
116+
end
117+
118+
sig { params(klass: RBI::Scope, attributes: T::Array[Symbol]).void }
119+
def generate_members(klass, attributes)
120+
members_type = "[#{attributes.map { |a| ":#{a}" }.join(", ")}]"
121+
klass.create_method("members", return_type: members_type, class_method: true)
122+
klass.create_method("members", return_type: members_type)
123+
end
124+
125+
sig { params(klass: RBI::Scope, attributes: T::Array[Symbol], types: T::Hash[Symbol, T.untyped]).void }
126+
def generate_attr_readers(klass, attributes, types)
127+
attributes.each do |attr|
128+
type = map_type_to_sorbet(types[attr])
129+
klass.create_method(attr.to_s, return_type: type)
130+
end
131+
end
132+
133+
sig { params(klass: RBI::Scope, attributes: T::Array[Symbol], types: T::Hash[Symbol, T.untyped]).void }
134+
def generate_to_h(klass, attributes, types)
135+
hash_pairs = attributes.map do |attr|
136+
type = map_type_to_sorbet(types[attr])
137+
"#{attr}: #{type}"
138+
end.join(", ")
139+
140+
klass.create_method("to_h", return_type: "{ #{hash_pairs} }")
141+
end
142+
143+
sig { params(klass: RBI::Scope, types: T::Hash[Symbol, T.untyped]).void }
144+
def generate_boolean_predicates(klass, types)
145+
types.each do |attr, type|
146+
next unless type == :boolean
147+
next if attr.to_s.end_with?("?")
148+
149+
klass.create_method("#{attr}?", return_type: "T::Boolean")
150+
end
151+
end
152+
153+
sig { params(type: T.untyped).returns(String) }
154+
def map_type_to_sorbet(type)
155+
case type
156+
when Class
157+
if type == Array
158+
"T::Array[T.untyped]"
159+
elsif type == Hash
160+
"T::Hash[T.untyped, T.untyped]"
161+
else
162+
"T.nilable(#{type.name || "T.untyped"})"
163+
end
164+
when :boolean
165+
"T.nilable(T::Boolean)"
166+
when :self
167+
"T.nilable(#{constant.name})"
168+
when Array
169+
if type.size == 1
170+
element_type = map_type_to_sorbet_element(type.first)
171+
"T.nilable(T::Array[#{element_type}])"
172+
else
173+
"T.nilable(T.untyped)"
174+
end
175+
when Proc
176+
"T.nilable(T.untyped)"
177+
else
178+
"T.nilable(T.untyped)"
179+
end
180+
end
181+
182+
sig { params(type: T.untyped).returns(String) }
183+
def map_type_to_sorbet_element(type)
184+
case type
185+
when Class
186+
type.name || "T.untyped"
187+
when :boolean
188+
"T::Boolean"
189+
when :self
190+
constant.name.to_s
191+
else
192+
"T.untyped"
193+
end
194+
end
195+
end
196+
end
197+
end
198+
end

sig/structure/rbs.rbs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ module Structure
44

55
def self.emit: (untyped klass) -> String?
66
def self.write: (untyped klass, ?dir: String) -> String?
7+
def self.write_all: (Array[untyped] | Module classes, ?dir: String) -> Array[String]
78

9+
private def self.structure_classes_in: (Module mod) -> Array[untyped]
810
private def self.emit_rbs_content: (class_name: String, attributes: Array[Symbol], types: Hash[Symbol, untyped], required: Array[Symbol], has_structure_modules: bool, custom_methods: untyped) -> String
911
private def self.parse_data_type: (untyped type, String class_name) -> String
1012
private def self.map_type_to_rbs: (untyped type, String class_name) -> String

test/fixtures/category.rbi

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
class Category
2+
sig { params(name: T.nilable(String), children: T.nilable(T::Array[Category])).void }
3+
def initialize(name:, children:); end
4+
5+
sig { params(name: T.nilable(String), children: T.nilable(T::Array[Category])).returns(Category) }
6+
def self.new(name:, children:); end
7+
8+
sig { params(name: T.nilable(String), children: T.nilable(T::Array[Category])).returns(Category) }
9+
def self.[](name:, children:); end
10+
11+
sig { params(data: T::Hash[T.any(String, Symbol), T.untyped], overrides: T.nilable(T::Hash[Symbol, T.untyped])).returns(Category) }
12+
def self.parse(data = {}, overrides = nil); end
13+
14+
sig { params(data: T.nilable(T::Hash[T.any(String, Symbol), T.untyped])).returns(T.nilable(Category)) }
15+
def self.load(data); end
16+
17+
sig { params(value: T.nilable(Category)).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
18+
def self.dump(value); end
19+
20+
sig { returns([:name, :children]) }
21+
def self.members; end
22+
23+
sig { returns([:name, :children]) }
24+
def members; end
25+
26+
sig { returns(T.nilable(String)) }
27+
def name; end
28+
29+
sig { returns(T.nilable(T::Array[Category])) }
30+
def children; end
31+
32+
sig { returns({ name: T.nilable(String), children: T.nilable(T::Array[Category]) }) }
33+
def to_h; end
34+
end

test/fixtures/person.rbi

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
class Person
2+
sig { params(name: T.nilable(String), age: T.nilable(Integer), active: T.nilable(T::Boolean), email: T.nilable(String)).void }
3+
def initialize(name:, age: nil, active:, email:); end
4+
5+
sig { params(name: T.nilable(String), age: T.nilable(Integer), active: T.nilable(T::Boolean), email: T.nilable(String)).returns(Person) }
6+
def self.new(name:, age: nil, active:, email:); end
7+
8+
sig { params(name: T.nilable(String), age: T.nilable(Integer), active: T.nilable(T::Boolean), email: T.nilable(String)).returns(Person) }
9+
def self.[](name:, age: nil, active:, email:); end
10+
11+
sig { params(data: T::Hash[T.any(String, Symbol), T.untyped], overrides: T.nilable(T::Hash[Symbol, T.untyped])).returns(Person) }
12+
def self.parse(data = {}, overrides = nil); end
13+
14+
sig { params(data: T.nilable(T::Hash[T.any(String, Symbol), T.untyped])).returns(T.nilable(Person)) }
15+
def self.load(data); end
16+
17+
sig { params(value: T.nilable(Person)).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
18+
def self.dump(value); end
19+
20+
sig { returns([:name, :age, :active, :email]) }
21+
def self.members; end
22+
23+
sig { returns([:name, :age, :active, :email]) }
24+
def members; end
25+
26+
sig { returns(T.nilable(String)) }
27+
def name; end
28+
29+
sig { returns(T.nilable(Integer)) }
30+
def age; end
31+
32+
sig { returns(T.nilable(T::Boolean)) }
33+
def active; end
34+
35+
sig { returns(T.nilable(String)) }
36+
def email; end
37+
38+
sig { returns({ name: T.nilable(String), age: T.nilable(Integer), active: T.nilable(T::Boolean), email: T.nilable(String) }) }
39+
def to_h; end
40+
41+
sig { returns(T::Boolean) }
42+
def active?; end
43+
end

0 commit comments

Comments
 (0)