Presentation by Daniel Azuma at RubyConf 2019. Covers how DSLs can clash with "normal" Ruby code, and techniques for hardening a DSL against such conflicts.
# shell_app.rb require "sinatra" require "json" get "/" do output = invoke format_response output end def invoke `echo "this is the output"` end def format_response output JSON.dump({"output" => output}) end
# shell_app.rb require "sinatra" require "json" get "/" do output = invoke format_response output end def invoke `echo "this is the output"` end def format_response output JSON.dump({"output" => output}) end
# shell_app.rb require "sinatra" require "json" get "/" do output = invoke format_response output end def invoke `echo "this is the output"` end def format_response output JSON.dump({"output" => output}) end
# shell_app.rb require "sinatra" require "json" get "/" do output = invoke format_response output end def invoke `echo "this is the output"` end def format_response output JSON.dump({"output" => output}) end
# shell_app.rb require "sinatra" require "json" get "/" do output = invoke format_response output end def invoke `echo "this is the output"` end def format_response output JSON.dump({"output" => output}) end LocalJumpError: no block given (yield)
.../lib/sinatra/base.rb:1071:in `block in invoke'
.../lib/sinatra/base.rb:1071:in `catch'
.../lib/sinatra/base.rb:1071:in `invoke'
shell_app.rb:7:in `block in '
.../lib/sinatra/base.rb:1635:in `call'
.../lib/sinatra/base.rb:1635:in `block in compile!'
.../lib/sinatra/base.rb:987:in `block (3 levels) in route!'
.../lib/sinatra/base.rb:1006:in `route_eval'
.../lib/sinatra/base.rb:987:in `block (2 levels) in route!'
.../lib/sinatra/base.rb:1035:in `block in process_route'
.../lib/sinatra/base.rb:1033:in `catch'
.../lib/sinatra/base.rb:1033:in `process_route'
.../lib/sinatra/base.rb:985:in `block in route!'
.../lib/sinatra/base.rb:984:in `each'
.../lib/sinatra/base.rb:984:in `route!'
.../lib/sinatra/base.rb:1097:in `block in dispatch!'
# shell_app.rb require "sinatra" require "json" get "/" do output = invoke format_response output end def invoke `echo "this is the output"` end def format_response output JSON.dump({"output" => output}) end LocalJumpError: no block given (yield)
.../lib/sinatra/base.rb:1071:in `block in invoke'
.../lib/sinatra/base.rb:1071:in `catch'
.../lib/sinatra/base.rb:1071:in `invoke'
shell_app.rb:7:in `block in '
.../lib/sinatra/base.rb:1635:in `call'
.../lib/sinatra/base.rb:1635:in `block in compile!'
.../lib/sinatra/base.rb:987:in `block (3 levels) in route!'
.../lib/sinatra/base.rb:1006:in `route_eval'
.../lib/sinatra/base.rb:987:in `block (2 levels) in route!'
.../lib/sinatra/base.rb:1035:in `block in process_route'
.../lib/sinatra/base.rb:1033:in `catch'
.../lib/sinatra/base.rb:1033:in `process_route'
.../lib/sinatra/base.rb:985:in `block in route!'
.../lib/sinatra/base.rb:984:in `each'
.../lib/sinatra/base.rb:984:in `route!'
.../lib/sinatra/base.rb:1097:in `block in dispatch!'
# shell_app.rb require "sinatra" require "json" get "/" do output = invoke format_response output end def invoke `echo "this is the output"` end def format_response output JSON.dump({"output" => output}) end
# shell_app.rb require "sinatra" require "json" get "/" do output = invoke format_response output end def invoke `echo "this is the output"` end def format_response output JSON.dump({"output" => output}) end
# shell_app.rb require "sinatra" require "json" get "/" do output = invoke format_response output end def invoke `echo "this is the output"` end def format_response output JSON.dump({"output" => output}) end method added to the “main” object
# routing.rb Rails.application.routes.draw do resources :brands, only: [:index, :show] do resources :products, only: [:index, :show] end resource :basket, only: [:show, :update, :destroy] resolve("Basket") { route_for(:basket) } end methods of the class ActionDispatch::Routing::Mapper
# routing.rb Rails.application.routes.draw do resources :brands, only: [:index, :show] do resources :products, only: [:index, :show] end resource :basket, only: [:show, :update, :destroy] resolve("Basket") { route_for(:basket) } end methods of the class ActionDispatch::Routing::Mapper uses instance_eval to set self to a mapper instance
# nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do puts "hi" end end describe "bar" do it "speaks" do puts "hello" end it "sleeps" do puts "zzz" end end
# nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do puts "hi" end end describe "bar" do it "speaks" do puts "hello" end it "sleeps" do puts "zzz" end end “describe” block
# nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do puts "hi" end end describe "bar" do it "speaks" do puts "hello" end it "sleeps" do puts "zzz" end end “describe” block spec
# nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do puts "hi" end end describe "bar" do it "speaks" do puts "hello" end it "sleeps" do puts "zzz" end end # nanospec.rb
# nanospec.rb module Kernel def describe name, &block end end # nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do puts "hi" end end describe "bar" do it "speaks" do puts "hello" end it "sleeps" do puts "zzz" end end
# nanospec.rb class Entity def initialize name @name = name end end module Kernel def describe name, &block entity = Entity.new name (@entities ||= []) << entity end end # nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do puts "hi" end end describe "bar" do it "speaks" do puts "hello" end it "sleeps" do puts "zzz" end end
# nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do puts "hi" end end describe "bar" do it "speaks" do puts "hello" end it "sleeps" do puts "zzz" end end # nanospec.rb class Entity def initialize name @name = name end end module Kernel def describe name, &block entity = Entity.new name (@entities ||= []) << entity end end
# nanospec.rb class Entity def initialize name @name = name end end module Kernel def describe name, &block entity = Entity.new name (@entities ||= []) << entity entity.instance_eval &block end end # nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do puts "hi" end end describe "bar" do it "speaks" do puts "hello" end it "sleeps" do puts "zzz" end end
# nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do puts "hi" end end describe "bar" do it "speaks" do puts "hello" end it "sleeps" do puts "zzz" end end # nanospec.rb class Entity def initialize name @name = name end end module Kernel def describe name, &block entity = Entity.new name (@entities ||= []) << entity entity.instance_eval &block end end
# nanospec.rb class Entity def initialize name @name = name end def it name, &block end end module Kernel def describe name, &block entity = Entity.new name (@entities ||= []) << entity entity.instance_eval &block end end # nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do puts "hi" end end describe "bar" do it "speaks" do puts "hello" end it "sleeps" do puts "zzz" end end
# nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do puts "hi" end end describe "bar" do it "speaks" do puts "hello" end it "sleeps" do puts "zzz" end end # nanospec.rb class Entity def initialize name @name = name @specs = {} end def it name, &block @specs[name] = block end end module Kernel def describe name, &block entity = Entity.new name (@entities ||= []) << entity entity.instance_eval &block end end
# nanospec.rb class Entity def initialize name @name = name @specs = {} end def it name, &block @specs[name] = block end end module Kernel def describe name, &block entity = Entity.new name (@entities ||= []) << entity entity.instance_eval &block end end at_exit do end # nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do puts "hi" end end describe "bar" do it "speaks" do puts "hello" end it "sleeps" do puts "zzz" end end
# nanospec.rb class Entity def initialize name @name = name @specs = {} end def it name, &block @specs[name] = block end def check @specs.each do |spec, block| puts "** #{@name} #{spec}" block.call end end end module Kernel def describe name, &block entity = Entity.new name (@entities ||= []) << entity entity.instance_eval &block end end at_exit do @entities.each { |entity| entity.check } end # nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do puts "hi" end end describe "bar" do it "speaks" do puts "hello" end it "sleeps" do puts "zzz" end end
# nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do puts "hi" end end describe "bar" do it "speaks" do puts "hello" end it "sleeps" do puts "zzz" end end # nanospec.rb class Entity def initialize name @name = name @specs = {} end def it name, &block @specs[name] = block end def check @specs.each do |spec, block| puts "** #{@name} #{spec}" block.call end end end module Kernel def describe name, &block entity = Entity.new name (@entities ||= []) << entity entity.instance_eval &block end end at_exit do @entities.each { |entity| entity.check } end
# nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do puts "hi" end end describe "bar" do it "speaks" do @name = "Ruby" puts "My name is #{@name}" end it "sleeps" do puts "zzz" end end
# nanospec.rb class Entity def initialize name @name = name @specs = {} end def it name, &block @specs[name] = block end def check @specs.each do |spec, block| puts "** #{@name} #{spec}" block.call end end end module Kernel def describe name, &block entity = Entity.new name (@entities ||= []) << entity entity.instance_eval &block end end at_exit do @entities.each { |entity| entity.check } end # nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do puts "hi" end end describe "bar" do it "speaks" do @name = "Ruby" puts "My name is #{@name}" end it "sleeps" do puts "zzz" end end
# shell_app.rb require "sinatra" require "json" get "/" do output = invoke format_response output end def invoke `echo "this is the output"` end def format_response output JSON.dump({"output" => output}) end LocalJumpError: no block given (yield)
.../lib/sinatra/base.rb:1071:in `block in invoke'
.../lib/sinatra/base.rb:1071:in `catch'
.../lib/sinatra/base.rb:1071:in `invoke'
shell_app.rb:7:in `block in '
.../lib/sinatra/base.rb:1635:in `call'
.../lib/sinatra/base.rb:1635:in `block in compile!'
.../lib/sinatra/base.rb:987:in `block (3 levels) in route!'
.../lib/sinatra/base.rb:1006:in `route_eval'
.../lib/sinatra/base.rb:987:in `block (2 levels) in route!'
.../lib/sinatra/base.rb:1035:in `block in process_route'
.../lib/sinatra/base.rb:1033:in `catch'
.../lib/sinatra/base.rb:1033:in `process_route'
.../lib/sinatra/base.rb:985:in `block in route!'
.../lib/sinatra/base.rb:984:in `each'
.../lib/sinatra/base.rb:984:in `route!'
.../lib/sinatra/base.rb:1097:in `block in dispatch!'
TIP: Use a naming convention for instance variables and private methods # nanospec.rb class Entity def initialize name @name = name @specs = {} end def it name, &block @specs[name] = block end def check @specs.each do |spec, block| puts "** #{@name} #{spec}" block.call end end end
# nanospec.rb class Entity def initialize name @__name = name @__specs = {} end def it name, &block @__specs[name] = block end def check @__specs.each do |spec, block| puts "** #{@name} #{spec}" block.call end end end TIP: Use a naming convention for instance variables and private methods
# nanospec.rb class Entity def initialize name @__name = name @__specs = {} end def it name, &block @__specs[name] = block end def __check @__specs.each do |spec, block| puts "** #{@name} #{spec}" block.call end end end TIP: Use a naming convention for instance variables and private methods
TIP: # nanospec.rb class Entity def initialize name @name = name @specs = {} end def it name, &block @specs[name] = block end def check @specs.each do |spec, block| puts "** #{@name} #{spec}" block.call end end end module Kernel def describe name, &block entity = Entity.new name (@entities ||= []) << entity entity.instance_eval &block end end at_exit do @entities.each { |entity| entity.check } end both the DSL and its implementation Delegate implementation to a separate object
# nanospec.rb class EntityImpl def initialize name @name = name @specs = {} end def add name, block @specs[name] = block end def check @specs.each do |spec, block| puts "** #{@name} #{spec}" block.call end end end TIP: class EntityDSL def initialize impl @__impl = impl end def it name, &block @__impl.add name, block end end module Kernel def describe name, &block entity = EntityImpl.new name (@entities ||= []) << entity entity_dsl = EntityDSL.new entity entity_dsl.instance_eval &block end end at_exit do @entities.each { |entity| entity.check } end DSL only Implementation only Delegate implementation to a separate object
TIP: class EntityDSL def initialize impl @__impl = impl end def it name, &block @__impl.add name, block end end module Kernel def describe name, &block entity = EntityImpl.new name (@entities ||= []) << entity entity_dsl = EntityDSL.new entity entity_dsl.instance_eval &block end end at_exit do @entities.each { |entity| entity.check } end DSL only # nanospec.rb class EntityImpl def initialize name @name = name @specs = {} end def add name, block @specs[name] = block end def check @specs.each do |spec, block| puts "** #{@name} #{spec}" block.call end end end Implementation only Delegate implementation to a separate object
# nanospec.rb class EntityImpl def initialize name @name = name @specs = {} end def add name, block @specs[name] = block end def check @specs.each do |spec, block| puts "** #{@name} #{spec}" block.call end end end Implementation only TIP: class EntityDSL def initialize impl @__impl = impl end def it name, &block @__impl.add name, block end end module Kernel def describe name, &block entity = EntityImpl.new name (@entities ||= []) << entity entity_dsl = EntityDSL.new entity entity_dsl.instance_eval &block end end at_exit do @entities.each { |entity| entity.check } end DSL only Delegate implementation to a separate object
# nanospec.rb class EntityImpl def initialize name @name = name @specs = {} end def add name, block @specs[name] = block end def check @specs.each do |spec, block| puts "** #{@name} #{spec}" block.call end end end TIP: class EntityDSL def initialize impl @__impl = impl end def it name, &block @__impl.add name, block end end module Kernel def describe name, &block entity = EntityImpl.new name (@entities ||= []) << entity entity_dsl = EntityDSL.new entity entity_dsl.instance_eval &block end end at_exit do @entities.each { |entity| entity.check } end DSL only Implementation only Delegate implementation to a separate object
# nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do puts "hi" end end describe "bar" do it "speaks" do puts "hello" end it "sleeps" do puts "zzz" end end
# nanospec.rb class Entity def initialize name @name = name @specs = {} end def it name, &block @specs[name] = block end def check @specs.each do |spec, block| puts "** #{@name} #{spec}" block.call end end end module Kernel def describe name, &block entity = Entity.new name (@entities ||= []) << entity entity.instance_eval &block end end at_exit do @entities.each { |entity| entity.check } end # nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do puts "hi" end end describe "bar" do it "speaks" do puts "hello" end it "sleeps" do puts "zzz" end end
# nanospec.rb class Entity def initialize name @name = name @specs = {} end def it name, &block @specs[name] = block end def check @specs.each do |spec, block| puts "** #{@name} #{spec}" block.call end end end module NanospecDSL def describe name, &block entity = Entity.new name (@entities ||= []) << entity entity.instance_eval &block end end extend NanospecDSL at_exit do @entities.each { |entity| entity.check } end # nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do puts "hi" end end describe "bar" do it "speaks" do puts "hello" end it "sleeps" do puts "zzz" end end
# nanospec.rb class Entity def initialize name @name = name @specs = {} end def it name, &block @specs[name] = block end def check @specs.each do |spec, block| puts "** #{@name} #{spec}" block.call end end end module NanospecDSL def describe name, &block entity = Entity.new name (@entities ||= []) << entity entity.instance_eval &block end end extend NanospecDSL at_exit do @entities.each { |entity| entity.check } end # nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do puts "hi" end end describe "bar" do it "speaks" do puts "hello" end it "sleeps" do puts "zzz" end end TIP: Add methods only where needed
# nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do puts "hi" end end describe "bar" do it "speaks" do puts "hello" end it "sleeps" do puts "zzz" end end
private method of Object # nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do try_speaking end end def try_speaking puts "testing 1...2...3..." end
# nanospec.rb class Entity def initialize name @name = name @specs = {} end def it name, &block @specs[name] = block end def check @specs.each do |spec, block| puts "** #{@name} #{spec}" block.call end end end ... subclass of Object # nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do try_speaking end end def try_speaking puts "testing 1...2...3..." end
# nanospec.rb class Entity < BasicObject def initialize name @name = name @specs = {} end def it name, &block @specs[name] = block end def check @specs.each do |spec, block| puts "** #{@name} #{spec}" block.call end end end ... # nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do try_speaking end end def try_speaking puts "testing 1...2...3..." end BasicObject?
# nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do try_speaking end end def try_speaking puts "testing 1...2...3..." end FAIL # nanospec.rb class Entity < BasicObject def initialize name @name = name @specs = {} end def it name, &block @specs[name] = block end def check @specs.each do |spec, block| puts "** #{@name} #{spec}" block.call end end end ... BasicObject?
# nanospec.rb class Entity < BasicObject def initialize name @name = name @specs = {} end def it name, &block @specs[name] = block end def check @specs.each do |spec, block| puts "** #{@name} #{spec}" block.call end end end ... # nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do try_speaking end end def try_speaking puts "testing 1...2...3..." end TIP: BasicObject is usually NOT an effective base class for a DSL
# nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do try_speaking end def try_speaking puts "testing 1...2...3..." end end TIP: Design DSLs to honor lexical scoping
# nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do try_speaking end def try_speaking puts "testing 1...2...3..." end end TIP: Design DSLs to honor lexical scoping method of singleton class block executed with instance_eval
TIP: Design DSLs to honor lexical scoping # nanospec_test.rb require "./nanospec.rb" describe "foo" do it "speaks" do try_speaking end def try_speaking puts "testing 1...2...3..." end end describe "bar" do it "speaks" do try_speaking end def try_speaking puts "I'm a different object." end end separate scopes
# nanospec_test.rb require "./nanospec.rb" describe "outer" do describe "inner" do it "speaks" do try_speaking end end def try_speaking puts "testing 1...2...3..." end end TIP: Design DSLs to honor lexical scoping nested block
# nanospec_test.rb require "./nanospec.rb" describe "outer" do describe "inner" do it "speaks" do try_speaking end end def try_speaking puts "testing 1...2...3..." end end TIP: Design DSLs to honor lexical scoping delegate methods
# nanospec_test.rb require "./nanospec.rb" describe "bar" do it "speaks" do try_speaking end it "sleeps" do puts "zzz" end def try_speaking puts "testing 1...2...3..." end end
Each “describe” block creates a class # nanospec_test.rb require "./nanospec.rb" describe "bar" do it "speaks" do try_speaking end it "sleeps" do puts "zzz" end def try_speaking puts "testing 1...2...3..." end end TIP: Consider modeling blocks as classes
Each test is executed within a separate instance # nanospec_test.rb require "./nanospec.rb" describe "bar" do it "speaks" do try_speaking end it "sleeps" do puts "zzz" end def try_speaking puts "testing 1...2...3..." end end TIP: Consider modeling blocks as classes
# nanospec_test.rb require "./nanospec.rb" describe "bar" do it "speaks" do try_speaking end it "sleeps" do puts "zzz" end def try_speaking puts "testing 1...2...3..." end end Method of the described class TIP: Consider modeling blocks as classes
# nanospec_test.rb require "./nanospec.rb" describe "bar" do it "speaks" do try_speaking end it "sleeps" do puts "zzz" end def try_speaking puts "testing 1...2...3..." end end Method of the described class Use class_eval instead of instance_eval TIP: Consider modeling blocks as classes
TIP: Consider modeling blocks as classes # nanospec_test.rb require "./nanospec.rb" describe "bar" do it "speaks" do try_speaking end it "sleeps" do puts "zzz" end def try_speaking puts "testing 1...2...3..." end end
≈ ≈ # nanospec_test.rb require "./nanospec.rb" describe "foo" do MESSAGE = "Hello world!" it "speaks" do puts MESSAGE end end describe "bar" do MESSAGE = "Hello RubyConf!" it "speaks" do puts MESSAGE end end
≈ # nanospec_test.rb require "./nanospec.rb" describe "foo" do MESSAGE = "Hello world!" it "speaks" do puts MESSAGE end end describe "bar" do MESSAGE = "Hello RubyConf!" it "speaks" do puts MESSAGE end end Constant redefined warning (both constants defined on Object)
TIP: Provide alternatives to constants # rspec_example.rb require "rspec" describe "foo" do let(:message) { "Hello, world!" } it "speaks" do puts message end end describe "bar" do let(:message) { "Hello, RubyConf!" } it "speaks" do puts message end end
Add methods only where needed BasicObject is usually NOT an effective base class for a DSL Design DSLs to honor lexical scoping Consider modeling blocks as classes ❤ DSL