light-services 3.2.1 โ†’ 3.3.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c2d31f74eaaa7add84302e90f4d2748c5c099f34ea44e1be0d8eb6faa286ec37
4
- data.tar.gz: 7bb83450e6be42ab27938df758644820476138684712f7d015694b4d1f675d92
3
+ metadata.gz: 814307f260796e4439cd7e9602159281b4457003c9b9a70f5596ecb81a80c7bc
4
+ data.tar.gz: e9ac19b493f3559b0e2ba1b978c7dd3e51615614c1c1f239f1326412e6126950
5
5
  SHA512:
6
- metadata.gz: e2cf4be4b70f330c77d53b104b4f4c36686a6a1aaeba1cee0478d25941c06c5ff7e31d9bc0d4998710dd8a4b811e06f39794d54b9102663480d5d32adfbca45a
7
- data.tar.gz: 56134a214344e9a3c7448b4cca8becdb05d03df864962faa5c1f9412cf65ce774a5245ac1a5eda2127e86bdc5b41d01a5ab2893109b401d8259f3dc704420220
6
+ metadata.gz: 3ba524199e7078ad1dbbf0a4df34ef4bb50f59c8950695e74e8a2b9f6691ae0ca673b16127700e2a7d0bdc66a1762fd886d4a89efe4e531840a092a49b0490b3
7
+ data.tar.gz: ecacfe8dd84b3fb1b1ac944b46e4ec6ed08d15eb6857f9fec82f070f90a87a51c97855d7ab46cc62f007f32c77f528fb178b9829c46603121978a5752905cb49
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.3.1 (2025-12-16)
4
+
5
+ ### Added
6
+
7
+ - Sorbet runtime type support for `arg` and `output` (validation only, no coercion)
8
+
9
+ ## 3.3.0 (2025-12-15)
10
+
11
+ ### Added
12
+
13
+ - Sorbet and Tapioca support for `arg` and `output`
14
+
3
15
  ## 3.2.1 (2025-12-15)
4
16
 
5
17
  ### Added
data/Gemfile CHANGED
@@ -7,6 +7,7 @@ gemspec
7
7
  group :test do
8
8
  # Optional type system support
9
9
  gem "dry-types", "~> 1.0"
10
+ gem "sorbet-runtime"
10
11
 
11
12
  gem "activerecord", "< 8"
12
13
  gem "connection_pool", "< 3"
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- light-services (3.2.1)
4
+ light-services (3.3.1)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
@@ -126,6 +126,7 @@ GEM
126
126
  simplecov_json_formatter (~> 0.1)
127
127
  simplecov-html (0.13.2)
128
128
  simplecov_json_formatter (0.1.4)
129
+ sorbet-runtime (0.6.12848)
129
130
  sqlite3 (2.8.1)
130
131
  mini_portile2 (~> 2.8.0)
131
132
  timeout (0.5.0)
@@ -154,6 +155,7 @@ DEPENDENCIES
154
155
  rubocop-rake
155
156
  rubocop-rspec
156
157
  simplecov
158
+ sorbet-runtime
157
159
  sqlite3
158
160
 
159
161
  BUNDLED WITH
data/README.md CHANGED
@@ -18,6 +18,7 @@ Light Services is a simple yet powerful way to organize business logic in Ruby a
18
18
  - ๐Ÿงช **RSpec Matchers**: Built-in RSpec matchers for expressive service tests
19
19
  - ๐ŸŒ **Framework Agnostic**: Compatible with Rails, Hanami, or any Ruby framework
20
20
  - ๐Ÿงฉ **Modularity**: Isolate and test your services with ease
21
+ - ๐Ÿ”ท **Sorbet & Tapioca**: Full support for Sorbet type checking and Tapioca DSL generation
21
22
  - โœ… **100% Test Coverage**: Thoroughly tested and reliable
22
23
  - โš”๏ธ **Battle-Tested**: In production use since 2017
23
24
 
data/docs/README.md CHANGED
@@ -16,6 +16,7 @@ Light Services is a simple yet powerful way to organize business logic in Ruby a
16
16
  - ๐Ÿ” **RuboCop Integration**: Custom cops to enforce best practices at lint time
17
17
  - ๐ŸŒ **Framework Agnostic**: Compatible with Rails, Hanami, or any Ruby framework
18
18
  - ๐Ÿงฉ **Modularity**: Isolate and test your services with ease
19
+ - ๐Ÿ”ท **Sorbet & Tapioca**: Full support for Sorbet type checking and Tapioca DSL generation
19
20
  - โœ… **100% Test Coverage**: Thoroughly tested and reliable
20
21
  - โš”๏ธ **Battle-Tested**: In production use since 2017
21
22
 
data/docs/SUMMARY.md CHANGED
@@ -19,6 +19,8 @@
19
19
  * [Rails Generators](generators.md)
20
20
  * [RuboCop Integration](rubocop.md)
21
21
  * [Ruby LSP Integration](ruby-lsp.md)
22
+ * [Sorbet Runtime Types](sorbet-runtime.md)
23
+ * [Tapioca / Sorbet Integration](tapioca.md)
22
24
 
23
25
  ## Examples
24
26
 
data/docs/arguments.md CHANGED
@@ -124,6 +124,38 @@ service = User::Create.run(name: "John", age: "25")
124
124
  service.age # => 25 (Integer, not String)
125
125
  ```
126
126
 
127
+ ### Sorbet Runtime Types
128
+
129
+ Light Services also supports [Sorbet runtime types](https://sorbet.org/docs/runtime) for type validation. Unlike dry-types, Sorbet types **only validate** and do not coerce values.
130
+
131
+ ```ruby
132
+ require "sorbet-runtime"
133
+
134
+ class User::Create < ApplicationService
135
+ # Basic types using T::Utils.coerce
136
+ arg :name, type: T::Utils.coerce(String)
137
+ arg :age, type: T::Utils.coerce(Integer)
138
+
139
+ # Nilable types
140
+ arg :email, type: T.nilable(String), optional: true
141
+
142
+ # Union types
143
+ arg :status, type: T.any(String, Symbol)
144
+
145
+ # Typed arrays
146
+ arg :tags, type: T::Array[String]
147
+
148
+ # Boolean type
149
+ arg :active, type: T::Boolean, default: true
150
+ end
151
+ ```
152
+
153
+ {% hint style="warning" %}
154
+ **Sorbet types do NOT coerce values.** If you pass `"25"` where an `Integer` is expected, it will raise an error instead of converting the string to an integer. Use dry-types if you need automatic coercion.
155
+ {% endhint %}
156
+
157
+ See the [Sorbet Runtime Types documentation](sorbet-runtime.md) for more details.
158
+
127
159
  ## Required Arguments
128
160
 
129
161
  By default, arguments are required. You can make them optional by setting `optional` to `true`.
data/docs/outputs.md CHANGED
@@ -124,6 +124,22 @@ class AI::Chat < ApplicationService
124
124
  end
125
125
  ```
126
126
 
127
+ ### Sorbet Runtime Types
128
+
129
+ Outputs also support [Sorbet runtime types](https://sorbet.org/docs/runtime) for type validation:
130
+
131
+ ```ruby
132
+ require "sorbet-runtime"
133
+
134
+ class AI::Chat < ApplicationService
135
+ output :messages, type: T::Array[Hash]
136
+ output :total_tokens, type: T::Utils.coerce(Integer)
137
+ output :metadata, type: T.nilable(Hash), optional: true
138
+ end
139
+ ```
140
+
141
+ See the [Sorbet Runtime Types documentation](sorbet-runtime.md) for more details.
142
+
127
143
  ## Default Values
128
144
 
129
145
  Set default values for outputs using the `default` option. The default value will be automatically set before the execution of steps.
@@ -0,0 +1,277 @@
1
+ # Sorbet Runtime Types
2
+
3
+ Light Services supports [Sorbet runtime types](https://sorbet.org/docs/runtime) for type validation of arguments and outputs. This provides runtime type checking using Sorbet's type system.
4
+
5
+ {% hint style="info" %}
6
+ This page covers **runtime type checking** with `sorbet-runtime`. For **static type analysis** with Sorbet and RBI file generation, see [Tapioca / Sorbet Integration](tapioca.md).
7
+ {% endhint %}
8
+
9
+ ## Installation
10
+
11
+ Add `sorbet-runtime` to your Gemfile:
12
+
13
+ ```ruby
14
+ gem "sorbet-runtime"
15
+ ```
16
+
17
+ Then run:
18
+
19
+ ```bash
20
+ bundle install
21
+ ```
22
+
23
+ ## Basic Usage
24
+
25
+ When `sorbet-runtime` is loaded, plain Ruby classes are automatically validated using Sorbet's type system:
26
+
27
+ ```ruby
28
+ require "sorbet-runtime"
29
+
30
+ class User::Create < ApplicationService
31
+ # Basic types - plain Ruby classes work directly!
32
+ arg :name, type: String
33
+ arg :age, type: Integer
34
+
35
+ # Nilable types (allows nil)
36
+ arg :email, type: T.nilable(String), optional: true
37
+
38
+ # Union types (multiple allowed types)
39
+ arg :status, type: T.any(String, Symbol), default: "pending"
40
+
41
+ # Typed arrays
42
+ arg :tags, type: T::Array[String], optional: true
43
+
44
+ # Boolean type
45
+ arg :active, type: T::Boolean, default: true
46
+
47
+ # Outputs with Sorbet types - plain classes work here too
48
+ output :user, type: User
49
+ output :metadata, type: Hash
50
+
51
+ step :create_user
52
+ step :build_metadata
53
+
54
+ private
55
+
56
+ def create_user
57
+ self.user = User.create!(
58
+ name: name,
59
+ age: age,
60
+ email: email,
61
+ status: status,
62
+ tags: tags || [],
63
+ active: active
64
+ )
65
+ end
66
+
67
+ def build_metadata
68
+ self.metadata = { created_at: Time.current }
69
+ end
70
+ end
71
+ ```
72
+
73
+ ## Type Reference
74
+
75
+ ### Basic Types
76
+
77
+ When `sorbet-runtime` is loaded, plain Ruby classes are automatically coerced to Sorbet types:
78
+
79
+ ```ruby
80
+ arg :name, type: String
81
+ arg :count, type: Integer
82
+ arg :price, type: Float
83
+ arg :data, type: Hash
84
+ arg :items, type: Array
85
+ ```
86
+
87
+ {% hint style="info" %}
88
+ You can also use `T::Utils.coerce(String)` explicitly, but it's not required - Light Services handles the coercion automatically.
89
+ {% endhint %}
90
+
91
+ ### Nilable Types
92
+
93
+ Allow `nil` values with `T.nilable`:
94
+
95
+ ```ruby
96
+ arg :nickname, type: T.nilable(String), optional: true
97
+ ```
98
+
99
+ ### Union Types
100
+
101
+ Allow multiple types with `T.any`:
102
+
103
+ ```ruby
104
+ arg :identifier, type: T.any(String, Integer)
105
+ arg :status, type: T.any(String, Symbol)
106
+ ```
107
+
108
+ ### Typed Arrays
109
+
110
+ Validate array element types with `T::Array`:
111
+
112
+ ```ruby
113
+ arg :tags, type: T::Array[String]
114
+ arg :numbers, type: T::Array[Integer]
115
+ arg :users, type: T::Array[User]
116
+ ```
117
+
118
+ {% hint style="warning" %}
119
+ **Generic Type Erasure:** Sorbet's `T::Array[String]` only validates that the value is an `Array` at runtime. The type parameter (`String`) is **erased** and not checked. For strict array element validation, use dry-types: `Types::Array.of(Types::Strict::String)`.
120
+ {% endhint %}
121
+
122
+ ### Boolean Type
123
+
124
+ Use `T::Boolean` for true/false values:
125
+
126
+ ```ruby
127
+ arg :active, type: T::Boolean
128
+ arg :verified, type: T::Boolean, default: false
129
+ ```
130
+
131
+ ### Complex Types
132
+
133
+ Combine types for more complex validations:
134
+
135
+ ```ruby
136
+ # Nilable array
137
+ arg :tags, type: T.nilable(T::Array[String]), optional: true
138
+
139
+ # Array of union types
140
+ arg :identifiers, type: T::Array[T.any(String, Integer)]
141
+ ```
142
+
143
+ ## Key Differences from dry-types
144
+
145
+ {% hint style="warning" %}
146
+ **Sorbet types do NOT coerce values.** Unlike dry-types which can automatically convert values (e.g., `"123"` โ†’ `123`), Sorbet runtime types only validate that values match the expected type.
147
+ {% endhint %}
148
+
149
+ ### Coercion Behavior Comparison
150
+
151
+ **With dry-types (coerces):**
152
+
153
+ ```ruby
154
+ # dry-types with coercible integer
155
+ arg :age, type: Types::Coercible::Integer
156
+
157
+ service = MyService.run(age: "25")
158
+ service.age # => 25 (Integer - coerced from String)
159
+ ```
160
+
161
+ **With Sorbet runtime (validates only):**
162
+
163
+ ```ruby
164
+ # Sorbet runtime type (plain class or T::Utils.coerce)
165
+ arg :age, type: Integer
166
+
167
+ service = MyService.run(age: "25")
168
+ # => Raises ArgTypeError: expected Integer, got String
169
+ ```
170
+
171
+ ### When to Use Each
172
+
173
+ | Use Case | Recommended |
174
+ |----------|-------------|
175
+ | Strict type validation | Sorbet runtime |
176
+ | Automatic value coercion | dry-types |
177
+ | Static type analysis | Sorbet + Tapioca |
178
+ | Input from external sources (APIs, forms) | dry-types (for coercion) |
179
+ | Internal service-to-service calls | Sorbet runtime |
180
+
181
+ ## Combining with Tapioca
182
+
183
+ For full Sorbet support, you can use both:
184
+
185
+ 1. **Runtime types** (`sorbet-runtime`) - Validates types at runtime
186
+ 2. **Static types** (Tapioca) - Generates RBI files for static analysis
187
+
188
+ See [Tapioca / Sorbet Integration](tapioca.md) for setting up static type analysis.
189
+
190
+ ```ruby
191
+ # typed: strict
192
+
193
+ class User::Create < ApplicationService
194
+ # Runtime validation with Sorbet types
195
+ arg :name, type: String
196
+ arg :age, type: Integer
197
+
198
+ output :user, type: User
199
+
200
+ # ...
201
+ end
202
+ ```
203
+
204
+ With Tapioca configured, you get:
205
+ - **Runtime validation** from `sorbet-runtime`
206
+ - **IDE autocompletion** from generated RBI files
207
+ - **Static type checking** from `srb tc`
208
+
209
+ ## Error Messages
210
+
211
+ When type validation fails, Light Services raises `ArgTypeError` with a descriptive message:
212
+
213
+ ```ruby
214
+ service = User::Create.run(name: 123, age: 25)
215
+ # => Light::Services::ArgTypeError: User::Create argument `name` expected String, but got Integer with value: 123
216
+ ```
217
+
218
+ ## Full Example
219
+
220
+ ```ruby
221
+ require "sorbet-runtime"
222
+
223
+ class Order::Create < ApplicationService
224
+ # Required arguments - plain classes work!
225
+ arg :customer, type: Customer
226
+ arg :items, type: T::Array[OrderItem]
227
+ arg :total, type: T.any(Integer, Float)
228
+
229
+ # Optional arguments
230
+ arg :notes, type: T.nilable(String), optional: true
231
+ arg :priority, type: T::Boolean, default: false
232
+ arg :tags, type: T::Array[String], optional: true
233
+
234
+ # Outputs
235
+ output :order, type: Order
236
+ output :confirmation_number, type: String
237
+
238
+ step :validate_items
239
+ step :create_order
240
+ step :generate_confirmation
241
+
242
+ private
243
+
244
+ def validate_items
245
+ fail!("Order must have at least one item") if items.empty?
246
+ end
247
+
248
+ def create_order
249
+ self.order = Order.create!(
250
+ customer: customer,
251
+ items: items,
252
+ total: total,
253
+ notes: notes,
254
+ priority: priority,
255
+ tags: tags || []
256
+ )
257
+ end
258
+
259
+ def generate_confirmation
260
+ self.confirmation_number = "ORD-#{order.id}-#{SecureRandom.hex(4).upcase}"
261
+ end
262
+ end
263
+
264
+ # Usage
265
+ result = Order::Create.run(
266
+ customer: current_user,
267
+ items: [item1, item2],
268
+ total: 99.99,
269
+ priority: true
270
+ )
271
+
272
+ if result.success?
273
+ puts "Order created: #{result.confirmation_number}"
274
+ else
275
+ puts "Failed: #{result.errors.full_messages}"
276
+ end
277
+ ```
data/docs/tapioca.md ADDED
@@ -0,0 +1,200 @@
1
+ # Tapioca / Sorbet Integration
2
+
3
+ Light Services provides a [Tapioca](https://github.com/Shopify/tapioca) DSL compiler that generates RBI signatures for methods automatically created by the `arg` and `output` DSL macros. This enables full Sorbet type checking for your services.
4
+
5
+ ## Features
6
+
7
+ When you use the `arg` or `output` keywords, Light Services dynamically generates methods at runtime:
8
+
9
+ ```ruby
10
+ class CreateUser < ApplicationService
11
+ arg :name, type: String
12
+ arg :email, type: String, optional: true
13
+ arg :role, type: [Symbol, String]
14
+
15
+ output :user, type: User
16
+ end
17
+ ```
18
+
19
+ The Tapioca compiler generates RBI signatures for these methods:
20
+
21
+ ```rbi
22
+ # sorbet/rbi/dsl/create_user.rbi
23
+ # typed: true
24
+
25
+ class CreateUser
26
+ sig { returns(String) }
27
+ def name; end
28
+
29
+ sig { returns(T::Boolean) }
30
+ def name?; end
31
+
32
+ sig { returns(T.nilable(String)) }
33
+ def email; end
34
+
35
+ sig { returns(T::Boolean) }
36
+ def email?; end
37
+
38
+ sig { returns(T.any(Symbol, String)) }
39
+ def role; end
40
+
41
+ sig { returns(T::Boolean) }
42
+ def role?; end
43
+
44
+ sig { returns(User) }
45
+ def user; end
46
+
47
+ sig { returns(T::Boolean) }
48
+ def user?; end
49
+
50
+ private
51
+
52
+ sig { params(value: String).returns(String) }
53
+ def name=(value); end
54
+
55
+ sig { params(value: T.nilable(String)).returns(T.nilable(String)) }
56
+ def email=(value); end
57
+
58
+ sig { params(value: T.any(Symbol, String)).returns(T.any(Symbol, String)) }
59
+ def role=(value); end
60
+
61
+ sig { params(value: User).returns(User) }
62
+ def user=(value); end
63
+ end
64
+ ```
65
+
66
+ ## Setup
67
+
68
+ ### 1. Install Tapioca
69
+
70
+ Add Tapioca to your Gemfile:
71
+
72
+ ```ruby
73
+ group :development do
74
+ gem "tapioca", require: false
75
+ end
76
+ ```
77
+
78
+ Then run:
79
+
80
+ ```bash
81
+ bundle install
82
+ bundle exec tapioca init
83
+ ```
84
+
85
+ ### 2. Generate RBI Files
86
+
87
+ The Light Services compiler is automatically discovered by Tapioca. Generate RBI files with:
88
+
89
+ ```bash
90
+ bundle exec tapioca dsl
91
+ ```
92
+
93
+ This will create RBI files in `sorbet/rbi/dsl/` for all your services.
94
+
95
+ ### 3. Re-generate After Changes
96
+
97
+ After adding or modifying `arg`/`output` declarations, regenerate the RBI files:
98
+
99
+ ```bash
100
+ bundle exec tapioca dsl LightServices
101
+ ```
102
+
103
+ ## Type Mappings
104
+
105
+ ### Ruby Types
106
+
107
+ Standard Ruby types are mapped directly:
108
+
109
+ | Ruby Type | Sorbet Type |
110
+ |-----------|-------------|
111
+ | `String` | `::String` |
112
+ | `Integer` | `::Integer` |
113
+ | `Float` | `::Float` |
114
+ | `Hash` | `::Hash` |
115
+ | `Array` | `::Array` |
116
+ | `Symbol` | `::Symbol` |
117
+ | `User` (custom) | `::User` |
118
+
119
+ ### Boolean Types
120
+
121
+ Boolean types are mapped to `T::Boolean`:
122
+
123
+ ```ruby
124
+ arg :active, type: [TrueClass, FalseClass]
125
+ # Generates: sig { returns(T::Boolean) }
126
+ ```
127
+
128
+ ### Union Types
129
+
130
+ Multiple types create union types:
131
+
132
+ ```ruby
133
+ arg :id, type: [String, Integer]
134
+ # Generates: sig { returns(T.any(::String, ::Integer)) }
135
+ ```
136
+
137
+ ### Optional Types
138
+
139
+ Optional arguments/outputs are wrapped in `T.nilable`:
140
+
141
+ ```ruby
142
+ arg :nickname, type: String, optional: true
143
+ # Generates: sig { returns(T.nilable(::String)) }
144
+ ```
145
+
146
+ ### Dry-Types
147
+
148
+ If you use [dry-types](https://dry-rb.org/gems/dry-types/), they are mapped to their primitive Ruby types:
149
+
150
+ | Dry Type | Sorbet Type |
151
+ |----------|-------------|
152
+ | `Types::String` | `::String` |
153
+ | `Types::Strict::String` | `::String` |
154
+ | `Types::Integer` | `::Integer` |
155
+ | `Types::Bool` | `T::Boolean` |
156
+ | `Types::Array` | `::Array` |
157
+ | `Types::Hash` | `::Hash` |
158
+ | `Types::Date` | `::Date` |
159
+ | `Types::Time` | `::Time` |
160
+ | `Types::DateTime` | `::DateTime` |
161
+ | `Types::Decimal` | `::BigDecimal` |
162
+ | `Types::Any` | `T.untyped` |
163
+
164
+ Parameterized dry-types (e.g., `Types::Array.of(String)`) are mapped to their base type.
165
+
166
+ ## Generated Methods
167
+
168
+ For each `arg` or `output`, three methods are generated:
169
+
170
+ | Method | Return Type | Visibility |
171
+ |--------|-------------|------------|
172
+ | `name` | The declared type | public |
173
+ | `name?` | `T::Boolean` | public |
174
+ | `name=` | The declared type | **private** |
175
+
176
+ ## Inheritance
177
+
178
+ The compiler handles inherited arguments and outputs. If a child service inherits from a parent, the RBI will include methods for both parent and child fields.
179
+
180
+ ## Troubleshooting
181
+
182
+ ### RBI files not generated
183
+
184
+ Ensure Light Services is properly loaded in your application. The compiler only runs if `Light::Services::Base` is defined.
185
+
186
+ ### Types showing as `T.untyped`
187
+
188
+ This happens when:
189
+ - No `type:` option is specified for the argument/output
190
+ - The type cannot be resolved (e.g., undefined constant)
191
+
192
+ ### Custom type mappings
193
+
194
+ If you need custom dry-types mappings, you can extend the `DRY_TYPE_MAPPINGS` constant in the compiler or open an issue to add common mappings.
195
+
196
+ ## See Also
197
+
198
+ - [Ruby LSP Integration](ruby-lsp.md) - Editor integration without Sorbet
199
+ - [Arguments](arguments.md) - Full `arg` DSL documentation
200
+ - [Outputs](outputs.md) - Full `output` DSL documentation
@@ -46,7 +46,7 @@ module Light
46
46
  end
47
47
 
48
48
  # Validate a value against the field's type definition.
49
- # Supports both Ruby class types and dry-types.
49
+ # Supports Ruby class types, dry-types, and Sorbet runtime types.
50
50
  #
51
51
  # @param value [Object] the value to validate
52
52
  # @return [Object] the value (possibly coerced by dry-types)
@@ -56,6 +56,8 @@ module Light
56
56
 
57
57
  if dry_type?(@type)
58
58
  coerce_and_validate_dry_type!(value)
59
+ elsif sorbet_type?(@type) || (sorbet_available? && plain_class_type?(@type))
60
+ validate_sorbet_type!(value)
59
61
  else
60
62
  validate_ruby_type!(value)
61
63
  value
@@ -64,6 +66,16 @@ module Light
64
66
 
65
67
  private
66
68
 
69
+ # Check if sorbet-runtime is available
70
+ def sorbet_available?
71
+ defined?(T::Types::Base)
72
+ end
73
+
74
+ # Check if the type is a plain Ruby class (not dry-types or Sorbet type)
75
+ def plain_class_type?(type)
76
+ type.is_a?(Class) || type.is_a?(Module)
77
+ end
78
+
67
79
  # Check if the type is a dry-types type
68
80
  def dry_type?(type)
69
81
  return false unless defined?(Dry::Types::Type)
@@ -71,6 +83,13 @@ module Light
71
83
  type.is_a?(Dry::Types::Type)
72
84
  end
73
85
 
86
+ # Check if the type is a Sorbet runtime type
87
+ def sorbet_type?(type)
88
+ return false unless defined?(T::Types::Base)
89
+
90
+ type.is_a?(T::Types::Base)
91
+ end
92
+
74
93
  # Validate and coerce value against dry-types
75
94
  # Returns the coerced value
76
95
  def coerce_and_validate_dry_type!(value)
@@ -80,6 +99,20 @@ module Light
80
99
  "#{@service_class} #{@field_type} `#{@name}` #{e.message}"
81
100
  end
82
101
 
102
+ # Validate value against Sorbet runtime types
103
+ # Note: Sorbet types only validate, they do not coerce values
104
+ # Automatically coerces plain Ruby classes to Sorbet types when needed
105
+ # @return [Object] the original value if valid
106
+ # @raise [ArgTypeError] if the value doesn't match the expected type
107
+ def validate_sorbet_type!(value)
108
+ sorbet_type = sorbet_type?(@type) ? @type : T::Utils.coerce(@type)
109
+ return value if sorbet_type.valid?(value)
110
+
111
+ raise Light::Services::ArgTypeError,
112
+ "#{@service_class} #{@field_type} `#{@name}` expected #{sorbet_type.name}, " \
113
+ "but got #{value.class} with value: #{value.inspect}"
114
+ end
115
+
83
116
  # Validate value against Ruby class types
84
117
  def validate_ruby_type!(value)
85
118
  return if [*@type].any? { |type| value.is_a?(type) }
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Light
4
4
  module Services
5
- VERSION = "3.2.1"
5
+ VERSION = "3.3.1"
6
6
  end
7
7
  end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ return unless defined?(Tapioca::Dsl::Compiler)
4
+
5
+ module Tapioca
6
+ module Dsl
7
+ module Compilers
8
+ # Tapioca DSL compiler for Light::Services
9
+ #
10
+ # Generates RBI signatures for methods automatically defined by the
11
+ # `arg`/`argument` and `output` DSL macros in light-services.
12
+ #
13
+ # For each argument and output, three methods are generated:
14
+ # - Getter: `def name` - returns the value
15
+ # - Predicate: `def name?` - returns boolean
16
+ # - Setter: `def name=` (private) - sets the value
17
+ #
18
+ # @example Service definition
19
+ # class CreateUser < Light::Services::Base
20
+ # arg :name, type: String
21
+ # arg :email, type: String, optional: true
22
+ # arg :role, type: [Symbol, String]
23
+ #
24
+ # output :user, type: User
25
+ # end
26
+ #
27
+ # @example Generated RBI
28
+ # class CreateUser
29
+ # sig { returns(String) }
30
+ # def name; end
31
+ #
32
+ # sig { returns(T::Boolean) }
33
+ # def name?; end
34
+ #
35
+ # sig { returns(T.nilable(String)) }
36
+ # def email; end
37
+ #
38
+ # sig { returns(T::Boolean) }
39
+ # def email?; end
40
+ #
41
+ # sig { returns(T.any(Symbol, String)) }
42
+ # def role; end
43
+ #
44
+ # sig { returns(T::Boolean) }
45
+ # def role?; end
46
+ #
47
+ # sig { returns(User) }
48
+ # def user; end
49
+ #
50
+ # sig { returns(T::Boolean) }
51
+ # def user?; end
52
+ #
53
+ # private
54
+ #
55
+ # sig { params(value: String).returns(String) }
56
+ # def name=(value); end
57
+ #
58
+ # # ... other setters
59
+ # end
60
+ class LightServices < Compiler
61
+ extend T::Sig
62
+
63
+ # Default type mappings for common dry-types to their underlying Ruby types
64
+ DRY_TYPE_MAPPINGS = {
65
+ "Types::String" => "::String",
66
+ "Types::Strict::String" => "::String",
67
+ "Types::Coercible::String" => "::String",
68
+ "Types::Integer" => "::Integer",
69
+ "Types::Strict::Integer" => "::Integer",
70
+ "Types::Coercible::Integer" => "::Integer",
71
+ "Types::Float" => "::Float",
72
+ "Types::Strict::Float" => "::Float",
73
+ "Types::Coercible::Float" => "::Float",
74
+ "Types::Decimal" => "::BigDecimal",
75
+ "Types::Strict::Decimal" => "::BigDecimal",
76
+ "Types::Coercible::Decimal" => "::BigDecimal",
77
+ "Types::Bool" => "T::Boolean",
78
+ "Types::Strict::Bool" => "T::Boolean",
79
+ "Types::True" => "::TrueClass",
80
+ "Types::Strict::True" => "::TrueClass",
81
+ "Types::False" => "::FalseClass",
82
+ "Types::Strict::False" => "::FalseClass",
83
+ "Types::Array" => "::Array",
84
+ "Types::Strict::Array" => "::Array",
85
+ "Types::Hash" => "::Hash",
86
+ "Types::Strict::Hash" => "::Hash",
87
+ "Types::Symbol" => "::Symbol",
88
+ "Types::Strict::Symbol" => "::Symbol",
89
+ "Types::Coercible::Symbol" => "::Symbol",
90
+ "Types::Date" => "::Date",
91
+ "Types::Strict::Date" => "::Date",
92
+ "Types::DateTime" => "::DateTime",
93
+ "Types::Strict::DateTime" => "::DateTime",
94
+ "Types::Time" => "::Time",
95
+ "Types::Strict::Time" => "::Time",
96
+ "Types::Nil" => "::NilClass",
97
+ "Types::Strict::Nil" => "::NilClass",
98
+ "Types::Any" => "T.untyped",
99
+ }.freeze
100
+
101
+ ConstantType = type_member { { fixed: T.class_of(::Light::Services::Base) } }
102
+
103
+ class << self
104
+ extend T::Sig
105
+
106
+ sig { override.returns(T::Enumerable[Module]) }
107
+ def gather_constants
108
+ all_classes.select do |klass|
109
+ klass < ::Light::Services::Base && klass.name && klass != ::Light::Services::Base
110
+ end
111
+ end
112
+ end
113
+
114
+ sig { override.void }
115
+ def decorate
116
+ arguments = constant.arguments
117
+ outputs = constant.outputs
118
+
119
+ return if arguments.empty? && outputs.empty?
120
+
121
+ root.create_path(constant) do |klass|
122
+ # Generate argument methods
123
+ arguments.each_value do |field|
124
+ generate_field_methods(klass, field)
125
+ end
126
+
127
+ # Generate output methods
128
+ outputs.each_value do |field|
129
+ generate_field_methods(klass, field)
130
+ end
131
+ end
132
+ end
133
+
134
+ private
135
+
136
+ sig { params(klass: RBI::Scope, field: ::Light::Services::Settings::Field).void }
137
+ def generate_field_methods(klass, field)
138
+ name = field.name.to_s
139
+ ruby_type = resolve_type(field)
140
+ return_type = field.optional ? as_nilable_type(ruby_type) : ruby_type
141
+
142
+ # Getter
143
+ klass.create_method(name, return_type: return_type)
144
+
145
+ # Predicate
146
+ klass.create_method("#{name}?", return_type: "T::Boolean")
147
+
148
+ # Setter (private)
149
+ klass.create_method(
150
+ "#{name}=",
151
+ parameters: [create_param("value", type: return_type)],
152
+ return_type: return_type,
153
+ visibility: RBI::Private.new,
154
+ )
155
+ end
156
+
157
+ sig { params(field: ::Light::Services::Settings::Field).returns(String) }
158
+ def resolve_type(field)
159
+ type = field.instance_variable_get(:@type)
160
+ return "T.untyped" unless type
161
+
162
+ if type.is_a?(Array)
163
+ resolve_array_type(type)
164
+ elsif dry_type?(type)
165
+ resolve_dry_type(type)
166
+ elsif type.is_a?(Class) || type.is_a?(Module)
167
+ ruby_type_for_class(type)
168
+ else
169
+ "T.untyped"
170
+ end
171
+ end
172
+
173
+ sig { params(types: T::Array[T.untyped]).returns(String) }
174
+ def resolve_array_type(types)
175
+ resolved_types = types.map do |t|
176
+ if t.is_a?(Class) || t.is_a?(Module)
177
+ ruby_type_for_class(t)
178
+ elsif dry_type?(t)
179
+ resolve_dry_type(t)
180
+ else
181
+ "T.untyped"
182
+ end
183
+ end.uniq
184
+
185
+ return resolved_types.first if resolved_types.size == 1
186
+
187
+ # Check if this is a boolean type (TrueClass + FalseClass)
188
+ if resolved_types.sort == ["::FalseClass", "::TrueClass"]
189
+ "T::Boolean"
190
+ else
191
+ "T.any(#{resolved_types.join(', ')})"
192
+ end
193
+ end
194
+
195
+ sig { params(klass: T.any(Class, Module)).returns(String) }
196
+ def ruby_type_for_class(klass)
197
+ name = klass.name
198
+ return "T.untyped" unless name
199
+
200
+ # Handle boolean types specially
201
+ if klass == TrueClass
202
+ "::TrueClass"
203
+ elsif klass == FalseClass
204
+ "::FalseClass"
205
+ else
206
+ "::#{name}"
207
+ end
208
+ end
209
+
210
+ sig { params(type: T.untyped).returns(T::Boolean) }
211
+ def dry_type?(type)
212
+ return false unless defined?(Dry::Types::Type)
213
+
214
+ type.is_a?(Dry::Types::Type)
215
+ end
216
+
217
+ sig { params(type: T.untyped).returns(String) }
218
+ def resolve_dry_type(type)
219
+ type_string = type.to_s
220
+
221
+ # Direct mapping lookup
222
+ return DRY_TYPE_MAPPINGS[type_string] if DRY_TYPE_MAPPINGS.key?(type_string)
223
+
224
+ # Handle parameterized types: Types::Array.of(...) โ†’ Types::Array
225
+ base_type = type_string.split(".").first
226
+ return DRY_TYPE_MAPPINGS[base_type] if DRY_TYPE_MAPPINGS.key?(base_type)
227
+
228
+ # Try to infer from primitive
229
+ infer_from_primitive(type)
230
+ end
231
+
232
+ sig { params(type: T.untyped).returns(String) }
233
+ def infer_from_primitive(type)
234
+ return "T.untyped" unless type.respond_to?(:primitive)
235
+
236
+ primitive = type.primitive
237
+ return "T.untyped" unless primitive.is_a?(Class) || primitive.is_a?(Module)
238
+
239
+ ruby_type_for_class(primitive)
240
+ rescue StandardError
241
+ "T.untyped"
242
+ end
243
+
244
+ sig { params(type: String).returns(String) }
245
+ def as_nilable_type(type)
246
+ # Don't double-wrap nilable types
247
+ return type if type.start_with?("T.nilable(")
248
+ return type if type == "T.untyped"
249
+
250
+ "T.nilable(#{type})"
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: light-services
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.2.1
4
+ version: 3.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kodkod
@@ -54,7 +54,9 @@ files:
54
54
  - docs/rubocop.md
55
55
  - docs/ruby-lsp.md
56
56
  - docs/service-rendering.md
57
+ - docs/sorbet-runtime.md
57
58
  - docs/steps.md
59
+ - docs/tapioca.md
58
60
  - docs/testing.md
59
61
  - lib/generators/light_services/install/USAGE
60
62
  - lib/generators/light_services/install/install_generator.rb
@@ -108,6 +110,7 @@ files:
108
110
  - lib/ruby_lsp/light_services/addon.rb
109
111
  - lib/ruby_lsp/light_services/definition.rb
110
112
  - lib/ruby_lsp/light_services/indexing_enhancement.rb
113
+ - lib/tapioca/dsl/compilers/light_services.rb
111
114
  - light-services.gemspec
112
115
  homepage: https://light-services-docs.vercel.app/
113
116
  licenses: