• Stars
    star
    255
  • Rank 159,729 (Top 4 %)
  • Language
    Ruby
  • License
    MIT License
  • Created over 7 years ago
  • Updated about 1 year ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

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

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

It's corresponding s-expression would be:

s(:int, 1)

s in Fast and Parser are a shorthand for creating an Parser::AST::Node. Each of these nodes has a #type and #children contained in it:

def s(type, *children)
  Parser::AST::Node.new(type, 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

It's 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 # => #<Parser::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 # => #<Parser::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 # => #<Parser::Source::Range (string) 4...8>
ast.location.name.source # => "name"

In the context of the rewriter, the objective is removing the method and inserting the new delegate content. Then, the scope is node.location.expression:

Fast.replace '(def $_ ... (send (send nil $_) \1))', ast do |node, captures|
  attribute, object = captures

  replace(
    node.location.expression,
    "delegate :#{attribute}, to: :#{object}"
  )
end

Replacing file

Now let's imagine we have a file like sample.rb with the following code:

def good_bye
  message = ["good", "bye"]
  puts message.join(' ')
end

and we decide to inline the contents of the message variable right after

def good_bye
  puts ["good", "bye"].join(' ')
end

To refactor and reach the proposed example, follow a few steps:

  1. Remove the local variable assignment
  2. Store the now-removed variable's value
  3. Substitute the value where the variable was used before

Entire example

assignment = nil
Fast.replace_file '({ lvasgn lvar } message )', 'sample.rb' do |node, _|
  # Find a variable assignment
  if node.type == :lvasgn
    assignment = node.children.last
    # Remove the node responsible for the assignment
    remove(node.location.expression)
  # Look for the variable being used
  elsif node.type == :lvar
    # Replace the variable with the contents of the variable
    replace(
      node.location.expression,
      assignment.location.expression.source
    )
  end
end 

Keep in mind the current example returns a content output but do not rewrite the file.

Other utility functions

To manipulate ruby files, sometimes you'll need some extra tasks.

Fast.ast_from_file(file)

This method parses code from a file and loads it into an AST representation.

Fast.ast_from_file('sample.rb')

Fast.search_file

You can use search_file to for search for expressions inside files.

Fast.search_file(expression, 'file.rb')

It's a combination of Fast.ast_from_file with Fast.search.

Fast.capture_file

You can use Fast.capture_file to only return captures:

Fast.capture_file('(class (const nil $_))', 'lib/fast.rb')
# => [:Rewriter, :ExpressionParser, :Find, :FindString, ...]

Fast.ruby_files_from(arguments)

The Fast.ruby_files_from(arguments) can get all ruby files from file list or folders:

Fast.ruby_files_from('lib') 
# => ["lib/fast/experiment.rb", "lib/fast/cli.rb", "lib/fast/version.rb", "lib/fast.rb"]

Note: it doesn't support glob special selectors like *.rb or **/* as it recursively looks for ruby files in the givem params.

fast in the command line

Fast also comes with a command line utility called fast. You can use it to search and find code much like the library version:

fast '(def match?)' lib/fast.rb

The CLI tool takes the following flags

  • Use -d or --debug for enable debug mode.
  • Use --ast to output the AST instead of the original code
  • Use --pry to jump debugging the first result with pry
  • Use -c to search from code example
  • Use -s to search similar code
  • Use -p or --parallel to parallelize the search

Define your Fastfile

Fastfile is loaded when you start a pattern with a ..

You can also define extra Fastfile in your home dir or setting a directory with the FAST_FILE_DIR.

You can define a Fastfile in any project with your custom shortcuts.

Fast.shortcut(:version, '(casgn nil VERSION (str _))', 'lib/fast/version.rb')

Let's say you'd like to show the version of your library. Your normal command line will look like:

$ fast '(casgn nil VERSION)' lib/*/version.rb

Or generalizing to search all constants in the version files:

$ fast casgn lib/*/version.rb

It will output but the command is not very handy. In order to just say fast .version you can use the previous snipped in your Fastfile.

And it will output something like this:

# lib/fast/version.rb:4
VERSION = '0.1.2'

Create shortcuts with blocks that are able to introduce custom coding in the scope of the Fast module

To bump a new version of your library for example you can type fast .bump_version and add the snippet to your library fixing the filename.

Fast.shortcut :bump_version do
  rewrite_file('(casgn nil VERSION (str _)', 'lib/fast/version.rb') do |node|
    target = node.children.last.loc.expression
    pieces = target.source.split(".").map(&:to_i)
    pieces.reverse.each_with_index do |fragment,i|
      if fragment < 9
        pieces[-(i+1)] = fragment +1
        break
      else
        pieces[-(i+1)] = 0
      end
    end
    replace(target, "'#{pieces.join(".")}'")
  end
end

You can find more examples in the Fastfile.

Fast with Pry

You can use --pry to stop on a particular source node, and run Pry at that location:

fast '(block (send nil it))' spec --pry

Inside the pry session you can access result for the first result that was located, or results to get all of the occurrences found.

Let's take a look at results:

results.map { |e| e.children[0].children[2] }
# => [s(:str, "parses ... as Find"),
# s(:str, "parses $ as Capture"),
# s(:str, "parses quoted values as strings"),
# s(:str, "parses {} as Any"),
# s(:str, "parses [] as All"), ...]

Fast with RSpec

Let's say we wanted to get all the it blocks in our RSpec code that currently do not have descriptions:

fast '(block (send nil it (nil)) (args) (!str)) ) )' spec

This will return the following:

# spec/fast_spec.rb:166
it { expect(described_class).to be_match(s(:int, 1), '(...)') }
# spec/fast_spec.rb:167
it { expect(described_class).to be_match(s(:int, 1), '(_ _)') }
# spec/fast_spec.rb:168
it { expect(described_class).to be_match(code['"string"'], '(str "string")') }

Experiments

Experiments can be used to run experiments against your code in an automated fashion. These experiments can be used to test the effectiveness of things like performance enhancements, or if a replacement piece of code actually works or not.

Let's create an experiment to try and remove all before and after blocks from our specs.

If the spec still pass we can confidently say that the hook is useless.

Fast.experiment("RSpec/RemoveUselessBeforeAfterHook") do
  # Lookup our spec files
  lookup 'spec'

  # Look for every block starting with before or after
  search "(block (send nil {before after}))"

  # Remove those blocks
  edit { |node| remove(node.loc.expression) }

  # Create a new file, and run RSpec against that new file
  policy { |new_file| system("bin/spring rspec --fail-fast #{new_file}") }
end
  • lookup can be used to pass in files or folders.
  • search contains the expression you want to match
  • edit is used to apply code change
  • policy is what we execute to verify the current change still passes

Each removal of a before and after block will occur in isolation to verify each one of them independently of the others. Each successful removal will be kept in a secondary change until we run out of blocks to remove.

You can see more examples in the experiments folder.

Running Multiple Experiments

To run multiple experiments, use fast-experiment runner:

fast-experiment <experiment-names> <files-or-folders>

You can limit the scope of experiments:

fast-experiment RSpec/RemoveUselessBeforeAfterHook spec/models/**/*_spec.rb

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

On the console we have a few functions like s and code to make it easy ;)

bin/console
code("a = 1") # => s(:lvasgn, s(:int, 1))

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/jonatas/fast. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.

License

The gem is available as open source under the terms of the MIT License.

See more on the official documentation.

More Repositories

1

CameraOverlay

Take pictures in the same place adding an extra transparent overlay with another picture. Android app, free on google play
Java
14
star
2

rubocopular

Inspect RuboCop node pattern matching
Ruby
11
star
3

blog-sniffer

Sniff all engineering blogs to your timescale database
Ruby
6
star
4

export

Setup and transform your data before export
Ruby
6
star
5

hp12c

HP12C parser in Ruby - for fun
Ruby
5
star
6

sql-data-science-training

Resources for my Postgresql and Data Science training.
Shell
5
star
7

gridlock

Gridlock / Cilada game
Ruby
5
star
8

fast-circle

Run circle ci more fast with less builds. It cancel builds and keep only the latest build on each branch.
Ruby
5
star
9

troper

Gerador de relatórios web - Projeto de estágio
Ruby
4
star
10

motolapse

Rotative timelapse experiments using raspberry pi, the official pi cam, a ULN2003 and a 28BYJ-48 motor
Python
4
star
11

Islaide

Sinatra app para criar "Islaides"
JavaScript
3
star
12

funcoes_sac

Funções matemáticas para trabalhar com sistemas de amortização constante
Ruby
3
star
13

vim-mql

Vim files to edit MQL Files
Vim Script
3
star
14

mktc-platform

Java
3
star
15

psql-talk

PSQL presentation resources
Ruby
3
star
16

sql-snippets

Postgresql snippets: Several POCs related to my work at Timescale and using the timescaledb extension
PLpgSQL
3
star
17

trybliss

learn blisssymbols in a cognitive learning game
JavaScript
3
star
18

appointme

Appointme is a simple schedule in natural language
JavaScript
2
star
19

pgconfbr2022-talk

Ruby
2
star
20

logo_pato_livre

Python
2
star
21

distribuicao_de_frequencia

distribuição de frequências estatísticas - aula de matemática
Ruby
2
star
22

mandala

Mandalas panel
JavaScript
2
star
23

parser_playground

I'm learning about parsers and having fun with Ruby
Ruby
2
star
24

bbhmm

Split the dev house expenses
Ruby
2
star
25

ideia.me

http://ideia.me
JavaScript
2
star
26

sorteio

Sistema de sorteio usado no minicurso da http://www.tecsul.org.br/
Ruby
1
star
27

mundo_pequeno_puzzle

Puzzle mundo pequeno
Ruby
1
star
28

tia_neta

Sistema para controlar os pedidos da tia neta
Ruby
1
star
29

dojo-meteor

Exemplo de app com meteor
CSS
1
star
30

sonification-workshop

Sonification workshop - Creating noise from your time series data with Timescaledb and Sonic PI.
1
star
31

jni_exemplo

Exemplo JNI com C
C
1
star
32

pgscraper

Postgresql extension for scraping data from web
Rust
1
star
33

artigo_elep

Artigo sobre expressividade da linguagem no ato de programar
Ruby
1
star
34

git-sandbox

1
star
35

helplate

command line tool to help you to build values in a liquid template
1
star
36

jogo_da_velha

Jogo da velha em Ruby (Exemplo Cucumber)
Ruby
1
star
37

marmelades

Learning elixir language and phoenix framework
Elixir
1
star
38

artigo_estatistica

A estatística aplicada à linguagem de programação Ruby
Ruby
1
star
39

axt

AST eXtension for the RubyVM::AST (ruby2.6 required)
Ruby
1
star
40

lorenzo-games

Games I'm building for my 5 year old son
JavaScript
1
star
41

mip

Matriz de Impacto & Probabilidade
Ruby
1
star
42

sql-pipelines

Talk about functional pipelines with Postgresql and Timescaledb Toolkit extension
1
star
43

habanero

Clube da pimenta
Ruby
1
star
44

fast-inline

Make some ruby code inline for researching purposes (WIP)
Ruby
1
star
45

trigger-task

Personal tool to set a few recurrent reminders
Ruby
1
star
46

churumelas

Small ruby challenges! Try it!
Ruby
1
star
47

rubocop-game

Learn about RuboCop checkers thinking about: is this code good or bad?
JavaScript
1
star
48

chave-dicotomica-android

Chave Dicotômica - Flora Arbórea e Arborescente do Rio Grande do Sul, Brasil
Ruby
1
star
49

chewy-diff

Analyze diff changes through the AST representation
Ruby
1
star
50

SL-FOR-GP

Sopa de Letrinhas Para Gerência De Projetos
Ruby
1
star
51

ohlc-processor

Elixir Open, High, Low, Close, Volume candlestick processor
Elixir
1
star