@Jatin Ganhotra answer seems the more accurate one, yet needs to be adapted to the question and provide some more info.
The below is an adapted solution
module DslAble
# It runs the `block` within this object context
# @note if the object misses any method, redirects the method to the
# original evaluate caller.
# Parameters are passed to the block
def evaluate(*args, **kargs, &block)
return unless block_given?
@self_before_evaluate = eval "self", block.binding, __FILE__, __LINE__
instance_exec(*args, **kargs, &block).tap do
@self_before_evaluate = nil
end
end
# When it's the case, redirect to the original `evaluate` caller
# @see https://www.dan-manges.com/blog/ruby-dsls-instance-eval-with-delegation
def method_missing(method, *args, **kargs, &block)
super unless @self_before_evaluate
@self_before_evaluate.send(method, *args, **kargs, &block)
end
end
With two main amendments and one improvement:
- (amendment) the instance variable should be set back to
nil
once the block has been evaluated
- (amendment) the default behaviour of
method_missing
should prevail when we are not in the context of an evaluate
call (that's where we call super
)
- (improvement)
instance_exec
allows to pass parameters to the block
. This way we can call evaluate
with params that would be received by the block, may the end user would still want to use them (and for backwards compatibility with existing definitions).
In the question's scenario, let's suppose there is a class Command
. You would include this module in it:
class Command
include DslAble # << here
def self.make_object(*names); some_logic_here; end
def desc(str); @last_desc = str; end
def switch(sym); @switches << Switch.new(sym).desc(@last_desc) ; end
def action(&blk); @switches.last.action(&blk); end
end
Then, the top level command
method would be defined like this:
def command(*names, &block)
command = Command.make_object(*names)
command.evaluate(command, &block)
end
- Observe that you pass
command
as a parameter to the block. Backwards compatible with previous definitions where command
is explicitly referred to as as an argument of the block.
New blocks you create for command
will implicitly refer to methods of your Command
object, making the below to work as you expected:
desc 'list all todos'
command :list do
desc 'show todos in long form'
switch :l
action do |global,option,args|
# some code that's not relevant to this question
end
end
Cross-context clashed methods
It must be noted, that the approach with missing_method
hook will refer back to the original caller, when evaluate
has been used. This means that within the command
block you are supposed to be able to refer to methods that were available in the original context of the block (i.e. argument
). However, if that method also exists in your Command
object, it will be called instead:
desc 'list all todos'
command :list do
desc 'the long form'
switch: :l
action { |*args| do_whatever }
# Nested command definition
desc "this desc does not define below's command, but override switch's one :/"
command :to_csv { do_some_stuff }
end
- Although the 2nd
command
will be correctly called (from the main context) via missing_method
, this approach will fail to link the last desc
description to the nested command
, because desc
exists as a Command
method (and the command
object is self
within its block).
- So in this particular usage (nesting), it is NOT backwards compatible unless you capture and resolve contexts like
rpec
does.
Before this change, the above would not happen. But I guess that's a normal problem of using nested DSLs that refer to methods that clash (desc
in this case).
Work Around
You could though work this around with named parameters to the command
method:
class Command
def my_desc(str = :unused)
return @desc if str == :unused
@desc = str
end
end
def command(*names, desc: nil, &block)
command = Command.make_object(*names)
command.my_desc(desc) if desc
command.evaluate(command, &block)
end
desc 'list all todos'
command :list do
desc 'the long form'
switch: :l
action { |*args| do_whatever }
# Nested command definition
cdesc = "this desc does not define below's command, but override switch's one :/"
command :to_csv, desc: cdesc { do_some_stuff }
end
There are other alternatives, but it is off topic here.
command
, does ayield
. Are you saying that I should put the &block in my method sig, and then instance_eval that block instead of yield? (updating question with this info) – Berti