enumancer 1.0.1 → 2.0.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: 442028382a920f907a919270d86ec0504c846189b4feb4977a61ac037b73d926
4
- data.tar.gz: '0619ee83dc5601322b699abe16aef370fbe0d818af78cf01f251405eef035ed7'
3
+ metadata.gz: 6c69c7e9a39930f42d98b2601e5fd6d2632ce25a19bfdbe2d6f1ee49a6b6a0e1
4
+ data.tar.gz: b7a8f6cdda9dddf00e54453d259e4355c1f3cc4d67b38a92f4c5c7fe811e2a5f
5
5
  SHA512:
6
- metadata.gz: afdeb22fd58d8783f5d77cc7e5302f9daf7f6fbab7ae5a9ef0f0040700e15ad0be2da640b2397d73de6e8875743282040819db6cfa981181f45a7240fd03cd29
7
- data.tar.gz: e6d94d75572491201eedeb486131f16cc23b2e1541632606cc09517c73612b81783e127f71632ec76e7f8c90a7854b54a0294c9417d6bdfdfbd0f2e3553804e7
6
+ metadata.gz: e2b678ef32d096a61d4d437d132ad47f471356c7ae9611d123af956a82ce0c56fea5078f5785258596f8cda042e7d0064020605f9f1a0bb5403ff3848a1218f5
7
+ data.tar.gz: 52037fa4302e276ae517e718f55ba8bd9c02a15d943d2457c4641e2129f2fbdbb15aa4082729fda9f3a9d91490e2d34428adfdc267cb0d9b91499afd31a69a13
data/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ ## 2.0.0
4
+
5
+ - Require Ruby 4.0 or newer.
6
+ - Replace enum inheritance with `Enumancer.define` and automatically closed types.
7
+ - Support plain enums, backed enums, and typed algebraic data types through one definition API.
8
+ - Generate frozen `Data` classes for variants, with canonical instances for variants without fields.
9
+ - Add exhaustive `match` and reusable `matcher` with unknown, duplicate, and missing branch checks before dispatch.
10
+ - Add recursive field constraints through `Enumancer::Self`.
11
+ - Support native Ruby patterns and JSON serialization, including nested declared types.
12
+ - Remove mutable registries, unregistered instances, and non-strict fallbacks.
13
+
14
+ This release intentionally breaks compatibility with 1.x. Replace subclasses of
15
+ `Enumancer::Enum` with `Enumancer.define`, `entry` with `variant`, and
16
+ `type Integer` with `backing: Integer`. See the README for examples.
data/LICENSE ADDED
@@ -0,0 +1,16 @@
1
+ BSD 3-Clause License with Attribution
2
+
3
+ Copyright (c) 2025, saikinmirai@gmail.com
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Attribution must be preserved. You must retain the original copyright notice, this list of conditions, and the following attribution clause in all copies or substantial portions of the Software.
9
+
10
+ 2. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
11
+
12
+ 3. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
13
+
14
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE.
15
+
16
+ Attribution: This software includes code developed by Łasačka (saikinmirai@gmail.com).
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumancer
4
+ module Names
5
+ def self.normalize(name)
6
+ unless name.is_a?(String) || name.is_a?(Symbol)
7
+ raise TypeError, 'Names must be strings or symbols'
8
+ end
9
+
10
+ name.to_sym
11
+ end
12
+ end
13
+
14
+ Definition = Data.define(:name, :fields, :backing)
15
+
16
+ class DefinitionBuilder
17
+ def initialize(backing:)
18
+ @backed = backing
19
+ @definitions = {}
20
+ end
21
+
22
+ def variant(name, *values, **fields)
23
+ name = Names.normalize(name)
24
+ unless /\A[a-z][a-z0-9]*(?:_[a-z0-9]+)*\z/.match?(name.to_s)
25
+ raise DefinitionError, "Invalid variant name: #{name.inspect}"
26
+ end
27
+ raise DefinitionError, "Duplicate variant: #{name}" if @definitions.key?(name)
28
+
29
+ if @backed
30
+ raise DefinitionError, 'Backed variants require exactly one value' unless values.length == 1
31
+ raise DefinitionError, 'Backed variants cannot have fields' unless fields.empty?
32
+ elsif !values.empty?
33
+ raise DefinitionError, 'Backing values require a backing type'
34
+ end
35
+
36
+ normalized = {}
37
+ fields.each do |field, constraint|
38
+ field = Names.normalize(field)
39
+ unless /\A[a-z][a-z0-9]*(?:_[a-z0-9]+)*\z/.match?(field.to_s)
40
+ raise DefinitionError, "Invalid field name: #{field.inspect}"
41
+ end
42
+ raise DefinitionError, "Duplicate field: #{field}" if normalized.key?(field)
43
+
44
+ unless constraint.is_a?(Module) || constraint.equal?(Self)
45
+ raise DefinitionError, "Invalid type for field #{field}: #{constraint.inspect}"
46
+ end
47
+ normalized[field] = constraint
48
+ end
49
+
50
+ @definitions[name] = Definition.new(name, normalized.freeze, values.first)
51
+ nil
52
+ end
53
+
54
+ def finish
55
+ raise DefinitionError, 'At least one variant is required' if @definitions.empty?
56
+
57
+ @definitions.freeze
58
+ freeze
59
+ @definitions.values.freeze
60
+ end
61
+ end
62
+
63
+ module BackingSnapshot
64
+ def self.copy(value, visiting = {}.compare_by_identity)
65
+ raise DefinitionError, 'Backing values cannot contain cycles' if visiting.key?(value)
66
+
67
+ visiting[value] = true
68
+ case value
69
+ when String
70
+ value.dup.freeze
71
+ when Array
72
+ value.dup.map! { |item| copy(item, visiting) }.freeze
73
+ when Hash
74
+ value.each_with_object(value.dup.clear) do |(key, item), result|
75
+ result[copy(key, visiting)] = copy(item, visiting)
76
+ end.freeze
77
+ else
78
+ raise DefinitionError, 'Custom backing values must be frozen' unless value.frozen?
79
+
80
+ value
81
+ end
82
+ ensure
83
+ visiting.delete(value)
84
+ end
85
+ end
86
+
87
+ private_constant :Names, :Definition, :DefinitionBuilder, :BackingSnapshot
88
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumancer
4
+ class DefinitionError < ArgumentError; end
5
+ class UnknownVariantError < KeyError; end
6
+ class MatchError < ArgumentError; end
7
+ class DuplicateBranchError < MatchError; end
8
+ class NonExhaustiveMatchError < MatchError; end
9
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumancer
4
+ class MatchBuilder
5
+ def initialize(names)
6
+ @names = names
7
+ @handlers = {}
8
+ end
9
+
10
+ def on(name, &handler)
11
+ name = Names.normalize(name)
12
+ raise UnknownVariantError, "Unknown variant: #{name}" unless @names.include?(name)
13
+ raise DuplicateBranchError, "Duplicate branch: #{name}" if @handlers.key?(name)
14
+ raise MatchError, "A handler is required for #{name}" unless handler
15
+
16
+ @handlers[name] = handler
17
+ nil
18
+ end
19
+
20
+ def finish
21
+ missing = @names - @handlers.keys
22
+ unless missing.empty?
23
+ raise NonExhaustiveMatchError, "Missing branches: #{missing.join(', ')}"
24
+ end
25
+
26
+ @handlers.freeze
27
+ freeze
28
+ @handlers
29
+ end
30
+ end
31
+
32
+ class Matcher
33
+ def initialize(type, &block)
34
+ raise MatchError, 'A match block is required' unless block
35
+
36
+ @type = type
37
+ builder = MatchBuilder.new(type.variants)
38
+ builder.instance_eval(&block)
39
+ @handlers = builder.finish
40
+ freeze
41
+ end
42
+
43
+ def call(value)
44
+ raise TypeError, 'The value does not belong to this type' unless @type === value
45
+
46
+ @handlers.fetch(value.tag).call(value)
47
+ end
48
+ end
49
+
50
+ private_constant :MatchBuilder
51
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumancer
4
+ module Serialization
5
+ def self.dump(value, visiting = {}.compare_by_identity)
6
+ with_visit(value, visiting) do
7
+ data = { name: value.tag }
8
+ if value.type.backed?
9
+ data[:value] = encode(value.value, visiting)
10
+ elsif !value.members.empty?
11
+ data[:fields] = value.to_h.transform_values { |field| encode(field, visiting) }
12
+ end
13
+ data
14
+ end
15
+ end
16
+
17
+ def self.encode(value, visiting = {}.compare_by_identity)
18
+ case value
19
+ when Value then dump(value, visiting)
20
+ when Array
21
+ with_visit(value, visiting) { value.map { |item| encode(item, visiting) } }
22
+ when Hash
23
+ with_visit(value, visiting) { value.transform_values { |item| encode(item, visiting) } }
24
+ when String, Symbol, Integer, Float, TrueClass, FalseClass, NilClass then value
25
+ else
26
+ raise TypeError, "Unsupported JSON value: #{value.class}"
27
+ end
28
+ end
29
+
30
+ def self.with_visit(value, visiting)
31
+ raise ArgumentError, 'Circular JSON value' if visiting.key?(value)
32
+
33
+ visiting[value] = true
34
+ begin
35
+ yield
36
+ ensure
37
+ visiting.delete(value)
38
+ end
39
+ end
40
+
41
+ def self.load(type, data)
42
+ raise ArgumentError, 'Expected a JSON object' unless data.is_a?(Hash)
43
+ raise ArgumentError, "Missing 'name' in JSON" unless data['name'].is_a?(String)
44
+
45
+ name = Names.normalize(data['name'])
46
+ schema = type.send(:definition_for, name)
47
+ allowed = ['name']
48
+ allowed << 'value' if type.backed?
49
+ allowed << 'fields' unless schema.fields.empty?
50
+ unknown = data.keys - allowed
51
+ raise ArgumentError, "Unknown JSON keys: #{unknown.join(', ')}" unless unknown.empty?
52
+
53
+ if type.backed?
54
+ instance = type[name]
55
+ if data.key?('value') && !JSON.parse(JSON.generate(encode(instance.value))).eql?(data['value'])
56
+ raise ArgumentError, 'The backing value does not match the variant'
57
+ end
58
+ return instance
59
+ end
60
+
61
+ attributes = data.fetch('fields', {})
62
+ raise ArgumentError, "Expected a JSON object for 'fields'" unless attributes.is_a?(Hash)
63
+
64
+ decoded = attributes.to_h do |field, value|
65
+ key = Names.normalize(field)
66
+ constraint = schema.fields[key]
67
+ [key, decode(constraint, value)]
68
+ end
69
+ type[name, **decoded]
70
+ end
71
+
72
+ def self.decode(constraint, value)
73
+ if constraint.is_a?(Type)
74
+ load(constraint, value)
75
+ elsif constraint.equal?(Symbol) && value.is_a?(String)
76
+ value.to_sym
77
+ else
78
+ value
79
+ end
80
+ end
81
+ end
82
+
83
+ private_constant :Serialization
84
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumancer
4
+ class Type < Module
5
+ attr_reader :variants
6
+
7
+ def initialize(definitions, backing:)
8
+ super()
9
+ @backing_type = backing
10
+ @definitions = {}
11
+ @classes = {}
12
+ @class_names = {}.compare_by_identity
13
+ @singletons = {}
14
+ @value_index = {}
15
+ include Value
16
+
17
+ definitions.each { |definition| install(definition) }
18
+ @variants = @definitions.keys.freeze
19
+ [@definitions, @classes, @class_names, @singletons, @value_index].each(&:freeze)
20
+ freeze
21
+ end
22
+
23
+ def backed? = !@backing_type.equal?(UNSET)
24
+
25
+ def ===(value)
26
+ @class_names.key?(value.class)
27
+ end
28
+
29
+ def [](name, **attributes)
30
+ name = Names.normalize(name)
31
+ definition_for(name)
32
+ @classes.fetch(name).new(**attributes)
33
+ end
34
+
35
+ def all
36
+ unless @singletons.length == @definitions.length
37
+ raise TypeError, 'Types with fields cannot enumerate all values; use variants'
38
+ end
39
+
40
+ @singletons.values.freeze
41
+ end
42
+
43
+ def values
44
+ raise TypeError, 'This type has no backing values' unless backed?
45
+
46
+ @definitions.values.map(&:backing).freeze
47
+ end
48
+
49
+ def from_value(value)
50
+ raise TypeError, 'This type has no backing values' unless backed?
51
+
52
+ @value_index.fetch(value) { raise KeyError, "Unknown backing value: #{value.inspect}" }
53
+ end
54
+
55
+ def matcher(&block)
56
+ Matcher.new(self, &block)
57
+ end
58
+
59
+ def match(value, &block)
60
+ matcher(&block).call(value)
61
+ end
62
+
63
+ def from_json(json)
64
+ Serialization.load(self, JSON.parse(json))
65
+ end
66
+
67
+ private
68
+
69
+ def initialize_copy(_other)
70
+ raise TypeError, 'Enumancer types cannot be copied'
71
+ end
72
+
73
+ def definition_for(name)
74
+ @definitions.fetch(name) { raise UnknownVariantError, "Unknown variant: #{name}" }
75
+ end
76
+
77
+ def install(definition)
78
+ name = definition.name
79
+ raise DefinitionError, "Reserved variant name: #{name}" if respond_to?(name, true)
80
+
81
+ constant = name.to_s.split('_').map(&:capitalize).join
82
+ raise DefinitionError, "Duplicate variant constant: #{constant}" if const_defined?(constant, false)
83
+
84
+ fields = definition.fields.transform_values { |constraint| constraint.equal?(Self) ? self : constraint }.freeze
85
+ reserved = fields.keys & (Data.instance_methods + Data.private_instance_methods + Value.instance_methods)
86
+ raise DefinitionError, "Reserved fields: #{reserved.join(', ')}" unless reserved.empty?
87
+
88
+ backing = prepare_backing(definition.backing) if backed?
89
+ definition = Definition.new(name, fields, backing)
90
+ klass = build_class(definition)
91
+ @definitions[name] = definition
92
+ @classes[name] = klass
93
+ @class_names[klass] = name
94
+ const_set(constant, klass)
95
+
96
+ if fields.empty?
97
+ instance = klass.new
98
+ @singletons[name] = instance
99
+ @value_index[backing] = instance if backed?
100
+ constructor = lambda do |*args, **attributes|
101
+ unless args.empty? && attributes.empty?
102
+ raise ArgumentError, "#{name} does not accept fields"
103
+ end
104
+
105
+ instance
106
+ end
107
+ klass.define_singleton_method(:new, &constructor)
108
+ klass.define_singleton_method(:[], &constructor)
109
+ end
110
+
111
+ define_singleton_method(name) { |**attributes| self[name, **attributes] }
112
+ klass.freeze
113
+ end
114
+
115
+ def prepare_backing(value)
116
+ unless @backing_type === value
117
+ raise TypeError, "Expected backing type #{@backing_type}, got #{value.class}"
118
+ end
119
+
120
+ snapshot = BackingSnapshot.copy(value)
121
+ raise DefinitionError, "Duplicate backing value: #{value.inspect}" if @value_index.key?(snapshot)
122
+
123
+ snapshot
124
+ end
125
+
126
+ def build_class(definition)
127
+ owner = self
128
+ fields = definition.fields
129
+ klass = Data.define(*fields.keys) do
130
+ include owner
131
+
132
+ define_method(:initialize) do |**attributes|
133
+ missing = fields.keys - attributes.keys
134
+ unknown = attributes.keys - fields.keys
135
+ raise ArgumentError, "Missing fields: #{missing.join(', ')}" unless missing.empty?
136
+ raise ArgumentError, "Unknown fields: #{unknown.join(', ')}" unless unknown.empty?
137
+
138
+ fields.each do |field, constraint|
139
+ unless constraint === attributes[field]
140
+ raise TypeError, "Invalid type for #{field}: expected #{constraint}, got #{attributes[field].class}"
141
+ end
142
+ end
143
+ super(**attributes)
144
+ end
145
+ end
146
+ klass.define_singleton_method(:enum_type) { owner }
147
+ klass.define_singleton_method(:variant_name) { definition.name }
148
+ klass.define_singleton_method(:inherited) { |_child| raise TypeError, 'Variants cannot be subclassed' }
149
+ klass.define_method(:value) { definition.backing } if backed?
150
+ klass.private_class_method(:allocate)
151
+ klass
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumancer
4
+ module Value
5
+ def type = self.class.enum_type
6
+ def tag = self.class.variant_name
7
+ def to_sym = tag
8
+ def to_s = tag.to_s
9
+
10
+ def match(&block)
11
+ type.match(self, &block)
12
+ end
13
+
14
+ def deconstruct_keys(keys)
15
+ fields = super(keys&.reject { |key| key == :tag })
16
+ keys.nil? || keys.include?(:tag) ? fields.merge(tag: tag) : fields
17
+ end
18
+
19
+ def to_json(*args)
20
+ Serialization.dump(self).to_json(*args)
21
+ end
22
+ end
23
+
24
+ private_constant :Value
25
+ end
data/lib/enumancer.rb CHANGED
@@ -1 +1,26 @@
1
- require_relative 'enum'
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'enumancer/errors'
5
+ require_relative 'enumancer/definition'
6
+ require_relative 'enumancer/matcher'
7
+ require_relative 'enumancer/value'
8
+ require_relative 'enumancer/serialization'
9
+ require_relative 'enumancer/type'
10
+
11
+ module Enumancer
12
+ Self = Object.new.freeze
13
+ UNSET = Object.new.freeze
14
+ private_constant :UNSET
15
+
16
+ def self.define(backing: UNSET, &block)
17
+ raise ArgumentError, 'A definition block is required' unless block
18
+ unless backing.equal?(UNSET) || backing.is_a?(Module)
19
+ raise TypeError, 'The backing type must be a class or module'
20
+ end
21
+
22
+ builder = DefinitionBuilder.new(backing: !backing.equal?(UNSET))
23
+ builder.instance_eval(&block)
24
+ Type.new(builder.finish, backing: backing)
25
+ end
26
+ end
data/readme.md ADDED
@@ -0,0 +1,241 @@
1
+ # Enumancer
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/enumancer?include_prereleases&logo=rubygems&logoColor=%23e0e0e0&label=version&labelColor=%23FF748C&color=pink&style=for-the-badge)](https://rubygems.org/gems/enumancer)
4
+
5
+ Closed enums and algebraic data types for **Ruby 4.0+**, with typed fields and
6
+ exhaustive matching. No runtime dependencies outside the Ruby standard library.
7
+
8
+ ## Installation
9
+
10
+ ```ruby
11
+ gem 'enumancer', '~> 2.0'
12
+ ```
13
+
14
+ ```sh
15
+ bundle install
16
+ ```
17
+
18
+ ## Plain enums
19
+
20
+ ```ruby
21
+ require 'enumancer'
22
+
23
+ Color = Enumancer.define do
24
+ variant :red
25
+ variant :green
26
+ variant :blue
27
+ end
28
+
29
+ Color.red.equal?(Color[:red]) # => true
30
+ Color.red.tag # => :red
31
+ Color.red.to_sym # => :red
32
+ Color.red.to_s # => "red"
33
+ Color.red.type # => Color
34
+ Color === Color.red # => true
35
+ Color.variants # => [:red, :green, :blue]
36
+ Color.all # => [Color.red, Color.green, Color.blue]
37
+ ```
38
+
39
+ Each variant has its own generated `Data` class, such as `Color::Red`.
40
+ Variants without fields return canonical frozen instances and store no payload
41
+ or tag fields in those instances. Plain enum instances have no `value` method.
42
+
43
+ Names can be symbols or strings. Variant and field names must be lowercase
44
+ snake_case identifiers. Names that conflict with existing type or instance
45
+ methods are rejected during definition.
46
+
47
+ ## Backed enums
48
+
49
+ ```ruby
50
+ Status = Enumancer.define(backing: Integer) do
51
+ variant :draft, 0
52
+ variant :published, 1
53
+ end
54
+
55
+ Status.draft.value # => 0
56
+ Status.from_value(1) # => Status.published
57
+ Status.values # => [0, 1]
58
+ Status.backed? # => true
59
+ ```
60
+
61
+ Every variant must have exactly one unique backing value matching the declared
62
+ class or module. Backed variants cannot have payload fields. `nil` is a backing
63
+ value when the declared type accepts it; it does not mean an omitted value.
64
+
65
+ Reverse lookup uses `eql?` and `hash`, so `1` and `1.0` are distinct backing
66
+ values. Unknown values raise `KeyError`.
67
+
68
+ Strings, arrays, and hashes used as backing values are copied and recursively
69
+ frozen without freezing the caller's objects. Cyclic backing values are rejected.
70
+ Custom backing objects must already be frozen; their nested state must remain
71
+ stable for reverse lookup.
72
+
73
+ ## Algebraic data types
74
+
75
+ ```ruby
76
+ Result = Enumancer.define do
77
+ variant :pending
78
+ variant :ok, value: Integer
79
+ variant :error, message: String
80
+ end
81
+
82
+ result = Result.ok(value: 42)
83
+ result.value # => 42
84
+ result.tag # => :ok
85
+ Result[:error, message: 'Failed'].message # => "Failed"
86
+ Result.variants # => [:pending, :ok, :error]
87
+ ```
88
+
89
+ Field constraints accept classes, modules, and other Enumancer types. All fields
90
+ are required. Missing and unknown fields raise `ArgumentError`; invalid values
91
+ raise `TypeError`. Checks also apply to generated class constructors and `with`:
92
+
93
+ ```ruby
94
+ result.with(value: 43) # => Result.ok(value: 43)
95
+ Result::Ok.new(value: 'wrong') # raises TypeError
96
+ ```
97
+
98
+ Records use `Data` equality and hashing: equal records must have the same variant
99
+ class and equal fields. Types with fields cannot enumerate every possible value;
100
+ use `variants` instead of `all`.
101
+
102
+ Records are shallowly immutable, following Ruby's `Data` semantics. Nested
103
+ payload objects remain shared with the caller. Freeze or copy mutable payloads
104
+ before using records as hash keys.
105
+
106
+ ## Exhaustive matching
107
+
108
+ ```ruby
109
+ result.match do
110
+ on(:pending) { 'Waiting' }
111
+ on(:ok) { |ok| ok.value.to_s }
112
+ on(:error) { |error| error.message }
113
+ end
114
+ ```
115
+
116
+ Each handler receives the entire variant instance. The selected handler's return
117
+ value becomes the match result, including `nil` and `false`.
118
+
119
+ The outer block runs in a branch-builder context. It collects handlers without
120
+ executing them. Every variant must be covered exactly once before any handler
121
+ runs:
122
+
123
+ - Missing branches raise `Enumancer::NonExhaustiveMatchError`.
124
+ - Duplicate branches raise `Enumancer::DuplicateBranchError`.
125
+ - Unknown variants raise `Enumancer::UnknownVariantError`.
126
+ - Missing handler blocks raise `Enumancer::MatchError`.
127
+
128
+ There is no wildcard branch. Completeness is checked at runtime against the
129
+ closed definition, including when the current value belongs to a covered branch.
130
+ Handler exceptions propagate normally.
131
+
132
+ For reuse, build a matcher once. Completeness is checked when it is built:
133
+
134
+ ```ruby
135
+ render = Result.matcher do
136
+ on(:pending) { 'Waiting' }
137
+ on(:ok) { |ok| ok.value.to_s }
138
+ on(:error) { |error| error.message }
139
+ end
140
+
141
+ render.call(Result.ok(value: 42)) # => "42"
142
+ ```
143
+
144
+ `Result.match(result) { ... }` also supports direct type-level dispatch.
145
+ Passing a value from another type to a matcher raises `TypeError`.
146
+
147
+ ## Recursive types
148
+
149
+ ```ruby
150
+ Expr = Enumancer.define do
151
+ variant :number, value: Integer
152
+ variant :add, left: Enumancer::Self, right: Enumancer::Self
153
+ end
154
+
155
+ evaluate = Expr.matcher do
156
+ on(:number) { |number| number.value }
157
+ on(:add) { |add| evaluate.call(add.left) + evaluate.call(add.right) }
158
+ end
159
+
160
+ tree = Expr.add(left: Expr.number(value: 2), right: Expr.number(value: 3))
161
+ evaluate.call(tree) # => 5
162
+ ```
163
+
164
+ `Enumancer::Self` resolves to the type being defined. Recursive fields reject
165
+ values from other types.
166
+
167
+ ## Native Ruby patterns
168
+
169
+ Generated classes also support native patterns:
170
+
171
+ ```ruby
172
+ case result
173
+ in Result::Ok(value:)
174
+ puts value
175
+ in { tag: :error, message: }
176
+ warn message
177
+ end
178
+ ```
179
+
180
+ `deconstruct_keys` exposes payload fields and `tag`. Use Enumancer's `match`
181
+ when every variant must have a handler.
182
+
183
+ ## JSON
184
+
185
+ ```ruby
186
+ Color.red.to_json
187
+ # => '{"name":"red"}'
188
+
189
+ Status.draft.to_json
190
+ # => '{"name":"draft","value":0}'
191
+
192
+ Result.ok(value: 42).to_json
193
+ # => '{"name":"ok","fields":{"value":42}}'
194
+
195
+ Result.from_json('{"name":"ok","fields":{"value":42}}')
196
+ # => Result.ok(value: 42)
197
+ ```
198
+
199
+ Deserialization validates variant names, envelope keys, field names, and field
200
+ types. Plain and backed enums return their canonical instances. Backed input
201
+ may omit `value`; if present, it must match the named variant's JSON value.
202
+
203
+ Fields explicitly constrained to another Enumancer type, including `Self`, are
204
+ decoded recursively. Fields declared as `Symbol` are restored from JSON strings.
205
+ Unparameterized `Array`, `Hash`, and `Object` fields follow ordinary JSON
206
+ semantics: collection element types, symbol keys, and nested enum identities
207
+ cannot be inferred from those constraints. Custom objects require conversion to
208
+ supported JSON values; arbitrary classes are not instantiated by deserialization.
209
+ Circular payloads are rejected.
210
+
211
+ ## Closed definitions
212
+
213
+ After the definition block finishes, the type, variant classes, and registry are
214
+ frozen. Variants cannot be added, removed, or subclassed. Unknown names raise
215
+ `Enumancer::UnknownVariantError`; there is no `:unknown` fallback. Type objects
216
+ cannot be copied.
217
+
218
+ ## Migrating from 1.x
219
+
220
+ Version 2 intentionally breaks compatibility with the inheritance API:
221
+
222
+ ```ruby
223
+ Status = Enumancer.define(backing: Integer) do
224
+ variant :draft, 0
225
+ variant :published, 1
226
+ end
227
+ ```
228
+
229
+ Replace `class Status < Enumancer::Enum` with the factory, `type Integer` with
230
+ `backing: Integer`, and `entry` with `variant`. Remove `strict:` options: names
231
+ and types are always checked. Replace `name_for(value)` with
232
+ `from_value(value).tag`, and `keys` with `variants`. Runtime registration,
233
+ `remove`, unregistered instances, and inheritance are no longer supported.
234
+
235
+ ## Development
236
+
237
+ ```sh
238
+ bundle install
239
+ bundle exec rspec
240
+ gem build enumancer.gemspec
241
+ ```
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: enumancer
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Łasačka
@@ -10,22 +10,30 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: |2
13
- Enumancer provides a predictable, type-safe registry for named values.
14
- Each entry is unique by both name and value, with optional type constraints and strict mode.
13
+ Enumancer provides plain enums, backed enums, and algebraic data types
14
+ with typed fields and exhaustive matching.
15
15
  email: saikinmirai@gmail.com
16
16
  executables: []
17
17
  extensions: []
18
18
  extra_rdoc_files: []
19
19
  files:
20
- - lib/enum.rb
20
+ - CHANGELOG.md
21
+ - LICENSE
21
22
  - lib/enumancer.rb
23
+ - lib/enumancer/definition.rb
24
+ - lib/enumancer/errors.rb
25
+ - lib/enumancer/matcher.rb
26
+ - lib/enumancer/serialization.rb
27
+ - lib/enumancer/type.rb
28
+ - lib/enumancer/value.rb
29
+ - readme.md
22
30
  homepage: https://github.com/lasaczka/enumancer
23
31
  licenses:
24
32
  - BSD-3-Clause-Attribution
25
33
  metadata:
26
34
  changelog_uri: https://github.com/lasaczka/enumancer/blob/main/CHANGELOG.md
27
35
  bug_tracker_uri: https://github.com/lasaczka/enumancer/issues
28
- documentation_uri: https://rubydoc.info/gems/enumancer/1.0.1
36
+ documentation_uri: https://rubydoc.info/gems/enumancer/2.0.0
29
37
  rdoc_options: []
30
38
  require_paths:
31
39
  - lib
@@ -33,14 +41,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
33
41
  requirements:
34
42
  - - ">="
35
43
  - !ruby/object:Gem::Version
36
- version: 3.0.0
44
+ version: '4.0'
37
45
  required_rubygems_version: !ruby/object:Gem::Requirement
38
46
  requirements:
39
47
  - - ">="
40
48
  - !ruby/object:Gem::Version
41
49
  version: '0'
42
50
  requirements: []
43
- rubygems_version: 3.8.0.dev
51
+ rubygems_version: 4.0.20
44
52
  specification_version: 4
45
- summary: Easy to use typed enums.
53
+ summary: Closed enums and algebraic data types for Ruby.
46
54
  test_files: []
data/lib/enum.rb DELETED
@@ -1,222 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Enumancer::Enum provides a declarative, type-safe registry for named values.
4
- # Each entry is unique by both name and value. Values can be of any type,
5
- # but you may optionally declare a type constraint using `type`.
6
- # If type is defined, values are strictly checked.
7
- #
8
- # Enum entries are registered via `.entry(name, value)` and accessed via `.name` or `[]`.
9
- # Each entry becomes a singleton method of the class and returns an instance of the enum.
10
- #
11
- # You may enable strict mode via `type Klass, strict: true`, which disables fallbacks
12
- # and raises errors when accessing unknown names or values.
13
- #
14
- # @example Define an enum
15
- # class MyEnum < Enumancer::Enum
16
- # type Integer, strict: true
17
- # entry :low, 1
18
- # entry :high, 2
19
- # end
20
- #
21
- # @example Access values
22
- # MyEnum[:low].value # => 1
23
- # MyEnum.high.to_sym # => :low
24
- # MyEnum.low == MyEnum[:low] # => true
25
- #
26
- # @example Serialize to JSON
27
- # MyEnum.low.to_json # => '{"name":"low","value":1}'
28
- #
29
- # @example Deserialize from JSON
30
- # MyEnum.from_json('{"name":"low"}') # => MyEnum.low
31
-
32
- require 'json'
33
-
34
- module Enumancer
35
- class Enum
36
- attr_reader :value
37
-
38
- def initialize(value)
39
- @value = value
40
- end
41
-
42
- # Returns the symbolic name of the value as a string
43
- #
44
- # @return [String]
45
- def to_s
46
- to_sym.to_s
47
- end
48
-
49
- # Returns the symbolic name of the value as a symbol
50
- #
51
- # @return [Symbol]
52
- def to_sym
53
- name = self.class.name_for(value)
54
- raise KeyError, "Unregistered enum value: #{value.inspect}" if name.nil? && self.class.strict?
55
- name || :unknown
56
- end
57
-
58
- # Returns a debug-friendly string representation
59
- #
60
- # @return [String]
61
- def inspect
62
- "#<#{self.class.name} #{to_sym.inspect}:#{value.inspect}>"
63
- end
64
-
65
- # Equality based on class and value
66
- #
67
- # @param other [Object]
68
- # @return [Boolean]
69
- def ==(other)
70
- other.is_a?(self.class) && other.value == value
71
- end
72
-
73
- alias eql? ==
74
- def hash = value.hash
75
-
76
- # Serializes the enum to JSON
77
- #
78
- # @return [String]
79
- def to_json(*args)
80
- { name: to_sym, value: value }.to_json(*args)
81
- end
82
-
83
- class << self
84
- # Called when subclassing Enum
85
- #
86
- # @param subclass [Class]
87
- # @return [void]
88
- def inherited(subclass)
89
- subclass.instance_variable_set(:@registry, {})
90
- subclass.instance_variable_set(:@values, {})
91
- subclass.instance_variable_set(:@strict_mode, false)
92
- end
93
-
94
- # Declares the expected type of all enum values
95
- #
96
- # @param klass [Class] the type constraint for values
97
- # @param strict [Boolean] whether to enable strict mode
98
- # @return [void]
99
- def type(klass, strict: false)
100
- unless klass.is_a?(Class)
101
- raise ArgumentError, "Expected a Class, got #{klass.inspect}"
102
- end
103
- @value_type = klass
104
- @strict_mode = strict
105
- end
106
-
107
- # Returns whether strict mode is enabled
108
- #
109
- # @return [Boolean]
110
- def strict?
111
- @strict_mode == true
112
- end
113
-
114
- # Registers a new enum entry with a unique name and value
115
- #
116
- # @param name [Symbol, String] symbolic name of the entry
117
- # @param value [Object] value of the entry
118
- # @raise [ArgumentError] if name or value is already registered
119
- # @raise [TypeError] if value does not match declared type
120
- # @return [void]
121
- def entry(name, value)
122
- name = name.to_sym
123
-
124
- if defined?(@value_type) && !value.is_a?(@value_type)
125
- raise TypeError, "Invalid value type for #{name}: expected #{@value_type}, got #{value.class}"
126
- end
127
-
128
- if @values.key?(value)
129
- existing = @values[value]
130
- raise ArgumentError, "Duplicate value #{value.inspect} for #{name}; already assigned to #{existing}"
131
- end
132
-
133
- if @registry.key?(name)
134
- raise ArgumentError, "Duplicate name #{name}; already registered with value #{@registry[name].value.inspect}"
135
- end
136
-
137
- instance = new(value)
138
- @registry[name] = instance
139
- @values[value] = name
140
-
141
- define_singleton_method(name) { instance }
142
- end
143
-
144
- # Retrieves an enum instance by name
145
- #
146
- # @param name [Symbol, String]
147
- # @return [Enumancer::Enum, nil]
148
- def [](name)
149
- @registry[name.to_sym]
150
- end
151
-
152
- # Retrieves an enum instance by name, raising if not found
153
- #
154
- # @param name [Symbol, String]
155
- # @return [Enumancer::Enum]
156
- # @raise [KeyError]
157
- def fetch(name)
158
- @registry.fetch(name.to_sym)
159
- end
160
-
161
- # Resolves the symbolic name for a given value
162
- #
163
- # @param value [Object]
164
- # @return [Symbol, nil]
165
- def name_for(value)
166
- @values[value]
167
- end
168
-
169
- # Returns all registered enum instances
170
- #
171
- # @return [Array<Enumancer::Enum>]
172
- def all
173
- @registry.values
174
- end
175
-
176
- # Returns all registered names
177
- #
178
- # @return [Array<Symbol>]
179
- def keys
180
- @registry.keys
181
- end
182
-
183
- # Returns all raw values
184
- #
185
- # @return [Array<Object>]
186
- def values
187
- @registry.values.map(&:value)
188
- end
189
-
190
- # Removes an enum entry by name
191
- #
192
- # @param name [Symbol, String]
193
- # @return [Enumancer::Enum, nil] the removed entry or nil
194
- def remove(name)
195
- name = name.to_sym
196
- entry = @registry.delete(name)
197
- if entry
198
- @values.delete(entry.value)
199
- singleton_class.undef_method(name) if respond_to?(name)
200
- end
201
- entry
202
- end
203
-
204
- # Deserializes an enum from JSON
205
- #
206
- # @param json [String]
207
- # @return [Enumancer::Enum, nil]
208
- # @raise [KeyError] if strict and name is unknown
209
- def from_json(json)
210
- data = JSON.parse(json, symbolize_names: true)
211
- name = data[:name]
212
- raise KeyError, "Missing 'name' key in JSON" if name.nil?
213
-
214
- entry = self[name]
215
- if strict? && entry.nil?
216
- raise KeyError, "Unregistered enum name: #{name.inspect}"
217
- end
218
- entry
219
- end
220
- end
221
- end
222
- end