ffast 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +37 -0
- data/.travis.yml +9 -0
- data/Gemfile +2 -0
- data/Guardfile +4 -2
- data/README.md +301 -28
- data/Rakefile +5 -3
- data/TODO.md +0 -3
- data/bin/console +8 -4
- data/bin/fast +18 -32
- data/bin/fast-experiment +34 -0
- data/examples/experimental_replacement.rb +46 -0
- data/examples/find_usage.rb +26 -0
- data/examples/method_complexity.rb +37 -0
- data/experiments/let_it_be_experiment.rb +9 -0
- data/experiments/remove_useless_hook.rb +9 -0
- data/fast.gemspec +20 -18
- data/lib/fast.rb +514 -114
- metadata +51 -14
data/Rakefile
CHANGED
data/TODO.md
CHANGED
@@ -1,6 +1,3 @@
|
|
1
|
-
- [ ] Add a friendly debug mode. Allow debug :expression or :match? independently
|
2
1
|
- [ ] Add matcher diagnostics. Allow check details of each matcher in the three
|
3
2
|
- [ ] Split stuff into files and add tests for each class
|
4
|
-
- [ ] Add negation !{int float}
|
5
|
-
- [ ] Extract matchers `s()`, `f()`, `c()`, `union()`
|
6
3
|
- [ ] Validate expressions and raise errors for invalid expressions
|
data/bin/console
CHANGED
@@ -1,16 +1,20 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
2
3
|
|
3
|
-
require
|
4
|
-
require
|
4
|
+
require 'bundler/setup'
|
5
|
+
require 'fast'
|
5
6
|
|
6
7
|
def s(type, *children)
|
7
8
|
Parser::AST::Node.new(type, children)
|
8
9
|
end
|
9
10
|
|
11
|
+
def code(string)
|
12
|
+
Fast.ast(string)
|
13
|
+
end
|
14
|
+
|
10
15
|
def reload!
|
11
16
|
load 'lib/fast.rb'
|
12
17
|
end
|
13
18
|
|
14
|
-
require
|
19
|
+
require 'pry'
|
15
20
|
Pry.start
|
16
|
-
|
data/bin/fast
CHANGED
@@ -1,39 +1,21 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
|
-
#
|
3
|
-
$LOAD_PATH.unshift(File.expand_path('../../lib', __FILE__))
|
2
|
+
# frozen_string_literal: true
|
4
3
|
|
5
|
-
|
6
|
-
|
4
|
+
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
5
|
+
|
6
|
+
require 'fast'
|
7
|
+
require 'coderay'
|
7
8
|
|
8
9
|
arguments = ARGV
|
9
10
|
pattern = arguments.shift
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
if directories.any?
|
15
|
-
files = directories.flat_map{|dir|Dir["#{dir}/*.rb"]}
|
16
|
-
end
|
17
|
-
|
18
|
-
|
19
|
-
def line_for(result)
|
20
|
-
if result.is_a?(Parser::AST::Node)
|
21
|
-
result.loc.expression.line
|
22
|
-
end
|
23
|
-
end
|
24
|
-
|
25
|
-
def code(result, show_ast=false)
|
26
|
-
if result.is_a?(Parser::AST::Node) && !show_ast
|
27
|
-
result = result.loc.expression.source
|
28
|
-
end
|
29
|
-
CodeRay.scan(result, :ruby).term
|
30
|
-
end
|
11
|
+
show_sexp = arguments.delete('--ast')
|
12
|
+
pry = arguments.delete('--pry')
|
13
|
+
debug = arguments.delete('--debug') || arguments.delete('-d')
|
14
|
+
files = Fast.ruby_files_from(*arguments || '.')
|
31
15
|
|
32
16
|
pattern = Fast.expression(pattern)
|
33
17
|
|
34
|
-
if debug
|
35
|
-
puts "Expression: #{pattern.map(&:to_s)}"
|
36
|
-
end
|
18
|
+
puts "Expression: #{pattern.map(&:to_s)}" if debug
|
37
19
|
|
38
20
|
files.each do |file|
|
39
21
|
results =
|
@@ -43,15 +25,19 @@ files.each do |file|
|
|
43
25
|
begin
|
44
26
|
Fast.search_file(pattern, file)
|
45
27
|
rescue Parser::SyntaxError
|
46
|
-
puts "Ops! An error occurred trying to search in #{pattern.inspect} in #{file}",
|
28
|
+
puts "Ops! An error occurred trying to search in #{pattern.inspect} in #{file}", $ERROR_INFO, $ERROR_POSITION
|
47
29
|
end
|
48
30
|
end
|
49
31
|
|
50
32
|
next unless results
|
51
33
|
|
52
|
-
results.each do |
|
34
|
+
results.each do |node|
|
53
35
|
next if result.nil? || result == []
|
54
|
-
|
55
|
-
|
36
|
+
if pry
|
37
|
+
require 'pry'
|
38
|
+
binding.pry # rubocop:disable Lint/Debugger
|
39
|
+
else
|
40
|
+
Fast.report(node, file: file, show_sexp: show_sexp)
|
41
|
+
end
|
56
42
|
end
|
57
43
|
end
|
data/bin/fast-experiment
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
5
|
+
|
6
|
+
require 'fast'
|
7
|
+
require 'coderay'
|
8
|
+
|
9
|
+
arguments = ARGV
|
10
|
+
|
11
|
+
experiment_files = Fast.ruby_files_from(File.expand_path('../experiments', __dir__))
|
12
|
+
experiment_files.each(&method(:require))
|
13
|
+
experiments = []
|
14
|
+
|
15
|
+
if arguments.any?
|
16
|
+
while arguments.any? && !(File.exist?(arguments.first) || Dir.exist?(arguments.first))
|
17
|
+
experiments << arguments.shift
|
18
|
+
end
|
19
|
+
experiments.map! { |name| Fast.experiments[name] }
|
20
|
+
else
|
21
|
+
experiments = Fast.experiments.values
|
22
|
+
end
|
23
|
+
|
24
|
+
if arguments.any?
|
25
|
+
ruby_files = arguments.all? { |e| File.exist?(e) && e.end_with?('.rb') }
|
26
|
+
experiments.each do |experiment|
|
27
|
+
if ruby_files
|
28
|
+
experiment.files = arguments
|
29
|
+
else
|
30
|
+
experiment.lookup arguments
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
experiments.each(&:run)
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
$LOAD_PATH << File.expand_path('../lib', __dir__)
|
4
|
+
require 'fast'
|
5
|
+
|
6
|
+
# It's a simple script that you can try to replace
|
7
|
+
# `create` by `build_stubbed` and it moves the file if
|
8
|
+
# successfully passed the specs
|
9
|
+
#
|
10
|
+
# $ ruby experimental_replacement.rb spec/*/*_spec.rb
|
11
|
+
def experimental_spec(file, name)
|
12
|
+
parts = file.split('/')
|
13
|
+
dir = parts[0..-2]
|
14
|
+
filename = "experiment_#{name}_#{parts[-1]}"
|
15
|
+
File.join(*dir, filename)
|
16
|
+
end
|
17
|
+
|
18
|
+
def experiment(file, name, search, replacement)
|
19
|
+
ast = Fast.ast_from_file(file)
|
20
|
+
|
21
|
+
results = Fast.search(ast, search)
|
22
|
+
unless results.empty?
|
23
|
+
new_content = Fast.replace_file(file, search, replacement)
|
24
|
+
new_spec = experimental_spec(file, name)
|
25
|
+
return if File.exist?(new_spec)
|
26
|
+
File.open(new_spec, 'w+') { |f| f.puts new_content }
|
27
|
+
if system("bin/spring rspec --fail-fast #{new_spec}")
|
28
|
+
system "mv #{new_spec} #{file}"
|
29
|
+
puts "✅ #{file}"
|
30
|
+
else
|
31
|
+
system "rm #{new_spec}"
|
32
|
+
puts "🔴 #{file}"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
rescue StandardError
|
36
|
+
# Avoid stop because weird errors like encoding issues
|
37
|
+
puts "🔴🔴 🔴 #{file}: #{$ERROR_INFO}"
|
38
|
+
end
|
39
|
+
|
40
|
+
ARGV.each do |file|
|
41
|
+
[
|
42
|
+
# Thread.new { experiment(file, 'build_stubbed', '(send nil create)', ->(node) { replace(node.location.selector, 'build_stubbed') }) },
|
43
|
+
experiment(file, 'seed', '(send nil create)', ->(node) { replace(node.location.selector, 'seed') })
|
44
|
+
|
45
|
+
] # .each(&:join)
|
46
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
# List files that matches with some expression
|
5
|
+
# Usage:
|
6
|
+
#
|
7
|
+
# ruby examples/find_usage.rb defs
|
8
|
+
#
|
9
|
+
# Or be explicit about directory or folder:
|
10
|
+
#
|
11
|
+
# ruby examples/find_usage.rb defs lib/
|
12
|
+
$LOAD_PATH.unshift(File.expand_path('lib', __dir__))
|
13
|
+
|
14
|
+
require 'fast'
|
15
|
+
require 'coderay'
|
16
|
+
|
17
|
+
arguments = ARGV
|
18
|
+
pattern = arguments.shift
|
19
|
+
files = Fast.ruby_files_from(arguments.any? ? arguments : '.')
|
20
|
+
files.select do |file|
|
21
|
+
begin
|
22
|
+
puts file if Fast.search_file(pattern, file).any?
|
23
|
+
rescue Parser::SyntaxError
|
24
|
+
[]
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
$LOAD_PATH << File.expand_path('../lib', __dir__)
|
4
|
+
require 'fast'
|
5
|
+
|
6
|
+
def node_size(node)
|
7
|
+
return 1 unless node.respond_to?(:children)
|
8
|
+
children = node.children
|
9
|
+
return 1 if children.empty? || children.length == 1
|
10
|
+
nodes, syms = children.partition { |e| e.respond_to?(:children) }
|
11
|
+
1 + syms.length + (nodes.map(&method(:node_size)).inject(:+) || 0)
|
12
|
+
end
|
13
|
+
|
14
|
+
def method_complexity(file)
|
15
|
+
ast = Fast.ast_from_file(file)
|
16
|
+
Fast.search(ast, '(class ...)').map do |node_class|
|
17
|
+
manager_name = node_class.children.first.children.last
|
18
|
+
|
19
|
+
defs = Fast.search(node_class, '(def !{initialize} ... ... )')
|
20
|
+
|
21
|
+
defs.map do |node|
|
22
|
+
complexity = node_size(node)
|
23
|
+
method_name = node.children.first
|
24
|
+
{ "#{manager_name}##{method_name}" => complexity }
|
25
|
+
end.inject(:merge) || {}
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
files = ARGV || Dir['**/*.rb']
|
30
|
+
|
31
|
+
complexities = files.map(&method(:method_complexity)).flatten.inject(:merge!)
|
32
|
+
|
33
|
+
puts '| Method | Complexity |'
|
34
|
+
puts '| ------ | ---------- |'
|
35
|
+
complexities.sort_by { |_, v| -v }.map do |method, complexity|
|
36
|
+
puts "| #{method} | #{complexity} |"
|
37
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# For specs using `let!(:something) { create ... }` it tries to use `let_it_be` instead
|
4
|
+
Fast.experiment('RSpec/LetItBe') do
|
5
|
+
lookup 'spec'
|
6
|
+
search '(block $(send nil let! (sym _)) (args) (send nil create))'
|
7
|
+
edit { |_, (let)| replace(let.loc.selector, 'let_it_be') }
|
8
|
+
policy { |new_file| system("bin/spring rspec --fail-fast #{new_file}") }
|
9
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Experimentally remove a before or an after block
|
4
|
+
Fast.experiment('RSpec/RemoveUselessBeforeAfterHook') do
|
5
|
+
lookup 'spec'
|
6
|
+
search '(block (send nil {before after}))'
|
7
|
+
edit { |node| remove(node.loc.expression) }
|
8
|
+
policy { |new_file| system("bin/spring rspec --fail-fast #{new_file}") }
|
9
|
+
end
|
data/fast.gemspec
CHANGED
@@ -1,27 +1,29 @@
|
|
1
|
-
#
|
2
|
-
|
1
|
+
# frozen_string_literal: true
|
3
2
|
|
4
3
|
Gem::Specification.new do |spec|
|
5
|
-
spec.name =
|
6
|
-
spec.version = '0.0.
|
7
|
-
spec.
|
8
|
-
spec.
|
4
|
+
spec.name = 'ffast'
|
5
|
+
spec.version = '0.0.2'
|
6
|
+
spec.required_ruby_version = '>= 2.3'
|
7
|
+
spec.authors = ['Jônatas Davi Paganini']
|
8
|
+
spec.email = ['jonatas.paganini@toptal.com']
|
9
9
|
|
10
|
-
spec.summary =
|
11
|
-
spec.description =
|
12
|
-
spec.license =
|
10
|
+
spec.summary = 'FAST: Find by AST.'
|
11
|
+
spec.description = 'Allow you to search for code using node pattern syntax.'
|
12
|
+
spec.license = 'MIT'
|
13
13
|
|
14
14
|
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
15
15
|
f.match(%r{^(test|spec|features)/})
|
16
16
|
end
|
17
|
-
spec.bindir =
|
18
|
-
spec.executables = ['fast']
|
19
|
-
spec.require_paths = [
|
17
|
+
spec.bindir = 'bin'
|
18
|
+
spec.executables = ['fast', 'fast-experiment']
|
19
|
+
spec.require_paths = %w[lib experiments]
|
20
20
|
|
21
|
-
spec.add_development_dependency
|
22
|
-
spec.add_development_dependency
|
23
|
-
spec.add_development_dependency
|
24
|
-
spec.
|
25
|
-
spec.
|
26
|
-
spec.add_development_dependency
|
21
|
+
spec.add_development_dependency 'bundler', '~> 1.14'
|
22
|
+
spec.add_development_dependency 'rake', '~> 10.0'
|
23
|
+
spec.add_development_dependency 'rspec', '~> 3.0'
|
24
|
+
spec.add_dependency 'coderay'
|
25
|
+
spec.add_dependency 'parser'
|
26
|
+
spec.add_development_dependency 'pry'
|
27
|
+
spec.add_development_dependency 'rubocop'
|
28
|
+
spec.add_development_dependency 'rubocop-rspec'
|
27
29
|
end
|
data/lib/fast.rb
CHANGED
@@ -1,107 +1,220 @@
|
|
1
|
-
require 'bundler/setup'
|
2
|
-
require 'parser'
|
3
|
-
require 'parser/current'
|
4
1
|
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
# suppress output to avoid parser gem warnings'
|
5
|
+
def suppress_output
|
6
|
+
original_stdout = $stdout.clone
|
7
|
+
original_stderr = $stderr.clone
|
8
|
+
$stderr.reopen File.new('/dev/null', 'w')
|
9
|
+
$stdout.reopen File.new('/dev/null', 'w')
|
10
|
+
yield
|
11
|
+
ensure
|
12
|
+
$stdout.reopen original_stdout
|
13
|
+
$stderr.reopen original_stderr
|
14
|
+
end
|
15
|
+
|
16
|
+
suppress_output do
|
17
|
+
require 'parser'
|
18
|
+
require 'parser/current'
|
19
|
+
end
|
20
|
+
|
21
|
+
# Fast is a tool to help you search in the code through the Abstract Syntax Tree
|
5
22
|
module Fast
|
6
|
-
VERSION =
|
23
|
+
VERSION = '0.1.0'
|
7
24
|
LITERAL = {
|
8
|
-
'...' => ->
|
9
|
-
'_' => ->
|
25
|
+
'...' => ->(node) { node&.children&.any? },
|
26
|
+
'_' => ->(node) { !node.nil? },
|
10
27
|
'nil' => nil
|
11
|
-
}
|
28
|
+
}.freeze
|
12
29
|
|
13
30
|
TOKENIZER = %r/
|
14
31
|
[\+\-\/\*\\!] # operators or negation
|
15
32
|
|
|
16
33
|
\d+\.\d* # decimals and floats
|
17
34
|
|
|
35
|
+
"[^"]+" # strings
|
36
|
+
|
|
18
37
|
_ # something not nil: match
|
19
38
|
|
|
20
39
|
\.{3} # a node with children: ...
|
21
40
|
|
|
22
|
-
|
41
|
+
\[|\] # square brackets `[` and `]` for all
|
42
|
+
|
|
43
|
+
\^ # node has children with
|
44
|
+
|
|
45
|
+
\? # maybe expression
|
46
|
+
|
|
47
|
+
[\d\w_]+[\\!\?]? # method names or numbers
|
23
48
|
|
|
24
49
|
\(|\) # parens `(` and `)` for tuples
|
25
50
|
|
|
26
51
|
\{|\} # curly brackets `{` and `}` for any
|
27
52
|
|
|
28
53
|
\$ # capture
|
54
|
+
|
|
55
|
+
\\\d # find using captured expression
|
29
56
|
/x
|
30
57
|
|
31
|
-
|
32
|
-
|
33
|
-
|
58
|
+
class << self
|
59
|
+
def match?(ast, search)
|
60
|
+
Matcher.new(ast, search).match?
|
61
|
+
end
|
34
62
|
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
end
|
47
|
-
end.new.rewrite(buffer, ast)
|
63
|
+
def replace(ast, search, replacement)
|
64
|
+
buffer = Parser::Source::Buffer.new('replacement')
|
65
|
+
buffer.source = ast.loc.expression.source
|
66
|
+
to_replace = search(ast, search)
|
67
|
+
types = to_replace.grep(Parser::AST::Node).map(&:type).uniq
|
68
|
+
rewriter = Rewriter.new
|
69
|
+
rewriter.buffer = buffer
|
70
|
+
rewriter.search = search
|
71
|
+
rewriter.replacement = replacement
|
72
|
+
rewriter.affect_types(*types)
|
73
|
+
rewriter.rewrite(buffer, ast)
|
48
74
|
end
|
49
|
-
end
|
50
75
|
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
76
|
+
def replace_file(file, search, replacement)
|
77
|
+
ast = ast_from_file(file)
|
78
|
+
replace(ast, search, replacement)
|
79
|
+
end
|
80
|
+
|
81
|
+
def search_file(pattern, file)
|
82
|
+
node = ast_from_file(file)
|
83
|
+
search node, pattern
|
84
|
+
end
|
55
85
|
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
86
|
+
def search(node, pattern)
|
87
|
+
if (match = Fast.match?(node, pattern))
|
88
|
+
yield node, match if block_given?
|
89
|
+
match != true ? [node, match] : [node]
|
90
|
+
elsif Fast.match?(node, '...')
|
61
91
|
node.children
|
62
92
|
.grep(Parser::AST::Node)
|
63
|
-
.flat_map{|e| search(
|
64
|
-
.compact.flatten
|
93
|
+
.flat_map { |e| search(e, pattern) }
|
94
|
+
.compact.uniq.flatten
|
65
95
|
end
|
66
96
|
end
|
67
|
-
end
|
68
97
|
|
69
|
-
|
70
|
-
|
71
|
-
|
98
|
+
def capture(node, pattern)
|
99
|
+
res =
|
100
|
+
if (match = Fast.match?(node, pattern))
|
101
|
+
match == true ? node : match
|
102
|
+
elsif Fast.match?(node, '...')
|
103
|
+
node.children
|
104
|
+
.grep(Parser::AST::Node)
|
105
|
+
.flat_map { |child| capture(child, pattern) }.compact.flatten
|
106
|
+
end
|
107
|
+
res&.size == 1 ? res[0] : res
|
108
|
+
end
|
72
109
|
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
buffer
|
77
|
-
end
|
110
|
+
def ast(content)
|
111
|
+
Parser::CurrentRuby.parse(content)
|
112
|
+
end
|
78
113
|
|
79
|
-
|
80
|
-
|
81
|
-
|
114
|
+
def ast_from_file(file)
|
115
|
+
ast(IO.read(file))
|
116
|
+
end
|
117
|
+
|
118
|
+
def highlight(node, show_sexp: false)
|
119
|
+
output =
|
120
|
+
if node.respond_to?(:loc) && !show_sexp
|
121
|
+
node.loc.expression.source
|
122
|
+
else
|
123
|
+
node
|
124
|
+
end
|
125
|
+
CodeRay.scan(output, :ruby).term
|
126
|
+
end
|
127
|
+
|
128
|
+
def report(result, show_sexp:, file:)
|
129
|
+
if file
|
130
|
+
line = result.loc.expression.line if result.is_a?(Parser::AST::Node)
|
131
|
+
puts Fast.highlight("# #{file}:#{line}")
|
132
|
+
end
|
133
|
+
puts Fast.highlight(result, show_sexp: show_sexp)
|
134
|
+
end
|
135
|
+
|
136
|
+
def buffer_for(file)
|
137
|
+
buffer = Parser::Source::Buffer.new(file.to_s)
|
138
|
+
buffer.source = IO.read(file)
|
139
|
+
buffer
|
140
|
+
end
|
141
|
+
|
142
|
+
def expression(string)
|
143
|
+
ExpressionParser.new(string).parse
|
144
|
+
end
|
82
145
|
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
146
|
+
def experiment(name, &block)
|
147
|
+
@experiments ||= {}
|
148
|
+
@experiments[name] = Experiment.new(name, &block)
|
149
|
+
end
|
150
|
+
|
151
|
+
attr_reader :experiments
|
152
|
+
attr_accessor :debugging
|
153
|
+
|
154
|
+
def debug
|
155
|
+
return yield if debugging
|
156
|
+
self.debugging = true
|
157
|
+
result = nil
|
158
|
+
Find.class_eval do
|
159
|
+
alias_method :original_match_recursive, :match_recursive
|
160
|
+
alias_method :match_recursive, :debug_match_recursive
|
161
|
+
result = yield
|
162
|
+
alias_method :match_recursive, :original_match_recursive # rubocop:disable Lint/DuplicateMethods
|
91
163
|
end
|
92
|
-
|
93
|
-
|
164
|
+
self.debugging = false
|
165
|
+
result
|
166
|
+
end
|
167
|
+
|
168
|
+
def ruby_files_from(*files)
|
169
|
+
directories = files.select(&File.method(:directory?))
|
170
|
+
|
171
|
+
if directories.any?
|
172
|
+
files -= directories
|
173
|
+
files |= directories.flat_map { |dir| Dir["#{dir}/**/*.rb"] }
|
174
|
+
files.uniq!
|
94
175
|
end
|
176
|
+
files
|
95
177
|
end
|
178
|
+
end
|
96
179
|
|
97
|
-
|
180
|
+
# Rewriter encapsulates `#match_index` allowing to rewrite only specific matching occurrences
|
181
|
+
# into the file. It empowers the `Fast.experiment` and offers some useful insights for running experiments.
|
182
|
+
class Rewriter < Parser::TreeRewriter
|
183
|
+
attr_reader :match_index
|
184
|
+
attr_accessor :buffer, :search, :replacement
|
185
|
+
def initialize(*args)
|
186
|
+
super
|
187
|
+
@match_index = 0
|
188
|
+
end
|
98
189
|
|
99
|
-
|
100
|
-
|
190
|
+
def match?(node)
|
191
|
+
Fast.match?(node, search)
|
192
|
+
end
|
193
|
+
|
194
|
+
def affect_types(*types) # rubocop:disable Metrics/MethodLength
|
195
|
+
types.map do |type|
|
196
|
+
self.class.send :define_method, "on_#{type}" do |node|
|
197
|
+
if captures = match?(node) # rubocop:disable Lint/AssignmentInCondition
|
198
|
+
@match_index += 1
|
199
|
+
if replacement.parameters.length == 1
|
200
|
+
instance_exec node, &replacement
|
201
|
+
else
|
202
|
+
instance_exec node, captures, &replacement
|
203
|
+
end
|
204
|
+
end
|
205
|
+
super(node)
|
206
|
+
end
|
207
|
+
end
|
101
208
|
end
|
102
|
-
result
|
103
209
|
end
|
104
210
|
|
211
|
+
# ExpressionParser empowers the AST search in Ruby.
|
212
|
+
# You can check a few classes inheriting `Fast::Find` and adding extra behavior.
|
213
|
+
# Parens encapsulates node search: `(node_type children...)` .
|
214
|
+
# Exclamation Mark to negate: `!(int _)` is equilvalent to a `not integer` node.
|
215
|
+
# Curly Braces allows [Any]: `({int float} _)` or `{(int _) (float _)}`.
|
216
|
+
# Square Braquets allows [All]: [(int _) !(int 0)] # all integer less zero.
|
217
|
+
# Dollar sign can be used to capture values: `(${int float} _)` will capture the node type.
|
105
218
|
class ExpressionParser
|
106
219
|
def initialize(expression)
|
107
220
|
@tokens = expression.scan TOKENIZER
|
@@ -111,25 +224,44 @@ module Fast
|
|
111
224
|
@tokens.shift
|
112
225
|
end
|
113
226
|
|
227
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
228
|
+
# rubocop:disable Metrics/AbcSize
|
114
229
|
def parse
|
115
230
|
case (token = next_token)
|
116
231
|
when '(' then parse_until_peek(')')
|
117
232
|
when '{' then Any.new(parse_until_peek('}'))
|
233
|
+
when '[' then All.new(parse_until_peek(']'))
|
234
|
+
when /^"/ then FindString.new(token[1..-2])
|
118
235
|
when '$' then Capture.new(parse)
|
119
|
-
when '!' then Not.new(parse)
|
236
|
+
when '!' then (@tokens.any? ? Not.new(parse) : Find.new(token))
|
237
|
+
when '?' then Maybe.new(parse)
|
238
|
+
when '^' then Parent.new(parse)
|
239
|
+
when '\\' then FindWithCapture.new(parse)
|
120
240
|
else Find.new(token)
|
121
241
|
end
|
122
242
|
end
|
243
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
244
|
+
# rubocop:enable Metrics/AbcSize
|
123
245
|
|
124
246
|
def parse_until_peek(token)
|
125
247
|
list = []
|
126
|
-
list << parse until @tokens.first == token
|
248
|
+
list << parse until @tokens.empty? || @tokens.first == token
|
127
249
|
next_token
|
128
250
|
list
|
129
251
|
end
|
252
|
+
|
253
|
+
def append_token_until_peek(token)
|
254
|
+
list = []
|
255
|
+
list << next_token until @tokens.empty? || @tokens.first == token
|
256
|
+
next_token
|
257
|
+
list.join
|
258
|
+
end
|
130
259
|
end
|
131
260
|
|
132
|
-
|
261
|
+
# Find is the top level class that respond to #match?(node) interface.
|
262
|
+
# It matches recurively and check deeply depends of the token type.
|
263
|
+
class Find
|
264
|
+
attr_accessor :token
|
133
265
|
def initialize(token)
|
134
266
|
self.token = token
|
135
267
|
end
|
@@ -139,54 +271,109 @@ module Fast
|
|
139
271
|
end
|
140
272
|
|
141
273
|
def match_recursive(node, expression)
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
match_recursive(node, expression.shift)
|
274
|
+
case expression
|
275
|
+
when Proc then expression.call(node)
|
276
|
+
when Find then expression.match?(node)
|
277
|
+
when Symbol then compare_symbol_or_head(node, expression)
|
278
|
+
when Enumerable
|
279
|
+
expression.each_with_index.all? do |exp, i|
|
280
|
+
match_recursive(i.zero? ? node : node.children[i - 1], exp)
|
281
|
+
end
|
151
282
|
else
|
152
283
|
node == expression
|
153
284
|
end
|
154
285
|
end
|
155
286
|
|
287
|
+
def compare_symbol_or_head(node, expression)
|
288
|
+
type = node.respond_to?(:type) ? node.type : node
|
289
|
+
type == expression
|
290
|
+
end
|
291
|
+
|
292
|
+
def debug_match_recursive(node, expression)
|
293
|
+
match = original_match_recursive(node, expression)
|
294
|
+
debug(node, expression, match)
|
295
|
+
match
|
296
|
+
end
|
297
|
+
|
298
|
+
def debug(node, expression, match)
|
299
|
+
puts "#{expression} == #{node} # => #{match}"
|
300
|
+
end
|
301
|
+
|
156
302
|
def to_s
|
157
303
|
"f[#{[*token].join(', ')}]"
|
158
304
|
end
|
159
305
|
|
306
|
+
def ==(other)
|
307
|
+
return false if other.nil? || !other.respond_to?(:token)
|
308
|
+
token == other.token
|
309
|
+
end
|
310
|
+
|
160
311
|
private
|
161
312
|
|
162
313
|
def valuate(token)
|
163
314
|
if token.is_a?(String)
|
164
|
-
if LITERAL.
|
165
|
-
|
166
|
-
elsif token =~ /\d+\.\d*/
|
167
|
-
token.to_f
|
168
|
-
elsif token =~ /\d+/
|
169
|
-
token.to_i
|
170
|
-
else
|
171
|
-
token.to_sym
|
172
|
-
end
|
315
|
+
return valuate(LITERAL[token]) if LITERAL.key?(token)
|
316
|
+
typecast_value(token)
|
173
317
|
else
|
174
318
|
token
|
175
319
|
end
|
176
320
|
end
|
321
|
+
|
322
|
+
def typecast_value(token)
|
323
|
+
case token
|
324
|
+
when /\d+\.\d*/ then token.to_f
|
325
|
+
when /\d+/ then token.to_i
|
326
|
+
else token.to_sym
|
327
|
+
end
|
328
|
+
end
|
329
|
+
end
|
330
|
+
|
331
|
+
# Find literal strings using double quotes
|
332
|
+
class FindString < Find
|
333
|
+
def initialize(token)
|
334
|
+
@token = token
|
335
|
+
end
|
336
|
+
|
337
|
+
def match?(node)
|
338
|
+
node == token
|
339
|
+
end
|
177
340
|
end
|
178
341
|
|
179
|
-
|
342
|
+
# Allow use previous captures while searching in the AST.
|
343
|
+
# Use `\\1` to point the match to the first captured element
|
344
|
+
class FindWithCapture < Find
|
345
|
+
attr_writer :previous_captures
|
346
|
+
|
347
|
+
def initialize(token)
|
348
|
+
token = token.token if token.respond_to?(:token)
|
349
|
+
raise 'You must use captures!' unless token
|
350
|
+
@capture_index = token.to_i
|
351
|
+
end
|
352
|
+
|
353
|
+
def match?(node)
|
354
|
+
node == @previous_captures[@capture_index - 1]
|
355
|
+
end
|
356
|
+
|
357
|
+
def to_s
|
358
|
+
"fc[\\#{@capture_index}]"
|
359
|
+
end
|
360
|
+
end
|
361
|
+
|
362
|
+
# Capture some expression while searching for it:
|
363
|
+
# Example: `(${int float} _)` will capture the node type
|
364
|
+
# Example: `$({int float} _)` will capture the node
|
365
|
+
# Example: `({int float} $_)` will capture the value
|
366
|
+
# Example: `(${int float} $_)` will capture both node type and value
|
367
|
+
# You can capture multiple levels
|
368
|
+
class Capture < Find
|
180
369
|
attr_reader :captures
|
181
370
|
def initialize(token)
|
182
371
|
super
|
183
372
|
@captures = []
|
184
373
|
end
|
185
374
|
|
186
|
-
def match?
|
187
|
-
if super
|
188
|
-
@captures << node
|
189
|
-
end
|
375
|
+
def match?(node)
|
376
|
+
@captures << node if super
|
190
377
|
end
|
191
378
|
|
192
379
|
def to_s
|
@@ -194,9 +381,27 @@ module Fast
|
|
194
381
|
end
|
195
382
|
end
|
196
383
|
|
384
|
+
# Sometimes you want to check some children but get the parent element,
|
385
|
+
# for such cases, parent can be useful.
|
386
|
+
# Example: You're searching for `int` usages in your code.
|
387
|
+
# But you don't want to check the integer itself, but who is using it:
|
388
|
+
# `^^(int _)` will give you the variable being assigned or the expression being used.
|
389
|
+
class Parent < Find
|
390
|
+
alias match_node match?
|
391
|
+
def match?(node)
|
392
|
+
node.children.grep(Parser::AST::Node).any?(&method(:match_node))
|
393
|
+
end
|
394
|
+
|
395
|
+
def to_s
|
396
|
+
"^#{token}"
|
397
|
+
end
|
398
|
+
end
|
399
|
+
|
400
|
+
# Matches any of the internal expressions. Works like a **OR** condition.
|
401
|
+
# `{int float}` means int or float.
|
197
402
|
class Any < Find
|
198
403
|
def match?(node)
|
199
|
-
token.any?{|expression| Fast.match?(node, expression) }
|
404
|
+
token.any? { |expression| Fast.match?(node, expression) }
|
200
405
|
end
|
201
406
|
|
202
407
|
def to_s
|
@@ -204,55 +409,250 @@ module Fast
|
|
204
409
|
end
|
205
410
|
end
|
206
411
|
|
412
|
+
# Intersect expressions. Works like a **AND** operator.
|
413
|
+
class All < Find
|
414
|
+
def match?(node)
|
415
|
+
token.all? { |expression| expression.match?(node) }
|
416
|
+
end
|
417
|
+
|
418
|
+
def to_s
|
419
|
+
"all[#{token}]"
|
420
|
+
end
|
421
|
+
end
|
422
|
+
|
423
|
+
# Negates the current expression
|
424
|
+
# `!int` is equilvalent to "not int"
|
207
425
|
class Not < Find
|
208
426
|
def match?(node)
|
209
427
|
!super
|
210
428
|
end
|
211
429
|
end
|
212
430
|
|
431
|
+
# True if the node does not exist
|
432
|
+
# When exists, it should match.
|
433
|
+
class Maybe < Find
|
434
|
+
def match?(node)
|
435
|
+
node.nil? || super
|
436
|
+
end
|
437
|
+
end
|
438
|
+
|
439
|
+
# Joins the AST and the search expression to create a complete match
|
213
440
|
class Matcher
|
214
441
|
def initialize(ast, fast)
|
215
442
|
@ast = ast
|
216
|
-
if fast.is_a?(String)
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
443
|
+
@fast = if fast.is_a?(String)
|
444
|
+
Fast.expression(fast)
|
445
|
+
else
|
446
|
+
[*fast].map(&Find.method(:new))
|
447
|
+
end
|
221
448
|
@captures = []
|
222
449
|
end
|
223
450
|
|
224
|
-
|
225
|
-
|
451
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
452
|
+
# rubocop:disable Metrics/AbcSize
|
453
|
+
def match?(ast = @ast, fast = @fast)
|
454
|
+
head, *tail = fast
|
226
455
|
return false unless head.match?(ast)
|
227
456
|
if tail.empty?
|
228
|
-
return ast == @ast ? find_captures : true
|
457
|
+
return ast == @ast ? find_captures : true # root node
|
229
458
|
end
|
230
459
|
child = ast.children
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
460
|
+
tail.each_with_index.all? do |token, i|
|
461
|
+
token.previous_captures = find_captures if token.is_a?(Fast::FindWithCapture)
|
462
|
+
token.is_a?(Array) ? match?(child[i], token) : token.match?(child[i])
|
463
|
+
end && find_captures
|
464
|
+
end
|
465
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
466
|
+
# rubocop:enable Metrics/AbcSize
|
467
|
+
|
468
|
+
def captures?(fast = @fast)
|
469
|
+
case fast
|
470
|
+
when Capture then true
|
471
|
+
when Array then fast.any?(&method(:captures?))
|
472
|
+
when Find then captures?(fast.token)
|
473
|
+
end
|
474
|
+
end
|
475
|
+
|
476
|
+
def find_captures(fast = @fast)
|
477
|
+
return true if fast == @fast && !captures?(fast)
|
478
|
+
case fast
|
479
|
+
when Capture then fast.captures
|
480
|
+
when Array then fast.flat_map(&method(:find_captures)).compact
|
481
|
+
when Find then find_captures(fast.token)
|
482
|
+
end
|
483
|
+
end
|
484
|
+
end
|
485
|
+
|
486
|
+
# You can define experiments and build experimental files to improve some code in
|
487
|
+
# an automated way. Let's create a hook to check if a `before` or `after` block
|
488
|
+
# is useless in a specific spec:
|
489
|
+
#
|
490
|
+
# ```ruby
|
491
|
+
# Fast.experiment("RSpec/RemoveUselessBeforeAfterHook") do
|
492
|
+
# lookup 'some_spec.rb'
|
493
|
+
# search "(block (send nil {before after}))"
|
494
|
+
# edit {|node| remove(node.loc.expression) }
|
495
|
+
# policy {|new_file| system("bin/spring rspec --fail-fast #{new_file}") }
|
496
|
+
# end
|
497
|
+
# ```
|
498
|
+
class Experiment
|
499
|
+
attr_writer :files
|
500
|
+
attr_reader :name, :replacement, :expression, :files_or_folders, :ok_if
|
501
|
+
|
502
|
+
def initialize(name, &block)
|
503
|
+
@name = name
|
504
|
+
instance_exec(&block)
|
505
|
+
end
|
506
|
+
|
507
|
+
def run_with(file)
|
508
|
+
ExperimentFile.new(file, self).run
|
509
|
+
end
|
510
|
+
|
511
|
+
def search(expression)
|
512
|
+
@expression = expression
|
513
|
+
end
|
514
|
+
|
515
|
+
def edit(&block)
|
516
|
+
@replacement = block
|
517
|
+
end
|
518
|
+
|
519
|
+
def lookup(files_or_folders)
|
520
|
+
@files_or_folders = files_or_folders
|
521
|
+
end
|
522
|
+
|
523
|
+
def policy(&block)
|
524
|
+
@ok_if = block
|
525
|
+
end
|
526
|
+
|
527
|
+
def files
|
528
|
+
@files ||= Fast.ruby_files_from(@files_or_folders)
|
529
|
+
end
|
530
|
+
|
531
|
+
def run
|
532
|
+
files.map(&method(:run_with))
|
533
|
+
end
|
534
|
+
end
|
535
|
+
|
536
|
+
# Encapsulate the join of an Experiment with an specific file.
|
537
|
+
# This is important to coordinate and regulate multiple experiments in the same file.
|
538
|
+
# It can track successfull experiments and failures and suggest new combinations to keep replacing the file.
|
539
|
+
class ExperimentFile
|
540
|
+
attr_reader :ok_experiments, :fail_experiments, :experiment
|
541
|
+
def initialize(file, experiment)
|
542
|
+
@file = file
|
543
|
+
@ast = Fast.ast_from_file(file) if file
|
544
|
+
@experiment = experiment
|
545
|
+
@ok_experiments = []
|
546
|
+
@fail_experiments = []
|
547
|
+
end
|
548
|
+
|
549
|
+
def search
|
550
|
+
experiment.expression
|
551
|
+
end
|
552
|
+
|
553
|
+
def experimental_filename(combination)
|
554
|
+
parts = @file.split('/')
|
555
|
+
dir = parts[0..-2]
|
556
|
+
filename = "experiment_#{[*combination].join('_')}_#{parts[-1]}"
|
557
|
+
File.join(*dir, filename)
|
558
|
+
end
|
559
|
+
|
560
|
+
def ok_with(combination)
|
561
|
+
@ok_experiments << combination
|
562
|
+
return unless combination.is_a?(Array)
|
563
|
+
combination.each do |element|
|
564
|
+
@ok_experiments.delete(element)
|
565
|
+
end
|
566
|
+
end
|
567
|
+
|
568
|
+
def failed_with(combination)
|
569
|
+
@fail_experiments << combination
|
570
|
+
end
|
571
|
+
|
572
|
+
def search_cases
|
573
|
+
Fast.search(@ast, experiment.expression) || []
|
574
|
+
end
|
575
|
+
|
576
|
+
# rubocop:disable Metrics/AbcSize
|
577
|
+
# rubocop:disable Metrics/MethodLength
|
578
|
+
def partial_replace(*indices)
|
579
|
+
replacement = experiment.replacement
|
580
|
+
new_content = Fast.replace_file @file, experiment.expression, ->(node, *captures) do # rubocop:disable Style/Lambda
|
581
|
+
if indices.nil? || indices.empty? || indices.include?(match_index)
|
582
|
+
if replacement.parameters.length == 1
|
583
|
+
instance_exec node, &replacement
|
584
|
+
else
|
585
|
+
instance_exec node, *captures, &replacement
|
586
|
+
end
|
237
587
|
end
|
238
588
|
end
|
589
|
+
return unless new_content
|
590
|
+
write_experiment_file(indices, new_content)
|
591
|
+
new_content
|
592
|
+
end
|
593
|
+
# rubocop:enable Metrics/AbcSize
|
594
|
+
# rubocop:enable Metrics/MethodLength
|
239
595
|
|
240
|
-
|
241
|
-
|
596
|
+
def write_experiment_file(index, new_content)
|
597
|
+
filename = experimental_filename(index)
|
598
|
+
File.open(filename, 'w+') { |f| f.puts new_content }
|
599
|
+
filename
|
600
|
+
end
|
601
|
+
|
602
|
+
def suggest_combinations
|
603
|
+
if @ok_experiments.empty? && @fail_experiments.empty?
|
604
|
+
(1..search_cases.size).to_a
|
242
605
|
else
|
243
|
-
|
606
|
+
@ok_experiments
|
607
|
+
.combination(2)
|
608
|
+
.map { |e| e.flatten.uniq.sort }
|
609
|
+
.uniq - @fail_experiments - @ok_experiments
|
244
610
|
end
|
245
611
|
end
|
246
612
|
|
247
|
-
def
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
613
|
+
def done!
|
614
|
+
count_executed_combinations = @fail_experiments.size + @ok_experiments.size
|
615
|
+
puts "Done with #{@file} after #{count_executed_combinations}"
|
616
|
+
return unless perfect_combination = @ok_experiments.last # rubocop:disable Lint/AssignmentInCondition
|
617
|
+
puts "mv #{experimental_filename(perfect_combination)} #{@file}"
|
618
|
+
`mv #{experimental_filename(perfect_combination)} #{@file}`
|
619
|
+
end
|
620
|
+
|
621
|
+
def run
|
622
|
+
while (combinations = suggest_combinations).any?
|
623
|
+
if combinations.size > 30
|
624
|
+
puts "Ignoring #{@file} because it have #{combinations.size} possible combinations"
|
625
|
+
break
|
626
|
+
end
|
627
|
+
puts "#{@file} - Possible combinations: #{combinations.inspect}"
|
628
|
+
while combination = combinations.shift # rubocop:disable Lint/AssignmentInCondition
|
629
|
+
run_partial_replacement_with(combination)
|
630
|
+
end
|
631
|
+
end
|
632
|
+
done!
|
633
|
+
end
|
634
|
+
|
635
|
+
# rubocop:disable Metrics/MethodLength
|
636
|
+
# rubocop:disable Metrics/AbcSize
|
637
|
+
def run_partial_replacement_with(combination)
|
638
|
+
puts "#{@file} applying partial replacement with: #{combination}"
|
639
|
+
content = partial_replace(*combination)
|
640
|
+
experimental_file = experimental_filename(combination)
|
641
|
+
puts `diff #{experimental_file} #{@file}`
|
642
|
+
if experimental_file == IO.read(@file)
|
643
|
+
raise 'Returned the same file thinking:'
|
644
|
+
end
|
645
|
+
File.open(experimental_file, 'w+') { |f| f.puts content }
|
646
|
+
|
647
|
+
if experiment.ok_if.call(experimental_file)
|
648
|
+
ok_with(combination)
|
649
|
+
puts "✅ #{combination} #{experimental_file}"
|
650
|
+
else
|
651
|
+
failed_with(combination)
|
652
|
+
puts "🔴 #{combination} #{experimental_file}"
|
255
653
|
end
|
256
654
|
end
|
655
|
+
# rubocop:enable Metrics/MethodLength
|
656
|
+
# rubocop:enable Metrics/AbcSize
|
257
657
|
end
|
258
658
|
end
|