From 745b317e12cc54d4f87c195645ca7d367ad1c659 Mon Sep 17 00:00:00 2001 From: Marc Siegel Date: Fri, 2 Feb 2018 14:05:36 -0500 Subject: [PATCH] Allow helper methods defined in the block's context to call DSL methods Previously, it turns out that this wasn't possible, which made refactoring code that used Docile to extract common helper methods, and re-use them in different blocks, a painful experience. This change should make it possible to extract methods from blocks into the context around the block, and have those extracted helper methods still able to call methods on the DSL object. --- lib/docile/execution.rb | 5 ++ lib/docile/fallback_context_proxy.rb | 25 +++++++ spec/docile_spec.rb | 102 +++++++++++++++++++++++++-- 3 files changed, 125 insertions(+), 7 deletions(-) diff --git a/lib/docile/execution.rb b/lib/docile/execution.rb index 0b65454..63f79a1 100644 --- a/lib/docile/execution.rb +++ b/lib/docile/execution.rb @@ -22,8 +22,13 @@ module Docile value_from_block = block_context.instance_variable_get(ivar) proxy_context.instance_variable_set(ivar, value_from_block) end + proxy_context.instance_exec(*args, &block) ensure + if block_context.respond_to?(:__docile_undo_fallback__) + block_context.send(:__docile_undo_fallback__) + end + block_context.instance_variables.each do |ivar| value_from_dsl_proxy = proxy_context.instance_variable_get(ivar) block_context.instance_variable_set(ivar, value_from_dsl_proxy) diff --git a/lib/docile/fallback_context_proxy.rb b/lib/docile/fallback_context_proxy.rb index 81512a6..95a4b24 100644 --- a/lib/docile/fallback_context_proxy.rb +++ b/lib/docile/fallback_context_proxy.rb @@ -38,6 +38,31 @@ module Docile def initialize(receiver, fallback) @__receiver__ = receiver @__fallback__ = fallback + + # Enables calling DSL methods from helper methods in the block's context + unless fallback.respond_to?(:method_missing) + # NOTE: There's no {#define_singleton_method} on Ruby 1.8.x + singleton_class = (class << fallback; self; end) + + # instrument {#method_missing} on the block's context to fallback to + # the DSL object. This allows helper methods in the block's context to + # contain calls to methods on the DSL object. + singleton_class. + send(:define_method, :method_missing) do |method, *args, &block| + if receiver.respond_to?(method.to_sym) + receiver.__send__(method.to_sym, *args, &block) + else + super(method, *args, &block) + end + end + + # instrument a helper method to remove the above instrumentation + singleton_class. + send(:define_method, :__docile_undo_fallback__) do + singleton_class.send(:remove_method, :method_missing) + singleton_class.send(:remove_method, :__docile_undo_fallback__) + end + end end # @return [Array] Instance variable names, excluding diff --git a/spec/docile_spec.rb b/spec/docile_spec.rb index 6a07820..47768b0 100644 --- a/spec/docile_spec.rb +++ b/spec/docile_spec.rb @@ -117,18 +117,106 @@ describe Docile do end end - class DSLWithNoMethod - def initialize(b); @b = b; end - attr_accessor :b - def push_element - @b.push 1 + context "when block's context has helper methods which call DSL methods" do + class BlockContextWithHelperMethods + def initialize(array_as_dsl) + @array_as_dsl = array_as_dsl + end + + # Classic dynamic programming factorial, using the methods of {Array} + # as a DSL to implement it, via helper methods {#calculate_factorials} + # and {#save_factorials} which are defined in this class, so therefore + # outside the block. + def factorial_as_dsl_against_array(n) + Docile.dsl_eval(@array_as_dsl) { calculate_factorials(n) }.last + end + + # Uses the helper method {#save_factorials} below. + def calculate_factorials(n) + (2..n).each { |i| save_factorial(i) } + end + + # Uses the methods {Array#push} and {Array#at} as a DSL from a helper + # method defined in the block's context. Successfully calling this + # proves that we can find helper methods from outside the block, and + # then find DSL methods from inside those helper methods. + def save_factorial(i) + push(i * at(i - 1)) + end + end + + subject { context.method(:factorial_as_dsl_against_array) } + + let(:context) { BlockContextWithHelperMethods.new(array_as_dsl) } + + let(:array_as_dsl) { [1, 1] } + + it "finds DSL methods within helper method defined in block's context" do + # see https://en.wikipedia.org/wiki/Factorial + [ + [0, 1], + [1, 1], + [2, 2], + [3, 6], + [4, 24], + [5, 120], + [6, 720], + [7, 5_040], + [8, 40_320], + [9, 362_880], + [10, 3_628_800], + [11, 39_916_800], + [12, 479_001_600], + [13, 6_227_020_800], + [14, 87_178_291_200], + [15, 1_307_674_368_000] + ].each do |n, expected_factorial| + array_as_dsl.replace([1, 1]) + expect(subject.call(n)).to eq expected_factorial + end + end + + it "removes fallback instrumentation from the DSL object after block" do + expect { subject.call(5) }. + not_to change { context.respond_to?(:method_missing) }. + from(false) + end + + it "removes method to remove fallbacl from the DSL object after block" do + expect { subject.call(5) }. + not_to change { context.respond_to?(:__docile_undo_fallback__) }. + from(false) + end + + context "when helper methods call methods that are undefined" do + let(:array_as_dsl) { "not an array" } + + it "raises NoMethodError" do + expect { subject.call(5) }. + to raise_error(NoMethodError, /undefined method `at' /) + end + + it "removes fallback instrumentation from the DSL object after block" do + expect { subject.call(5) rescue nil }. + not_to change { context.respond_to?(:method_missing) }. + from(false) + end end end context "when DSL have NoMethod error inside" do - it "raise error from nil" do + class DSLWithNoMethod + def initialize(b); @b = b; end + attr_accessor :b + def push_element + @b.push 1 + end + end + + it "raise NoMethodError error from nil" do Docile.dsl_eval(DSLWithNoMethod.new(nil)) do - expect { push_element }.to raise_error(NoMethodError, /undefined method `push' (for|on) nil:NilClass/) + expect { push_element }. + to raise_error(NoMethodError, /undefined method `push' (for|on) nil:NilClass/) end end end