Implementing fastlane in 50 lines of code
Fastlane is a tool used by almost every mobile developer. The main advantages are its simplicity, the time it saves us and the fact that it’s built on top of Ruby. But we tend to take this tool for granted. Today I want to present how fastlane works, internally, and how it takes advantage of Ruby.
Let’s take a clean sheet of paper, and rebuild its core component from scratch.
Fastlane DSL
Fastlane uses a Domain Specific Language to define its lanes.
For instance let’s look at a lane definition:
lane :first_lane do
puts "first_lane"
end
It looks like a Ruby method definition (there is a name and a body) but it is not exactly the same. Let’s first understand how such a definition is possible.
A minimal Ruby method is defined like this:
def foo
end
foo # returns nothing right now
To pass parameters to a method, we can do it like so:
def foo(name)
puts name
end
foo("bar") # prints bar
Ruby also accepts blocks of code as parameters. These blocks (or closures in other languages) can be stored and executed with the call
method.
def foo(name, &block)
block.call
end
# the two following forms are correct to define blocks
foo("bar") { puts "bar" } # prints bar
foo("bar") do
puts "bar"
end # prints bar
In Ruby, we do not need parentheses to pass arguments to a method, so foo
becomes:
foo "bar" do
puts "bar"
end # prints bar
Finally, we can use a symbol instead of a string for the name
argument.
foo :bar do
puts "bar"
end
Attentive readers will notice that this call to foo
is really similar to how we define lanes in a Fastfile. Renaming foo
to lane
we end up with:
def lane(name, &block)
block.call
end
lane :first_lane do
puts "first_lane"
end
We have just demonstrated that a lane definition is plain old Ruby behind the scene. There is no magic nor complex parsing, it’s a simple method call. In reality, when you write your Fastfile, the methods lane
or private_lane
are hidden from you but there anyway.
Storing lanes
For now our lane first_lane
is executed immediately. We want a way to store a lane and to call its block later when we need it.
First, let’s declare a Lane
class that will store both the lane name
and block
.
class Lane
# defines a getter method for the instance variable name
attr_accessor :name
def initialize(name, block)
@name = name
@block = block
end
def call
@block.call
end
end
Now in our lane
method, we can create a Lane
instance and return it.
def lane(name, &block)
Lane.new(name, block)
end
first = lane :first_lane do
puts "first_lane"
end
# nothing is executed for now, until...
first.call # prints "first_lane"
To define multiple lanes, it would be nicer to create some object to store them all. Let’s create a Runner
class that will store all the lanes defined, and expose a method execute
to call a specific lane by its name.
class Runner
def initialize
@lanes = []
end
def add(lane)
@lanes << lane
end
# Find the lane that matches lane_name and call its block
def execute(lane_name)
lane = @lanes.find { |l| l.name == lane_name }
lane.call
end
end
Now that we have this runner at our disposal, let’s use it to store and call our custom lanes:
RUNNER = Runner.new
def lane(name, &block)
RUNNER.add(Lane.new(name, block))
end
lane :first_lane do
puts "first_lane"
end
lane :second_lane do
puts "second_lane"
end
RUNNER.execute(:first_lane) # prints "first_lane"
RUNNER.execute(:second_lane) # prints "second_lane"
Calling lanes from another lane
Fow now, our implementation is rather trivial and does not allow us to call second_lane
from first_lane
.
lane :first_lane do
puts "first_lane"
second_lane
end
If we try so, we get an error: undefined local variable or method `second_lane`
.
When we think about it, it makes complete sense. We never defined a method called second_lane
. We just defined a lane named second_lane
but we can’t call it like this. We need our runner to execute it. But how can we invoke our runner in such a case?
To fix this issue, let’s look at a helpful feature Ruby provides. If we take a class Foo
with no methods at all and call the method bar
on an instance of Foo
, we will get the same error as before, stating the bar
method is undefined.
class Foo
end
foo = Foo.new
foo.bar # undefined method `bar`
Ruby gives us a way to catch this undefined method error. We can implement the method_missing
method in the Foo
class, and that gives us an opportunity to execute some code in the case of an undefined method. For instance:
class Foo
def method_missing(method_sym)
puts "method_missing: #{method_sym}"
end
end
foo = Foo.new
foo.bar # prints "method_missing: bar"
The undefined method
error is gone. We can call anything on foo
, we will pass in the method_missing
.
With this trick up our sleeve, back to our lanes. We saw how method_missing
can be helpful, but we need to implement this method in a class. So let’s create a FastFile
class that will wrap our runner and our lanes definitions.
class FastFile
def initialize
@runner = Runner.new
end
def lane(name, &block)
@runner.add(Lane.new(name, block))
end
def ___
lane :first_lane do
puts "first_lane"
end
lane :second_lane do
puts "second_lane"
end
end
end
Note that we created a method ___
. That’s temporary, just to take a moment to think about what it should do.
First this method will list the lanes we defined (the equivalent of a Fastfile). And second it will execute a lane as an entry point. Indeed, when we run fastlane scan
in our terminal, scan
is the entry point. That’s the single lane fastlane will execute, all the other lanes called during the execution of scan
are internal.
So this method ___
could be renamed run(lane_name)
, and take as parameter the lane name to execute as the entry point.
class FastFile
# ...
def run(lane_name)
lane :first_lane do
puts "first_lane"
end
lane :second_lane do
puts "second_lane"
end
@runner.execute(lane_name)
end
end
fastfile = Fastfile.new
fastfile.run(:first_lane)
Now that we have a nice class around our lane definitions, let’s try again to call second_lane
from first_lane
.
class FastFile
# ...
def run(lane_name)
lane :first_lane do
puts "first_lane"
second_lane
end
...
end
end
We get the same error as before undefined local variable or method `second_lane`
, but this time, we are in a class, so we can implement method_missing
. The idea here is to catch the undefined method error with method_missing
where method_sym
equals to second_lane
. And then we can use our runner to execute the lane second_lane
from its name.
class FastFile
# ...
def run(lane_name)
lane :first_lane do
puts "first_lane"
second_lane
end
lane :second_lane do
puts "second_lane"
end
@runner.execute(lane_name)
end
def method_missing(method_sym)
@runner.execute(method_sym)
end
end
fastfile = Fastfile.new
fastfile.run(:first_lane) # prints "first_lane\nsecond_lane"
And that does the trick, the error is gone and second_lane
has been called from first_lane
.
Creating a Fastfile
All works fine but we can improve things a little bit. For now the lanes are defined in the run
method, but that’s not elegant. We want to define all those lanes in a separate file called a Fastfile
. This way the DSL in the Fastfile is separated from the fastlane gem (in our case, our single ruby file).
So let’s create a Fastfile with our custom lanes (the name of file does not matter but we follow fastlane convention).
lane :first_lane do
puts "first_lane"
second_lane
end
lane :second_lane do
puts "second_lane"
end
Now in our run
method we need to execute the content of this Fastfile, because the lanes are not longer defined.
We will first read the content of the Fastfile with File.read
then evaluate its content. Ruby provides a method eval
that evaluates some Ruby string at runtime. This is perfect for what we want, instead of defining our lanes in the run
method, we will write them in another file and evaluate the content of this file dynamically.
The method run
becomes:
class FastFile
# ...
def run(lane_name)
content = File.read("Fastfile")
eval(content)
@runner.execute(lane_name)
end
# ...
end
Note: keep in mind that the eval
method evaluates the Ruby content within the context of the FastFile
class. This is important to remember as it can lead to weird behaviors. For instance, you can’t define a String extension in your Fastfile the same way you would normally do in a Ruby file.
Conclusion
We made it! We recreated the behavior of fastlane. On one side a Fastfile where we define our lanes for our project. On the other side, our Ruby script that executes our lanes. Of course the real fastlane gem is a far more advanced (with error handling, hooks, actions, etc), but the core idea is here.
The few things to remembers are:
- fastlane uses Ruby method calls to create its DSL
- it uses the helpful
method_missing
Ruby method for lanes to call one another - the content of the Fastfile is evaluated at runtime within a specific context
With this new understanding, you can extend the fastlane DSL the way you want. For instance, it’s really simple to implement a replacement of lane
called secure_lane
that will ensure all parameters passed to a lane are non optional.