nummy 0.1.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.
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