module Matchable::ClassMethods
Class method hooks for adding pattern matching interfaces
@author baweaver @since 0.1.0
Public Instance Methods
Hook for the `deconstruct` instance method which triggers its definition based on a deconstruction method passed. If the method is not yet defined by the class it will wait until such a method is added to execute.
@param method_name [Symbol]
Name of the method to bind to
@return [Array[status, method_name]]
# File lib/matchable.rb, line 51 def deconstruct(method_name) return if matchable_module.const_defined?("MATCHABLE_METHOD") # :new should mean :initialize if one wants to match against arguments # to :new method_name = :initialize if method_name == :new matchable_module.const_set("MATCHABLE_METHOD", method_name) # If this was called after the method was added, go ahead and attach, # otherwise we need some trickery to make sure the method is defined # first if they used this at the top of the class above its definition. if method_defined?(method_name) attach_deconstructor(method_name) return [true, method_name] end # Otherwise we set a flag, and hand it to `method_added` to clean up # after this method @_awaited_deconstruction_method = method_name [false, method_name] end
Hook for the `deconstruct_keys` method which triggers its defintion based on the keys passed to this method.
@param *keys [Array]
Keys to deconstruct values from. Each must have an associated instance method to work, or this will fail.
@return [void]
# File lib/matchable.rb, line 99 def deconstruct_keys(*keys) # Return early if called more than once return if matchable_module.const_defined?('MATCHABLE_KEYS') # Ensure keys are symbols, then generate Ruby code for each # key assignment branch to be used below sym_keys = keys.map(&:to_sym) # Retain a reference to which keys we deconstruct from matchable_module.const_set('MATCHABLE_KEYS', sym_keys) # Lazy Hash mapping of all keys to all values wrapped in lazy # procs. # # see: #lazy_match_value matchable_module.const_set('MATCHABLE_LAZY_VALUES', lazy_match_values(sym_keys)) # `public_send` can be slow, and `to_h` and `each_with_object` can also # be slow. This defines the direct method calls in-line to prevent # any performance penalties to generate optimal match code. # # This generates and adds a method to the prepended module. We add YARDoc # to this because the generated source can be seen and we want to be nice. # # We also intercept name errors to give more useful errors should it # be implemented incorrectly. matchable_module.class_eval <<~RUBY, __FILE__ , __LINE__ + 1 # Pattern Matching hooks for hash-like matches. # # This method was generated by Matchable. Make sure all properties have # associated methods attached or this will raise an error. # # @param keys [Array[Symbol]] # Keys to limit the deconstruction to. If keys are `nil` then return # all possible keys instead. # # @return [Hash[Symbol, Any]] # Deconstructed keys and values def deconstruct_keys(keys) # If `keys` is `nil` we want to return all possible keys. This # generates all of them as a direct Hash representation and # returns that, rather than guard all methods below on # `keys.nil? || ...`. if keys.nil? return { #{nil_guard_values(sym_keys)} } end # If keys are present, we want to iterate the keys to add requested # values. Before we iterate we also want to ensure only valid keys # are being passed through here. deconstructed_values = {} valid_keys = MATCHABLE_KEYS & keys # This is where things get interesting. Each value is retrieved through # a lazy hash in which `method_name or `key` points to a proc: # # key: -> o { o.key } # # The actual method is interpolated directly and `eval`'d to make this # faster than `public_send`. valid_keys.each do |key| deconstructed_values[key] = MATCHABLE_LAZY_VALUES[key].call(self) end # ...and once this is done, return back the deconstructed values. deconstructed_values # We rescue `NameError` here to return a more useful message and indicate # there are some missing methods for the match. rescue NameError => e raise Matchable::UnmatchedName, e end RUBY # To mask the return of the above class_eval nil end
Generated Ruby Hash based on a mapping of valid keys to a lazy function to retrieve them directly without the need for `public_send` or similar methods. This code instead directly interpolates the method call and evaluates that, but will not run the code until called as a proc in the actual `deconstruct_keys` method.
@param method_names [Array]
Names of the methods
@return [Hash[Symbol, Proc]]
Mapping of deconstruction key to lazy retrieval function
# File lib/matchable.rb, line 203 def lazy_match_values(method_names) method_names # Name of the method points to a lazy function to retrieve it .map { |method_name| " #{method_name}: -> o { o.#{method_name} }," } # Join them into one String .join("\n") # Wrap them in Hash brackets .then { |kv_pairs| "{\n#{kv_pairs}\n}"} # ...and `eval` it to turn it into a Hash .then { |ruby_code| eval ruby_code } end
Method Added hook, will trigger only if `deconstruct` could not bind to a method because it didn't exist yet.
@param method_name [Symbol]
Name of the method currently being defined
@return [void]
# File lib/matchable.rb, line 80 def method_added(method_name) return unless defined?(@_awaited_deconstruction_method) return unless @_awaited_deconstruction_method == method_name attach_deconstructor(method_name) remove_instance_variable(:@_awaited_deconstruction_method) # Return is irrelevant here, mask response from `remove_instance_variable` nil end
Generates key-value pairs of `method_name` pointing to `method_name` for the case where `keys` is `nil`, requiring all keys to be directly returned.
@param method_names [Array]
Names of the methods
@return [String]
Ruby code for all key-value pairs for method names
# File lib/matchable.rb, line 186 def nil_guard_values(method_names) method_names .map { |method_name| "#{method_name}: #{method_name}" } .join(",\n") end
Private Instance Methods
Attaches the deconstructor to the parent class. If the method is initialize we want to deconstruct based on the parameters of class instantiation rather than alias that method, as this is a common method of deconstruction.
@param method_name [Symbol]
Method to deconstruct from
@return [void]
# File lib/matchable.rb, line 224 def attach_deconstructor(method_name) i_method = instance_method(method_name) deconstruction_code = # If the method is `initialize` we want to treat it differently, as # it represents a unique destructuring based on the method's parameters. if method_name == :initialize # Example of parameters: # # -> a, b = 2, *c, d:, e: 3, **f, &fn {}.parameters # # => [ # # [:req, :a], [:opt, :b], [:rest, :c], [:keyreq, :d], [:key, :e], # # [:keyrest, :f], [:block, :fn] # # ] # # The `last` of each is the name of the param. This assumes a tied # method to each of these names, and will fail otherwise. param_names = i_method.parameters.map(&:last) # Take the literal names of those parameters and treat them like # method calls to have the entire thing inlined "[#{param_names.join(', ')}]" # Otherwise we just want the method name, don't do anything special to # this. If you have any other methods that might make sense here let me # know by filing an issue. else method_name end # Then we evaluate that in the context of our prepended module and away # we go with our new method. Added YARDoc because this will show up in the # actual code and we want to be nice. matchable_module.class_eval <<~RUBY, __FILE__ , __LINE__ + 1 # Pattern Matching hook for array-like deconstruction methods. # # This method was generated by Matchable and based on the `#{method_name}` # method. Make sure all properties have associated methods attached or # this will raise an error. # # @return [Array] def deconstruct #{deconstruction_code} # We rescue `NameError` here to return a more useful message and indicate # there are some missing methods for the match. rescue NameError => e raise Matchable::UnmatchedName, e end RUBY # Return back nil because this value really should not be relied upon nil end
Prepended module to define methods against
@return [Module]
# File lib/matchable.rb, line 280 def matchable_module if const_defined?(MODULE_NAME) const_get(MODULE_NAME) else const_set(MODULE_NAME, Module.new).tap(&method(:prepend)) end end