SkillAgentSearch skills...

Fast

Find in AST - Search and refactor code directly in Abstract Syntax Tree as you do with grep for strings

Install / Use

/learn @jonatas/Fast
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

Fast

Build Status Maintainability Test Coverage

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 or any? in Ruby
  • [] - looks for all elements to match, like all? in Ruby.
  • $ - will capture the contents of the current expression like a Regex group
  • _ - represents any non-nil value, or something being present
  • nil - 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> with node as param allowing you to build custom rules.
  • .<method-name> - will call <method-name> from the node

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
View on GitHub
GitHub Stars262
CategoryDevelopment
Updated1h ago
Forks10

Languages

Ruby

Security Score

100/100

Audited on Mar 31, 2026

No findings