lib/matchable.rb in matchable-0.1.0 vs lib/matchable.rb in matchable-0.1.1
- old
+ new
@@ -3,39 +3,60 @@
require_relative "matchable/version"
# Interface for Pattern Matching hooks
#
# @author baweaver
-# @since 0.0.1
+# @since 0.1.0
#
module Matchable
+ # Nicety wrapper to ensure unmatched methods give a clear response on what's
+ # missing
+ #
+ # @author baweaver
+ # @since 0.1.1
+ #
+ class UnmatchedName < StandardError
+ def initialize(msg)
+ @msg = <<~ERROR
+ Some attributes are missing methods for the match. Ensure all attributes
+ have a method of the same name, or an `attr_` method.
+
+ Original Error: #{msg}
+ ERROR
+
+ super(@msg)
+ end
+ end
+
+ DeconstructedBranch = Struct.new(:method_name, :code_branch, :guard_condition)
+
# Constant to prepend methods and extensions to
MODULE_NAME = "MatchableDeconstructors".freeze
# Extend class methods for pattern matching hooks
def self.included(klass) = klass.extend(ClassMethods)
# Class method hooks for adding pattern matching interfaces
#
# @author baweaver
- # @since 0.0.1
+ # @since 0.1.0
module ClassMethods
# 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]]
def deconstruct(method_name)
- return if deconstructable_module.const_defined?('DECONSTRUCTION_METHOD')
+ 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
- deconstructable_module.const_set('DECONSTRUCTION_METHOD', method_name)
+ 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)
@@ -75,59 +96,124 @@
# method to work, or this will fail.
#
# @return [void]
def deconstruct_keys(*keys)
# Return early if called more than once
- return if deconstructable_module.const_defined?('DECONSTRUCTION_KEYS')
+ 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)
- deconstructions = sym_keys.map { deconstructed_value(_1) }.join("\n\n")
+ sym_keys = keys.map(&:to_sym)
# Retain a reference to which keys we deconstruct from
- deconstructable_module.const_set('DECONSTRUCTION_KEYS', sym_keys)
+ 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.
- deconstructable_module.class_eval <<~RUBY, __FILE__ , __LINE__ + 1
+ #
+ # 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
- #{deconstructions}
+ # 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
- # Generates Ruby code for `deconstruct_keys` branches which will
- # directly call the method rather than utilizing `public_send` or
- # similar methods.
+ # 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.
#
- # Note that in the case of `keys` being `nil` it is expected to return
- # all keys that are possible from a pattern match rather than nothing,
- # hence adding this guard in every case.
+ # @param method_names [Array[Symbol]]
+ # Names of the methods
#
- # @param method_name [Symbol]
- # Name of the method to add a deconstructed key from
- #
# @return [String]
- # Evaluatable Ruby code for adding a deconstructed key to requested
- # values.
- private def deconstructed_value(method_name)
- <<~RUBY
- if keys.nil? || keys.include?(:#{method_name})
- deconstructed_values[:#{method_name}] = #{method_name}
- end
- RUBY
+ # Ruby code for all key-value pairs for method names
+ def nil_guard_values(method_names)
+ method_names
+ .map { |method_name| "#{method_name}: #{method_name}" }
+ .join(",\n")
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[Symbol]]
+ # Names of the methods
+ #
+ # @return [Hash[Symbol, Proc]]
+ # Mapping of deconstruction key to lazy retrieval function
+ 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
+
# 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.
#
@@ -136,30 +222,63 @@
#
# @return [void]
private def attach_deconstructor(method_name)
i_method = instance_method(method_name)
- deconstruction_code = if method_name == :initialize
- param_names = i_method.parameters.map(&:last)
+ 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)
- "[#{param_names.join(', ')}]"
- else
- method_name
- end
+ # 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
- deconstructable_module.class_eval <<~RUBY, __FILE__ , __LINE__ + 1
+ # 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]
- private def deconstructable_module
+ private def matchable_module
if const_defined?(MODULE_NAME)
const_get(MODULE_NAME)
else
const_set(MODULE_NAME, Module.new).tap(&method(:prepend))
end