ffast 0.0.1 → 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- 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
|