nummy 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/nummy/enum.rb ADDED
@@ -0,0 +1,391 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nummy/auto_sequenceable"
4
+ require "nummy/ordered_const_enumerable"
5
+ require "nummy/errors"
6
+
7
+ module Nummy
8
+ # An opinionated class for defining enum-like data using constants.
9
+ #
10
+ # There are many gems out there for defining enum-like data, including
11
+ # +ActiveRecord::Enum+, +Ruby::Enum+, and +Dry::Types::Enum+. The defining
12
+ # characteristic of this particular class is that it is meant to be light on
13
+ # metaprogramming in order to make it easier for static analysis tools to work
14
+ # with the enum fields. By basing the enums on constants, tools like Ruby LSP
15
+ # can provide better support for things like "Go To Definition",
16
+ # autocompletion of enum keys, and documentation for specific enum fields on
17
+ # hover.
18
+ #
19
+ # The enums are built on top of {AutoSequenceable}, which provides support for
20
+ # defining sequences of values, and {OrderedConstEnumerable} to provide support for
21
+ #
22
+ # This class is designed to be used as a superclass that can be inherited from
23
+ # to create classes that define the enum fields.
24
+ #
25
+ # @see AutoSequenceable
26
+ # @see OrderedConstEnumerable
27
+ #
28
+ # @example Compass points
29
+ # class CardinalDirection < Nummy::Enum
30
+ # NORTH = 0
31
+ # EAST = 90
32
+ # SOUTH = 180
33
+ # WEST = 270
34
+ # end
35
+ #
36
+ # CompassPoint::NORTH
37
+ # # => 0
38
+ #
39
+ # CompassPoint.values
40
+ # # => [0, 90, 180, 270]
41
+ #
42
+ # CompassPoint.key?(:NORTH)
43
+ # # => true
44
+ #
45
+ # CompassPoint.key?(:NORTH_NORTH_WEST)
46
+ # # => false
47
+ #
48
+ # @example Using as a guard
49
+ # class Status < Nummy::Enum
50
+ # DRAFT = :draft
51
+ # PUBLISHED = :published
52
+ # RETRACTED = :retracted
53
+ # end
54
+ #
55
+ # def update_status(status)
56
+ # raise ArgumentError, "invalid status" unless Status.any?(status)
57
+ # end
58
+ class Enum
59
+ class << self
60
+ include AutoSequenceable
61
+ include OrderedConstEnumerable
62
+
63
+ # Anonymous block forwarding doesn't work when the block is passed within
64
+ # another block, which is what we do in +.enum+ below.
65
+ #
66
+ # rubocop:disable Naming/BlockForwarding
67
+
68
+ # Creates a new subclass of +Nummy::Enum+ using the given name-value pairs from
69
+ # the keyword arguments.
70
+ #
71
+ # @overload define(*auto_keys, **pairs)
72
+ # @param auto_keys [Array<Symbol>] keys that will be assigned a value using {Enum.auto}
73
+ # @param pairs [Hash{Symbol => Object}] keys that will be assigned the given values
74
+ # @return [Class<Nummy::Enum>]
75
+ #
76
+ # @overload define(*auto_keys, **pairs, &)
77
+ # @param auto_keys [Array<Symbol>] keys that will be assigned a value using {Enum.auto}
78
+ # @param pairs [Hash{Symbol => Object}] keys that will be assigned the given values
79
+ # @yield [Class<Nummy::Enum>] the new subclass
80
+ # @return [Class<Nummy::Enum>]
81
+ def define(*auto_keys, **pairs, &block)
82
+ Class.new(self) do
83
+ auto_keys.each { |key| const_set(key, auto) }
84
+ pairs.each { |key, value| const_set(key, value) }
85
+
86
+ class_eval(&block) if block_given?
87
+ end
88
+ end
89
+ # rubocop:enable Naming/BlockForwarding
90
+
91
+ # Prevent inheriting from subclasses of {Enum} in order to keep the
92
+ # constant lookup logic simple.
93
+ #
94
+ # Without inheritance, we only need to lookup constants on +self+. With
95
+ # inheritance, we would have to look up constants on +self+ and all of its
96
+ # superclasses, and we would also need to keep track of ordering.
97
+ #
98
+ # For the use case of enums, inheritance should not be neccessary. New
99
+ # enums can be combined using other methods, like {#merge}.
100
+ #
101
+ # @private
102
+ def inherited(subclass)
103
+ if subclass.superclass != Enum
104
+ raise InheritanceError, "cannot subclass enum #{display_name}"
105
+ end
106
+
107
+ super(subclass)
108
+ end
109
+
110
+ # Returns whether the value of any constant in +self+ is +==+ to +other+
111
+ #
112
+ # This is intended to allow the enum to be used in case expressions,
113
+ #
114
+ # @param [Object] other
115
+ # @return [Boolean]
116
+ #
117
+ # @example Checking if a value matches a value in +self+.
118
+ # class Status < Nummy::Enum
119
+ # DRAFT = 'draft'
120
+ # PUBLISHED = 'published'
121
+ # end
122
+ #
123
+ # puts Status === 'draft'
124
+ # # => true
125
+ #
126
+ # puts Status === 'retracted'
127
+ # # => false
128
+ #
129
+ # @example Matching a value in a case expression
130
+ # class SuccessStatus < Nummy::Enum
131
+ # OK = 200
132
+ # CREATED = 201
133
+ # # ...
134
+ # end
135
+ #
136
+ # class RedirectStatus < Nummy::Enum
137
+ # MULTIPLE_CHOICES = 300
138
+ # MOVED_PERMANENTLY = 301
139
+ # # ...
140
+ # end
141
+ #
142
+ # case status
143
+ # when SucessStatus
144
+ # # ...
145
+ # when RedirectStatus
146
+ # # ...
147
+ # end
148
+ def ===(other)
149
+ each_value.any? { |value| value == other }
150
+ end
151
+
152
+ # @!method each_const_name
153
+ # Alias for {OrderedConstEnumerable#each_const_name}.
154
+ alias each_key each_const_name
155
+
156
+ # @!method each_const_value
157
+ # Alias for {OrderedConstEnumerable#each_const_value}.
158
+ #
159
+ # @note
160
+ # This method is used as the basis for the enumeration methods provided
161
+ # by the +Enumerable+ module.
162
+ alias each_value each_const_value
163
+ alias each each_const_value
164
+
165
+ # @!method each_const_pair
166
+ # Alias for {OrderedConstEnumerable#each_const_pair}.
167
+ alias each_pair each_const_pair
168
+
169
+ # Returns a new +Array+ containing the keys in +self+.
170
+ #
171
+ # The keys will be the same as the names of the own (i.e. non-inherited)
172
+ # constants in +self+, ordered following the logic in
173
+ # {OrderedConstEnumerable}.
174
+ #
175
+ # @return [Array<Symbol>]
176
+ def keys
177
+ # Not using each_key.to_a because each_key just calls nummy_constants
178
+ # internally, so we can skip converting the constants to an enumerator
179
+ # and just get the relevant constants directly.
180
+ nummy_constants
181
+ end
182
+
183
+ # Returns the key of the constant in +self+ that has the given value.
184
+ #
185
+ # If no key has the given value, returns +nil+.
186
+ #
187
+ # @param target_value [Object]
188
+ # @return [Symbol, NilClass]
189
+ #
190
+ # @raise [KeyError] if no key exists.
191
+ def key(target_value)
192
+ each_pair { |key, value| return key if value == target_value }
193
+
194
+ # stree-ignore
195
+ raise KeyError.new("no key found for value: #{target_value.inspect}", receiver: self)
196
+ end
197
+
198
+ # Returns whether +self+ has the given key.
199
+ #
200
+ # @param [Symbol, String] key
201
+ # @return [Boolean]
202
+ def key?(key)
203
+ return false if key_scoped?(key)
204
+ const_defined?(key, false)
205
+ end
206
+
207
+ # Returns the value of the constant in +self+ that has the given key.
208
+ #
209
+ # @raise [KeyError] if the given key is invalid or does not exist.
210
+ def value(key)
211
+ if key_scoped?(key)
212
+ # stree-ignore
213
+ raise KeyError.new("cannot use scoped enum key: #{key.inspect}", receiver: self, key:)
214
+ end
215
+
216
+ nummy_const_get(key)
217
+ rescue NameError
218
+ # Avoid attaching the NameError as the cause, because it just adds
219
+ # unnecessary noise in the stack trace.
220
+
221
+ # stree-ignore
222
+ raise KeyError.new("key not found: #{key.inspect}", receiver: self, key:), cause: nil
223
+ end
224
+
225
+ # Allow bracketed lookup of constants by key.
226
+ alias [] value
227
+
228
+ # Returns whether +self+ has a constant with the given value.
229
+ #
230
+ # @param target_value [Object]
231
+ # @return [Boolean]
232
+ def value?(target_value)
233
+ each_value.any? { |value| value == target_value }
234
+ end
235
+
236
+ # Returns an +Array+ containing the values of the constants in +self+.
237
+ #
238
+ # @return [Array<Object>]
239
+ def values
240
+ each_value.to_a
241
+ end
242
+
243
+ # Returns an +Array+ containing the values of the given keys in +self+.
244
+ #
245
+ # @param [Array<Symbol, String>] selected_keys
246
+ # @return [Array<Object>]
247
+ # @raise [KeyError] if any of the given names are invalid or do not exist.
248
+ def values_at(*selected_keys)
249
+ selected_keys.map! { |key| value(key) }
250
+ end
251
+
252
+ # Returns an +Array+ containing the key-value pairs in +self+.
253
+ #
254
+ # @return [Array<Array<Symbol, Object>>]
255
+ def pairs
256
+ each_pair.to_a
257
+ end
258
+
259
+ # Returns the value for the constant with the given key, with ability to
260
+ # return default values if no constant is defined.
261
+ #
262
+ # @overload fetch(key)
263
+ # Returns the value for the constant with the given key.
264
+ #
265
+ # @raise [KeyError] if no such constant exists.
266
+ # @return [Object]
267
+ #
268
+ # @overload fetch(key, default_value)
269
+ # Returns the value for the constant with the given key. If no such
270
+ # constant exists, returns the given default value.
271
+ #
272
+ # @return [Object]
273
+ #
274
+ # @overload fetch(key, &)
275
+ # Returns the value for the constant with the given key. If no such
276
+ # constant exists, calls the given block and returns its result.
277
+ #
278
+ # @yieldparam [Symbol] key
279
+ # @yieldreturn [Object]
280
+ # @return [Object]
281
+ def fetch(key, *args)
282
+ case args.size
283
+ when 1
284
+ warn "block supersedes default value argument" if block_given?
285
+ when (2..)
286
+ # stree-ignore
287
+ raise ArgumentError, "wrong number of arguments (given #{args.size + 1}, expected 1..2)"
288
+ end
289
+
290
+ value(key)
291
+ rescue KeyError
292
+ if block_given?
293
+ yield key
294
+ elsif args.any?
295
+ args.first
296
+ else
297
+ raise
298
+ end
299
+ end
300
+
301
+ # Creates a new sublcass of {Enum} by merging the entries of +self+ with
302
+ # the entries of the other enums.
303
+ #
304
+ # Internally, this method converts each of the arguments to hashes then
305
+ # merges them together, so the merging of duplicate keys follows the
306
+ # behavior of +Hash#merge+.
307
+ def merge(*other_enums, &)
308
+ return self if other_enums.empty?
309
+
310
+ to_h
311
+ .merge(*other_enums.map!(&:to_h), &)
312
+ .each_with_object(Class.new(Enum)) do |(key, value), merged|
313
+ merged.const_set(key, value)
314
+ end
315
+ end
316
+
317
+ # Creates a new sublcass of {Enum} consisting of the entries of +self+
318
+ # that correspond to the given keys.
319
+ #
320
+ # @param [Array<Symbol>] selected_keys
321
+ # @return [Class<Enum>]
322
+ #
323
+ # @raise [KeyError] if any of the given keys do not exist in +self+.
324
+ def slice(*selected_keys)
325
+ selected_keys.each_with_object(Class.new(Enum)) do |key, sliced|
326
+ sliced.const_set(key, self[key])
327
+ end
328
+ end
329
+
330
+ # Returns whether +self+ has any pairs.
331
+ #
332
+ # @return [Boolean]
333
+ def empty?
334
+ size.zero?
335
+ end
336
+
337
+ # Returns the count of the constants in +self+.
338
+ #
339
+ # @return [Integer]
340
+ def size
341
+ keys.size
342
+ end
343
+
344
+ alias length size
345
+
346
+ # Converts the enum to a Hash, where the constant names as keys and the
347
+ # constant values as values.
348
+ #
349
+ # @return [Hash{Symbol => Object}]
350
+ def to_h
351
+ each_pair.to_h
352
+ end
353
+
354
+ # Alias to support splatting enums into keyword args.
355
+ alias to_hash to_h
356
+
357
+ # Returns a string representation of +self+.
358
+ #
359
+ # The string will contain the name-value pairs of the constants in +self+.
360
+ #
361
+ # @return [String]
362
+ def inspect
363
+ parts = [display_name]
364
+ each_pair { |key, value| parts << "#{key}=#{value.inspect}" }
365
+
366
+ "#<#{parts.join(" ")}>"
367
+ end
368
+
369
+ # Implements +PP+ support for +self+.
370
+ def pretty_print(pp)
371
+ pp.group(1, "#<#{display_name}", ">") do
372
+ each_pair do |key, value|
373
+ pp.breakable
374
+ pp.text "#{key}="
375
+ pp.pp value
376
+ end
377
+ end
378
+ end
379
+
380
+ private
381
+
382
+ def display_name
383
+ name || Nummy::Enum.name
384
+ end
385
+
386
+ def key_scoped?(key)
387
+ key.to_s.include?(":")
388
+ end
389
+ end
390
+ end
391
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nummy
4
+ # Error raised when trying to inherit from a subclass of {Enum}.
5
+ class InheritanceError < StandardError
6
+ end
7
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nummy
4
+ # A mixin that makes a class +Enumerable+ using its +members+ method.
5
+ #
6
+ # This can be used for things like iterating over the members of a +Data+
7
+ # class without having to write the boilerplate code to do so.
8
+ #
9
+ # This aims to implement small set of methods, modeled after +Struct+, rather
10
+ # than a more fully-featured set of methods like +Hash+.
11
+ module MemberEnumerable
12
+ include Enumerable
13
+
14
+ # Iterates through the values of the members in +self+.
15
+ #
16
+ # The order of the pairs is the same as the order of the array returned by
17
+ # calling +members+.
18
+ #
19
+ # @overload each(&)
20
+ # Calls the given block with the value of each member in +self+.
21
+ #
22
+ # @yieldparam [Object] value
23
+ # @return [self]
24
+ #
25
+ # @overload each
26
+ # Returns an +Enumerator+ that calls the given block with the value of
27
+ # each member in +self+.
28
+ #
29
+ # @return [Enumerator<Object>]
30
+ def each(&)
31
+ return enum_for unless block_given?
32
+
33
+ members.each { |member| yield send(member) }
34
+ self
35
+ end
36
+
37
+ # Iterates through the name-value pairs of the members in +self+.
38
+ #
39
+ # The order of the pairs is the same as the order of the array returned by
40
+ # calling +members+.
41
+ #
42
+ # @overload each_pair(&)
43
+ # Calls the given block with a two-item array containing the name and
44
+ # value of each member in +self+.
45
+ #
46
+ # @yieldparam [Symbol] name
47
+ # @yieldparam [Object] value
48
+ # @return [self]
49
+ #
50
+ # @overload each_pair
51
+ # Returns an +Enumerator+ that iterates over with a two-item array
52
+ # containing the name and value of each member in +self+.
53
+ # @return [Enumerator<Array<Symbol, Object>>]
54
+ def each_pair
55
+ return enum_for(__method__) unless block_given?
56
+
57
+ members.each { |member| yield member, send(member) }
58
+ self
59
+ end
60
+
61
+ # Returns an +Array+ containing the values in +self+.
62
+ #
63
+ # @return [Array<Object>]
64
+ def values
65
+ deconstruct
66
+ end
67
+
68
+ # Returns an +Array+ containing the values of the given members in +self+.
69
+ #
70
+ # @param [Array<Symbol, String>] selected_members
71
+ # @return [Array<Object>]
72
+ #
73
+ # @raise [NoMethodError] if any of the given members are invalid or do not exist.
74
+ def values_at(*selected_members)
75
+ selected_members.map! { |member| send(member) }
76
+ end
77
+
78
+ # Returns the count of members in +self+.
79
+ #
80
+ # @return [Integer]
81
+ def size
82
+ members.size
83
+ end
84
+
85
+ alias length size
86
+ end
87
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nummy/const_enumerable"
4
+
5
+ module Nummy
6
+ # A module that can be extended in order to enumerate over constants
7
+ # in a +Hash+-like manner. This can be used to build enum-like classes and
8
+ # modules that work nicely with static analysis.
9
+ #
10
+ # The enumeration order for methods defined by this module is guaranteed to be
11
+ # stable across calls and methods, and in _most_ cases will match the order in
12
+ # which the constants were defined.
13
+ #
14
+ # The only exception is for constants that are defined *before* extending
15
+ # {ConstEnumerable}. Those constants are sorted in stable way, but the order
16
+ # will not necessarily match insertion order. This is because those constants
17
+ # are looked up using +Module#constants+ when this module is extended, and
18
+ # that method does not have any ordering guarantees. Instead, these constants
19
+ # are sorted using +Symbol#<=>+.
20
+ #
21
+ # @note
22
+ # If stable ordering is not important, consider using {ConstEnumerable},
23
+ # which is more performant but does not guarantee ordering.
24
+ #
25
+ # @see ConstEnumerable
26
+ # @see Enum
27
+ module OrderedConstEnumerable
28
+ include ConstEnumerable
29
+
30
+ # @!method each_const_name(...)
31
+ # Same as {ConstEnumerable#each_const_name}, but with stable ordering.
32
+
33
+ # @!method each_const_value(...)
34
+ # Same as {ConstEnumerable#each_const_value}, but with stable ordering.
35
+
36
+ # @!method each_const_pair(...)
37
+ # Same as {ConstEnumerable#each_const_pair}, but with stable ordering.
38
+
39
+ # @private
40
+ def self.extended(mod)
41
+ # Sort own constants lexicographically to ensure a stable order, because
42
+ # +Module#constants+ does not have its own ordering guarantees.
43
+ sorted_constants = mod.send(:constants, false).sort!
44
+
45
+ sorted_constants.each do |name|
46
+ mod.send(:nummy_record_const_insertion_order, name)
47
+ end
48
+ end
49
+
50
+ # Hook used to track the order in which constants are added to the group.
51
+ #
52
+ # We do this because +Module#constants+ does not guarantee the order that
53
+ # constants will be returned in.
54
+ #
55
+ # @note
56
+ # This hook was added in Ruby 3.2.
57
+ #
58
+ # @private
59
+ def const_added(name)
60
+ super(name)
61
+ nummy_record_const_insertion_order(name)
62
+ end
63
+
64
+ private
65
+
66
+ def nummy_constants
67
+ nummy_const_insertion_orders.keys & constants(false)
68
+ end
69
+
70
+ def nummy_const_insertion_orders
71
+ # using a Hash to get the deduplication benefits of a Set while also
72
+ # preserving the insertion order. We only care about the keys, so the
73
+ # values will all be ignored.
74
+ @nummy_const_insertion_orders ||= {}
75
+ end
76
+
77
+ def nummy_record_const_insertion_order(name)
78
+ nummy_const_insertion_orders[name] = nil
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nummy
4
+ # The current version of the gem.
5
+ VERSION = "0.1.0"
6
+ end
data/lib/nummy.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nummy/errors"
4
+ require "nummy/version"
5
+
6
+ # Utilities that that build on Ruby's Enumerable module to provide
7
+ # functionality like enumerated types ("enums"), enumerating over constants,
8
+ # and enumerating over the members of data classes.
9
+ #
10
+ # This module does NOT provide additional methods to the Enumerable module.
11
+ module Nummy
12
+ autoload :AutoSequenceable, "nummy/auto_sequenceable"
13
+ autoload :ConstEnumerable, "nummy/const_enumerable"
14
+ autoload :OrderedConstEnumerable, "nummy/ordered_const_enumerable"
15
+ autoload :MemberEnumerable, "nummy/member_enumerable"
16
+ autoload :Enum, "nummy/enum"
17
+
18
+ # Alias for {Enum.define}
19
+ def self.enum(...)
20
+ Enum.define(...)
21
+ end
22
+ end