diff --git a/docs/modules/ROOT/pages/node_pattern_compiler.adoc b/docs/modules/ROOT/pages/node_pattern_compiler.adoc index 1d8a1bbb8..ee0447944 100644 --- a/docs/modules/ROOT/pages/node_pattern_compiler.adoc +++ b/docs/modules/ROOT/pages/node_pattern_compiler.adoc @@ -70,7 +70,7 @@ The `Lexer` emits tokens with types that are: * symbols of the form `:tTOKEN_TYPE` for the rest (e.g. `:tPREDICATE`) -Tokens are stored as `[type, value]`. +Tokens are stored as `[type, value]`, or `[type, [value, location]]` if locations are emitted. [discrete] ==== Generation @@ -238,3 +238,15 @@ see `Node#in_sequence_head`) ==== Precedence Like the node pattern subcompiler, it generates code that has higher or equal precedence to `&&`, so as to make chaining convenient. + +== Variant: WithMeta + +These variants of the Parser / Builder / Lexer generate `location` information (exactly like the `parser` gem) for AST nodes as well as comments with their locations (like the `parser` gem). + +Since this information is not typically used when one ony wants to define methods, it is not loaded by default. + +== Variant: Debug + +These variants of the Compiler / Subcompilers works by adding tracing code before and after each compilation of `NodePatternSubcompiler` and `SequenceSubcompiler`. +A unique ID is assigned to each node and the tracing code flips a corresponding switch when the expression is about to be evaluated, and after (joined with `&&` so it only flips the switch if the node was a match). +Atoms are not compiled differently as they are not really matchable (when not compiled as a node pattern) diff --git a/lib/rubocop/ast.rb b/lib/rubocop/ast.rb index fb7e0ee02..a4eb269fa 100644 --- a/lib/rubocop/ast.rb +++ b/lib/rubocop/ast.rb @@ -79,3 +79,6 @@ require_relative 'ast/token' require_relative 'ast/traversal' require_relative 'ast/version' + +::RuboCop::AST::NodePattern::Parser.autoload :WithMeta, "#{__dir__}/ast/node_pattern/with_meta" +::RuboCop::AST::NodePattern::Compiler.autoload :Debug, "#{__dir__}/ast/node_pattern/compiler/debug" diff --git a/lib/rubocop/ast/node_pattern/compiler/debug.rb b/lib/rubocop/ast/node_pattern/compiler/debug.rb new file mode 100644 index 000000000..705be9a0f --- /dev/null +++ b/lib/rubocop/ast/node_pattern/compiler/debug.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require 'rainbow' + +module RuboCop + module AST + class NodePattern + class Compiler + # Variant of the Compiler with tracing information for nodes + class Debug < Compiler + # Compiled node pattern requires a named parameter `trace`, + # which should be an instance of this class + class Trace + def initialize + @visit = {} + end + + def enter(node_id) + @visit[node_id] = false + true + end + + def success(node_id) + @visit[node_id] = true + end + + # return nil (not visited), false (not matched) or true (matched) + def matched?(node_id) + @visit[node_id] + end + end + + attr_reader :node_ids + + # @api private + class Colorizer + # Result of a NodePattern run against a particular AST + # Consider constructor is private + Result = Struct.new(:colorizer, :trace, :returned) do # rubocop:disable Metrics/BlockLength + # @return [String] a Rainbow colorized version of ruby + def colorize + ast.loc.expression.source_buffer.source.chars.map.with_index do |char, i| + Rainbow(char).color((color_map[i] || COLORS[:not_visitable])) + end.join + end + + # @return [Hash] a map for {character_position => color} + def color_map + @color_map ||= + match_map + .map { |node, matched| color_map_for(node, matched) } + .inject(:merge) + end + + # @return [Hash] a map for {node => matched?}, depth-first + def match_map + @match_map ||= + ast + .each_descendant + .to_a + .prepend(ast) + .to_h { |node| [node, matched?(node)] } + end + + # @return a value of `Trace#matched?` or `:not_visitable` + def matched?(node) + id = colorizer.compiler.node_ids.fetch(node) { return :not_visitable } + trace.matched?(id) + end + + private + + COLORS = { + not_visitable: :lightseagreen, + nil => :yellow, + false => :red, + true => :green + }.freeze + + def color_map_for(node, matched = matched?(node)) + return {} unless (range = node.loc&.expression) + + color = COLORS.fetch(matched) + range.to_a.to_h { |char| [char, color] } + end + + def ast + colorizer.node_pattern.ast + end + end + + attr_reader :pattern, :compiler, :node_pattern + + def initialize(pattern) + @pattern = pattern + @compiler = ::RuboCop::AST::NodePattern::Compiler::Debug.new + @node_pattern = ::RuboCop::AST::NodePattern.new(pattern, compiler: @compiler) + end + + # @return [Node] the Ruby AST + def test(ruby) + ruby = ruby_ast(ruby) if ruby.is_a?(String) + trace = Trace.new + returned = @node_pattern.as_lambda.call(ruby, trace: trace) + Result.new(self, trace, returned) + end + + private + + def ruby_ast(ruby) + buffer = ::Parser::Source::Buffer.new('(ruby)', source: ruby) + ruby_parser.parse(buffer) + end + + def ruby_parser + require 'parser/current' + builder = ::RuboCop::AST::Builder.new + ::Parser::CurrentRuby.new(builder) + end + end + + def initialize + super + @node_ids = Hash.new { |h, k| h[k] = h.size }.compare_by_identity + end + + def named_parameters + super << :trace + end + + def parser + @parser ||= Parser::WithMeta.new + end + + def_delegators :parser, :comments, :tokens + + # @api private + module InstrumentationSubcompiler + def do_compile + "#{tracer(:enter)} && #{super} && #{tracer(:success)}" + end + + private + + def tracer(kind) + id = compiler.node_ids[node] + "trace.#{kind}(#{id})" + end + end + + # @api private + class NodePatternSubcompiler < Compiler::NodePatternSubcompiler + include InstrumentationSubcompiler + end + + # @api private + class SequenceSubcompiler < Compiler::SequenceSubcompiler + include InstrumentationSubcompiler + end + end + end + end + end +end diff --git a/lib/rubocop/ast/node_pattern/with_meta.rb b/lib/rubocop/ast/node_pattern/with_meta.rb new file mode 100644 index 000000000..25e291ca5 --- /dev/null +++ b/lib/rubocop/ast/node_pattern/with_meta.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +module RuboCop + module AST + class NodePattern + class Parser + # Overrides Parser to use `WithMeta` variants and provide additional methods + class WithMeta < Parser + # Overrides Lexer to token locations and comments + class Lexer < NodePattern::Lexer + attr_reader :source_buffer + + def initialize(str_or_buffer) + @source_buffer = if str_or_buffer.respond_to?(:source) + str_or_buffer + else + ::Parser::Source::Buffer.new('(string)', source: str_or_buffer) + end + @comments = [] + super(@source_buffer.source) + end + + def token(type, value) + super(type, [value, pos]) + end + + def emit_comment + @comments << Comment.new(pos) + super + end + + # @return [::Parser::Source::Range] last match's position + def pos + ::Parser::Source::Range.new(source_buffer, ss.pos - ss.matched_size, ss.pos) + end + end + + # Overrides Builder to emit nodes with locations + class Builder < NodePattern::Builder + def emit_atom(type, token) + value, loc = token + begin_l = loc.resize(1) + end_l = loc.end.adjust(begin_pos: -1) + begin_l = nil if begin_l.source.match?(/\w/) + end_l = nil if end_l.source.match?(/\w/) + n(type, [value], source_map(token, begin_t: begin_l, end_t: end_l)) + end + + def emit_unary_op(type, operator_t = nil, *children) + children[-1] = children[-1].first if children[-1].is_a?(Array) # token? + map = source_map(children.first.loc.expression, operator_t: operator_t) + n(type, children, map) + end + + def emit_list(type, begin_t, children, end_t) + expr = children.first.loc.expression.join(children.last.loc.expression) + map = source_map(expr, begin_t: begin_t, end_t: end_t) + n(type, children, map) + end + + def emit_call(type, selector_t, args = nil) + selector, = selector_t + begin_t, arg_nodes, end_t = args + + map = source_map(selector_t, begin_t: begin_t, end_t: end_t, selector_t: selector_t) + n(type, [selector, *arg_nodes], map) + end + + private + + def n(type, children, source_map) + super(type, children, { location: source_map }) + end + + def loc(token_or_range) + return token_or_range[1] if token_or_range.is_a?(Array) + + token_or_range + end + + def join_exprs(left_expr, right_expr) + left_expr.loc.expression + .join(right_expr.loc.expression) + end + + def source_map(token_or_range, begin_t: nil, end_t: nil, operator_t: nil, selector_t: nil) + expression_l = loc(token_or_range) + expression_l = expression_l.expression if expression_l.respond_to?(:expression) + locs = [begin_t, end_t, operator_t, selector_t].map { |token| loc(token) } + begin_l, end_l, operator_l, selector_l = locs + + expression_l = locs.compact.inject(expression_l, :join) + + ::Parser::Source::Map::Send.new(_dot_l = nil, selector_l, begin_l, end_l, expression_l) + .with_operator(operator_l) + end + end + + attr_reader :comments, :tokens + + def do_parse + r = super + @comments = @lexer.comments + @tokens = @lexer.tokens + r + end + end + end + end + end +end diff --git a/spec/rubocop/ast/node_pattern/helper.rb b/spec/rubocop/ast/node_pattern/helper.rb index 86245c3ce..8f58980fb 100644 --- a/spec/rubocop/ast/node_pattern/helper.rb +++ b/spec/rubocop/ast/node_pattern/helper.rb @@ -1,5 +1,28 @@ # frozen_string_literal: true +require_relative 'parse_helper' + +Failure = Struct.new(:expected, :actual) + +module NodePatternHelper + include ParseHelper + + def assert_equal(expected, actual, mess = nil) + expect(actual).to eq(expected), *mess + end + + def assert(test, mess = nil) + expect(test).to eq(true), *mess + end + + def expect_parsing(ast, source, source_maps) + version = '-' + try_parsing(ast, source, parser, source_maps, version) + end +end + RSpec.shared_context 'parser' do - let(:parser) { RuboCop::AST::NodePattern::Parser.new } + include NodePatternHelper + + let(:parser) { RuboCop::AST::NodePattern::Parser::WithMeta.new } end diff --git a/spec/rubocop/ast/node_pattern/lexer_spec.rb b/spec/rubocop/ast/node_pattern/lexer_spec.rb index 66b2e1e59..53ad93443 100644 --- a/spec/rubocop/ast/node_pattern/lexer_spec.rb +++ b/spec/rubocop/ast/node_pattern/lexer_spec.rb @@ -2,7 +2,7 @@ RSpec.describe RuboCop::AST::NodePattern::Lexer do let(:source) { '(send nil? #func(:foo) #func (bar))' } - let(:lexer) { RuboCop::AST::NodePattern::Parser::Lexer.new(source) } + let(:lexer) { RuboCop::AST::NodePattern::Parser::WithMeta::Lexer.new(source) } let(:tokens) do tokens = [] while (token = lexer.next_token) @@ -12,9 +12,10 @@ end it 'provides tokens via next_token' do # rubocop:disable RSpec/ExampleLength - type, (text, _range) = tokens[3] + type, (text, range) = tokens[3] expect(type).to eq :tFUNCTION_CALL expect(text).to eq :func + expect(range.to_range).to eq 11...16 expect(tokens.map(&:first)).to eq [ '(', @@ -31,7 +32,7 @@ let(:source) { '(array sym $int+ x)' } it 'works' do - expect(tokens.map(&:last)).to eq \ + expect(tokens.map(&:last).map(&:first)).to eq \ %i[( array sym $ int + x )] end end diff --git a/spec/rubocop/ast/node_pattern/parse_helper.rb b/spec/rubocop/ast/node_pattern/parse_helper.rb new file mode 100644 index 000000000..2adde05a2 --- /dev/null +++ b/spec/rubocop/ast/node_pattern/parse_helper.rb @@ -0,0 +1,329 @@ +# frozen_string_literal: true + +# Copied from Parser, some lines commented out with `# !!!` +module ParseHelper + include AST::Sexp + + # !!! require 'parser/all' + # !!! require 'parser/macruby' + # !!! require 'parser/rubymotion' + + ALL_VERSIONS = %w(1.8 1.9 2.0 2.1 2.2 2.3 2.4 2.5 2.6 2.7 2.8 mac ios) + + def setup + @diagnostics = [] + + super if defined?(super) + end + + def parser_for_ruby_version(version) + case version + when '1.8' then parser = Parser::Ruby18.new + when '1.9' then parser = Parser::Ruby19.new + when '2.0' then parser = Parser::Ruby20.new + when '2.1' then parser = Parser::Ruby21.new + when '2.2' then parser = Parser::Ruby22.new + when '2.3' then parser = Parser::Ruby23.new + when '2.4' then parser = Parser::Ruby24.new + when '2.5' then parser = Parser::Ruby25.new + when '2.6' then parser = Parser::Ruby26.new + when '2.7' then parser = Parser::Ruby27.new + when '2.8' then parser = Parser::Ruby28.new + when 'mac' then parser = Parser::MacRuby.new + when 'ios' then parser = Parser::RubyMotion.new + else raise "Unrecognized Ruby version #{version}" + end + + parser.diagnostics.consumer = lambda do |diagnostic| + @diagnostics << diagnostic + end + + parser + end + + def with_versions(versions) + (versions & ALL_VERSIONS).each do |version| + @diagnostics.clear + + parser = parser_for_ruby_version(version) + yield version, parser + end + end + + def assert_source_range(expect_range, range, version, what) + if expect_range == nil + # Avoid "Use assert_nil if expecting nil from .... This will fail in Minitest 6."" + assert_nil range, + "(#{version}) range of #{what}" + else + assert range.is_a?(Parser::Source::Range), + "(#{version}) #{range.inspect}.is_a?(Source::Range) for #{what}" + assert_equal expect_range, range.to_range, + "(#{version}) range of #{what}" + end + end + + # Use like this: + # ~~~ + # assert_parses( + # s(:send, s(:lit, 10), :+, s(:lit, 20)) + # %q{10 + 20}, + # %q{~~~~~~~ expression + # | ^ operator + # | ~~ expression (lit) + # }, + # %w(1.8 1.9) # optional + # ) + # ~~~ + def assert_parses(ast, code, source_maps='', versions=ALL_VERSIONS) + with_versions(versions) do |version, parser| + try_parsing(ast, code, parser, source_maps, version) + end + + # Also try parsing with lexer set to use UTF-32LE internally + with_versions(versions) do |version, parser| + parser.instance_eval { @lexer.force_utf32 = true } + try_parsing(ast, code, parser, source_maps, version) + end + end + + def try_parsing(ast, code, parser, source_maps, version) + source_file = Parser::Source::Buffer.new('(assert_parses)', source: code) + + begin + parsed_ast = parser.parse(source_file) + rescue => exc + backtrace = exc.backtrace + Exception.instance_method(:initialize).bind(exc). + call("(#{version}) #{exc.message}") + exc.set_backtrace(backtrace) + raise + end + + if ast.nil? + assert_nil parsed_ast, "(#{version}) AST equality" + return + end + + assert_equal ast, parsed_ast, + "(#{version}) AST equality" + + parse_source_map_descriptions(source_maps) do |range, map_field, ast_path, line| + + astlet = traverse_ast(parsed_ast, ast_path) + + if astlet.nil? + # This is a testsuite bug. + raise "No entity with AST path #{ast_path} in #{parsed_ast.inspect}" + end + + assert astlet.frozen? + + assert astlet.location.respond_to?(map_field), + "(#{version}) #{astlet.location.inspect}.respond_to?(#{map_field.inspect}) for:\n#{parsed_ast.inspect}" + + found_range = astlet.location.send(map_field) + + assert_source_range(range, found_range, version, line.inspect) + end + + # !!! assert parser.instance_eval { @lexer }.cmdarg.empty?, + # !!! "(#{version}) expected cmdarg to be empty after parsing" + + # !!! assert_equal 0, parser.instance_eval { @lexer.instance_eval { @paren_nest } }, + # !!! "(#{version}) expected paren_nest to be 0 after parsing" + end + + # Use like this: + # ~~~ + # assert_diagnoses( + # [:warning, :ambiguous_prefix, { prefix: '*' }], + # %q{foo *bar}, + # %q{ ^ location + # | ~~~ highlights (0)}) + # ~~~ + def assert_diagnoses(diagnostic, code, source_maps='', versions=ALL_VERSIONS) + with_versions(versions) do |version, parser| + source_file = Parser::Source::Buffer.new('(assert_diagnoses)', source: code) + + begin + parser = parser.parse(source_file) + rescue Parser::SyntaxError + # do nothing; the diagnostic was reported + end + + assert_equal 1, @diagnostics.count, + "(#{version}) emits a single diagnostic, not\n" \ + "#{@diagnostics.map(&:render).join("\n")}" + + emitted_diagnostic = @diagnostics.first + + level, reason, arguments = diagnostic + arguments ||= {} + message = Parser::Messages.compile(reason, arguments) + + assert_equal level, emitted_diagnostic.level + assert_equal reason, emitted_diagnostic.reason + assert_equal arguments, emitted_diagnostic.arguments + assert_equal message, emitted_diagnostic.message + + parse_source_map_descriptions(source_maps) do |range, map_field, ast_path, line| + + case map_field + when 'location' + assert_source_range range, + emitted_diagnostic.location, + version, 'location' + + when 'highlights' + index = ast_path.first.to_i + + assert_source_range range, + emitted_diagnostic.highlights[index], + version, "#{index}th highlight" + + else + raise "Unknown diagnostic range #{map_field}" + end + end + end + end + + # Use like this: + # ~~~ + # assert_diagnoses_many( + # [ + # [:warning, :ambiguous_literal], + # [:error, :unexpected_token, { :token => :tLCURLY }] + # ], + # %q{m /foo/ {}}, + # SINCE_2_4) + # ~~~ + def assert_diagnoses_many(diagnostics, code, versions=ALL_VERSIONS) + with_versions(versions) do |version, parser| + source_file = Parser::Source::Buffer.new('(assert_diagnoses_many)', source: code) + + begin + parser = parser.parse(source_file) + rescue Parser::SyntaxError + # do nothing; the diagnostic was reported + end + + assert_equal diagnostics.count, @diagnostics.count + + diagnostics.zip(@diagnostics) do |expected_diagnostic, actual_diagnostic| + level, reason, arguments = expected_diagnostic + arguments ||= {} + message = Parser::Messages.compile(reason, arguments) + + assert_equal level, actual_diagnostic.level + assert_equal reason, actual_diagnostic.reason + assert_equal arguments, actual_diagnostic.arguments + assert_equal message, actual_diagnostic.message + end + end + end + + def refute_diagnoses(code, versions=ALL_VERSIONS) + with_versions(versions) do |version, parser| + source_file = Parser::Source::Buffer.new('(refute_diagnoses)', source: code) + + begin + parser = parser.parse(source_file) + rescue Parser::SyntaxError + # do nothing; the diagnostic was reported + end + + assert_empty @diagnostics, + "(#{version}) emits no diagnostics, not\n" \ + "#{@diagnostics.map(&:render).join("\n")}" + end + end + + def assert_context(context, code, versions=ALL_VERSIONS) + with_versions(versions) do |version, parser| + source_file = Parser::Source::Buffer.new('(assert_context)', source: code) + + parsed_ast = parser.parse(source_file) + + nodes = find_matching_nodes(parsed_ast) { |node| node.type == :send && node.children[1] == :get_context } + assert_equal 1, nodes.count, "there must exactly 1 `get_context()` call" + + node = nodes.first + assert_equal context, node.context, "(#{version}) expect parsing context to match" + end + end + + SOURCE_MAP_DESCRIPTION_RE = + /(?x) + ^(?# $1 skip) ^(\s*) + (?# $2 highlight) ([~\^]+|\!) + \s+ + (?# $3 source_map_field) ([a-z_]+) + (?# $5 ast_path) (\s+\(([a-z_.\/0-9]+)\))? + $/ + + def parse_source_map_descriptions(descriptions) + unless block_given? + return to_enum(:parse_source_map_descriptions, descriptions) + end + + descriptions.each_line do |line| + # Remove leading " |", if it exists. + line = line.sub(/^\s*\|/, '').rstrip + + next if line.empty? + + if (match = SOURCE_MAP_DESCRIPTION_RE.match(line)) + if match[2] != '!' + begin_pos = match[1].length + end_pos = begin_pos + match[2].length + range = begin_pos...end_pos + end + source_map_field = match[3] + + if match[5] + ast_path = match[5].split('.') + else + ast_path = [] + end + + yield range, source_map_field, ast_path, line + else + raise "Cannot parse source map description line: #{line.inspect}." + end + end + end + + def traverse_ast(ast, path) + path.inject(ast) do |astlet, path_component| + # Split "dstr/2" to :dstr and 1 + type_str, index_str = path_component.split('/') + + type = type_str.to_sym + + if index_str.nil? + index = 0 + else + index = index_str.to_i - 1 + end + + matching_children = \ + astlet.children.select do |child| + AST::Node === child && child.type == type + end + + matching_children[index] + end + end + + def find_matching_nodes(ast, &block) + return [] unless ast.is_a?(AST::Node) + + result = [] + result << ast if block.call(ast) + ast.children.each { |child| result += find_matching_nodes(child, &block) } + + result + end +end diff --git a/spec/rubocop/ast/node_pattern/parser_spec.rb b/spec/rubocop/ast/node_pattern/parser_spec.rb index 0a6b315a3..28dc6d305 100644 --- a/spec/rubocop/ast/node_pattern/parser_spec.rb +++ b/spec/rubocop/ast/node_pattern/parser_spec.rb @@ -6,8 +6,51 @@ include_context 'parser' describe 'sequences' do + it 'parses simple sequences properly' do + expect_parsing( + s(:sequence, s(:node_type, :int), s(:number, 42)), + '(int 42)', + '^ begin + | ^ end + |~~~~~~~~ expression + | ~~~ expression (node_type) + | ~~ expression (number)' + ) + end + + it 'parses capture vs repetition with correct priority ' do + s_int = s(:capture, s(:node_type, :int)) + s_str = s(:capture, s(:node_type, :str)) + expect_parsing( + s(:sequence, + s(:wildcard, '_'), + s(:repetition, s_int, :*), + s(:repetition, s(:sequence, s_str), :+)), + '(_ $int* ($str)+)', + '^ begin + | ^ end + |~~~~~~~~~~~~~~~~~ expression + | ~~~~~ expression (repetition) + | ^ operator (repetition) + | ^ operator (repetition.capture) + | ~~~ expression (repetition.capture.node_type)' + ) + end + + it 'parses function calls' do + expect_parsing( + s(:function_call, :func, s(:number, 1), s(:number, 2), s(:number, 3)), + '#func(1, 2, 3)', + ' ^ begin + | ^ end + |~~~~~ selector + | ^ expression (number)' + ) + end + it 'generates specialized nodes' do - ast = parser.parse('($_)') + source_file = Parser::Source::Buffer.new('(spec)', source: '($_)') + ast = parser.parse(source_file) expect(ast.class).to eq ::RuboCop::AST::NodePattern::Node::Sequence expect(ast.child.class).to eq ::RuboCop::AST::NodePattern::Node::Capture end diff --git a/tasks/debug.rake b/tasks/debug.rake new file mode 100644 index 000000000..e941db4ff --- /dev/null +++ b/tasks/debug.rake @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +desc 'Compile pattern to Ruby code for debugging purposes' +task compile: :generate do + if (pattern = ARGV[1]) + require_relative '../lib/rubocop/ast' + puts ::RuboCop::AST::NodePattern.new(pattern).compile_as_lambda + else + puts 'Usage:' + puts " rake compile '(send nil? :example...)'" + end + exit(0) +end + +desc 'Parse pattern to AST for debugging purposes' +task parse: :generate do + if (pattern = ARGV[1]) + require_relative '../lib/rubocop/ast' + puts ::RuboCop::AST::NodePattern::Parser.new.parse(pattern) + else + puts 'Usage:' + puts " rake parse '(send nil? :example...)'" + end + exit(0) +end + +desc 'Tokens of pattern for debugging purposes' +task tokenize: :generate do + if (pattern = ARGV[1]) + require_relative '../lib/rubocop/ast' + puts ::RuboCop::AST::NodePattern::Parser::WithMeta.new.tokenize(pattern).last + else + puts 'Usage:' + puts " rake parse '(send nil? :example...)'" + end + exit(0) +end + +desc 'Test pattern against ruby code' +task test_pattern: :generate do + if (pattern = ARGV[1]) && (ruby = ARGV[2]) + require_relative '../lib/rubocop/ast' + colorizer = ::RuboCop::AST::NodePattern::Compiler::Debug::Colorizer.new(pattern) + puts colorizer.test(ruby).colorize + else + puts 'Usage:' + puts " rake test-pattern '(send nil? :example...)' 'example(42)'" + end + exit(0) +end