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.
data/Rakefile CHANGED
@@ -1,6 +1,8 @@
1
- require "bundler/gem_tasks"
2
- require "rspec/core/rake_task"
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
3
5
 
4
6
  RSpec::Core::RakeTask.new(:spec)
5
7
 
6
- task :default => :spec
8
+ task default: :spec
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 "bundler/setup"
4
- require "fast"
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 "pry"
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
- require "fast"
6
- require "coderay"
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
- show_ast = arguments.delete("--ast")
11
- debug = arguments.delete("--debug") || arguments.delete("-d")
12
- files = arguments || Dir['**/*.rb']
13
- directories = arguments.select{|a|File.directory?(a)}
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 |result|
34
+ results.each do |node|
53
35
  next if result.nil? || result == []
54
- line = [code("# #{file}:#{line_for(result)}"), code(result, show_ast)]
55
- puts line
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
@@ -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
- # coding: utf-8
2
-
1
+ # frozen_string_literal: true
3
2
 
4
3
  Gem::Specification.new do |spec|
5
- spec.name = "ffast"
6
- spec.version = '0.0.1'
7
- spec.authors = ["Jônatas Davi Paganini"]
8
- spec.email = ["jonatas.paganini@toptal.com"]
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 = %q{FAST: Find by AST.}
11
- spec.description = %q{Allow you to search for code using node pattern syntax.}
12
- spec.license = "MIT"
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 = "bin"
18
- spec.executables = ['fast']
19
- spec.require_paths = ["lib"]
17
+ spec.bindir = 'bin'
18
+ spec.executables = ['fast', 'fast-experiment']
19
+ spec.require_paths = %w[lib experiments]
20
20
 
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_development_dependency "parser", "~> 2.4.0.0"
25
- spec.add_development_dependency 'coderay', '~> 1.1.1'
26
- spec.add_development_dependency "pry"
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 = "0.1.0"
23
+ VERSION = '0.1.0'
7
24
  LITERAL = {
8
- '...' => -> (node) { node && !node.children.nil? },
9
- '_' => -> (node) { !node.nil? },
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
- [\dA-z_]+[\\!\?]? # method names or numbers
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
- def self.match?(ast, fast)
32
- Matcher.new(ast, fast).match?
33
- end
58
+ class << self
59
+ def match?(ast, search)
60
+ Matcher.new(ast, search).match?
61
+ end
34
62
 
35
- def self.replace(ast, fast, replacement)
36
- nodes = match?(ast, fast)
37
- return unless nodes && !nodes.empty?
38
- buffer = Parser::Source::Buffer.new('replacement')
39
- buffer.source = ast.loc.expression.source
40
- to_replace = nodes.grep(Parser::AST::Node)
41
- types = to_replace.map(&:type).uniq
42
- types.map do |type|
43
- Class.new(Parser::Rewriter) do
44
- define_method "on_#{type}" do |node|
45
- instance_exec node, &replacement
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
- def self.search_file pattern, file
52
- node = ast_from_file(file)
53
- search pattern, node
54
- end
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
- def self.search pattern, node
57
- if (match = Fast.match?(node, pattern))
58
- match == true ? node : [match, node]
59
- else
60
- if node && node.children.any?
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(pattern, e) }
64
- .compact.flatten.uniq
93
+ .flat_map { |e| search(e, pattern) }
94
+ .compact.uniq.flatten
65
95
  end
66
96
  end
67
- end
68
97
 
69
- def self.ast_from_file(file)
70
- Parser::CurrentRuby.parse(IO.read(file))
71
- end
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
- def self.buffer_for(file)
74
- buffer = Parser::Source::Buffer.new(file.to_s)
75
- buffer.source = IO.read(file)
76
- buffer
77
- end
110
+ def ast(content)
111
+ Parser::CurrentRuby.parse(content)
112
+ end
78
113
 
79
- def self.expression(string)
80
- ExpressionParser.new(string).parse
81
- end
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
- def self.debug
84
- return yield if Find.instance_methods.include?(:debug)
85
- Find.class_eval do
86
- alias original_match_recursive match_recursive
87
- def match_recursive a, b
88
- match = original_match_recursive(a, b)
89
- debug(a, b, match)
90
- match
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
- def debug a, b, match
93
- puts "#{b} == #{a} # => #{match}"
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
- result = yield
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
- Find.class_eval do
100
- alias match_recursive original_match_recursive
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
- class Find < Struct.new(:token)
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
- if expression.respond_to?(:call)
143
- expression.call(node)
144
- elsif expression.is_a?(Find)
145
- expression.match?(node)
146
- elsif expression.is_a?(Symbol)
147
- type = node.respond_to?(:type) ? node.type : node
148
- type == expression
149
- elsif expression.respond_to?(:shift)
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.has_key?(token)
165
- LITERAL[token]
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
- class Capture < Find
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? node
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
- @fast = Fast.expression(fast)
218
- else
219
- @fast = fast.map(&Find.method(:new))
220
- end
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
- def match?(ast=@ast, fast=@fast)
225
- head,*tail = fast
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 # root node
457
+ return ast == @ast ? find_captures : true # root node
229
458
  end
230
459
  child = ast.children
231
- return false if tail.size != child.size
232
- results = tail.each_with_index.map do |token, i|
233
- if token.is_a?(Array)
234
- match?(child[i], token)
235
- else
236
- token.match?(child[i])
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
- if results.any?{|e|e==false}
241
- return false
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
- find_captures
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 find_captures(fast=@fast)
248
- case fast
249
- when Capture
250
- fast.captures
251
- when Array
252
- fast.flat_map(&method(:find_captures)).compact
253
- when Find
254
- find_captures(fast.token)
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