From 3408898e13bf481b98d322d46a7d8e0cbe563cf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B4natas=20Davi=20Paganini?= Date: Sun, 24 May 2020 01:09:45 -0300 Subject: [PATCH] Remove ExpressionParser and use the amazing RuboCop::AST::NodePattern --- fast.gemspec | 2 +- lib/fast.rb | 531 +---------------------------------- lib/fast/cli.rb | 6 +- spec/fast/cli_spec.rb | 6 +- spec/fast/experiment_spec.rb | 2 +- spec/fast/rewriter_spec.rb | 38 +-- spec/fast/shortcut_spec.rb | 6 +- spec/fast_spec.rb | 357 +++-------------------- 8 files changed, 72 insertions(+), 876 deletions(-) diff --git a/fast.gemspec b/fast.gemspec index 6504437..c66778d 100644 --- a/fast.gemspec +++ b/fast.gemspec @@ -24,10 +24,10 @@ Gem::Specification.new do |spec| spec.executables = %w[fast fast-experiment] spec.require_paths = %w[lib experiments] - spec.add_dependency 'astrolabe' spec.add_dependency 'coderay' spec.add_dependency 'parallel' spec.add_dependency 'parser' + spec.add_dependency 'rubocop-ast' spec.add_development_dependency 'bundler' spec.add_development_dependency 'guard' diff --git a/lib/fast.rb b/lib/fast.rb index 233ab7e..399843b 100644 --- a/lib/fast.rb +++ b/lib/fast.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'fileutils' -require 'astrolabe/builder' +require 'rubocop-ast' require_relative 'fast/rewriter' # suppress output to avoid parser gem warnings' @@ -21,52 +21,11 @@ def suppress_output require 'parser/current' end +RuboCop::AST::NodePattern.class_eval do + alias_method :match?, :match +end # Fast is a tool to help you search in the code through the Abstract Syntax Tree module Fast - # Literals are shortcuts allowed inside {ExpressionParser} - LITERAL = { - '...' => ->(node) { node&.children&.any? }, - '_' => ->(node) { !node.nil? }, - 'nil' => nil - }.freeze - - # Allowed tokens in the node pattern domain - TOKENIZER = %r/ - [\+\-\/\*\\!] # operators or negation - | - ===? # == or === - | - \d+\.\d* # decimals and floats - | - "[^"]+" # strings - | - _ # something not nil: match - | - \.{3} # a node with children: ... - | - \[|\] # square brackets `[` and `]` for all - | - \^ # node has children with - | - \? # maybe expression - | - [\d\w_]+[=\\!\?]? # method names or numbers - | - \(|\) # parens `(` and `)` for tuples - | - \{|\} # curly brackets `{` and `}` for any - | - \$ # capture - | - \#\w[\d\w_]+[\\!\?]? # custom method call - | - \.\w[\d\w_]+\? # instance method call - | - \\\d # find using captured expression - | - %\d # bind extra arguments to the expression - /x.freeze - class << self # @return [Astrolabe::Node] from the parsed content # @example @@ -75,7 +34,7 @@ class << self def ast(content, buffer_name: '(string)') buffer = Parser::Source::Buffer.new(buffer_name) buffer.source = content - Parser::CurrentRuby.new(Astrolabe::Builder.new).parse(buffer) + Parser::CurrentRuby.new(RuboCop::AST::Builder.new).parse(buffer) end # @return [Astrolabe::Node] parsed from file content @@ -92,7 +51,7 @@ def ast_from_file(file) # @example # Fast.match?("int", Fast.ast("1")) # => true def match?(pattern, ast, *args) - Matcher.new(pattern, ast, *args).match? + expression(pattern).match(ast, *args) end # Search with pattern directly on file @@ -192,34 +151,7 @@ def capture(pattern, node) end def expression(string) - ExpressionParser.new(string).parse - end - - attr_accessor :debugging - - # Utility function to inspect search details using debug block. - # - # It prints output of all matching cases. - # - # @example - # Fast.debug do - # Fast.match?([:int, 1], s(:int, 1)) - # end - # int == (int 1) # => true - # 1 == 1 # => true - def debug - return yield if debugging - - self.debugging = true - result = nil - Find.class_eval do - alias_method :original_match_recursive, :match_recursive - alias_method :match_recursive, :debug_match_recursive - result = yield - alias_method :match_recursive, :original_match_recursive # rubocop:disable Lint/DuplicateMethods - end - self.debugging = false - result + RuboCop::AST::NodePattern.new(string) end # @return [Array] with all ruby files from arguments. @@ -252,457 +184,10 @@ def expression_from(node) children_expression = node.children.map(&method(:expression_from)).join(' ') "(#{node.type}#{' ' + children_expression if node.children.any?})" when nil, 'nil' - 'nil' + 'nil?' when Symbol, String, Numeric '_' end end end - - # ExpressionParser empowers the AST search in Ruby. - # All classes inheriting Fast::Find have a grammar shortcut that is processed here. - # - # @example find a simple int node - # Fast.expression("int") - # # => # - # @example parens make the expression an array of Fast::Find and children classes - # Fast.expression("(int _)") - # # => [#, #] - # @example not int token - # Fast.expression("!int") - # # => #> - # @example int or float token - # Fast.expression("{int float}") - # # => #, - # # # - # # #]> - # @example capture something not nil - # Fast.expression("$_") - # # => #> - # @example capture a hash with keys that all are not string and not symbols - # Fast.expression("(hash (pair ([!sym !str] _))") - # # => [#, - # # [#, - # # [#>, - # # #>]>, - # # #]]]")") - # @example of match using string expression - # Fast.match?(Fast.ast("{1 => 1}"),"(hash (pair ([!sym !str] _))") => true")") - class ExpressionParser - # @param expression [String] - def initialize(expression) - @tokens = expression.scan TOKENIZER - end - - # rubocop:disable Metrics/CyclomaticComplexity - # rubocop:disable Metrics/AbcSize - # rubocop:disable Metrics/MethodLength - def parse - case (token = next_token) - when '(' then parse_until_peek(')') - when '{' then Any.new(parse_until_peek('}')) - when '[' then All.new(parse_until_peek(']')) - when /^"/ then FindString.new(token[1..-2]) - when /^#\w/ then MethodCall.new(token[1..-1]) - when /^\.\w[\w\d_]+\?/ then InstanceMethodCall.new(token[1..-1]) - when '$' then Capture.new(parse) - when '!' then (@tokens.any? ? Not.new(parse) : Find.new(token)) - when '?' then Maybe.new(parse) - when '^' then Parent.new(parse) - when '\\' then FindWithCapture.new(parse) - when /^%\d/ then FindFromArgument.new(token[1..-1]) - else Find.new(token) - end - end - - # rubocop:enable Metrics/CyclomaticComplexity - # rubocop:enable Metrics/AbcSize - # rubocop:enable Metrics/MethodLength - - private - - def next_token - @tokens.shift - end - - def parse_until_peek(token) - list = [] - list << parse until @tokens.empty? || @tokens.first == token - next_token - list - end - end - - # Find is the top level class that respond to #match?(node) interface. - # It matches recurively and check deeply depends of the token type. - class Find - attr_accessor :token - def initialize(token) - self.token = token - end - - def match?(node) - match_recursive(valuate(token), node) - end - - def match_recursive(expression, node) - case expression - when Proc then expression.call(node) - when Find then expression.match?(node) - when Symbol then compare_symbol_or_head(expression, node) - when Enumerable - expression.each_with_index.all? do |exp, i| - match_recursive(exp, i.zero? ? node : node.children[i - 1]) - end - else - node == expression - end - end - - def compare_symbol_or_head(expression, node) - case node - when Parser::AST::Node - node.type == expression.to_sym - when String - node == expression.to_s - else - node == expression - end - end - - def debug_match_recursive(expression, node) - match = original_match_recursive(expression, node) - debug(expression, node, match) - match - end - - def debug(expression, node, match) - puts "#{expression} == #{node} # => #{match}" - end - - def to_s - "f[#{[*token].map(&:to_s).join(', ')}]" - end - - def ==(other) - return false if other.nil? || !other.respond_to?(:token) - - token == other.token - end - - private - - def valuate(token) - if token.is_a?(String) - return valuate(LITERAL[token]) if LITERAL.key?(token) - - typecast_value(token) - else - token - end - end - - def typecast_value(token) - case token - when /^\d+\.\d*/ then token.to_f - when /^\d+/ then token.to_i - else token.to_sym - end - end - end - - # Find literal strings using double quotes - class FindString < Find - def initialize(token) - @token = token - end - - def match?(node) - node == token - end - end - - # Find using custom methods - class MethodCall < Find - def initialize(method_name) - @method_name = method_name - end - - def match?(node) - Kernel.send(@method_name, node) - end - end - - # Search using custom instance methods - class InstanceMethodCall < Find - def initialize(method_name) - @method_name = method_name - end - - def match?(node) - node.send(@method_name) - end - end - - # Allow use previous captures while searching in the AST. - # Use `\\1` to point the match to the first captured element - # or sequential numbers considering the order of the captures. - # - # @example check comparision of integers that will always return true - # ast = Fast.ast("1 == 1") => s(:send, s(:int, 1), :==, s(:int, 1)) - # Fast.match?("(send $(int _) == \1)", ast) # => [s(:int, 1)] - class FindWithCapture < Find - attr_writer :previous_captures - - def initialize(token) - token = token.token if token.respond_to?(:token) - raise 'You must use captures!' unless token - - @capture_index = token.to_i - end - - def match?(node) - node == @previous_captures[@capture_index - 1] - end - - def to_s - "fc[\\#{@capture_index}]" - end - end - - # Allow the user to interpolate expressions from external stuff. - # Use `%1` in the expression and the Matcher#prepare_arguments will - # interpolate the argument in the expression. - # @example interpolate the node value 1 - # Fast.match?("(int %1)", Fast.ast("1"), 1) # => true - # Fast.match?("(int %1)", Fast.ast("1"), 2) # => false - # @example interpolate multiple arguments - # Fast.match?("(%1 %2)", Fast.ast("1"), :int, 1) # => true - class FindFromArgument < Find - attr_writer :arguments - - def initialize(token) - token = token.token if token.respond_to?(:token) - raise 'You must define index' unless token - - @capture_argument = token.to_i - 1 - raise 'Arguments start in one' if @capture_argument.negative? - end - - def match?(node) - raise 'You must define arguments to match' unless @arguments - - compare_symbol_or_head @arguments[@capture_argument], node - end - - def to_s - "find_with_arg[\\#{@capture_argument}]" - end - end - - # Capture some expression while searching for it. - # - # The captures behaves exactly like Fast::Find and the only difference is that - # when it {#match?} stores #captures for future usage. - # - # @example capture int node - # capture = Fast::Capture.new("int") => # - # capture.match?(Fast.ast("1")) # => [s(:int, 1)] - # - # @example binding directly in the Fast.expression - # Fast.match?(Fast.ast("1"), "(int $_)") # => [1] - # - # @example capture the value of a local variable assignment - # (${int float} _) - # @example expression to capture only the node type - # (${int float} _) - # @example expression to capture entire node - # $({int float} _) - # @example expression to capture only the node value of int or float nodes - # ({int float} $_) - # @example expression to capture both node type and value - # ($_ $_) - # - # You can capture stuff in multiple levels and - # build expressions that reference captures with Fast::FindWithCapture. - class Capture < Find - # Stores nodes that matches with the current expression. - attr_reader :captures - - def initialize(token) - super - @captures = [] - end - - # Append the matching node to {#captures} if it matches - def match?(node) - @captures << node if super - end - - def to_s - "c[#{token}]" - end - end - - # Sometimes you want to check some children but get the parent element, - # for such cases, parent can be useful. - # Example: You're searching for `int` usages in your code. - # But you don't want to check the integer itself, but who is using it: - # `^^(int _)` will give you the variable being assigned or the expression being used. - class Parent < Find - alias match_node match? - def match?(node) - node.each_child_node.any?(&method(:match_node)) - end - - def to_s - "^#{token}" - end - end - - # Matches any of the internal expressions. Works like a **OR** condition. - # @example Matchig int or float - # Fast.expression("{int float}") - class Any < Find - def match?(node) - token.any? { |expression| Fast.match?(expression, node) } - end - - def to_s - "any[#{token.map(&:to_s).join(', ')}]" - end - end - - # Intersect expressions. Works like a **AND** operator. - class All < Find - def match?(node) - token.all? { |expression| expression.match?(node) } - end - - def to_s - "all[#{token}]" - end - end - - # Negates the current expression - # `!int` is equilvalent to "not int" - class Not < Find - def match?(node) - !super - end - end - - # True if the node does not exist - # When exists, it should match. - class Maybe < Find - def match?(node) - node.nil? || super - end - end - - # Joins the AST and the search expression to create a complete matcher that - # recusively check if the node pattern expression matches with the given AST. - # - ### Using captures - # - # One of the most important features of the matcher is find captures and also - # bind them on demand in case the expression is using previous captures. - # - # @example simple match - # ast = Fast.ast("a = 1") - # expression = Fast.expression("(lvasgn _ (int _))") - # Matcher.new(expression, ast).match? # true - # - # @example simple capture - # ast = Fast.ast("a = 1") - # expression = Fast.expression("(lvasgn _ (int $_))") - # Matcher.new(expression, ast).match? # => [1] - # - class Matcher - def initialize(pattern, ast, *args) - @ast = ast - @expression = if pattern.is_a?(String) - Fast.expression(pattern) - else - [*pattern].map(&Find.method(:new)) - end - @captures = [] - prepare_arguments(@expression, args) if args.any? - end - - # @return [true] if the @param ast recursively matches with expression. - # @return #find_captures case matches - def match?(expression = @expression, ast = @ast) - head, *tail_expression = expression - return false unless head.match?(ast) - return find_captures if tail_expression.empty? - - match_tail?(tail_expression, ast.children) - end - - # @return [true] if all children matches with tail - def match_tail?(tail, child) - tail.each_with_index.all? do |token, i| - prepare_token(token) - token.is_a?(Array) ? match?(token, child[i]) : token.match?(child[i]) - end && find_captures - end - - # Look recursively into @param expression to check if the expression is have - # captures. - # @return [true] if any sub expression have captures. - def captures?(expression = @expression) - case expression - when Capture then true - when Array then expression.any?(&method(:captures?)) - when Find then captures?(expression.token) - end - end - - # Find search captures recursively. - # - # @return [Array] of captures from the expression - # @return [true] in case of no captures in the expression - # @see Fast::Capture - # @see Fast::FindFromArgument - def find_captures(expression = @expression) - return true if expression == @expression && !captures?(expression) - - case expression - when Capture then expression.captures - when Array then expression.flat_map(&method(:find_captures)).compact - when Find then find_captures(expression.token) - end - end - - private - - # Prepare arguments case the expression needs to bind extra arguments. - # @return [void] - def prepare_arguments(expression, arguments) - case expression - when Array - expression.each do |item| - prepare_arguments(item, arguments) - end - when Fast::FindFromArgument - expression.arguments = arguments - when Fast::Find - prepare_arguments expression.token, arguments - end - end - - # Prepare token with previous captures - # @param [FindWithCapture] token set the current captures - # @return [void] - # @see [FindWithCapture#previous_captures] - def prepare_token(token) - case token - when Fast::FindWithCapture - token.previous_captures = find_captures - end - end - end end diff --git a/lib/fast/cli.rb b/lib/fast/cli.rb index 2771e96..4de6b02 100644 --- a/lib/fast/cli.rb +++ b/lib/fast/cli.rb @@ -18,8 +18,8 @@ module Fast # @param colorize [Boolean] skips `CodeRay` processing when false. def highlight(node, show_sexp: false, colorize: true) output = - if node.respond_to?(:loc) && !show_sexp - node.loc.expression.source + if node.respond_to?(:source) && !show_sexp + node.source else node end @@ -145,7 +145,7 @@ def run! # Create fast expression from node pattern using the command line # @return [Array] with the expression from string. def expression - Fast.expression(@pattern) + @pattern end # Search for each file independent. diff --git a/spec/fast/cli_spec.rb b/spec/fast/cli_spec.rb index 36a64b6..2d197d5 100644 --- a/spec/fast/cli_spec.rb +++ b/spec/fast/cli_spec.rb @@ -135,10 +135,10 @@ def highlight(output) before do Fast.shortcuts.delete :show_version - Fast.shortcut(:show_version, '(casgn nil _ (str _))', 'lib/fast/version.rb') + Fast.shortcut(:show_version, '(casgn nil? _ (str _))', 'lib/fast/version.rb') end - its(:pattern) { is_expected.to eq('(casgn nil _ (str _))') } + its(:pattern) { is_expected.to eq('(casgn nil? _ (str _))') } it 'uses the predefined values from the shortcut' do expect { cli.run! }.to output(highlight(<<~RUBY)).to_stdout @@ -149,7 +149,7 @@ def highlight(output) end context 'with args --headless --captures' do - let(:args) { ['(casgn nil _ (str $_))', 'lib/fast/version.rb', '--captures', '--headless'] } + let(:args) { ['(casgn nil? _ (str $_))', 'lib/fast/version.rb', '--captures', '--headless'] } it 'prints only captured scope' do expect { cli.run! }.to output(highlight(Fast::VERSION) + "\n").to_stdout diff --git a/spec/fast/experiment_spec.rb b/spec/fast/experiment_spec.rb index ffb4d02..f02ab9f 100644 --- a/spec/fast/experiment_spec.rb +++ b/spec/fast/experiment_spec.rb @@ -8,7 +8,7 @@ subject(:experiment) do Fast.experiment('RSpec/ReplaceCreateWithBuildStubbed') do lookup 'some_spec.rb' - search '(send nil create)' + search '(send nil? :create ...)' edit { |node| replace(node.loc.selector, 'build_stubbed') } policy { |new_file| system("bin/spring rspec --fail-fast #{new_file}") } end diff --git a/spec/fast/rewriter_spec.rb b/spec/fast/rewriter_spec.rb index 06f7e7d..4d2255e 100644 --- a/spec/fast/rewriter_spec.rb +++ b/spec/fast/rewriter_spec.rb @@ -51,12 +51,9 @@ def self.thanks context 'with rename constant example' do let(:rename_const) do - described_class.replace_file('({casgn const} nil AUTHOR )', 'sample.rb') do |node| - if node.type == :const - replace(node.location.expression, 'CREATOR') - else - replace(node.location.name, 'CREATOR') - end + described_class.replace_file('{(const nil? :AUTHOR) (casgn nil? :AUTHOR ...)}', 'sample.rb') do |node| + target = node.const_type? ? node.loc.expression : node.loc.name + replace(target, 'CREATOR') end end @@ -88,13 +85,13 @@ def self.thanks context 'when inline local variable example' do let(:inline_var) do - described_class.replace_file('({lvar lvasgn } message )', 'sample.rb') do |node, _| - if node.type == :lvasgn + described_class.replace_file('{(lvasgn :message _) (lvar :message)}', 'sample.rb') do |node| + if node.lvasgn_type? @assignment = node.children.last - remove(node.location.expression) + remove(node.loc.expression) else - replace(node.location.expression, - @assignment.location.expression.source) # rubocop:disable RSpec/InstanceVariable + replace(node.loc.expression, + @assignment.source) # rubocop:disable RSpec/InstanceVariable end end end @@ -138,7 +135,7 @@ def self.thanks context 'with the method with a `delegate` call' do let(:example) { Fast.ast 'def name; person.name end' } - let(:expression) { '(def $_ (_) (send (send nil $_) \1))' } + let(:expression) { '(def $_ (_) (send (send nil? $_) _))' } let(:replacement) do lambda do |node, captures| new_source = "delegate :#{captures[0]}, to: :#{captures[1]}" @@ -151,7 +148,7 @@ def self.thanks context 'when call !a.empty?` with `a.any?`' do let(:example) { Fast.ast '!a.empty?' } - let(:expression) { '(send (send (send nil $_ ) :empty?) !)' } + let(:expression) { '(send (send (send nil? $_ ) :empty?) :!)' } let(:replacement) { ->(node, captures) { replace(node.location.expression, "#{captures[0]}.any?") } } it { is_expected.to eq('a.any?') } @@ -159,24 +156,11 @@ def self.thanks context 'when use `match_index` to filter an specific occurence' do let(:example) { Fast.ast 'create(:a, :b, :c);create(:b, :c, :d)' } - let(:expression) { '(send nil :create)' } + let(:expression) { '(send nil? :create ...)' } let(:replacement) { ->(node, _captures) { replace(node.location.selector, 'build_stubbed') if match_index == 2 } } it { is_expected.to eq('create(:a, :b, :c);build_stubbed(:b, :c, :d)') } end - - context 'when use &:method shortcut instead of blocks' do - let(:example) { Fast.ast '(1..100).map { |i| i.to_s }' } - let(:expression) { '(block ... (args (arg $_) ) (send (lvar \1) $_))' } - let(:replacement) do - lambda do |node, captures| - replacement = node.children[0].location.expression.source + "(&:#{captures.last})" - replace(node.location.expression, replacement) - end - end - - it { is_expected.to eq('(1..100).map(&:to_s)') } - end end describe '.rewrite_file' do diff --git a/spec/fast/shortcut_spec.rb b/spec/fast/shortcut_spec.rb index 56ae108..09a6df5 100644 --- a/spec/fast/shortcut_spec.rb +++ b/spec/fast/shortcut_spec.rb @@ -6,10 +6,10 @@ describe Fast::Shortcut do context 'when the params are arguments' do subject(:shortcut) do - Fast.shortcut(:match_methods, '(def match?)', 'lib/fast.rb') + Fast.shortcut(:match_methods, '(def :match?)', 'lib/fast.rb') end - its(:args) { is_expected.to eq(['(def match?)', 'lib/fast.rb']) } + its(:args) { is_expected.to eq(['(def :match?)', 'lib/fast.rb']) } it 'records the search with right params in the #shortcuts' do is_expected.to be_an(described_class) @@ -34,7 +34,7 @@ context 'when a block is given' do subject(:bump) do Fast.shortcut :bump_version do - rewrite_file('(casgn nil VERSION (str _)', 'sample_version.rb') do |node| + rewrite_file('(casgn nil? :VERSION (str _))', 'sample_version.rb') do |node| target = node.children.last.loc.expression replace(target, '0.0.2'.inspect) end diff --git a/spec/fast_spec.rb b/spec/fast_spec.rb index a1f67cf..1f0942b 100644 --- a/spec/fast_spec.rb +++ b/spec/fast_spec.rb @@ -3,256 +3,39 @@ require 'spec_helper' RSpec.describe Fast do - let(:f) { ->(arg) { Fast::Find.new(arg) } } - let(:nf) { ->(arg) { Fast::Not.new(arg) } } - let(:c) { ->(arg) { Fast::Capture.new(arg) } } - let(:any) { ->(arg) { Fast::Any.new(arg) } } - let(:all) { ->(arg) { Fast::All.new(arg) } } - let(:maybe) { ->(arg) { Fast::Maybe.new(arg) } } - let(:parent) { ->(arg) { Fast::Parent.new(arg) } } - let(:defined_proc) { described_class::LITERAL } - let(:code) { ->(string) { described_class.ast(string) } } - - def s(type, *children) - Astrolabe::Node.new(type, children) - end - - describe '.expression' do - it 'parses ... as Find' do - expect(described_class.expression('...')).to be_a(Fast::Find) - end - - it 'parses $ as Capture' do - expect(described_class.expression('$...')).to be_a(Fast::Capture) - end - - it 'parses #custom_method as method call' do - expect(described_class.expression('#method')).to be_a(Fast::MethodCall) - end - - it 'parses `.method?` into instance method calls' do - expect(described_class.expression('.odd?')).to be_a(Fast::InstanceMethodCall) - end - - it 'parses quoted values as strings' do - expect(described_class.expression('"string"')).to be_a(Fast::FindString) - end - - it 'parses {} as Any' do - expect(described_class.expression('{}')).to be_a(Fast::Any) - end - - it 'parses [] as All' do - expect(described_class.expression('[]')).to be_a(Fast::All) - end - - it 'parses ? as Maybe' do - expect(described_class.expression('?')).to be_a(Fast::Maybe) - end - - it 'parses ^ as Parent' do - expect(described_class.expression('^')).to be_a(Fast::Parent) - end - - it 'parses \\1 as FindWithCapture' do - expect(described_class.expression('\\1')).to be_a(Fast::FindWithCapture) - end - - it 'binds %1 as first argument' do - expect(described_class.expression('%1')).to be_a(Fast::FindFromArgument) - end - - it '`!` isolated should be a find' do - expect(described_class.expression('!')).to be_a(Fast::Find) - end - - it '`!` negate expression after it' do - expect(described_class.expression('! a')).to be_a(Fast::Not) - end - - it 'allows ... as a proc shortcuts' do - expect(described_class.expression('...')).to eq(f['...']) - end - - it 'allows _ as a proc shortcuts' do - expect(described_class.expression('_')).to eq(f['_']) - end + include RuboCop::AST::Sexp - it 'allows setter methods in find' do - expect(described_class.expression('attribute=')).to eq(f['attribute=']) - end - - it 'ignores semicolon' do - expect(described_class.expression(':send')).to eq(described_class.expression('send')) - end - - it 'ignores empty spaces' do - expect(described_class.expression('(send (send nil _) _)')) - .to eq([f['send'], [f['send'], f['nil'], f['_']], f['_']]) - end - - it 'wraps expressions deeply' do - expect(described_class.expression('(send (send nil a) b)')).to eq([f['send'], [f['send'], f['nil'], f['a']], f['b']]) - end - - it 'wraps expressions in multiple levels' do - expect(described_class.expression('(send (send (send nil a) b) c)')).to eq([f['send'], [f['send'], [f['send'], f['nil'], f['a']], f['b']], f['c']]) - end - - describe '`{}`' do - it 'works as `or` allowing to match any internal expression' do - expect(described_class.expression('(send $({int float} _) + $(int _))')).to eq([f['send'], c[[any[[f['int'], f['float']]], f['_']]], f['+'], c[[f['int'], f['_']]]]) - end - end - - describe '`[]`' do - it 'works as `and` allowing to match all internal expression' do - puts(described_class.expression('[!str !sym]')) - puts(all[[nf[f['str']], nf[f['sym']]]]) - expect(described_class.expression('[!str !sym]')).to eq(all[[nf[f['str']], nf[f['sym']]]]) - end - end - - describe '`!`' do - it 'negates inverting the logic' do - expect(described_class.expression('!str')).to eq(nf[f['str']]) - end - - it 'negates nested expressions' do - expect(described_class.expression('!{str sym}')).to eq(nf[any[[f['str'], f['sym']]]]) - end - - it 'negates entire nodes' do - expect(described_class.expression('!(int _)')).to eq(nf[[f['int'], f['_']]]) - end - end - - describe '`?`' do - it 'allow partial existence' do - expect(described_class.expression('?str')).to eq(maybe[f['str']]) - end - - it 'allow maybe not combined with `!`' do - expect(described_class.expression('?!str')).to eq(maybe[nf[f['str']]]) - end - - it 'allow maybe combined with or' do - expect(described_class.expression('?{str sym}')).to eq(maybe[any[[f['str'], f['sym']]]]) - end - end - - describe '`$`' do - it 'captures internal references' do - expect(described_class.expression('(send (send nil $a) b)')).to eq([f['send'], [f['send'], f['nil'], c[f['a']]], f['b']]) - end - - it 'captures internal nodes' do - expect(described_class.expression('(send $(send nil a) b)')).to eq([f['send'], c[[f['send'], f['nil'], f['a']]], f['b']]) - end - end - - describe '#custom_method' do - before do - Kernel.class_eval do - def custom_method(node) - (node.type == :int) && (node.children == [1]) - end - end - end - - after do - Kernel.class_eval do - undef :custom_method - end - end - - it 'allow interpolate custom methods' do - expect(described_class).to be_match('#custom_method', s(:int, 1)) - expect(described_class).not_to be_match('#custom_method', s(:int, 2)) - end - end - end + let(:code) { ->(string) { described_class.ast(string) } } describe '.match?' do - context 'with pure array expression' do - it 'matches AST code with a pure array' do - expect(described_class).to be_match([:int, 1], s(:int, 1)) - end - - it 'matches deeply with sub arrays' do - expect(described_class).to be_match([:send, [:send, nil, :object], :method], s(:send, s(:send, nil, :object), :method)) - end - end - - context 'with complex AST' do - let(:ast) { code['a += 1'] } - - it 'matches ending expression soon' do - expect(described_class).to be_match([:op_asgn, '...'], ast) - end - - it 'matches going deep in the details' do - expect(described_class).to be_match([:op_asgn, '...', '_'], ast) - end - - it 'matches going deeply with multiple skips' do - expect(described_class).to be_match([:op_asgn, '...', '_', '...'], ast) - end - end - context 'with `Fast.expressions`' do it { expect(described_class).to be_match('(...)', s(:int, 1)) } it { expect(described_class).to be_match('(_ _)', s(:int, 1)) } - it { expect(described_class).to be_match('(int .odd?)', s(:int, 1)) } - it { expect(described_class).to be_match('.nil?', nil) } - it { expect(described_class).not_to be_match('(int .even?)', s(:int, 1)) } + it { expect(described_class).to be_match('(int odd?)', s(:int, 1)) } + it { expect(described_class).not_to be_match('(int even?)', s(:int, 1)) } it { expect(described_class).to be_match('(str "string")', code['"string"']) } it { expect(described_class).to be_match('(float 111.2345)', code['111.2345']) } - it { expect(described_class).to be_match('(const nil I18n)', code['I18n']) } - - context 'with astrolable node methods' do - it { expect(described_class).to be_match('.send_type?', code['method']) } - it { expect(described_class).to be_match('(.root? (!.root?))', code['a.b']) } - it { expect(described_class).not_to be_match('(!.root? (.root?))', code['a.b']) } - end - end - - context 'when mixing procs inside expressions' do - let(:expression) do - ['_', '_', :+, ->(node) { %i[int float].include?(node.type) }] - end - - it 'matches int' do - expect(described_class).to be_match(expression, code['a += 1']) - end - - it 'matches float' do - expect(described_class).to be_match(expression, code['a += 1.2']) - end - - it 'does not match string' do - expect(described_class).not_to be_match(expression, code['a += ""']) - end + it { expect(described_class).to be_match('(const nil? :I18n)', code['I18n']) } end it 'ignores empty spaces' do expect(described_class).to be_match( - '(send (send (send nil _) _) _)', + '(send (send (send nil? _) _) _)', s(:send, s(:send, s(:send, nil, :a), :b), :c) ) end describe '`{}`' do it 'allows match `or` operator' do - expect(described_class).to be_match('{int float} _', code['1.2']) + expect(described_class).to be_match('({int float} _)', code['1.2']) end it 'allows match first case' do - expect(described_class).to be_match('{int float} _', code['1']) + expect(described_class).to be_match('({int float} _)', code['1']) end it 'return false if does not match' do - expect(described_class).not_to be_match('{int float} _', code['""']) + expect(described_class).not_to be_match('({int float} _)', code['""']) end it 'works in nested levels' do @@ -260,7 +43,7 @@ def custom_method(node) end it 'works with complex operations nested levels' do - expect(described_class).to be_match('(send ({int float} _) + (int _))', code['2 + 5']) + expect(described_class).to be_match('(send ({int float} _) :+ (int _))', code['2 + 5']) end it 'does not match if the correct operator is missing' do @@ -268,15 +51,12 @@ def custom_method(node) end it 'matches with the correct operator' do - expect(described_class).to be_match('(send ({int float} _) {+-} (int _))', code['2 - 5']) + expect(described_class).to be_match('(send ({int float} _) {:+ :-} (int _))', code['2 - 5']) end it 'matches multiple symbols' do - expect(described_class).to be_match('(send {nil ...} b)', code['b']) - end - - it 'allows the maybe concept' do - expect(described_class).to be_match('(send {nil ...} b)', code['a.b']) + expect(described_class).to be_match('(send {nil? _ } :b)', code['b']) + expect(described_class).to be_match('(send {nil? _} :b)', code['a.b']) end end @@ -297,35 +77,21 @@ def custom_method(node) it { expect(described_class).not_to be_match('!({str int float} _)', code['1']) } end - describe '`maybe` do partial search with `?`' do - it 'allow maybe is a method call`' do - expect(described_class).to be_match('(send ?(send nil a) b)', code['a.b']) - end - - it 'allow without the method call' do - expect(described_class).to be_match('(send ?(send nil a) b)', code['b']) - end - - it 'does not match if the node does not satisfy the expressin' do - expect(described_class).not_to be_match('(send ?(send nil a) b)', code['b.a']) - end - end - describe '`$` for capturing' do it 'last children' do - expect(described_class.match?('(send nil $_)', s(:send, nil, :a))).to eq([:a]) + expect(described_class.match?('(send nil? $...)', code['a'])).to eq([:a]) end it 'the entire node' do - expect(described_class.match?('($(int _))', s(:int, 1))).to eq([s(:int, 1)]) + expect(described_class.match?('$(int _)', s(:int, 1))).to eq(s(:int, 1)) end it 'the value' do - expect(described_class.match?('(sym $_)', s(:sym, :a))).to eq([:a]) + expect(described_class.match?('(sym $_)', s(:sym, :a))).to eq(:a) end it 'multiple nodes' do - expect(described_class.match?('(:send $(:int _) :+ $(:int _))', s(:send, s(:int, 1), :+, s(:int, 2)))).to eq([s(:int, 1), s(:int, 2)]) + expect(described_class.match?('(send $(int _) :+ $(int _))', s(:send, s(:int, 1), :+, s(:int, 2)))).to eq([s(:int, 1), s(:int, 2)]) end it 'specific children' do @@ -333,60 +99,45 @@ def custom_method(node) end it 'complex negated joined condition' do - expect(described_class.match?('$!({str int float} _)', s(:sym, :sym))).to eq([s(:sym, :sym)]) + expect(described_class.match?('$!({str int float} _)', s(:sym, :sym))).to eq(s(:sym, :sym)) end describe 'capture method' do let(:ast) { code['def reverse_string(string) string.reverse end'] } it 'anonymously name' do - expect(described_class.match?('(def $_ ... ...)', ast)).to eq([:reverse_string]) + expect(described_class.match?('(def $_ _ ...)', ast)).to eq(:reverse_string) end it 'static name' do - expect(described_class.match?('(def $reverse_string ... ...)', ast)).to eq([:reverse_string]) + expect(described_class.match?('(def $:reverse_string _ ...)', ast)).to eq(:reverse_string) end it 'parameter' do - expect(described_class.match?('(def reverse_string (args (arg $_)) ...)', ast)).to eq([:string]) + expect(described_class.match?('(def :reverse_string (args (arg $_)) ...)', ast)).to eq(:string) end it 'content' do - expect(described_class.match?('(def reverse_string (args (arg _)) $...)', ast)).to eq([s(:send, s(:lvar, :string), :reverse)]) + expect(described_class.match?('(def :reverse_string (args (arg _)) $...)', ast)).to eq([s(:send, s(:lvar, :string), :reverse)]) end end describe 'capture symbol in multiple conditions' do - let(:expression) { '(send {nil ...} $_)' } - - it { expect(described_class.match?(expression, code['b'])).to eq([:b]) } - it { expect(described_class.match?(expression, code['a.b'])).to eq([:b]) } - end - end - - describe '\\ to match with previous captured symbols' do - it 'allow capture method name and reuse in children calls' do - ast = code['def name; person.name end'] - expect(described_class.match?('(def $_ (_) (send (send nil _) \1))', ast)).to eq([:name]) - end - - it 'captures local variable values in multiple nodes' do - expect(described_class.match?('(begin (lvasgn _ $(...)) (lvasgn _ \1))', code["a = 1\nb = 1"])).to eq([s(:int, 1)]) - end + let(:expression) { '(send {nil? _} $_)' } - it 'allow reuse captured integers' do - expect(described_class.match?('(begin (lvasgn _ (int $_)) (lvasgn _ (int \1)))', code["a = 1\nb = 1"])).to eq([1]) + it { expect(described_class.match?(expression, code['b'])).to eq(:b) } + it { expect(described_class.match?(expression, code['a.b'])).to eq(:b) } end end describe '`Parent` can follow expression in children with `^`' do it 'ignores type and search in children using expression following' do - expect(described_class).to be_match('^(int _)', code['a = 1']) + expect(described_class).to be_match('`(int _)', code['a = 1']) end it 'captures parent of parent and also ignore non node children' do ast = code['b = a = 1'] - expect(described_class.match?('$^^(int _)', ast)).to eq([ast]) + expect(described_class.match?('$``(int _)', ast)).to eq(ast) end end @@ -396,7 +147,7 @@ def custom_method(node) expect(described_class).to be_match('(str %1)', code['"test"'], 'test') expect(described_class).to be_match('(%1 %2)', code['"test"'], :str, 'test') expect(described_class).to be_match('(%1 %2)', code[':symbol'], :sym, :symbol) - expect(described_class).to be_match('{%1 %2}', code[':symbol'], :str, :sym) + expect(described_class).to be_match('({%1 %2} _)', code[':symbol'], :str, :sym) expect(described_class).not_to be_match('(lvasgn %1 (int _))', code['a = 1'], :b) expect(described_class).not_to be_match('{%1 %2}', code['1'], :str, :sym) end @@ -458,35 +209,35 @@ def self.thanks end it 'capture things flatten and unique nodes' do - method_names = described_class.search_file('(def $_)', 'sample.rb').grep(Symbol) + method_names = described_class.search_file('(def $_ _ ...)', 'sample.rb').grep(Symbol) expect(method_names).to eq(%i[initialize welcome]) end it 'captures const symbol' do - _, capture = described_class.search_file('(casgn nil $_ ...)', 'sample.rb') + _, capture = described_class.search_file('(casgn nil? $_ ...)', 'sample.rb') expect(capture).to eq(:AUTHOR) end it 'captures const assignment values' do - _, capture = described_class.search_file('(casgn nil _ (str $_))', 'sample.rb') + _, capture = described_class.search_file('(casgn nil? _ (str $_))', 'sample.rb') expect(capture).to eq('Jônatas Davi Paganini') end describe '.capture_file' do it 'captures puts arguments' do - res = described_class.capture_file('(send nil puts $(dstr ))', 'sample.rb') + res = described_class.capture_file('(send nil? :puts $(dstr ...))', 'sample.rb') strings = res.map { |node| node.loc.expression.source } expect(strings).to eq(['"Olá #{@name}"', '"Hola #{@name}"', '"Hello #{@name}"']) end it 'capture dynamic strings into nodes' do - res = described_class.capture_file('$(dstr _)', 'sample.rb') + res = described_class.capture_file('$(dstr ...)', 'sample.rb') strings = res.map { |node| node.loc.expression.source } expect(strings).to eq(['"Olá #{@name}"', '"Hola #{@name}"', '"Hello #{@name}"']) end it 'captures instance variables' do - ivars = described_class.capture_file('(ivasgn $_)', 'sample.rb') + ivars = described_class.capture_file('(ivasgn $_ ...)', 'sample.rb') expect(ivars).to eq(%i[@name @lang]) end @@ -499,7 +250,7 @@ def self.thanks describe '.search_all' do it 'allow search multiple files in the same time' do - results = described_class.search_all('(casgn nil VERSION)', ['lib/fast/version.rb']) + results = described_class.search_all('(casgn nil? :VERSION ...)', ['lib/fast/version.rb']) expect(results).to have_key('lib/fast/version.rb') expect(results['lib/fast/version.rb'].map { |n| n.loc.expression.source }).to eq(["VERSION = '#{Fast::VERSION}'"]) end @@ -509,13 +260,13 @@ def self.thanks after { File.delete('test-empty-file.rb') } - it { expect(described_class.search_all('(casgn nil VERSION)', ['test-empty-file.rb'])).to be_nil } + it { expect(described_class.search_all('(casgn nil? :VERSION)', ['test-empty-file.rb'])).to be_nil } end end describe '.capture_all' do it 'allow search multiple files in the same time' do - results = described_class.capture_all('(casgn nil VERSION (str $_))', ['lib/fast/version.rb']) + results = described_class.capture_all('(casgn nil? :VERSION (str $_))', ['lib/fast/version.rb']) expect(results).to eq('lib/fast/version.rb' => [Fast::VERSION]) end @@ -524,26 +275,13 @@ def self.thanks after { File.delete('test-empty-file.rb') } - it { expect(described_class.capture_all('(casgn nil VERSION)', ['test-empty-file.rb'])).to be_nil } - end - end - - describe '.debug' do - specify do - expect do - described_class.debug do - described_class.match?([:int, 1], s(:int, 1)) - end - end.to output(<<~OUTPUT).to_stdout - int == (int 1) # => true - 1 == 1 # => true - OUTPUT + it { expect(described_class.capture_all('(casgn nil? VERSION)', ['test-empty-file.rb'])).to be_nil } end end describe '.capture' do it 'single element' do - expect(described_class.capture('(lvasgn _ (int $_))', code['a = 1'])).to eq([1]) + expect(described_class.capture('(lvasgn _ (int $_))', code['a = 1'])).to eq(1) end it 'array elements' do @@ -551,11 +289,11 @@ def self.thanks end it 'nodes' do - expect(described_class.capture('(lvasgn _ $(int _))', code['a = 1'])).to eq([code['1']]) + expect(described_class.capture('(lvasgn _ $(int _))', code['a = 1'])).to eq(code['1']) end it 'multiple nodes' do - expect(described_class.capture('$(lvasgn _ (int _))', code['a = 1'])).to eq([code['a = 1']]) + expect(described_class.capture('$(lvasgn _ (int _))', code['a = 1'])).to eq(code['a = 1']) end end @@ -584,17 +322,6 @@ def self.thanks it { expect(described_class.expression_from(code['nil'])).to eq('(nil)') } it { expect(described_class.expression_from(code['a = 1'])).to eq('(lvasgn _ (int _))') } it { expect(described_class.expression_from(code['[1]'])).to eq('(array (int _))') } - it { expect(described_class.expression_from(code['def name; person.name end'])).to eq('(def _ (args) (send (send nil _) _))') } - end - - describe 'Find and descendant classes' do - describe '#to_s' do - it { expect(described_class.expression('...').to_s).to eq('f[...]') } - it { expect(described_class.expression('$...').to_s).to eq('c[f[...]]') } - it { expect(described_class.expression('{ a b }').to_s).to eq('any[f[a], f[b]]') } - it { expect(described_class.expression('\\1').to_s).to eq('fc[\\1]') } - it { expect(described_class.expression('^int').to_s).to eq('^f[int]') } - it { expect(described_class.expression('%1').to_s).to eq('find_with_arg[\\0]') } - end + it { expect(described_class.expression_from(code['def name; person.name end'])).to eq('(def _ (args) (send (send nil? _) _))') } end end