BLOG / 2008-09-12

Taskable

Update, November 2026: This version never made it into Facets. The instance_exec the TODO is waiting on was in fact already in Ruby 1.8.7. Quarry, used for the demo, later became QED, and its assert lives on in the AE gem.

On more than a few occasions I have taken a stab at writing a general purpose task system, akin to Rake’s, but one that works within the framework of Ruby class inheritance. I recall my first attempt was quite an unwieldy beast, and my subsequent attempts were fairly unwieldy too, but over time they became more concise. Below is the most concise variation yet, and I was wondering what others thought of it – do you see any flaws in the design; does it satisfy all the criteria of such a system; would you find it useful; etc.

One issue to note about the code, is the use of the *_trigger methods. I’m not sure that’s the best approach. My original approach was to provide a #run method (you can see it commented out), but this requires dividing Taskable into two parts, a module for extending and a module for including, and I try to avoid that design when I can.

module Taskable

  def self.append_features(base)
    base.extend(self)
  end

  # Without an argument, returns list of tasks defined for this class.
  #
  # If a task's target name is given, will return the first
  # task matching the name found in the class' inheritance chain.
  # This is important to ensure tasks are inherited in the same manner
  # that methods are.
  def tasks(target=nil)
    if target
      target = target.to_sym
      anc = ancestors.select{|a| a < Taskable} #DSL
      t = nil; anc.find{|a| t = a.tasks[target]}
      return t
    else
      @tasks ||= {}
    end
  end

  # Set a description to be used by the next defined task in this class.
  def desc(description)
    @desc = description
  end

  # Define a task.
  def task(target_and_requisite, &function)
    target, requisite, function = *Task.parse_arguments(target_and_requisite, &function)
    task = tasks[target.to_sym] ||= (
      tdesc = @desc
      @desc = nil
      Task.new(self, target, tdesc)
    )
    task.update(requisite, &function)
    define_method("#{target}_trigger"){ task.run(self) }  # or use #run?
    define_method("#{target}:task", &function) # TODO: in 1.9 use instance_exec instead.
  end

  # Run a task.
  # Hmmm... to add this would require another module (to include).
  # But I'm not sure. Maybe trigger method is the better way?
  #
  #def run(target)
  #  #t = self.class.tasks(target)
  #  #t.run(self)
  #  send("#{target}_trigger")
  #end

  # = Task Class
  #
  class Task
    attr :base
    attr :target
    attr :requisite
    attr :function
    attr :description

    def initialize(base, target, description=nil, requisite=nil, &function)
      @base        = base
      @target      = target.to_sym
      @description = description
      @requisite   = requisite || []
      @function    = function
    end

    #
    def update(requisite, &function)
      @requisite.concat(requisite).uniq!
      @function = function if function
    end

    #
    def prerequisite
      base.ancestors.select{ |a| a.is_a?(Taskable) }.collect{ |a|
        a.tasks[target].requisite
      }.flatten.uniq
    end

    # invoke target
    def run(object)
      rd = rule_dag
      rd.each do |t|
        object.send("#{t}:task")
      end
    end

    #
    #def call(object)
    #  object.instance_eval(&function)
    #end

    # Collect task dependencies for running.
    def rule_dag(cache=[])
      prerequisite.each do |r|
        next if cache.include?(r)
        t = base.tasks[r]
        t.rule_dag(cache)
        #cache << dep
      end
      cache << target.to_s
      cache
    end

    #
    def self.parse_arguments(name_and_reqs, &action)
      if Hash===name_and_reqs
        target = name_and_reqs.keys.first.to_s
        reqs = [name_and_reqs.values.first].flatten
      else
        target = name_and_reqs.to_s
        reqs = []
      end
      return target, reqs, action
    end
  end
end

Here’s a very simple QEDoc demo/spec:

class Example
  include Taskable

  task :task_with_no_requisites do
    cache << "task_with_no_requisites"
  end

  task :task_with_one_requisite => [:task_with_no_requisites] do
    cache << "task_with_one_requisite"
  end

  def cache
    @cache ||= []
  end
end

example = Example.new
#example.run :task_with_no_requisites
example.task_with_no_requisites_trigger
example.cache.assert == ["task_with_no_requisites"]

example = Example.new
#example.run :task_with_one_requisite
example.task_with_one_requisite_trigger
example.cache.assert == ["task_with_no_requisites", "task_with_one_requisite"]

So, what do you think?

Thomas Sawyer · Published
← All posts