Dheeraj Kumar

Ruby, Golang, Scala, Javascript

A Quick Dive Into Rake Internals

| Comments

In the latest Ruby Weekly, I saw an article being referenced, titled Disable dangerous rake tasks in production. The author addresses a valid concern for the development community, and I’m sure we’ve all had our fair share of… misadventures in production :D

The best way to implement guards is obviously using Rake’s prerequisites system. It did lead me to wonder how an alternate syntax for custom guards might be implemented. This is what I had in mind:

An alternate, mildly pointless syntax
1
2
3
4
5
6
desc 'Does something dangerous'
guard 'guards:no_production'
guard 'guards:feature_x_enabled'
task :danger do
  # ... something potentially dangerous ...
end

I want to run this task only when feature X is enabled, and make sure it doesn’t run in production. This is how it can be done using prerequisites:

Perfectly working syntax
1
2
3
4
desc 'Does something dangerous'
task :danger => [ 'guards:no_production', 'guards:feature_x_enabled' ] do
  # ... something potentially dangerous ...
end

…but my love for pretty DSLs shook its head firmly against it :P

I also had an ulterior motive – to figure out how the desc method works. Since the definition of a task is a separate method call from the description, how do the descriptions get associated? I’ve always wanted to look into it, but it was pretty far down my list. “Oh well” I thought, “no time like the present!”

Hence I began the dive into Rake’s source code, starting with the desc method:

dsl_definition.rb#L161link
1
2
3
4
5
6
7
8
9
10
11
# Describes the next rake task.  Duplicate descriptions are discarded.
#
# Example:
#   desc "Run the Unit Tests"
#   task test: [:build]
#     # ... run tests
#   end
#
def desc(description) # :doc:
  Rake.application.last_description = description
end

Of course, it had to store the last description. It seems so obvious now!

Here’s how last_description is defined & used:

task_manager.rblink
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
module Rake

  # The TaskManager module is a mixin for managing tasks.
  module TaskManager
    # Track the last comment made in the Rakefile.
    attr_accessor :last_description

    def initialize # :nodoc:
      super
      @last_description = nil
    end

    def define_task(task_class, *args, &block) # :nodoc:
      task = #...
      task.add_description(get_description(task))
      task
    end

    private

    # Return the current description, clearing it in the process.
    def get_description(task)
      desc = @last_description
      @last_description = nil
      desc
    end
  end
end

That looks pretty straightforward:
1. Initialize a last_description variable, reset it for every task.
2. Add the last description to every task.

This module is included within the Application object like this:

application.rblink
1
2
3
4
5
module Rake
  class Application
    include TaskManager
  end
end

Let’s now see how descriptions are defined & used:

task.rblink
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
module Rake
  module Task
    def initialize(task_name, app)
      @comments        = []
    end

    # Clear the existing prerequisites and actions of a rake task.
    def clear
      clear_comments
    end

    # Clear the existing comments on a rake task.
    def clear_comments
      @comments = []
      self
    end

    # Add a description to the task.  The description can consist of an option
    # argument list (enclosed brackets) and an optional comment.
    def add_description(description)
      return unless description
      comment = description.strip
      add_comment(comment) if comment && ! comment.empty?
    end

    def comment=(comment) # :nodoc:
      add_comment(comment)
    end

    def add_comment(comment) # :nodoc:
      return if comment.nil?
      @comments << comment unless @comments.include?(comment)
    end
    private :add_comment

    # Full collection of comments. Multiple comments are separated by
    # newlines.
    def full_comment
      transform_comments("\n")
    end
  end
end

Again, very straightforward:
1. Define an array of comments
2. Add any new comments to the array
3. Collate them for display.

Armed with this knowledge, let’s see how we can achieve the pretty syntax we wanted. We’ll start by patching the DSL module to add a new guard method:

dsl.rb
1
2
3
4
5
6
7
8
9
module Rake
  module DSL
    private

    def guard(args) # :doc:
      Rake.application.last_guards << args
    end
  end
end

Let’s define the last_guards now:

task_guards.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
module Rake
  # The TaskGuards module is a mixin for adding guards for tasks.
  module TaskGuards
    # Track the last set of guards defined in the Rakefile.
    attr_accessor :last_guards

    def initialize # :nodoc:
      super
      @last_guards = []
    end

    private

    # Return the current guards, clearing it in the process.
    def get_guards(task)
      guards = @last_guards
      @last_guards = []
      guards
    end
  end
end

We’re following pretty much the same code as the desc feature’s. Now to write some code to actually add the guards as prerequisites:

task_guards.rb
1
2
3
4
5
6
7
module Rake
  module TaskGuards
    def define_task(task_class, *args, &block) # :nodoc:
      super.enhance get_guards(task)
    end
  end
end

We first start by calling super with no arguments, which calls the ancestor’s define_task method with all arguments preserved. This would return a Rake::Task object, on which we call the #enhance method. This accepts an array of prerequisites, which are provided by our #get_guards method.

Let’s hook these up by including the TaskGuards module:

application.rb
1
2
3
4
5
module Rake
  class Application
    include TaskGuards
  end
end

That should do it! Here’s the entire code, in all its glory:

Il gigante contorta
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
module Rake
  # The TaskGuards module is a mixin for adding guards for tasks.
  module TaskGuards
    # Track the last set of guards defined in the Rakefile.
    attr_accessor :last_guards

    def initialize # :nodoc:
      super
      @last_guards = []
    end

    def define_task(task_class, *args, &block) # :nodoc:
      super.enhance get_guards(task)
    end

    private

    # Return the current guards, clearing it in the process.
    def get_guards(task)
      guards = @last_guards
      @last_guards = []
      guards
    end
  end
end


module Rake
  module DSL
    private

    def guard(args) # :doc:
      Rake.application.last_guards << args
    end
  end
end

module Rake
  class Application
    include TaskGuards
  end
end

I’d stick this into a file, and include that in my Rakefile if I actually wanted to use this feature :D

This is how I’d write the two guards I used as an example. Note how we fail fast via the exit method:

guards.rake
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
namespace :guards do

  task :feature_x_enabled do
    # ...
  end

  task :no_production do
    if is_environment(:production)
      puts 'This task cannot be run in the production environment!'
      exit
    end
  end
end

def is_environment(environment)
  if defined?(Rails)
    Rails.env == environment
  elsif rack_env = ENV['RACK_ENV']
    rack_env == environment
  else
    [ '1', 'TRUE' ].include? ENV[environment.upcase]
  end
end

This was a mildly pointless experiment I tried to get my DSL fix, and also to understand how Rake works under the hood. I discovered how modular the library is, and how readable it makes the code. All in all, a well-spent Sunday evening!

Comments