Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/bkuhlmann/marameters

A dynamic method parameter inspector.
https://github.com/bkuhlmann/marameters

metaprogramming methods parameters ruby

Last synced: about 2 months ago
JSON representation

A dynamic method parameter inspector.

Awesome Lists containing this project

README

        

:toc: macro
:toclevels: 5
:figure-caption!:

:amazing_print_link: link:https://github.com/amazing-print/amazing_print[Amazing Print]
:article_link: link:https://alchemists.io/articles/ruby_method_parameters_and_arguments[method parameters and arguments]

= Marameters

Marameters is a portmanteau (i.e. `[m]ethod + p[arameters] = marameters`) which is designed to provide additional insight and diagnostics for method parameters. For context, the difference between a method's parameters and arguments is:

* *Parameters*: Represents the _expected_ values to be passed to a method when messaged as defined when the method is implemented. Example: `def demo one, two: nil`.
* *Arguments*: Represents the _actual_ values passed to the method when messaged. Example: `demo 1, two: 2`.

This gem will help you debug methods or aid your workflow when
metaprogramming -- as used in the link:https://alchemists.io/projects/infusible[Infusible] gem -- when architecting more sophisticated applications.

toc::[]

== Features

* Provides specialized objects for keyword, positional, and block parameters.

== Requirements

. link:https://www.ruby-lang.org[Ruby].
. A solid understanding of {article_link}.

== Setup

To install _with_ security, run:

[source,bash]
----
# 💡 Skip this line if you already have the public certificate installed.
gem cert --add <(curl --compressed --location https://alchemists.io/gems.pem)
gem install marameters --trust-policy HighSecurity
----

To install _without_ security, run:

[source,bash]
----
gem install marameters
----

You can also add the gem directly to your project:

[source,bash]
----
bundle add marameters
----

Once the gem is installed, you only need to require it:

[source,ruby]
----
require "marameters"
----

== Usage

At a high level, you can use `Marameters` as a single Object API for accessing all capabilities provided by this gem. Here's an overview:

[source,ruby]
----
# Setup
def demo(one, two = 2, three: 3) = puts "One: #{one}, Two: #{two}, Three: #{three}"

parameters = method(:demo).parameters
arguments = %w[one two]

# Marameters::Categorizer wrapper

Marameters.categorize parameters, arguments
# #

# Marameters::Probe wrapper

Marameters.of self, :demo # []

probe = Marameters.for parameters
probe.to_a # [[:req, :one], [:opt, :two], [:key, :three]]
probe.positionals # [:one, :two]
probe.keywords # [:three]
probe.block # nil

# Marameters::Signature wrapper

Marameters.signature({req: :one, opt: [:two, 2], key: [:three, 3]}).to_s
# one, two = 2, three: 3
----

Read on to learn more about the details on how each of these methods work and the objects they wrap.

=== Probe

The probe allows you to analyze a method's parameters. To understand how, consider the following:

[source,ruby]
----
class Demo
def initialize logger: Logger.new(STDOUT)
@logger = logger
end

def all one, two = nil, *three, four:, five: nil, **six, &seven
logger.debug [one, two, three, four, five, six, seven]
end

def none = logger.debug "Nothing to see here."

private

attr_reader :logger
end
----

You can then probe the `#all` method's parameters as follows:

[source,ruby]
----
probe = Marameters::Probe.new Demo.instance_method(:all).parameters

probe.block # :seven
probe.block? # true
probe.empty? # false
probe.keywords # [:four, :five]
probe.keywords? # true
probe.kind?(:keyrest) # true
probe.kinds # [:req, :opt, :rest, :keyreq, :key, :keyrest, :block]
probe.name?(:three) # true
probe.names # [:one, :two, :three, :four, :five, :six, :seven]
probe.only_bare_splats? # false
probe.only_double_splats? # false
probe.only_single_splats? # false
probe.positionals # [:one, :two]
probe.positionals? # true
probe.splats # [:three, :six]
probe.splats? # true
probe.to_a # [[:req, :one], [:opt, :two], [:rest, :three], [:keyreq, :four], [:key, :five], [:keyrest, :six], [:block, :seven]]
----

In contrast the above, we can also probe the `#none` method which has no parameters for a completely
different result:

[source,ruby]
----
probe = Marameters::Probe.new Demo.instance_method(:none).parameters

probe.block # nil
probe.block? # false
probe.empty? # true
probe.keywords # []
probe.keywords? # false
probe.kind?(:req) # true
probe.kinds # []
probe.name?(:three) # false
probe.names # []
probe.only_bare_splats? # false
probe.only_double_splats? # false
probe.only_single_splats? # false
probe.positionals # []
probe.positionals? # false
probe.splats # []
probe.splats? # false
probe.to_a # []
----

=== Categorizer

The categorizer allows you to dynamically build positional, keyword, and block arguments for message passing. This is most valuable when you know the object and method while needing to align the arguments in the right order. Here's a demonstration where {amazing_print_link} (i.e. `ap`) is used to format the output:

[source,ruby]
----
function = proc { "test" }

module Demo
def self.test one, two = nil, *three, four:, five: nil, **six, &seven
puts "The .#{__method__} method received the following arguments:\n"

[one, two, three, four, five, six, seven].each.with_index 1 do |argument, index|
puts "#{index}. #{argument.inspect}"
end

puts
end
end

module Inspector
def self.call arguments
Marameters::Categorizer.new(Demo.method(:test).parameters)
.call(arguments).then do |splat|
ap splat
puts
Demo.test(*splat.positionals, **splat.keywords, &splat.block)
end
end
end

Inspector.call [1, nil, nil, {four: 4}]

# # 4
# },
# positionals = [
# 1,
# nil
# ]
# >
#
# The .test method received the following arguments:
# 1. 1
# 2. nil
# 3. []
# 4. 4
# 5. nil
# 6. {}
# 7. nil
----

When we step through the above implementation and output, we see the following unfold:

. The `Demo` module allows us to define a maximum set of parameters and then print the arguments received for inspection purposes.
. The `Inspector` module provides a wrapper around the `Categorizer` so we can conveniently pass in different arguments for experimentation purposes.
. We pass in our arguments to `Inspector.call` where `nil` is used for optional arguments and hashes for keyword arguments.
. Once inside `Inspector.call`, the `Categorizer` is initialized with the `Demo.test` method parameters.
. Then the `splat` (i.e. Struct) is printed out so you can see the categorized positional, keyword, and block arguments.
. Finally, `Demo.test` method is called with the splatted arguments.

The above example satisfies the minimum required arguments but if we pass in the maximum arguments -- loosely speaking -- we see more detail:

[source,ruby]
----
Inspector.call [1, 2, [98, 99], {four: 4}, {five: 5}, {twenty: 20, thirty: 30}, function]

# Output

# #,
# keywords = {
# :four => 4,
# :five => 5,
# :twenty => 20,
# :thirty => 30
# },
# positionals = [
# 1,
# 2,
# 98,
# 99
# ]
# >
#
# The .test method received the following arguments:
# 1. 1
# 2. 2
# 3. [98, 99]
# 4. 4
# 5. 5
# 6. {:twenty=>20, :thirty=>30}
# 7. #
----

Once again, it is important to keep in mind that the argument positions _must_ align with the parameter positions since the parameters are an array of elements too. For illustration purposes -- using the above example -- we can compare the parameters to the arguments as follows:

[source,ruby]
----
parameters = Demo.method(:test).parameters
arguments = [1, 2, [98, 99], {four: 4}, {five: 5}, {twenty: 20, thirty: 30}, function]
----

With {amazing_print_link}, we can print out this information:

[source,ruby]
----
ap parameters
ap arguments
----

...which can be further illustrated by this comparison table:

[options="header"]
|===
| Parameter | Argument
| `%i[reg one]` | `1`
| `%i[opt two]` | `2`
| `%i[rest three]` | `[98, 99]`
| `%i[keyreq four]` | `{four: 4}`
| `%i[key five]` | `{five: 5}`
| `%i[keyrest six]` | `{twenty: 20, thirty: 30}`
| `%i[block seven]` | `#`
|===

This also means that:

* All positions must be filled if you want to supply arguments beyond the first couple of positions because everything is positional due to the nature of how link:https://rubyapi.org/o/method#method-i-parameters[Method#parameters] works. Use `nil` to fill an optional argument when you don't need it.
* The `:rest` (single splat) argument must be an array or `nil` if not present because even though it is _optional_, it is still _positional_.
* The `:keyrest` (double splat) argument -- much like the `:rest` argument -- must be a hash or `nil` if not present.

Lastly, in all of the above examples, only an array of arguments has been used but you can pass in a single argument too (i.e. non-array). This is handy for method signatures which have only a single parameter or only use splats. Having to remember to wrap your argument in an array each time can get tedious so when _only_ a single argument is supplied, the categorizer will automatically cast the argument as an array. A good example of this use case is when using structs. Example:

[source,ruby]
----
url = Struct.new :label, :url, keyword_init: true

Marameters.categorize(url.method(:new).parameters, {label: "Eaxmple", url: "https://example.com"})
.then { |splat| url.new(*splat.positionals, **splat.keywords) }

# Yields: #
----

For further details, please refer back to my {article_link} article mentioned in the _Requirements_ section.

=== Signature

The signature class is the inverse of the probe class in that you want to feed it parameters for turning into a method signature. This is useful when dynamically building method signatures or using the same signature when metaprogramming multiple methods.

The following demonstrates how you might construct a method signature with all possible parameters:

[source,ruby]
----
signature = Marameters::Signature.new(
{
req: :one,
opt: [:two, 2],
rest: :three,
keyreq: :four,
key: [:five, 5],
keyrest: :six,
block: :seven
}
)

puts signature
# one, two = 2, *three, four:, five: 5, **six, &seven
----

You'll notice that the parameters are a hash _and_ some values can be tuples. The reason is that
it's easier to write a hash than a double nested array as normally produced by the probe or directly
from `Method#parameters`. The optional positional and keyword parameters use tuples because you
might want to supply a default value and this provides a way for you to do that with minimal syntax.
This can be demonstrated further by using optional keywords (same applies for optional positionals):

[source,ruby]
----
# With no default
puts Marameters::Signature.new({key: :demo})
# demo: nil

# With explicit nil as default
puts Marameters::Signature.new({key: [:demo, nil]})
# demo: nil

# With string as default
puts Marameters::Signature.new({key: [:demo, "test"]})
# demo: "test"

# With symbol as default
puts Marameters::Signature.new({key: [:demo, :test]})
# demo: :test

# With object(dependency) as default
puts Marameters::Signature.new({key: [:demo, "*Object.new"]})
# demo: Object.new
----

In the case of object dependencies, you need to wrap these in a string _and_ prefix them with a star
(`*`) so the signature builder won't confuse them as normal strings. There are two reasons why this
is important:

* The star (`*`) signifies you want an object to be passed through without further processing while
also not being confused as a normal string.
* Objects wrapped as strings allows your dependency to be lazy loaded. Otherwise, if `Object.new`
was pass in directly, you'd be passing the evaluated instance (i.e.
`#`) which is not what you want until much later when your method is
defined.

When you put all of this together, you can dynamically build a method as follows:

[source,ruby]
----
signature = Marameters::Signature.new({opt: [:text, "This is a test."]})

Example = Module.new do
module_eval <<~DEFINITION, __FILE__, __LINE__ + 1
def self.say(#{signature}) = text
DEFINITION
end

puts Example.say
# This is a test.

puts Example.say "Hello"
# Hello
----

== Development

To contribute, run:

[source,bash]
----
git clone https://github.com/bkuhlmann/marameters
cd marameters
bin/setup
----

You can also use the IRB console for direct access to all objects:

[source,bash]
----
bin/console
----

== Tests

To test, run:

[source,bash]
----
bin/rake
----

== link:https://alchemists.io/policies/license[License]

== link:https://alchemists.io/policies/security[Security]

== link:https://alchemists.io/policies/code_of_conduct[Code of Conduct]

== link:https://alchemists.io/policies/contributions[Contributions]

== link:https://alchemists.io/projects/marameters/versions[Versions]

== link:https://alchemists.io/community[Community]

== Credits

* Built with link:https://alchemists.io/projects/gemsmith[Gemsmith].
* Engineered by link:https://alchemists.io/team/brooke_kuhlmann[Brooke Kuhlmann].