ffast 0.0.1 → 0.0.2

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