100 Languages Speedrun: Episode 17: Rake

taw

Tomasz Wegrzanowski

Posted on December 7, 2021

100 Languages Speedrun: Episode 17: Rake

Once upon a time, people were coding in C, and building a full program was a convoluted process taking many steps. Make was created to manage dependencies, and to run just the needed commands.

Newer languages generally don't require such convoluted build system, but Make-style dependency managers have been adopted for a lot of different situations.

The original Make is honestly a terrible mess of shell code held together by some flimsy duct tape, and nobody should ever use it, but it led to creation of many much better systems, including Ruby-based Rake this episode is about. Rake is included in Ruby on Rails, but you can and should also use it for any other projects, even projects that aren't Ruby-based.

Hello, World!

Rake is very flexible, but the simplest thing is just creating a single Rakefile file in your directory, and putting all the code there.

Let's give it a go:

task :default => :hello

desc "Say helo"
task :hello do
  puts "Hello, World!"
end
Enter fullscreen mode Exit fullscreen mode

task block defines a task. desc before it provides a simple description. Tasks can have dependencies which you can specify with =>.

Given this Rakefile we can do things like:

$ rake hello
Hello, World!
Enter fullscreen mode Exit fullscreen mode

Not passing any arguments is same as rake default:

$ rake
Hello, World!
Enter fullscreen mode Exit fullscreen mode

rake -T lists all available tasks. Tasks without descriptions are treated as internal and not displayed by default:

$ rake -T
rake hello  # Say helo
Enter fullscreen mode Exit fullscreen mode

If you want to see those internal tasks you can also pass -A:

$ rake -T -A
rake default  #
rake hello    # Say helo
Enter fullscreen mode Exit fullscreen mode

And for sake of debugging, if you pass --trace, Rake will tell you exactly what it's doing:

$ rake --trace
** Invoke default (first_time)
** Invoke hello (first_time)
** Execute hello
Hello, World!
** Execute default
Enter fullscreen mode Exit fullscreen mode

Where "invoke" means task was requested (which then invokes dependencies), and "execute" that it was actually executed (after its dependencies). If there's complicated dependency graph, Rake keeps tracks of which tasks it already did, so it won't do something twice for no reason.

File tasks

One common category of tasks are file tasks. You can define them with file and list their file dependencies. If you run rake hello.html, it will only actually do anything if hello.md changed. It does it by comparing last modified date.

task :default => "hello.html"

file "hello.html" => "hello.md" do
  sh "pandoc hello.md -o hello.html"
end
Enter fullscreen mode Exit fullscreen mode

Rules

There's also a shortcut for defining that every *.foo file can be built from *.bar files.

Tasks are defined as blocks, and their first argument (usually ignored) is the task object itself. t.name is the task or file we're doing, t.source is the source, and so on.

task :default => "hello.html"

rule ".html" => ".md" do |t|
  sh "pandoc", t.source, "-o", t.name
end
Enter fullscreen mode Exit fullscreen mode

As you can see, we can pass array of arguments to sh command. This way Rake has zero issues with escaping file names with special characters. So many Unix shell scripts and Make files just crash if file names contain something as exotic as spaces. It's one of so many advantages of using a real programming language and not shell.

Fibonacci

Rake has a lot more features, but what I covered should be enough to get you started. So let's use it for something it was absolutely not designed to do, starting with the Fibonacci sequence.

require "pathname"

(1..2).each do |i|
  file "#{i}.txt" do |t|
    Pathname(t.name).write "1"
  end
end

(3..100).each do |i|
  file "#{i}.txt" => ["#{i-1}.txt", "#{i-2}.txt"] do |t|
    Pathname(t.name).write t.sources.map{|n| Pathname(n).read.to_i}.sum
  end
end

desc "Cleanup Fibonacci files"
task "clean" do
  sh "trash *.txt"
end
Enter fullscreen mode Exit fullscreen mode

For 1.txt and 2.txt the rule is very simple - we create them by writing 1 to the file.

For every other file like "75.txt", it depends on previous two files "73.txt" and "74.txt". The task reads the source files, adds them, and writes the result to target file.

There's also a cleanup task.

$ rake 100.txt
$ cat 100.txt
354224848179261915075
Enter fullscreen mode Exit fullscreen mode

But let's see exactly what's happening when we ask Rake to create some file:

$ rake --trace 10.txt
** Invoke 10.txt (first_time)
** Invoke 9.txt (first_time)
** Invoke 8.txt (first_time)
** Invoke 7.txt (first_time)
** Invoke 6.txt (first_time)
** Invoke 5.txt (first_time)
** Invoke 4.txt (first_time)
** Invoke 3.txt (first_time)
** Invoke 2.txt (first_time)
** Execute 2.txt
** Invoke 1.txt (first_time)
** Execute 1.txt
** Execute 3.txt
** Invoke 2.txt (not_needed)
** Execute 4.txt
** Invoke 3.txt (not_needed)
** Execute 5.txt
** Invoke 4.txt (not_needed)
** Execute 6.txt
** Invoke 5.txt (not_needed)
** Execute 7.txt
** Invoke 6.txt (not_needed)
** Execute 8.txt
** Invoke 7.txt (not_needed)
** Execute 9.txt
** Invoke 8.txt (not_needed)
** Execute 10.txt
Enter fullscreen mode Exit fullscreen mode

And when we run it the second time, when all the files already exist:

$ rake --trace 10.txt
** Invoke 10.txt (first_time, not_needed)
** Invoke 9.txt (first_time, not_needed)
** Invoke 8.txt (first_time, not_needed)
** Invoke 7.txt (first_time, not_needed)
** Invoke 6.txt (first_time, not_needed)
** Invoke 5.txt (first_time, not_needed)
** Invoke 4.txt (first_time, not_needed)
** Invoke 3.txt (first_time, not_needed)
** Invoke 2.txt (first_time, not_needed)
** Invoke 1.txt (first_time, not_needed)
** Invoke 2.txt (not_needed)
** Invoke 3.txt (not_needed)
** Invoke 4.txt (not_needed)
** Invoke 5.txt (not_needed)
** Invoke 6.txt (not_needed)
** Invoke 7.txt (not_needed)
** Invoke 8.txt (not_needed)
Enter fullscreen mode Exit fullscreen mode

FizzBuzz

It doesn't make much sense to do FizzBuzz with a dependency manager, but let's do it anyway:

desc "FizzBuzz"
task "fizzbuzz", [:n] do |t, args|
  n = args.n.to_i
  puts ["Fizz"[(n % 3)*4..], "Buzz"[(n % 5)*4..], " #{n}"].join.split.first
end
Enter fullscreen mode Exit fullscreen mode

Rake tasks can specify optional arguments. You can call them with:

$ rake 'fizzbuzz[1]'
1
$ rake "fizzbuzz[5]"
Buzz
$ rake "fizzbuzz[9]"
Fizz
$ rake "fizzbuzz[15]"
FizzBuzz
Enter fullscreen mode Exit fullscreen mode

Depending on shell, you might or might not need to quote the square brackets. In zsh you do.

The FizzBuzz line is just mildly obfuscated Ruby, I'll leave it to you as a fun little puzzle.

Should you use Rake?

Absolutely! It's by far the best general purpose dependency manager, and Ruby is a delight to work with. I 100% endorse it.

I only showed some very basic examples, but Rake has convenient methods for all common use cases, and Ruby has far better support for Unix tasks than any other language by a huge margin.

Code

All code examples for the series will be in this repository.

Code for the Rake episode is available here.

💖 💪 🙅 🚩
taw
Tomasz Wegrzanowski

Posted on December 7, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related