Fast
Find in AST - Search and refactor code directly in Abstract Syntax Tree as you do with grep for strings
Install / Use
/learn @jonatas/FastREADME
Fast
Fast, short for "Find AST", is a tool to search, prune, and edit Ruby ASTs.
Ruby is a flexible language that allows us to write code in multiple different ways to achieve the same end result, and because of this it's hard to verify how the code was written without an AST.
Check out the official documentation: https://jonatas.github.io/fast.
Token Syntax for find in AST
The current version of Fast covers the following token elements:
()- represents a node search{}- looks for any element to match, like a Set inclusion orany?in Ruby[]- looks for all elements to match, likeall?in Ruby.$- will capture the contents of the current expression like aRegexgroup_- represents any non-nil value, or something being presentnil- matches exactly nil...- matches a node with children^- references the parent node of an expression?- represents an element which maybe present\1- represents a substitution for any of the previously captured elements%1- to bind the first extra argument in an expression""- will match a literal string with double quotes#<method-name>- will call<method-name>withnodeas param allowing you to build custom rules..<method-name>- will call<method-name>from thenode
The syntax is inspired by the RuboCop Node Pattern.
Installation
$ gem install ffast
How it works
S-Expressions
Fast works by searching the abstract syntax tree using a series of expressions
to represent code called s-expressions.
s-expressions, or symbolic expressions, are a way to represent nested data. They originate from the LISP programming language, and are frequetly used in other languages to represent ASTs.
Integer Literals
For example, let's take an Integer in Ruby:
1
Its corresponding s-expression would be:
s(:int, 1)
s in Fast is a shorthand for creating a Fast::Node.
Each of these nodes has a #type and #children contained in it:
def s(type, *children)
Fast::Node.new(type, children: children)
end
Variable Assignments
Now let's take a look at a local variable assignment:
value = 42
It's corresponding s-expression would be:
ast = s(:lvasgn, :value, s(:int, 42))
If we wanted to find this particular assignment somewhere in our AST, we can use
Fast to look for a local variable named value with a value 42:
Fast.match?('(lvasgn value (int 42))', ast) # => true
Wildcard Token
If we wanted to find a variable named value that was assigned any integer value
we could replace 42 in our query with an underscore ( _ ) as a shortcut:
Fast.match?('(lvasgn value (int _))', ast) # => true
Set Inclusion Token
If we weren't sure the type of the value we're assigning, we can use our set
inclusion token ({}) from earlier to tell Fast that we expect either a Float
or an Integer:
Fast.match?('(lvasgn value ({float int} _))', ast) # => true
All Matching Token
Say we wanted to say what we expect the value's type to not be, we can use the
all matching token ([]) to express multiple conditions that need to be true.
In this case we don't want the value to be a String, Hash, or an Array by
prefixing all of the types with !:
Fast.match?('(lvasgn value ([!str !hash !array] _))', ast) # => true
Node Child Token
We can match any node with children by using the child token ( ... ):
Fast.match?('(lvasgn value ...)', ast) # => true
We could even match any local variable assignment combining both _ and ...:
Fast.match?('(lvasgn _ ...)', ast) # => true
Capturing the Value of an Expression
You can use $ to capture the contents of an expression for later use:
Fast.match?('(lvasgn value $...)', ast) # => [s(:int, 42)]
Captures can be used in any position as many times as you want to capture whatever information you might need:
Fast.match?('(lvasgn $_ $...)', ast) # => [:value, s(:int, 42)]
Keep in mind that
_means something not nil and...means a node with children.
Calling Custom Methods
You can also define custom methods to set more complicated rules. Let's say we're looking for duplicated methods in the same class. We need to collect method names and guarantee they are unique.
def duplicated(method_name)
@methods ||= []
already_exists = @methods.include?(method_name)
@methods << method_name
already_exists
end
puts Fast.search_file('(def #duplicated)', 'example.rb')
The same principle can be used in the node level or for debugging purposes.
require 'pry'
def debug(node)
binding.pry
end
puts Fast.search_file('#debug', 'example.rb')
If you want to get only def nodes you can also intersect expressions with []:
puts Fast.search_file('[ def #debug ]', 'example.rb')
Methods
Let's take a look at a method declaration:
def my_method
call_other_method
end
Its corresponding s-expression would be:
ast =
s(:def, :my_method,
s(:args),
s(:send, nil, :call_other_method))
Note the node (args). We can't use ... to match it, as it
has no children (or arguments in this case), but we can match it with a wildcard
_ as it's not nil.
Call Chains
Let's take a look at a few other examples. Sometimes you have a chain of calls on
a single Object, like a.b.c.d. Its corresponding s-expression would be:
ast =
s(:send,
s(:send,
s(:send,
s(:send, nil, :a),
:b),
:c),
:d)
Alternate Syntax
You can also search using nested arrays with pure values, or shortcuts or procs:
Fast.match? [:send, [:send, '...'], :d], ast # => true
Fast.match? [:send, [:send, '...'], :c], ast # => false
Shortcut tokens like child nodes ... and wildcards _ are just placeholders
for procs. If you want, you can even use procs directly like so:
Fast.match?([
:send, [
-> (node) { node.type == :send },
[:send, '...'],
:c
],
:d
], ast) # => true
This also works with expressions:
Fast.match?('(send (send (send (send nil $_) $_) $_) $_)', ast) # => [:a, :b, :c, :d]
Debugging
If you find that a particular expression isn't working, you can use debug to
take a look at what Fast is doing:
Fast.debug { Fast.match?([:int, 1], s(:int, 1)) }
Each comparison made while searching will be logged to your console (STDOUT) as Fast goes through the AST:
int == (int 1) # => true
1 == 1 # => true
Bind arguments to expressions
We can also dynamically interpolate arguments into our queries using the
interpolation token %. This works much like sprintf using indexes starting
from 1:
Fast.match? '(lvasgn %1 (int _))', ('a = 1'), :a # => true
Using previous captures in search
Imagine you're looking for a method that is just delegating something to
another method, like this name method:
def name
person.name
end
This can be represented as the following AST:
(def :name
(args)
(send
(send nil :person) :name))
We can create a query that searches for such a method:
Fast.match?('(def $_ ... (send (send nil _) \1))', ast) # => [:name]
Fast.search
Search allows you to go search the entire AST, collecting nodes that matches given expression. Any matching node is then returned:
Fast.search('(int _)', Fast.ast('a = 1')) # => s(:int, 1)
If you use captures along with a search, both the matching nodes and the captures will be returned:
Fast.search('(int $_)', Fast.ast('a = 1')) # => [s(:int, 1), 1]
You can also bind external parameters from the search:
Fast.search('(int %1)', Fast.ast('a = 1'), 1) # => [s(:int, 1)]
Fast.capture
To only pick captures and ignore the nodes, use Fast.capture:
Fast.capture('(int $_)', Fast.ast('a = 1')) # => 1
Fast.replace
Let's consider the following example:
def name
person.name
end
And, we want to replace code to use delegate in the expression:
delegate :name, to: :person
We already target this example using \1 on
Search and refer to previous capture and
now it's time to know about how to rewrite content.
The Fast.replace yields a #{Fast::Rewriter} context.
The internal replace method accepts a range and every node have
a location with metadata about ranges of the node expression.
ast = Fast.ast("def name; person.name end")
# => s(:def, :name, s(:args), s(:send, s(:send, nil, :person), :name))
Generally, we use the location.expression:
ast.location.expression # => #<Fast::Source::Range (string) 0...25>
But location also brings some metadata about specific fragments:
ast.location.instance_variables # => [:@keyword, :@operator, :@name, :@end, :@expression, :@node]
Range for the keyword that identifies the method definition:
ast.location.keyword # => #<Fast::Source::Range (string) 0...3>
You can always pick the source of a source range:
ast.location.keyword.source # => "def"
Or only the method name:
ast.location.name # => #<Fast::So
