errgonomic 0.9.3 → 0.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58963c6e1c1cfcf52d8fe4b065dd986acda616f4e55001d7d2bc13cbeab1eb28
4
- data.tar.gz: 9d6f93fdd27c94525f2f2a2dc53010eb4f99760dae7514eaf4f035f5c9947869
3
+ metadata.gz: d39f466feedb411b53780fa853b509dc6a40db2f12aef63235a6817881ce2d95
4
+ data.tar.gz: bc81bba0f57ddaac60178b0319533feeb054be296614332b8a9744d72b0ae9d1
5
5
  SHA512:
6
- metadata.gz: 2adf6eadd64c94629ba265ea9747d943314616f8c26943ad075ea5399bc86c4747e433946273989640b668e0841c337cdc6f3ecc873fed9006aa82eb439510d9
7
- data.tar.gz: 475c6a42553b89700d4de4d0d7257bcbb71388c61d8b12da69a51ce4aa30a75f7074757a498bb20d5074539748b8e799bf8c78303ef3dc8ac33a74306d615aad
6
+ metadata.gz: 26b24e1089898ad573927824616829559a6c9fa5c169abf71addcad1284a345597ae12928f4a22153ce1d06f64db3572e7e4d0d6c596df2dffef4a1eb260e72e
7
+ data.tar.gz: 5f20b741f3feb1c4f5b84daee2f3934edc09f89f962a459c485dfed8356d63e84297f778b3b74f761ada2c1be2ea5c371c73b318a2ef5aa54c8ba635612ba450
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.10.0] - 2026-09-10
4
+
5
+ This release gives `deconstruct` the Rust shape and names the four variants at top level, so a pattern reads as `in Some(v)`.
6
+
7
+ ### Upgrading from 0.9.3
8
+
9
+ `deconstruct` answers `[value]` for a `Some`, an `Ok` and an `Err` and `[]` for a `None`, so a pattern written against 0.9.x's `[self, value]` has to change shape: `in Errgonomic::Option::Some, v` becomes `in Some(v)`, `in Errgonomic::Result::Err, String => msg` becomes `in Err(String => msg)`, and `in Errgonomic::Option::None` stays as it is or becomes `in None`. `Some`, `None`, `Ok` and `Err` are now top-level names for the four variants, for patterns, as well as constructors. An application that defines its own constant under one of those names has to rename it: errgonomic refuses to load over one with a `NameError`, and a `class` or `module` of that name written after it loads raises `TypeError`.
10
+
11
+ ### Changes
12
+
13
+ - [Behavior change] `deconstruct` answers `[value]` for a `Some`, an `Ok` and an `Err` and `[]` for a `None`, the one-payload shape `Data.define(:value)` and Rust's tuple variants share, where 0.9.x answered `[self, value]` and `[None]`. `in Some(v)` binds the value, `in Ok(Some(v))` nests, a two-branch `case/in` with no `else` is exhaustive, and a wrong type reaches `NoMatchingPatternError`. There is no `deconstruct_keys`: a one-payload sum type has no named field, and a `Some` around a Hash nests as `in Some({ id: })` through the Hash's own protocol
14
+ - A value-less `Err()` deconstructs to `[]`, so `in Err` and `in Err()` match it and `in Err(e)` matches only an `Err` that carries a value. The sentinel `Err()` holds in place of a value is internal, and a pattern variable must never bind it
15
+ - `Some`, `None`, `Ok` and `Err` are defined at top level beside the constructors of the same name, so a pattern reads as it does in Rust. Each is an `Errgonomic::VariantName` rather than the class it names: it matches as that class in a pattern and a `case/when`, and a bare one, written as Rust writes `return None`, refuses to stand in for a value. `to_s`, `to_json`, `as_json` and every ActiveRecord boundary that unwraps an Option raise `Errgonomic::SerializeError` on it, where the class would write `Errgonomic::Option::None` into a string, a column or a query, and a boolean column would store `true`. A constant of the same name that the application already defines stops the gem's load with a `NameError`, and a `class None` written after the gem loads raises `TypeError`, rather than one replacing or reopening the other. Rails defines none of the four
16
+
3
17
  ## [0.9.3] - 2026-09-10
4
18
 
5
19
  This release removes the public `value` slot from `Some`, `Ok` and `Err`, and freezes every instance as it is constructed, so an Option or a Result is the value the README already said it was.
data/README.md CHANGED
@@ -111,17 +111,21 @@ Some(1).each.to_a # => [1]
111
111
 
112
112
  `map` wraps whatever the block returns, as Rust's does, so a block that itself returns an Option gives `Some(Some(x))`. `and_then` is the spelling for that block.
113
113
 
114
- Options support pattern matching:
114
+ Options pattern match in the Rust shape. `Some`, `None`, `Ok` and `Err` name the variants in a pattern as well as building them, so a pattern reads as it does in Rust:
115
115
 
116
116
  ```ruby
117
117
  case measurement
118
- in Errgonomic::Option::Some, value
118
+ in Some(value)
119
119
  "Measurement is #{value}"
120
- in Errgonomic::Option::None
120
+ in None
121
121
  "Measurement is not available"
122
122
  end
123
123
  ```
124
124
 
125
+ Leave the `else` off: the two branches cover an Option, and a value that is not one raises `NoMatchingPatternError` where an `else` would take it quietly. When that value is a Result, the error's message refuses to print; see the `case/in` note below. `deconstruct` answers `[value]` for a `Some` and `[]` for a `None`, the one-payload shape `Data.define(:value)` and Rust's tuple variants share, and there is no `deconstruct_keys`, because a one-payload sum type has no named field. Patterns nest through the inner value's own protocol instead: `in Ok(Some(value))` reaches through a Result, and `in Some({ id: })` reaches into a Hash a `Some` wraps.
126
+
127
+ A bare `Some`, `None`, `Ok` or `Err`, without parentheses, is that name and not a value, where Rust's `return None` is one. It matches as the class it names in a pattern or a `case/when`, and refuses to stand in for a value: interpolation, `to_s`, `join`, `to_json` and `as_json` raise `Errgonomic::SerializeError` (`bare None names a variant for a pattern, not a value; build one with parentheses`), and so does handing one to an ActiveRecord attribute, a bulk write or a `where`. It is not a class, so `is_a?`, `kind_of?` and `instance_of?` raise `TypeError` when given the bare name (`class or module required`), `Some.new`, `Some <= Errgonomic::Option::Any` and `Some.name` raise `NoMethodError`, and Minitest's `assert_kind_of Some, x` raises the same `TypeError` before it can assert anything. Each has a working spelling: check the variant with `x.some?`, `x.none?`, `x.ok?` or `x.err?`, or give the fully qualified class anywhere a class or module is required, as in `x.is_a?(Errgonomic::Option::Some)`, `assert_kind_of Errgonomic::Option::Some, x`, `Errgonomic::Option::Some.new(1)`, `Errgonomic::Option::Some <= Errgonomic::Option::Any` or `Errgonomic::Option::Some.name`. Build with the constructor, `Some(1)`, rather than `.new`. An application constant of the same name collides loudly: defined before errgonomic loads, it stops the load with a `NameError`, and a `class None` or `module Ok` written after raises `TypeError` where it is written. Two forms get past that. `None = …` assigned after the load replaces the name with only Ruby's `already initialized constant` warning, and in a Rails application Zeitwerk skips an autoloaded `app/models/ok.rb` because `Ok` is already defined, so the first call on `Ok` raises `NoMethodError`.
128
+
125
129
  An unhandled Option refuses to leak into your output: `to_s`, `to_json` and `as_json` raise `Errgonomic::SerializeError`, so you handle the inner value deliberately rather than shipping `Some("...")` to a user. The refusal names what it was carrying (`cannot serialize an unwrapped Some("cell-a1b2")`), so a payload built out of many values says which one went unhandled; the value's `inspect` is bounded to 60 characters, with an ellipsis past that. The refusal covers `as_json` because Hash and Array serialization recurses through that method, and an Option nested in a payload would otherwise serialize as `{"value": ...}`. A converted ActiveRecord model is the one exception, at the model boundary: it unwraps each attribute as it serializes, so a record's own `as_json` says what an unconverted record's says. See [Rails integration](#rails-integration).
126
130
 
127
131
  The `to_s` refusal is the loudest guard of the three, because a string is where a wrapper turns into data: string interpolation, `Array#join`, `format`, `String()`, a bare ERB `<%= %>` and the key of a Hash on its way to JSON all reach the value through `to_s`, and every one of them raises rather than writing `Some("...")` into a hostname, a column or a page. Rust gives `Option` a `Debug` and no `Display`, and `inspect` is the `Debug` here: it renders `Some(1)`, and it is what a log line or a `rescue` should call (`"got #{opt.inspect}"`). The refusal says so: `Some(1) refuses to_s; use inspect for a log line, or unwrap_or / expect! for the value`.
@@ -172,11 +176,11 @@ Results also pattern match, including against the kind of inner value:
172
176
 
173
177
  ```ruby
174
178
  case result
175
- in Errgonomic::Result::Ok, value
179
+ in Ok(value)
176
180
  "Measurement is #{value}"
177
- in Errgonomic::Result::Err, String => msg
181
+ in Err(String => msg)
178
182
  "Measurement failed with a message: #{msg}"
179
- in Errgonomic::Result::Err, Exception => e
183
+ in Err(Exception => e)
180
184
  "Measurement produced an exception -- #{e.class}: #{e}"
181
185
  end
182
186
  ```
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'set'
4
4
  require 'stringio'
5
+ require_relative 'variant_name'
5
6
 
6
7
  module Errgonomic
7
8
  module Option
@@ -174,20 +175,65 @@ module Errgonomic
174
175
  [self.class, value].hash
175
176
  end
176
177
 
178
+ # The Rust shape: a Some deconstructs to its one payload and a None to
179
+ # nothing, so `in Some(v)` binds the value and `in None` matches. There
180
+ # is no deconstruct_keys, because a one-payload sum type has no named
181
+ # field; a Some wrapping a Hash nests as `in Some({id:})` through the
182
+ # Hash's own protocol.
183
+ #
177
184
  # @example
178
- # measurement = Errgonomic::Option::Some.new(1)
185
+ # Some(1).deconstruct # => [1]
186
+ # None().deconstruct # => []
187
+ # Some(1).respond_to?(:deconstruct_keys) # => false
188
+ #
189
+ # @example a two-branch case/in with no else is exhaustive
190
+ # measurement = Some(1)
179
191
  # case measurement
180
- # in Errgonomic::Option::Some, value
192
+ # in Some(value)
181
193
  # "Measurement is #{value}"
182
- # in Errgonomic::Option::None
194
+ # in None
183
195
  # "Measurement is not available"
184
- # else
185
- # "not matched"
186
196
  # end # => "Measurement is 1"
197
+ # case None()
198
+ # in Some(value)
199
+ # "Measurement is #{value}"
200
+ # in None
201
+ # "Measurement is not available"
202
+ # end # => "Measurement is not available"
203
+ #
204
+ # @example the wrong type falls through to Ruby's own exhaustiveness check
205
+ # begin
206
+ # case 1
207
+ # in Some(value) then value
208
+ # in None then nil
209
+ # end
210
+ # rescue NoMatchingPatternError => e
211
+ # [e.class, e.message]
212
+ # end # => [NoMatchingPatternError, "1"]
213
+ #
214
+ # @example a Result that falls through carries a message that refuses to print
215
+ # begin
216
+ # case Ok(1)
217
+ # in Some(value) then value
218
+ # in None then nil
219
+ # end
220
+ # rescue NoMatchingPatternError => e
221
+ # [e.class, (e.message rescue $!.class)]
222
+ # end # => [NoMatchingPatternError, Errgonomic::SerializeError]
223
+ #
224
+ # @example patterns nest through the inner value's own protocol
225
+ # case Ok(Some(1))
226
+ # in Ok(Some(value)) then value
227
+ # end # => 1
228
+ # case Some({ id: 7, name: 'x' })
229
+ # in Some({ id: }) then id
230
+ # end # => 7
231
+ # case Some(1)
232
+ # in Errgonomic::Option::Some(value) then "bound #{value}"
233
+ # else "not matched"
234
+ # end # => "bound 1"
187
235
  def deconstruct
188
- return [self, value] if some?
189
-
190
- [Errgonomic::Option::None]
236
+ to_a
191
237
  end
192
238
 
193
239
  # Options order like Rust's: None sorts before any Some, and Somes
@@ -961,3 +1007,8 @@ end
961
1007
  def None
962
1008
  Errgonomic::Option::None.new
963
1009
  end
1010
+
1011
+ # The variants under their short names, so a pattern reads as it does in
1012
+ # Rust: `in Some(v)`, `in None`.
1013
+ Errgonomic::VariantName.define(:Some, Errgonomic::Option::Some)
1014
+ Errgonomic::VariantName.define(:None, Errgonomic::Option::None)
@@ -516,10 +516,10 @@ module Errgonomic
516
516
  # Errgonomic::Rails.unwrap_options(1) # => 1
517
517
  def self.unwrap_options(value)
518
518
  case value
519
- when Errgonomic::Option::Any
520
- value.unwrap_or(nil)
519
+ when Errgonomic::Option::Any, Errgonomic::VariantName
520
+ unwrap_option(value)
521
521
  when Array
522
- value.any? { |v| v.is_a?(Errgonomic::Option::Any) } ? value.map { |v| unwrap_options(v) } : value
522
+ value.any? { |v| unwrapped_at_boundary?(v) } ? value.map { |v| unwrap_options(v) } : value
523
523
  else
524
524
  value
525
525
  end
@@ -527,13 +527,16 @@ module Errgonomic
527
527
 
528
528
  # Take the value inside an Option, and a None as nil, where the boundary
529
529
  # takes one value: an attribute is a single typed field, so a collection
530
- # that happens to hold an Option is that collection.
530
+ # that happens to hold an Option is that collection. A bare variant name
531
+ # is refused here, since a boolean column would cast it to true.
531
532
  #
532
533
  # @example
533
534
  # Errgonomic::Rails.unwrap_option(Some(1)) # => 1
534
535
  # Errgonomic::Rails.unwrap_option(None()) # => nil
535
536
  # Errgonomic::Rails.unwrap_option([Some(1)]) # => [Some(1)]
537
+ # Errgonomic::Rails.unwrap_option(None) # => raise Errgonomic::SerializeError, "bare None names a variant for a pattern, not a value; build one with parentheses"
536
538
  def self.unwrap_option(value)
539
+ value.refuse! if value.is_a?(Errgonomic::VariantName)
537
540
  value.is_a?(Errgonomic::Option::Any) ? value.unwrap_or(nil) : value
538
541
  end
539
542
 
@@ -548,7 +551,7 @@ module Errgonomic
548
551
  # plain = { title: 'x' }
549
552
  # Errgonomic::Rails.unwrap_option_values(plain).equal?(plain) # => true
550
553
  def self.unwrap_option_values(hash)
551
- return hash unless hash.each_value.any?(Errgonomic::Option::Any)
554
+ return hash unless hash.each_value.any? { |value| unwrapped_at_boundary?(value) }
552
555
 
553
556
  hash.transform_values { |value| unwrap_option(value) }
554
557
  end
@@ -561,11 +564,17 @@ module Errgonomic
561
564
  # plain = [{ title: 'x' }]
562
565
  # Errgonomic::Rails.unwrap_option_rows(plain).equal?(plain) # => true
563
566
  def self.unwrap_option_rows(rows)
564
- return rows unless rows.any? { |row| row.is_a?(Hash) && row.each_value.any?(Errgonomic::Option::Any) }
567
+ return rows unless rows.any? { |row| row.is_a?(Hash) && row.each_value.any? { |v| unwrapped_at_boundary?(v) } }
565
568
 
566
569
  rows.map { |row| row.is_a?(Hash) ? unwrap_option_values(row) : row }
567
570
  end
568
571
 
572
+ # An Option, or a bare variant name, which a boundary refuses rather than
573
+ # let a column type cast it.
574
+ def self.unwrapped_at_boundary?(value)
575
+ value.is_a?(Errgonomic::Option::Any) || value.is_a?(Errgonomic::VariantName)
576
+ end
577
+
569
578
  # A declared default that is a Proc is not a value yet: ActiveModel calls
570
579
  # it with no arguments each time a record is built. Wrap it rather than
571
580
  # unwrap it, so what it returns meets the type where a literal default
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'variant_name'
4
+
3
5
  module Errgonomic
4
6
  module Result
5
7
  # The base class for Result's Ok and Err class variants. We implement as
@@ -471,27 +473,79 @@ module Errgonomic
471
473
  pp.text(inspect)
472
474
  end
473
475
 
474
- # @example simple pattern match with variable capture of the value
475
- # result = Errgonomic::Result::Ok.new(1)
476
- # case result
477
- # in Errgonomic::Result::Ok, value
476
+ # The Rust shape: each variant deconstructs to its one payload, so
477
+ # `in Ok(v)` binds the value and `in Err(e)` binds the error. A
478
+ # value-less Err deconstructs to nothing: the sentinel behind it is
479
+ # internal and must never bind to a pattern variable.
480
+ #
481
+ # @example
482
+ # Ok(1).deconstruct # => [1]
483
+ # Err(:e).deconstruct # => [:e]
484
+ # Err().deconstruct # => []
485
+ # Ok(1).respond_to?(:deconstruct_keys) # => false
486
+ #
487
+ # @example a value-less Err matches `in Err` and `in Err()`, never `in Err(e)`
488
+ # case Err()
489
+ # in Err(e) then e
490
+ # in Err then :no_value
491
+ # end # => :no_value
492
+ # case Err()
493
+ # in Err() then :no_value
494
+ # end # => :no_value
495
+ # case Err(:x)
496
+ # in Err(e) then e
497
+ # in Err then :no_value
498
+ # end # => :x
499
+ # begin
500
+ # case Err()
501
+ # in Err(e) then e
502
+ # end
503
+ # rescue NoMatchingPatternError => e
504
+ # e.class
505
+ # end # => NoMatchingPatternError
506
+ #
507
+ # @example a two-branch case/in with no else is exhaustive
508
+ # case Ok(1)
509
+ # in Ok(value)
478
510
  # "Measurement is #{value}"
479
- # in Errgonomic::Result::Err, err
511
+ # in Err(err)
480
512
  # "Measurement is not available"
481
513
  # end # => "Measurement is 1"
482
514
  #
483
- # @example more advanced pattern match against the kind of value
484
- # result = Errgonomic::Result::Err.new(StandardError.new("nope"))
515
+ # @example the wrong type falls through to Ruby's own exhaustiveness check
516
+ # begin
517
+ # case :done
518
+ # in Ok(value) then value
519
+ # in Err(err) then err
520
+ # end
521
+ # rescue NoMatchingPatternError => e
522
+ # [e.class, e.message]
523
+ # end # => [NoMatchingPatternError, "done"]
524
+ #
525
+ # @example an Option that falls through carries a message that refuses to print
526
+ # begin
527
+ # case Some(1)
528
+ # in Ok(value) then value
529
+ # in Err(err) then err
530
+ # end
531
+ # rescue NoMatchingPatternError => e
532
+ # [e.class, (e.message rescue $!.class)]
533
+ # end # => [NoMatchingPatternError, Errgonomic::SerializeError]
534
+ #
535
+ # @example a pattern reaches the kind of value inside the variant
536
+ # result = Err(StandardError.new("nope"))
485
537
  # case result
486
- # in Errgonomic::Result::Ok, value
538
+ # in Ok(value)
487
539
  # "Measurement is #{value}"
488
- # in Errgonomic::Result::Err, String => msg
540
+ # in Err(String => msg)
489
541
  # "Measurement failed with a message: #{msg}"
490
- # in Errgonomic::Result::Err, Exception => e
542
+ # in Err(Exception => e)
491
543
  # "Measurement produced an exception -- #{e.class}: #{e}"
492
544
  # end # => "Measurement produced an exception -- StandardError: nope"
493
545
  def deconstruct
494
- [self, value]
546
+ return [] if value.equal?(Err::Arbitrary)
547
+
548
+ [value]
495
549
  end
496
550
 
497
551
  protected
@@ -643,3 +697,8 @@ end
643
697
  def Err(value = Errgonomic::Result::Err::Arbitrary)
644
698
  Errgonomic::Result::Err.new(value)
645
699
  end
700
+
701
+ # The variants under their short names, so a pattern reads as it does in
702
+ # Rust: `in Ok(v)`, `in Err(e)`.
703
+ Errgonomic::VariantName.define(:Ok, Errgonomic::Result::Ok)
704
+ Errgonomic::VariantName.define(:Err, Errgonomic::Result::Err)
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Errgonomic
4
+ # What a bare `Some`, `None`, `Ok` or `Err` evaluates to. Rust writes a
5
+ # value as `return None`, where here the value is `None()`; the bare name
6
+ # is for patterns. It matches as the class it names, and refuses to become
7
+ # a string, so a missing pair of parentheses raises rather than writing a
8
+ # class name into a column. It is not a class, so an application's own
9
+ # `class None` fails where it is written instead of reopening the gem's.
10
+ #
11
+ # @example
12
+ # case Some(1)
13
+ # in Some(value) then value
14
+ # end # => 1
15
+ # None === None() # => true
16
+ # None === Some(1) # => false
17
+ # None.inspect # => "Errgonomic::Option::None"
18
+ # "tier-#{None}" # => raise Errgonomic::SerializeError, "bare None names a variant for a pattern, not a value; build one with parentheses"
19
+ # [Ok].join # => raise Errgonomic::SerializeError, "bare Ok names a variant for a pattern, not a value; build one with parentheses"
20
+ # Some(1).is_a?(Some) # => raise TypeError, "class or module required"
21
+ # Some(1).is_a?(Errgonomic::Option::Some) # => true
22
+ class VariantName
23
+ # Name a variant at top level, refusing a constant the application
24
+ # already holds there rather than replacing it.
25
+ def self.define(name, variant)
26
+ if Object.const_defined?(name, false)
27
+ existing = Object.const_get(name)
28
+ return if existing.is_a?(VariantName) && existing.names?(variant)
29
+
30
+ raise NameError.new("#{name} is already defined as #{existing.inspect}; errgonomic defines #{name} " \
31
+ "at top level to name #{variant} in patterns, so rename the application's constant", name)
32
+ end
33
+ Object.const_set(name, new(name, variant))
34
+ end
35
+
36
+ def initialize(name, variant)
37
+ @name = name
38
+ @variant = variant
39
+ freeze
40
+ end
41
+
42
+ def names?(variant)
43
+ @variant.equal?(variant)
44
+ end
45
+
46
+ def ===(other)
47
+ @variant === other # rubocop:disable Style/CaseEquality
48
+ end
49
+
50
+ def inspect
51
+ @variant.inspect
52
+ end
53
+
54
+ # Refuse to stand in for a value.
55
+ def refuse!(*_args)
56
+ raise Errgonomic::SerializeError,
57
+ "bare #{@name} names a variant for a pattern, not a value; build one with parentheses"
58
+ end
59
+ alias to_s refuse!
60
+ alias to_json refuse!
61
+ alias as_json refuse!
62
+ end
63
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Errgonomic
4
- VERSION = '0.9.3'
4
+ VERSION = '0.10.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: errgonomic
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.3
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nick Zadrozny
@@ -90,6 +90,7 @@ files:
90
90
  - lib/errgonomic/rails/active_record_optional.rb
91
91
  - lib/errgonomic/result.rb
92
92
  - lib/errgonomic/type.rb
93
+ - lib/errgonomic/variant_name.rb
93
94
  - lib/errgonomic/version.rb
94
95
  - sig/errgonomic.rbs
95
96
  homepage: https://omc.io/