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 +4 -4
- data/CHANGELOG.md +16 -0
- data/LICENSE +16 -0
- data/lib/enumancer/definition.rb +88 -0
- data/lib/enumancer/errors.rb +9 -0
- data/lib/enumancer/matcher.rb +51 -0
- data/lib/enumancer/serialization.rb +84 -0
- data/lib/enumancer/type.rb +154 -0
- data/lib/enumancer/value.rb +25 -0
- data/lib/enumancer.rb +26 -1
- data/readme.md +241 -0
- metadata +16 -8
- data/lib/enum.rb +0 -222
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6c69c7e9a39930f42d98b2601e5fd6d2632ce25a19bfdbe2d6f1ee49a6b6a0e1
|
|
4
|
+
data.tar.gz: b7a8f6cdda9dddf00e54453d259e4355c1f3cc4d67b38a92f4c5c7fe811e2a5f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
+
[](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:
|
|
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
|
|
14
|
-
|
|
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
|
-
-
|
|
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/
|
|
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:
|
|
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:
|
|
51
|
+
rubygems_version: 4.0.20
|
|
44
52
|
specification_version: 4
|
|
45
|
-
summary:
|
|
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
|