Building DSLs

25.1 Example: Configuration DSL

class ServerConfig
  attr_reader :settings

  def self.configure(&block)
    config = new
    config.instance_eval(&block)
    config
  end

  def initialize = @settings = { routes: [] }

  def host(value) = @settings[:host] = value
  def port(value) = @settings[:port] = value
  def route(path, to:) = @settings[:routes] << { path:, to: }

  def ssl(&block)
    ssl_config = SslConfig.new
    ssl_config.instance_eval(&block)
    @settings[:ssl] = ssl_config.to_h
  end
end

class SslConfig
  def initialize = @opts = {}
  def cert(path) = @opts[:cert] = path
  def key(path) = @opts[:key] = path
  def to_h = @opts
end

config = ServerConfig.configure do
  host "0.0.0.0"
  port 8443
  route "/users", to: "users#index"
  route "/health", to: "health#show"
  ssl do
    cert "/etc/ssl/cert.pem"
    key "/etc/ssl/key.pem"
  end
end
pp config.settings

25.2 Example: HTML Builder with method_missing

class HTML
  def initialize = @out = +""
  def self.render(&) = new.tap { it.instance_eval(&) }.to_s

  def method_missing(tag, content = nil, **attrs, &block)
    attr_str = attrs.map { |k, v| %( #{k}="#{v}") }.join
    @out << "<#{tag}#{attr_str}>"
    block ? instance_eval(&block) : @out << content.to_s
    @out << "</#{tag}>"
  end

  def respond_to_missing?(*) = true
  def to_s = @out
end

puts HTML.render {
  div(class: "card") do
    h1 "Title"
    p_ "Paragraph"          # p is a Kernel method, so use p_ or define it explicitly
  end
}

25.3 DSL Techniques

instance_eval (clean syntax, but the block loses access to the outer self) vs yielding a builder object (config { |c| c.host "x" }, which keeps the outer context: used by Bundler and Rails config).

Class macros (has_many, validates, attribute) via class methods plus define_method.

Optional parentheses, keyword arguments, blocks, and method_missing make Ruby a natural DSL host: RSpec, Rake, Rails routes, Sinatra, Gemfile, Capybara, Chef/Puppet recipes, Vagrantfile, Fastlane.