Change the context/binding inside a block in ruby
Asked Answered
B

5

46

I have a DSL in Ruby that works like so:

desc 'list all todos'
command :list do |c|
  c.desc 'show todos in long form'
  c.switch :l
  c.action do |global,option,args|
    # some code that's not relevant to this question
  end
end

desc 'make a new todo'
command :new do |c|
  # etc.
end

A fellow developer suggested I enhance my DSL to not require passing c to the command block, and thus not require the c. for all the methods inside; presumably, he implied I could make the following code work the same:

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

desc 'make a new todo'
command :new do
  # etc.
end

The code for command looks something like

def command(*names)
  command = make_command_object(..)
  yield command                                                                                                                      
end

I tried several things and was unable to get it to work; I couldn't figure out how to change the context/binding of the code inside the command block to be different than the default.

Any ideas on if this is possible and how I might do it?

Berti answered 1/5, 2011 at 20:20 Comment(0)
A
37

Paste this code:

  def evaluate(&block)
    @self_before_instance_eval = eval "self", block.binding
    instance_eval &block
  end

  def method_missing(method, *args, &block)
    @self_before_instance_eval.send method, *args, &block
  end

For more information, refer to this really good article here

Arv answered 1/5, 2011 at 20:52 Comment(1)
is evaluate special? The linked article doesn't indicate it as such. My code, in the definition of command, does a yield. 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
P
14

Maybe

def command(*names, &blk)
  command = make_command_object(..)
  command.instance_eval(&blk)
end

can evaluate the block in the context of command object.

Predestinarian answered 5/5, 2011 at 17:36 Comment(0)
E
6
class CommandDSL
  def self.call(&blk)
    # Create a new CommandDSL instance, and instance_eval the block to it
    instance = new
    instance.instance_eval(&blk)
    # Now return all of the set instance variables as a Hash
    instance.instance_variables.inject({}) { |result_hash, instance_variable|
      result_hash[instance_variable] = instance.instance_variable_get(instance_variable)
      result_hash # Gotta have the block return the result_hash
    }
  end

  def desc(str); @desc = str; end
  def switch(sym); @switch = sym; end
  def action(&blk); @action = blk; end
end

def command(name, &blk)
  values_set_within_dsl = CommandDSL.call(&blk)

  # INSERT CODE HERE
  p name
  p values_set_within_dsl 
end

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

Will print:

:list
{:@desc=>"show todos in long form", :@switch=>:l, :@action=>#<Proc:0x2392830@C:/Users/Ryguy/Desktop/tesdt.rb:38>}
Enclosure answered 1/5, 2011 at 23:6 Comment(0)
W
2

I wrote a class that handles this exact issue, and deals with things like @instance_variable access, nesting, and so forth. Here's the write-up from another question:

Block call in Ruby on Rails

Winfrid answered 28/8, 2012 at 19:31 Comment(0)
D
0

@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.

Deonnadeonne answered 6/2, 2024 at 12:20 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.