llull 0.2.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8e0e20df9c2ad6bc3b999fdab13ce83dd7b31cf9c1faa5e64ab57386be60f948
4
+ data.tar.gz: 80fbc31fb798cfa5ade0b83d07d1f1fdfa2f459c597972d5de515ac0c3fdbfa4
5
+ SHA512:
6
+ metadata.gz: c49f21eea1c1714c6db2a1b875bdae5a050a58f37c3586d83e73cad0c811ba64abe5c7a0c34be012ad4e2c0e714929fe80c45c3931f06ad991703dbe1c1fc64d
7
+ data.tar.gz: 746c6cf76d8e3ab28790872283cca0ed04efc2378c720774556921639cf04d219508caf5a40790809afe650f818a8be04436198272c28cff6ead1a420105624e
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Thomas Murphy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,371 @@
1
+ # Llull
2
+
3
+ Composable Result types for Ruby. Implements functional programming patterns inspired by [railway-oriented programming](https://fsharpforfunandprofit.com/rop/). Named after [Ramon Llull](https://en.wikipedia.org/wiki/Ramon_Llull), whose _ars combinatoria_ explored the systematic combination of concepts.
4
+
5
+ ## Overview
6
+
7
+ Replace nested conditionals and exception handling:
8
+
9
+ ```ruby
10
+ def create_order(params)
11
+ return { error: "Invalid params" } unless valid_params?(params)
12
+
13
+ user = User.find(params[:user_id])
14
+ return { error: "User not found" } unless user
15
+
16
+ begin
17
+ order = Order.create!(params.merge(user: user))
18
+ { success: true, order: order }
19
+ rescue ActiveRecord::RecordInvalid => e
20
+ { error: e.message }
21
+ end
22
+ end
23
+ ```
24
+
25
+ With composable operations:
26
+
27
+ ```ruby
28
+ def create_order(params)
29
+ validate_params(params)
30
+ .and_then { |valid_params| find_user(valid_params) }
31
+ .and_then { |user, params| create_order_record(user, params) }
32
+ .tap_success { |order| send_confirmation(order) }
33
+ end
34
+ ```
35
+
36
+ ## Installation
37
+
38
+ Add to your Gemfile:
39
+
40
+ ```ruby
41
+ gem 'llull'
42
+ ```
43
+
44
+ Then run:
45
+
46
+ ```bash
47
+ bundle install
48
+ ```
49
+
50
+ ## Usage
51
+
52
+ Include `Llull` in your classes to access the `Success()` and `Failure()` constructors:
53
+
54
+ ```ruby
55
+ class MyService
56
+ include Llull
57
+
58
+ def call(input)
59
+ Success(input)
60
+ .and_then { |data| process_data(data) }
61
+ .map { |result| format_result(result) }
62
+ end
63
+ end
64
+ ```
65
+
66
+ ### Basic Result Operations
67
+
68
+ #### Creating Results
69
+
70
+ ```ruby
71
+ # Success with a value
72
+ success = Success("hello world")
73
+ success.success? # => true
74
+ success.value # => "hello world"
75
+
76
+ # Failure with error type and data
77
+ failure = Failure(:validation, "Email is required")
78
+ failure.failure? # => true
79
+ failure.error_type # => :validation
80
+ failure.error_data # => "Email is required"
81
+ ```
82
+
83
+ #### Transforming Values
84
+
85
+ ```ruby
86
+ # map: transform successful values
87
+ Success(10).map { |x| x * 2 } # => Success(20)
88
+ Failure(:error, "oops").map { |x| x * 2 } # => Failure(:error, "oops")
89
+
90
+ # and_then: chain operations that return Results
91
+ Success("hello")
92
+ .and_then { |str| Success(str.upcase) }
93
+ .and_then { |str| Success("#{str}!") }
94
+ # => Success("HELLO!")
95
+ ```
96
+
97
+ #### Error Recovery
98
+
99
+ ```ruby
100
+ # or_else: recover with another Result-returning operation
101
+ result = Failure(:primary_failed, "error")
102
+ .or_else { |type, data| Success("backup plan") }
103
+ # => Success("backup plan")
104
+
105
+ # recover: convert failure to success value
106
+ result = Failure(:not_found, "User missing")
107
+ .recover { |type, data| "Default User" }
108
+ # => Success("Default User")
109
+ ```
110
+
111
+ #### Unwrapping Values
112
+
113
+ ```ruby
114
+ # Unsafe unwrapping (raises on failure)
115
+ Success("hello").unwrap! # => "hello"
116
+ Failure(:error, "oops").unwrap! # => raises Llull::ResultError
117
+
118
+ # Safe unwrapping with default
119
+ Success("hello").unwrap_or("default") # => "hello"
120
+ Failure(:error, "oops").unwrap_or("default") # => "default"
121
+
122
+ # Dynamic default based on error
123
+ Failure(:not_found, "User").value_or { |type, data| "Missing #{data}" }
124
+ # => "Missing User"
125
+ ```
126
+
127
+ ### Rails Service Objects
128
+
129
+ Complete example:
130
+
131
+ ```ruby
132
+ class UserRegistrationService
133
+ include Llull
134
+
135
+ def call(params)
136
+ validate_params(params)
137
+ .and_then { |valid_params| check_email_uniqueness(valid_params) }
138
+ .and_then { |valid_params| create_user(valid_params) }
139
+ .tap_success { |user| send_welcome_email(user) }
140
+ end
141
+
142
+ private
143
+
144
+ def validate_params(params)
145
+ errors = []
146
+ errors << "Email is required" if params[:email].blank?
147
+ errors << "Invalid email format" unless params[:email]&.include?("@")
148
+ errors << "Password too short" if params[:password]&.length.to_i < 8
149
+
150
+ errors.empty? ? Success(params) : Failure(:validation, errors)
151
+ end
152
+
153
+ def check_email_uniqueness(params)
154
+ existing = User.find_by(email: params[:email])
155
+ existing ? Failure(:duplicate_email, "Email already taken") : Success(params)
156
+ end
157
+
158
+ def create_user(params)
159
+ user = User.new(params)
160
+ user.save ? Success(user) : Failure(:creation_failed, user.errors.full_messages)
161
+ end
162
+
163
+ def send_welcome_email(user)
164
+ WelcomeMailer.deliver_later(user)
165
+ end
166
+ end
167
+ ```
168
+
169
+ #### Controller Integration
170
+
171
+ Controller usage:
172
+
173
+ ```ruby
174
+ class UsersController < ApplicationController
175
+ def create
176
+ UserRegistrationService.new.call(user_params)
177
+ .on_success { |user| redirect_to user, notice: "Welcome!" }
178
+ .on_failure(:validation) { |errors|
179
+ flash.now[:errors] = errors
180
+ render :new, status: :unprocessable_entity
181
+ }
182
+ .on_failure(:duplicate_email) { |message|
183
+ flash.now[:error] = message
184
+ render :new, status: :conflict
185
+ }
186
+ .on_failure { |type, data|
187
+ flash[:error] = "Registration failed"
188
+ redirect_to signup_path
189
+ }
190
+ end
191
+ end
192
+ ```
193
+
194
+ ### Pattern Matching (Ruby 3+)
195
+
196
+ Ruby 3+ pattern matching support:
197
+
198
+ ```ruby
199
+ result = UserService.new.call(params)
200
+
201
+ case result
202
+ in [:success, user]
203
+ puts "Created user: #{user.email}"
204
+ in [:failure, :validation, errors]
205
+ puts "Validation failed: #{errors.join(', ')}"
206
+ in [:failure, error_type, data]
207
+ puts "Other error: #{error_type}"
208
+ end
209
+
210
+ # Or with hash patterns
211
+ case result
212
+ in { success: true, value: user }
213
+ puts "Success: #{user.email}"
214
+ in { success: false, error_type: :validation }
215
+ puts "Validation error"
216
+ end
217
+ ```
218
+
219
+ ### Advanced Pattern Matching DSL
220
+
221
+ Complex error handling:
222
+
223
+ ```ruby
224
+ PaymentService.new.call(order, payment_method).match do |m|
225
+ m.success { |payment| redirect_to_confirmation(payment) }
226
+ m.failure(:insufficient_funds) { |data| offer_payment_plan }
227
+ m.failure(:invalid_card) { |data| ask_for_different_card }
228
+ m.failure(:service_unavailable) { |data| try_again_later }
229
+ m.failure { |type, data| log_unexpected_error(type, data) }
230
+ end
231
+ ```
232
+
233
+ ### Side Effects
234
+
235
+ Execute side effects without affecting the Result chain:
236
+
237
+ ```ruby
238
+ CreateOrderService.new.call(params)
239
+ .tap_success { |order| analytics.track("order_created", order.id) }
240
+ .tap_failure { |type, data| logger.error("Order failed: #{type}") }
241
+ .and_then { |order| PaymentService.new.call(order) }
242
+ .tap_success { |payment| send_receipt(payment) }
243
+ ```
244
+
245
+ ### Collection Operations
246
+
247
+ Multiple Results:
248
+
249
+ ```ruby
250
+ # Process multiple items, fail fast on first error
251
+ user_data = [
252
+ { email: "user1@example.com", name: "Alice" },
253
+ { email: "user2@example.com", name: "Bob" },
254
+ { email: "user3@example.com", name: "Carol" }
255
+ ]
256
+
257
+ results = user_data.map { |data| UserService.new.call(data) }
258
+ all_users = collect(results) # Success([user1, user2, user3]) or first Failure
259
+
260
+ # Transform a collection with Results
261
+ result = traverse(user_emails) do |email|
262
+ user = User.find_by(email: email)
263
+ user ? Success(user) : Failure(:not_found, email)
264
+ end
265
+ # => Success([user1, user2, ...]) or first Failure
266
+
267
+ # Convert Result containing array into array of Results
268
+ result = Success([1, 2, 3])
269
+ individual_results = sequence(result)
270
+ # => [Success(1), Success(2), Success(3)]
271
+
272
+ failure_result = Failure(:error, "failed")
273
+ sequence(failure_result)
274
+ # => [Failure(:error, "failed")]
275
+
276
+ # Partition mixed Results into successes and failures
277
+ mixed_results = [
278
+ Success("good1"),
279
+ Failure(:error, "bad1"),
280
+ Success("good2")
281
+ ]
282
+ successes, failures = partition(mixed_results)
283
+ # successes => ["good1", "good2"]
284
+ # failures => [[:error, "bad1"]]
285
+ ```
286
+
287
+ ### Error Recovery
288
+
289
+ Multiple fallback strategies:
290
+
291
+ ```ruby
292
+ PrimaryPaymentService.new.call(order)
293
+ .or_else { |type, data| BackupPaymentService.new.call(order) }
294
+ .or_else { |type, data| ManualReviewService.new.call(order) }
295
+ .recover { |type, data|
296
+ # Last resort: create pending order
297
+ order.update!(status: 'pending_payment')
298
+ order
299
+ }
300
+ ```
301
+
302
+ ### Integration with ActiveRecord
303
+
304
+ ```ruby
305
+ class Order
306
+ def self.create_with_validation(params)
307
+ order = new(params)
308
+
309
+ if order.valid?
310
+ order.save ? Llull::Success(order) : Llull::Failure(:save_failed, order.errors)
311
+ else
312
+ Llull::Failure(:validation, order.errors.full_messages)
313
+ end
314
+ end
315
+ end
316
+
317
+ # Usage
318
+ Order.create_with_validation(params)
319
+ .and_then { |order| PaymentService.new.call(order) }
320
+ .and_then { |payment| ShippingService.new.call(payment.order) }
321
+ ```
322
+
323
+ ## API Reference
324
+
325
+ ### Result Methods
326
+
327
+ - `success?` / `failure?` - Check result status
328
+ - `value` / `error_type` / `error_data` - Access result data
329
+ - `map { |value| ... }` - Transform successful values
330
+ - `and_then { |value| ... }` - Chain Result-returning operations
331
+ - `or_else { |type, data| ... }` - Recover with Result-returning operation
332
+ - `recover { |type, data| ... }` - Convert failure to success value
333
+ - `unwrap!` - Extract value (raises on failure)
334
+ - `unwrap_or(default)` - Extract value with default
335
+ - `value_or { |type, data| ... }` - Extract value with dynamic default
336
+ - `tap_success { |value| ... }` - Side effect on success
337
+ - `tap_failure { |type, data| ... }` - Side effect on failure
338
+ - `on_success { |value| ... }` - Rails-friendly success handler
339
+ - `on_failure(type = nil) { |type, data| ... }` - Rails-friendly failure handler
340
+
341
+ ### Module Methods
342
+
343
+ - `Success(value)` - Create successful Result
344
+ - `Failure(type, data = nil)` - Create failed Result
345
+ - `collect(results)` - Combine multiple Results
346
+ - `traverse(collection) { |item| ... }` - Map collection to Results and collect
347
+ - `sequence(result)` - Convert Result containing array to array of Results
348
+ - `partition(results)` - Split array of Results into [successes, failures]
349
+
350
+ ### Pattern Matching Support
351
+
352
+ Results work with Ruby 3+ pattern matching via `deconstruct` and `deconstruct_keys`.
353
+
354
+ ## Requirements
355
+
356
+ - Ruby 3.2+
357
+ - No external dependencies
358
+
359
+ ## Development
360
+
361
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
362
+
363
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
364
+
365
+ ## Contributing
366
+
367
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/llull.
368
+
369
+ ## License
370
+
371
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llull
4
+ # Pattern matching functionality for Result instances
5
+ module PatternMatching
6
+ # Pattern matching support for Ruby 3+ (array-style)
7
+ # @return [Array] deconstructed result for pattern matching
8
+ def deconstruct
9
+ success? ? [:success, value] : [:failure, error_type, error_data]
10
+ end
11
+
12
+ # Pattern matching support for Ruby 3+ (hash-style)
13
+ # @param keys [Array<Symbol>] requested keys for pattern matching
14
+ # @return [Hash] deconstructed result for hash pattern matching
15
+ def deconstruct_keys(_keys)
16
+ if success?
17
+ { success: true, value: value }
18
+ else
19
+ { success: false, error_type: error_type, error_data: error_data }
20
+ end
21
+ end
22
+
23
+ # Advanced pattern matching with DSL
24
+ # @param block [Proc] block that receives a Matcher for defining patterns
25
+ # @return [Object] the result of executing the matched handler
26
+ def match(&block)
27
+ matcher = Matcher.new(self)
28
+ block.call(matcher)
29
+ matcher.execute
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pattern_matching"
4
+
5
+ # Llull provides composable Result types for Ruby applications.
6
+ # Inspired by functional programming and railway-oriented programming patterns.
7
+ module Llull
8
+ # Result represents the result of an operation that can either succeed or fail.
9
+ # It provides a functional programming approach to error handling without exceptions.
10
+ class Result
11
+ attr_reader :value, :error_type, :error_data
12
+
13
+ # @param success [Boolean] whether this is a success or failure
14
+ # @param value [Object] the successful value (nil for failures)
15
+ # @param error_type [Symbol] the type/category of error (nil for successes)
16
+ # @param error_data [Object] additional error data (nil for successes)
17
+ def initialize(success, value, error_type = nil, error_data = nil)
18
+ @success = success
19
+ @value = value
20
+ @error_type = error_type
21
+ @error_data = error_data
22
+ freeze
23
+ end
24
+
25
+ # @return [Boolean] true if this is a successful result
26
+ def success?
27
+ @success
28
+ end
29
+
30
+ # @return [Boolean] true if this is a failed result
31
+ def failure?
32
+ !@success
33
+ end
34
+
35
+ # Transform the value if successful, return self if failed
36
+ # @param block [Proc] transformation to apply to the value
37
+ # @return [Result] new Result with transformed value or original failure
38
+ def map(&block)
39
+ return self if failure?
40
+
41
+ Llull::Success(block.call(value))
42
+ rescue StandardError => e
43
+ Llull::Failure(:exception, e)
44
+ end
45
+
46
+ # Chain operations that return Results
47
+ # @param block [Proc] operation that returns a Result
48
+ # @return [Result] the result of the operation or original failure
49
+ def and_then(&block)
50
+ return self if failure?
51
+
52
+ begin
53
+ result = block.call(value)
54
+ raise TypeError, "Block must return a Result" unless result.is_a?(Result)
55
+
56
+ result
57
+ rescue TypeError
58
+ raise # Re-raise TypeError so it's not caught by the general rescue below
59
+ rescue StandardError => e
60
+ Llull::Failure(:exception, e)
61
+ end
62
+ end
63
+
64
+ alias flat_map and_then
65
+ alias bind and_then
66
+
67
+ # Recover from failure by chaining another Result-returning operation
68
+ # @param block [Proc] operation that takes (error_type, error_data) and returns a Result
69
+ # @return [Result] the result of recovery operation or original success
70
+ def or_else(&block)
71
+ return self if success?
72
+
73
+ result = block.call(error_type, error_data)
74
+ raise TypeError, "Block must return a Result" unless result.is_a?(Result)
75
+
76
+ result
77
+ rescue StandardError => e
78
+ Llull::Failure(:exception, e)
79
+ end
80
+
81
+ # Recover from failure by transforming error into a success value
82
+ # @param block [Proc] operation that takes (error_type, error_data) and returns a value
83
+ # @return [Result] Success with transformed value or original success
84
+ def recover(&block)
85
+ return self if success?
86
+
87
+ Llull::Success(block.call(error_type, error_data))
88
+ rescue StandardError => e
89
+ Llull::Failure(:exception, e)
90
+ end
91
+
92
+ # Extract the value, raising an exception if this is a failure
93
+ # @return [Object] the success value
94
+ # @raise [ResultError] if this is a failure
95
+ def unwrap!
96
+ raise ResultError.new(error_type, error_data) if failure?
97
+
98
+ value
99
+ end
100
+
101
+ # Extract the value or return a default if this is a failure
102
+ # @param default [Object] the default value to return on failure
103
+ # @return [Object] the success value or the default
104
+ def unwrap_or(default)
105
+ success? ? value : default
106
+ end
107
+
108
+ # Extract the value or call a block with error information if this is a failure
109
+ # @param block [Proc] operation that takes (error_type, error_data) and returns a value
110
+ # @return [Object] the success value or the result of the block
111
+ def value_or(&block)
112
+ success? ? value : block.call(error_type, error_data)
113
+ end
114
+
115
+ # Execute a side effect if successful, return self unchanged
116
+ # @param block [Proc] operation to execute with the success value
117
+ # @return [Result] self, unchanged
118
+ def tap_success(&block)
119
+ block.call(value) if success?
120
+ self
121
+ end
122
+
123
+ # Execute a side effect if failed, return self unchanged
124
+ # @param block [Proc] operation to execute with (error_type, error_data)
125
+ # @return [Result] self, unchanged
126
+ def tap_failure(&block)
127
+ block.call(error_type, error_data) if failure?
128
+ self
129
+ end
130
+
131
+ alias tap_error tap_failure
132
+
133
+ # Execute a block if successful, return self for chaining
134
+ # @param block [Proc] operation to execute with the success value
135
+ # @return [Result] self, unchanged
136
+ def on_success(&block)
137
+ block.call(value) if success?
138
+ self
139
+ end
140
+
141
+ # Execute a block if failed with optional error type matching, return self for chaining
142
+ # @param match_type [Symbol, nil] specific error type to match, or nil for any failure
143
+ # @param block [Proc] operation to execute with (error_type, error_data)
144
+ # @return [Result] self, unchanged
145
+ def on_failure(match_type = nil, &block)
146
+ block.call(error_type, error_data) if failure? && (match_type.nil? || match_type == error_type)
147
+ self
148
+ end
149
+
150
+ # Inspect method for better debugging
151
+ def inspect
152
+ if success?
153
+ "#<Llull::Result::Success(#{value.inspect})>"
154
+ else
155
+ "#<Llull::Result::Failure(#{error_type.inspect}, #{error_data.inspect})>"
156
+ end
157
+ end
158
+
159
+ def to_s
160
+ inspect
161
+ end
162
+
163
+ # Equality comparison
164
+ def ==(other)
165
+ return false unless other.is_a?(Result)
166
+
167
+ success? == other.success? &&
168
+ value == other.value &&
169
+ error_type == other.error_type &&
170
+ error_data == other.error_data
171
+ end
172
+
173
+ alias eql? ==
174
+
175
+ def hash
176
+ [self.class, success?, value, error_type, error_data].hash
177
+ end
178
+
179
+ include PatternMatching
180
+ end
181
+
182
+ # Matcher class for the pattern matching DSL
183
+ class Matcher
184
+ def initialize(result)
185
+ @result = result
186
+ @success_handlers = []
187
+ @failure_handlers = {}
188
+ @executed = false
189
+ end
190
+
191
+ # Register a handler for successful results
192
+ # @param block [Proc] handler that receives the success value
193
+ # @return [Matcher] self for chaining
194
+ def success(&block)
195
+ @success_handlers << block
196
+ self
197
+ end
198
+
199
+ # Register a handler for failed results
200
+ # @param type [Symbol, nil] specific error type to match, or nil for catch-all
201
+ # @param block [Proc] handler that receives (error_type, error_data) for catch-all
202
+ # or just (error_data) for specific type matches
203
+ # @return [Matcher] self for chaining
204
+ def failure(type = nil, &block)
205
+ if type.nil?
206
+ @failure_handlers[:catch_all] = block
207
+ else
208
+ @failure_handlers[type] = block
209
+ end
210
+ self
211
+ end
212
+
213
+ # Execute the appropriate handler based on the result
214
+ # @return [Object] the result of executing the matched handler
215
+ def execute
216
+ return if @executed
217
+
218
+ @executed = true
219
+
220
+ if @result.success?
221
+ @success_handlers.each { |handler| handler.call(@result.value) }
222
+ elsif @failure_handlers.key?(@result.error_type)
223
+ # Try specific error type handler first
224
+ @failure_handlers[@result.error_type].call(@result.error_data)
225
+ elsif @failure_handlers.key?(:catch_all)
226
+ @failure_handlers[:catch_all].call(@result.error_type, @result.error_data)
227
+ end
228
+ end
229
+ end
230
+
231
+ # Custom exception for Result errors
232
+ class ResultError < StandardError
233
+ attr_reader :error_type, :error_data
234
+
235
+ def initialize(error_type, error_data)
236
+ @error_type = error_type
237
+ @error_data = error_data
238
+ super("Result failure: #{error_type} - #{error_data}")
239
+ end
240
+ end
241
+
242
+ # Constructor functions for creating Results
243
+ module_function
244
+
245
+ # Create a successful Result
246
+ # @param value [Object] the successful value
247
+ # @return [Result] a successful Result containing the value
248
+ def Success(value)
249
+ Result.new(true, value)
250
+ end
251
+
252
+ # Create a failed Result
253
+ # @param error_type [Symbol] the type/category of error
254
+ # @param error_data [Object] additional error information
255
+ # @return [Result] a failed Result containing the error information
256
+ def Failure(error_type, error_data = nil)
257
+ Result.new(false, nil, error_type, error_data)
258
+ end
259
+
260
+ # Collect multiple Results into a single Result
261
+ # Returns Success with array of all values if all Results are successful,
262
+ # otherwise returns the first Failure encountered
263
+ # @param results [Array<Result>] array of Results to collect
264
+ # @return [Result] Success with array of values or first Failure
265
+ def collect(results)
266
+ results.each do |result|
267
+ raise TypeError, "All elements must be Results" unless result.is_a?(Result)
268
+ return result if result.failure?
269
+ end
270
+ Llull::Success(results.map(&:value))
271
+ end
272
+
273
+ alias all collect
274
+
275
+ # Transform a collection by applying a function that returns Results
276
+ # Similar to collect but maps over input and collects the results
277
+ # @param collection [Array] input collection to transform
278
+ # @param block [Proc] function that takes an element and returns a Result
279
+ # @return [Result] Success with array of transformed values or first Failure
280
+ def traverse(collection, &)
281
+ results = collection.map(&)
282
+ collect(results)
283
+ end
284
+
285
+ # Convert a Result containing an array into an array of Results
286
+ # @param result [Result] Result containing an array value
287
+ # @return [Array<Result>] array of individual Results
288
+ def sequence(result)
289
+ raise TypeError, "Argument must be a Result" unless result.is_a?(Result)
290
+
291
+ if result.success?
292
+ raise TypeError, "Result value must be an Array" unless result.value.is_a?(Array)
293
+
294
+ result.value.map { |item| Llull::Success(item) }
295
+ else
296
+ [result] # Return array containing the single failure
297
+ end
298
+ end
299
+
300
+ # Partition an array of Results into successes and failures
301
+ # @param results [Array<Result>] array of Results to partition
302
+ # @return [Array] tuple of [success_values, failure_tuples]
303
+ # where success_values is Array of successful values
304
+ # and failure_tuples is Array of [error_type, error_data] pairs
305
+ def partition(results)
306
+ validate_results_array(results)
307
+
308
+ successes, failures = results.partition(&:success?)
309
+ [successes.map(&:value), failures.map { |f| [f.error_type, f.error_data] }]
310
+ end
311
+
312
+ private
313
+
314
+ # Validate that all elements in array are Results
315
+ # @param results [Array] array to validate
316
+ # @raise [TypeError] if any element is not a Result
317
+ def validate_results_array(results)
318
+ results.each do |result|
319
+ raise TypeError, "All elements must be Results" unless result.is_a?(Result)
320
+ end
321
+ end
322
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llull
4
+ VERSION = "0.2.0"
5
+ end
data/lib/llull.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "llull/version"
4
+ require_relative "llull/result"
5
+
6
+ module Llull
7
+ class Error < StandardError; end
8
+
9
+ # Include the module functions so they're available when including Llull
10
+ end
data/sig/llull.rbs ADDED
@@ -0,0 +1,93 @@
1
+ module Llull
2
+ VERSION: String
3
+
4
+ class Result[out Value, out ErrorData]
5
+ @success: bool
6
+ @value: Value?
7
+ @error_type: Symbol?
8
+ @error_data: ErrorData?
9
+
10
+ def initialize: (bool success, Value? value, Symbol? error_type, ErrorData? error_data) -> void
11
+
12
+ def success?: () -> bool
13
+ def failure?: () -> bool
14
+ def value: () -> Value?
15
+ def error_type: () -> Symbol?
16
+ def error_data: () -> ErrorData?
17
+
18
+ # Monadic operations
19
+ def map: [NewValue] () { (Value) -> NewValue } -> Result[NewValue, ErrorData]
20
+ def and_then: [NewValue] () { (Value) -> Result[NewValue, ErrorData] } -> Result[NewValue, ErrorData]
21
+ def flat_map: [NewValue] () { (Value) -> Result[NewValue, ErrorData] } -> Result[NewValue, ErrorData]
22
+ def bind: [NewValue] () { (Value) -> Result[NewValue, ErrorData] } -> Result[NewValue, ErrorData]
23
+
24
+ # Recovery operations
25
+ def or_else: [NewValue] () { (Symbol, ErrorData) -> Result[NewValue, ErrorData] } -> Result[NewValue | Value, ErrorData]
26
+ def recover: [NewValue] () { (Symbol, ErrorData) -> NewValue } -> Result[NewValue | Value, ErrorData]
27
+
28
+ # Unwrapping
29
+ def unwrap!: () -> Value
30
+ def unwrap_or: [DefaultValue] (DefaultValue default) -> (Value | DefaultValue)
31
+ def value_or: [DefaultValue] () { (Symbol, ErrorData) -> DefaultValue } -> (Value | DefaultValue)
32
+
33
+ # Side effects
34
+ def tap_success: () { (Value) -> untyped } -> self
35
+ def tap_failure: () { (Symbol, ErrorData) -> untyped } -> self
36
+ def tap_error: () { (Symbol, ErrorData) -> untyped } -> self
37
+
38
+ # Rails-friendly helpers
39
+ def on_success: () { (Value) -> untyped } -> self
40
+ def on_failure: (?Symbol? match_type) { (Symbol, ErrorData) -> untyped } -> self
41
+
42
+ # Pattern matching support
43
+ def deconstruct: () -> [:success, Value] | [:failure, Symbol, ErrorData]
44
+ def deconstruct_keys: (Array[Symbol] keys) -> { success: bool, value: Value?, error_type: Symbol?, error_data: ErrorData? }
45
+
46
+ # Matching DSL
47
+ def match: [ReturnType] () { (Matcher[Value, ErrorData]) -> ReturnType } -> ReturnType
48
+
49
+ # Equality and inspection
50
+ def ==: (untyped other) -> bool
51
+ def eql?: (untyped other) -> bool
52
+ def hash: () -> Integer
53
+ def inspect: () -> String
54
+ def to_s: () -> String
55
+ end
56
+
57
+ class Matcher[Value, ErrorData]
58
+ def initialize: (Result[Value, ErrorData] result) -> void
59
+ def success: () { (Value) -> untyped } -> self
60
+ def failure: (?Symbol? type) { (Symbol, ErrorData) -> untyped } | { (ErrorData) -> untyped } -> self
61
+ def execute: () -> untyped
62
+ end
63
+
64
+ class ResultError < StandardError
65
+ attr_reader error_type: Symbol
66
+ attr_reader error_data: untyped
67
+
68
+ def initialize: (Symbol error_type, untyped error_data) -> void
69
+ end
70
+
71
+ # Constructor functions
72
+ def self.Success: [Value] (Value value) -> Result[Value, untyped]
73
+ def self.Failure: [ErrorData] (Symbol error_type, ?ErrorData? error_data) -> Result[untyped, ErrorData]
74
+
75
+ # Collection operations
76
+ def self.collect: [Value, ErrorData] (Array[Result[Value, ErrorData]] results) -> Result[Array[Value], ErrorData]
77
+ def self.all: [Value, ErrorData] (Array[Result[Value, ErrorData]] results) -> Result[Array[Value], ErrorData]
78
+ def self.traverse: [Input, Value, ErrorData] (Array[Input] collection) { (Input) -> Result[Value, ErrorData] } -> Result[Array[Value], ErrorData]
79
+ def self.sequence: [Value, ErrorData] (Result[Array[Value], ErrorData] result) -> Array[Result[Value, ErrorData]]
80
+ def self.partition: [Value, ErrorData] (Array[Result[Value, ErrorData]] results) -> [Array[Value], Array[[Symbol, ErrorData]]]
81
+
82
+ # Module functions available when including Llull
83
+ def Success: [Value] (Value value) -> Result[Value, untyped]
84
+ def Failure: [ErrorData] (Symbol error_type, ?ErrorData? error_data) -> Result[untyped, ErrorData]
85
+ def collect: [Value, ErrorData] (Array[Result[Value, ErrorData]] results) -> Result[Array[Value], ErrorData]
86
+ def all: [Value, ErrorData] (Array[Result[Value, ErrorData]] results) -> Result[Array[Value], ErrorData]
87
+ def traverse: [Input, Value, ErrorData] (Array[Input] collection) { (Input) -> Result[Value, ErrorData] } -> Result[Array[Value], ErrorData]
88
+ def sequence: [Value, ErrorData] (Result[Array[Value], ErrorData] result) -> Array[Result[Value, ErrorData]]
89
+ def partition: [Value, ErrorData] (Array[Result[Value, ErrorData]] results) -> [Array[Value], Array[[Symbol, ErrorData]]]
90
+
91
+ class Error < StandardError
92
+ end
93
+ end
metadata ADDED
@@ -0,0 +1,126 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: llull
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Thomas Murphy
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-12-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '13.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '13.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubocop
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.21'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.21'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop-rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.6'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.6'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop-rspec
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.0'
83
+ description: Inspired by railway-oriented programming, Llull provides chainable operations
84
+ that make complex business logic clear and maintainable.
85
+ email:
86
+ - thomaspmurphy@proton.me
87
+ executables: []
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - LICENSE.txt
92
+ - README.md
93
+ - Rakefile
94
+ - lib/llull.rb
95
+ - lib/llull/pattern_matching.rb
96
+ - lib/llull/result.rb
97
+ - lib/llull/version.rb
98
+ - sig/llull.rbs
99
+ homepage: https://github.com/thomaspmurphy/llull
100
+ licenses:
101
+ - MIT
102
+ metadata:
103
+ allowed_push_host: https://rubygems.org
104
+ homepage_uri: https://github.com/thomaspmurphy/llull
105
+ source_code_uri: https://github.com/thomaspmurphy/llull
106
+ rubygems_mfa_required: 'true'
107
+ post_install_message:
108
+ rdoc_options: []
109
+ require_paths:
110
+ - lib
111
+ required_ruby_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: 3.2.0
116
+ required_rubygems_version: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: '0'
121
+ requirements: []
122
+ rubygems_version: 3.4.10
123
+ signing_key:
124
+ specification_version: 4
125
+ summary: Composable result types for Ruby.
126
+ test_files: []