From 0d9f4ea0d45f6577a4a13f898e981958a1f039c6 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 19 Aug 2022 15:37:45 +0900 Subject: Import spec examples from ruby/syntax_suggest --- .../fixtures/derailed_require_tree.rb.txt | 74 + spec/syntax_suggest/fixtures/rexe.rb.txt | 569 ++ spec/syntax_suggest/fixtures/routes.rb.txt | 121 + spec/syntax_suggest/fixtures/ruby_buildpack.rb.txt | 1344 +++ spec/syntax_suggest/fixtures/syntax_tree.rb.txt | 9234 ++++++++++++++++++++ .../fixtures/this_project_extra_def.rb.txt | 64 + spec/syntax_suggest/fixtures/webmock.rb.txt | 35 + spec/syntax_suggest/integration/exe_cli_spec.rb | 22 + .../integration/ruby_command_line_spec.rb | 151 + .../integration/syntax_suggest_spec.rb | 211 + spec/syntax_suggest/spec_helper.rb | 90 + spec/syntax_suggest/unit/api_spec.rb | 83 + spec/syntax_suggest/unit/around_block_scan_spec.rb | 165 + spec/syntax_suggest/unit/block_expand_spec.rb | 200 + .../unit/capture_code_context_spec.rb | 202 + spec/syntax_suggest/unit/clean_document_spec.rb | 259 + spec/syntax_suggest/unit/cli_spec.rb | 224 + spec/syntax_suggest/unit/code_block_spec.rb | 77 + spec/syntax_suggest/unit/code_frontier_spec.rb | 135 + spec/syntax_suggest/unit/code_line_spec.rb | 164 + spec/syntax_suggest/unit/code_search_spec.rb | 505 ++ .../unit/display_invalid_blocks_spec.rb | 172 + spec/syntax_suggest/unit/explain_syntax_spec.rb | 255 + spec/syntax_suggest/unit/lex_all_spec.rb | 29 + .../unit/pathname_from_message_spec.rb | 56 + spec/syntax_suggest/unit/priority_queue_spec.rb | 95 + 26 files changed, 14536 insertions(+) create mode 100644 spec/syntax_suggest/fixtures/derailed_require_tree.rb.txt create mode 100755 spec/syntax_suggest/fixtures/rexe.rb.txt create mode 100644 spec/syntax_suggest/fixtures/routes.rb.txt create mode 100644 spec/syntax_suggest/fixtures/ruby_buildpack.rb.txt create mode 100644 spec/syntax_suggest/fixtures/syntax_tree.rb.txt create mode 100644 spec/syntax_suggest/fixtures/this_project_extra_def.rb.txt create mode 100644 spec/syntax_suggest/fixtures/webmock.rb.txt create mode 100644 spec/syntax_suggest/integration/exe_cli_spec.rb create mode 100644 spec/syntax_suggest/integration/ruby_command_line_spec.rb create mode 100644 spec/syntax_suggest/integration/syntax_suggest_spec.rb create mode 100644 spec/syntax_suggest/spec_helper.rb create mode 100644 spec/syntax_suggest/unit/api_spec.rb create mode 100644 spec/syntax_suggest/unit/around_block_scan_spec.rb create mode 100644 spec/syntax_suggest/unit/block_expand_spec.rb create mode 100644 spec/syntax_suggest/unit/capture_code_context_spec.rb create mode 100644 spec/syntax_suggest/unit/clean_document_spec.rb create mode 100644 spec/syntax_suggest/unit/cli_spec.rb create mode 100644 spec/syntax_suggest/unit/code_block_spec.rb create mode 100644 spec/syntax_suggest/unit/code_frontier_spec.rb create mode 100644 spec/syntax_suggest/unit/code_line_spec.rb create mode 100644 spec/syntax_suggest/unit/code_search_spec.rb create mode 100644 spec/syntax_suggest/unit/display_invalid_blocks_spec.rb create mode 100644 spec/syntax_suggest/unit/explain_syntax_spec.rb create mode 100644 spec/syntax_suggest/unit/lex_all_spec.rb create mode 100644 spec/syntax_suggest/unit/pathname_from_message_spec.rb create mode 100644 spec/syntax_suggest/unit/priority_queue_spec.rb (limited to 'spec/syntax_suggest') diff --git a/spec/syntax_suggest/fixtures/derailed_require_tree.rb.txt b/spec/syntax_suggest/fixtures/derailed_require_tree.rb.txt new file mode 100644 index 0000000000..668ac4010b --- /dev/null +++ b/spec/syntax_suggest/fixtures/derailed_require_tree.rb.txt @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +# Tree structure used to store and sort require memory costs +# RequireTree.new('get_process_mem') +module DerailedBenchmarks + class RequireTree + REQUIRED_BY = {} + + attr_reader :name + attr_writer :cost + attr_accessor :parent + + def initialize(name) + @name = name + @children = {} + @cost = 0 + + def self.reset! + REQUIRED_BY.clear + if defined?(Kernel::REQUIRE_STACK) + Kernel::REQUIRE_STACK.clear + + Kernel::REQUIRE_STACK.push(TOP_REQUIRE) + end + end + + def <<(tree) + @children[tree.name.to_s] = tree + tree.parent = self + (REQUIRED_BY[tree.name.to_s] ||= []) << self.name + end + + def [](name) + @children[name.to_s] + end + + # Returns array of child nodes + def children + @children.values + end + + def cost + @cost || 0 + end + + # Returns sorted array of child nodes from Largest to Smallest + def sorted_children + children.sort { |c1, c2| c2.cost <=> c1.cost } + end + + def to_string + str = String.new("#{name}: #{cost.round(4)} MiB") + if parent && REQUIRED_BY[self.name.to_s] + names = REQUIRED_BY[self.name.to_s].uniq - [parent.name.to_s] + if names.any? + str << " (Also required by: #{ names.first(2).join(", ") }" + str << ", and #{names.count - 2} others" if names.count > 3 + str << ")" + end + end + str + end + + # Recursively prints all child nodes + def print_sorted_children(level = 0, out = STDOUT) + return if cost < ENV['CUT_OFF'].to_f + out.puts " " * level + self.to_string + level += 1 + sorted_children.each do |child| + child.print_sorted_children(level, out) + end + end + end +end diff --git a/spec/syntax_suggest/fixtures/rexe.rb.txt b/spec/syntax_suggest/fixtures/rexe.rb.txt new file mode 100755 index 0000000000..92e44d4d1e --- /dev/null +++ b/spec/syntax_suggest/fixtures/rexe.rb.txt @@ -0,0 +1,569 @@ +#!/usr/bin/env ruby +# +# rexe - Ruby Command Line Executor Filter +# +# Inspired by https://github.com/thisredone/rb + +# frozen_string_literal: true + + +require 'bundler' +require 'date' +require 'optparse' +require 'ostruct' +require 'shellwords' + +class Rexe + + VERSION = '1.5.1' + + PROJECT_URL = 'https://github.com/keithrbennett/rexe' + + + module Helpers + + # Try executing code. If error raised, print message (but not stack trace) & exit -1. + def try + begin + yield + rescue Exception => e + unless e.class == SystemExit + $stderr.puts("rexe: #{e}") + $stderr.puts("Use the -h option to get help.") + exit(-1) + end + end + end + end + + + class Options < Struct.new( + :input_filespec, + :input_format, + :input_mode, + :loads, + :output_format, + :output_format_tty, + :output_format_block, + :requires, + :log_format, + :noop) + + + def initialize + super + clear + end + + + def clear + self.input_filespec = nil + self.input_format = :none + self.input_mode = :none + self.output_format = :none + self.output_format_tty = :none + self.output_format_block = :none + self.loads = [] + self.requires = [] + self.log_format = :none + self.noop = false + end + end + + + + + + class Lookups + def input_modes + @input_modes ||= { + 'l' => :line, + 'e' => :enumerator, + 'b' => :one_big_string, + 'n' => :none + } + end + + + def input_formats + @input_formats ||= { + 'j' => :json, + 'm' => :marshal, + 'n' => :none, + 'y' => :yaml, + } + end + + + def input_parsers + @input_parsers ||= { + json: ->(string) { JSON.parse(string) }, + marshal: ->(string) { Marshal.load(string) }, + none: ->(string) { string }, + yaml: ->(string) { YAML.load(string) }, + } + end + + + def output_formats + @output_formats ||= { + 'a' => :amazing_print, + 'i' => :inspect, + 'j' => :json, + 'J' => :pretty_json, + 'm' => :marshal, + 'n' => :none, + 'p' => :puts, # default + 'P' => :pretty_print, + 's' => :to_s, + 'y' => :yaml, + } + end + + + def formatters + @formatters ||= { + amazing_print: ->(obj) { obj.ai + "\n" }, + inspect: ->(obj) { obj.inspect + "\n" }, + json: ->(obj) { obj.to_json }, + marshal: ->(obj) { Marshal.dump(obj) }, + none: ->(_obj) { nil }, + pretty_json: ->(obj) { JSON.pretty_generate(obj) }, + pretty_print: ->(obj) { obj.pretty_inspect }, + puts: ->(obj) { require 'stringio'; sio = StringIO.new; sio.puts(obj); sio.string }, + to_s: ->(obj) { obj.to_s + "\n" }, + yaml: ->(obj) { obj.to_yaml }, + } + end + + + def format_requires + @format_requires ||= { + json: 'json', + pretty_json: 'json', + amazing_print: 'amazing_print', + pretty_print: 'pp', + yaml: 'yaml' + } + end + end + + + + class CommandLineParser + + include Helpers + + attr_reader :lookups, :options + + def initialize + @lookups = Lookups.new + @options = Options.new + end + + + # Inserts contents of REXE_OPTIONS environment variable at the beginning of ARGV. + private def prepend_environment_options + env_opt_string = ENV['REXE_OPTIONS'] + if env_opt_string + args_to_prepend = Shellwords.shellsplit(env_opt_string) + ARGV.unshift(args_to_prepend).flatten! + end + end + + + private def add_format_requires_to_requires_list + formats = [options.input_format, options.output_format, options.log_format] + requires = formats.map { |format| lookups.format_requires[format] }.uniq.compact + requires.each { |r| options.requires << r } + end + + + private def help_text + unless @help_text + @help_text ||= <<~HEREDOC + + rexe -- Ruby Command Line Executor/Filter -- v#{VERSION} -- #{PROJECT_URL} + + Executes Ruby code on the command line, + optionally automating management of standard input and standard output, + and optionally parsing input and formatting output with YAML, JSON, etc. + + rexe [options] [Ruby source code] + + Options: + + -c --clear_options Clear all previous command line options specified up to now + -f --input_file Use this file instead of stdin for preprocessed input; + if filespec has a YAML and JSON file extension, + sets input format accordingly and sets input mode to -mb + -g --log_format FORMAT Log format, logs to stderr, defaults to -gn (none) + (see -o for format options) + -h, --help Print help and exit + -i, --input_format FORMAT Input format, defaults to -in (None) + -ij JSON + -im Marshal + -in None (default) + -iy YAML + -l, --load RUBY_FILE(S) Ruby file(s) to load, comma separated; + ! to clear all, or precede a name with '-' to remove + -m, --input_mode MODE Input preprocessing mode (determines what `self` will be) + defaults to -mn (none) + -ml line; each line is ingested as a separate string + -me enumerator (each_line on STDIN or File) + -mb big string; all lines combined into one string + -mn none (default); no input preprocessing; + self is an Object.new + -n, --[no-]noop Do not execute the code (useful with -g); + For true: yes, true, y, +; for false: no, false, n + -o, --output_format FORMAT Output format, defaults to -on (no output): + -oa Amazing Print + -oi Inspect + -oj JSON + -oJ Pretty JSON + -om Marshal + -on No Output (default) + -op Puts + -oP Pretty Print + -os to_s + -oy YAML + If 2 letters are provided, 1st is for tty devices, 2nd for block + --project-url Outputs project URL on Github, then exits + -r, --require REQUIRE(S) Gems and built-in libraries to require, comma separated; + ! to clear all, or precede a name with '-' to remove + -v, --version Prints version and exits + + --------------------------------------------------------------------------------------- + + In many cases you will need to enclose your source code in single or double quotes. + + If source code is not specified, it will default to 'self', + which is most likely useful only in a filter mode (-ml, -me, -mb). + + If there is a .rexerc file in your home directory, it will be run as Ruby code + before processing the input. + + If there is a REXE_OPTIONS environment variable, its content will be prepended + to the command line so that you can specify options implicitly + (e.g. `export REXE_OPTIONS="-r amazing_print,yaml"`) + + HEREDOC + + @help_text.freeze + end + + @help_text + end + + + # File file input mode; detects the input mode (JSON, YAML, or None) from the extension. + private def autodetect_file_format(filespec) + extension = File.extname(filespec).downcase + if extension == '.json' + :json + elsif extension == '.yml' || extension == '.yaml' + :yaml + else + :none + end + end + + + private def open_resource(resource_identifier) + command = case (`uname`.chomp) + when 'Darwin' + 'open' + when 'Linux' + 'xdg-open' + else + 'start' + end + + `#{command} #{resource_identifier}` + end + + + # Using 'optparse', parses the command line. + # Settings go into this instance's properties (see Struct declaration). + def parse + + prepend_environment_options + + OptionParser.new do |parser| + + parser.on('-c', '--clear_options', "Clear all previous command line options") do |v| + options.clear + end + + parser.on('-f', '--input_file FILESPEC', + 'Use this file instead of stdin; autodetects YAML and JSON file extensions') do |v| + unless File.exist?(v) + raise "File #{v} does not exist." + end + options.input_filespec = v + options.input_format = autodetect_file_format(v) + if [:json, :yaml].include?(options.input_format) + options.input_mode = :one_big_string + end + end + + parser.on('-g', '--log_format FORMAT', 'Log format, logs to stderr, defaults to none (see -o for format options)') do |v| + options.log_format = lookups.output_formats[v] + if options.log_format.nil? + raise("Output mode was '#{v}' but must be one of #{lookups.output_formats.keys}.") + end + end + + parser.on("-h", "--help", "Show help") do |_help_requested| + puts help_text + exit + end + + parser.on('-i', '--input_format FORMAT', + 'Mode with which to parse input values (n = none (default), j = JSON, m = Marshal, y = YAML') do |v| + + options.input_format = lookups.input_formats[v] + if options.input_format.nil? + raise("Input mode was '#{v}' but must be one of #{lookups.input_formats.keys}.") + end + end + + parser.on('-l', '--load RUBY_FILE(S)', 'Ruby file(s) to load, comma separated, or ! to clear') do |v| + if v == '!' + options.loads.clear + else + loadfiles = v.split(',').map(&:strip).map { |s| File.expand_path(s) } + removes, adds = loadfiles.partition { |filespec| filespec[0] == '-' } + + existent, nonexistent = adds.partition { |filespec| File.exists?(filespec) } + if nonexistent.any? + raise("\nDid not find the following files to load: #{nonexistent}\n\n") + else + existent.each { |filespec| options.loads << filespec } + end + + removes.each { |filespec| options.loads -= [filespec[1..-1]] } + end + end + + parser.on('-m', '--input_mode MODE', + 'Mode with which to handle input (-ml, -me, -mb, -mn (default)') do |v| + + options.input_mode = lookups.input_modes[v] + if options.input_mode.nil? + raise("Input mode was '#{v}' but must be one of #{lookups.input_modes.keys}.") + end + end + + # See https://stackoverflow.com/questions/54576873/ruby-optionparser-short-code-for-boolean-option + # for an excellent explanation of this optparse incantation. + # According to the answer, valid options are: + # -n no, -n yes, -n false, -n true, -n n, -n y, -n +, but not -n -. + parser.on('-n', '--[no-]noop [FLAG]', TrueClass, "Do not execute the code (useful with -g)") do |v| + options.noop = (v.nil? ? true : v) + end + + parser.on('-o', '--output_format FORMAT', + 'Mode with which to format values for output (`-o` + [aijJmnpsy])') do |v| + options.output_format_tty = lookups.output_formats[v[0]] + options.output_format_block = lookups.output_formats[v[-1]] + options.output_format = ($stdout.tty? ? options.output_format_tty : options.output_format_block) + if [options.output_format_tty, options.output_format_block].include?(nil) + raise("Bad output mode '#{v}'; each must be one of #{lookups.output_formats.keys}.") + end + end + + parser.on('-r', '--require REQUIRE(S)', + 'Gems and built-in libraries (e.g. shellwords, yaml) to require, comma separated, or ! to clear') do |v| + if v == '!' + options.requires.clear + else + v.split(',').map(&:strip).each do |r| + if r[0] == '-' + options.requires -= [r[1..-1]] + else + options.requires << r + end + end + end + end + + parser.on('-v', '--version', 'Print version') do + puts VERSION + exit(0) + end + + # Undocumented feature: open Github project with default web browser on a Mac + parser.on('', '--open-project') do + open_resource(PROJECT_URL) + exit(0) + end + + parser.on('', '--project-url') do + puts PROJECT_URL + exit(0) + end + + end.parse! + + # We want to do this after all options have been processed because we don't want any clearing of the + # options (by '-c', etc.) to result in exclusion of these needed requires. + add_format_requires_to_requires_list + + options.requires = options.requires.sort.uniq + options.loads.uniq! + + options + + end + end + + + class Main + + include Helpers + + attr_reader :callable, :input_parser, :lookups, + :options, :output_formatter, + :log_formatter, :start_time, :user_source_code + + + def initialize + @lookups = Lookups.new + @start_time = DateTime.now + end + + + private def load_global_config_if_exists + filespec = File.join(Dir.home, '.rexerc') + load(filespec) if File.exists?(filespec) + end + + + private def init_parser_and_formatters + @input_parser = lookups.input_parsers[options.input_format] + @output_formatter = lookups.formatters[options.output_format] + @log_formatter = lookups.formatters[options.log_format] + end + + + # Executes the user specified code in the manner appropriate to the input mode. + # Performs any optionally specified parsing on input and formatting on output. + private def execute(eval_context_object, code) + if options.input_format != :none && options.input_mode != :none + eval_context_object = input_parser.(eval_context_object) + end + + value = eval_context_object.instance_eval(&code) + + unless options.output_format == :none + print output_formatter.(value) + end + rescue Errno::EPIPE + exit(-13) + end + + + # The global $RC (Rexe Context) OpenStruct is available in your user code. + # In order to make it possible to access this object in your loaded files, we are not creating + # it here; instead we add properties to it. This way, you can initialize an OpenStruct yourself + # in your loaded code and it will still work. If you do that, beware, any properties you add will be + # included in the log output. If the to_s of your added objects is large, that might be a pain. + private def init_rexe_context + $RC ||= OpenStruct.new + $RC.count = 0 + $RC.rexe_version = VERSION + $RC.start_time = start_time.iso8601 + $RC.source_code = user_source_code + $RC.options = options.to_h + + def $RC.i; count end # `i` aliases `count` so you can more concisely get the count in your user code + end + + + private def create_callable + eval("Proc.new { #{user_source_code} }") + end + + + private def lookup_action(mode) + input = options.input_filespec ? File.open(options.input_filespec) : STDIN + { + line: -> { input.each { |l| execute(l.chomp, callable); $RC.count += 1 } }, + enumerator: -> { execute(input.each_line, callable); $RC.count += 1 }, + one_big_string: -> { big_string = input.read; execute(big_string, callable); $RC.count += 1 }, + none: -> { execute(Object.new, callable) } + }.fetch(mode) + end + + + private def output_log_entry + if options.log_format != :none + $RC.duration_secs = Time.now - start_time.to_time + STDERR.puts(log_formatter.($RC.to_h)) + end + end + + + # Bypasses Bundler's restriction on loading gems + # (see https://stackoverflow.com/questions/55144094/bundler-doesnt-permit-using-gems-in-project-home-directory) + private def require!(the_require) + begin + require the_require + rescue LoadError => error + gem_path = `gem which #{the_require}` + if gem_path.chomp.strip.empty? + raise error # re-raise the error, can't fix it + else + load_dir = File.dirname(gem_path) + $LOAD_PATH += load_dir + require the_require + end + end + end + + + # This class' entry point. + def call + + try do + + @options = CommandLineParser.new.parse + + options.requires.each { |r| require!(r) } + load_global_config_if_exists + options.loads.each { |file| load(file) } + + @user_source_code = ARGV.join(' ') + @user_source_code = 'self' if @user_source_code == '' + + @callable = create_callable + + init_rexe_context + init_parser_and_formatters + + # This is where the user's source code will be executed; the action will in turn call `execute`. + lookup_action(options.input_mode).call unless options.noop + + output_log_entry + end + end + end +end + + +def bundler_run(&block) + # This used to be an unconditional call to with_clean_env but that method is now deprecated: + # [DEPRECATED] `Bundler.with_clean_env` has been deprecated in favor of `Bundler.with_unbundled_env`. + # If you instead want the environment before bundler was originally loaded, + # use `Bundler.with_original_env` + + if Bundler.respond_to?(:with_unbundled_env) + Bundler.with_unbundled_env { block.call } + else + Bundler.with_clean_env { block.call } + end +end + + +bundler_run { Rexe::Main.new.call } diff --git a/spec/syntax_suggest/fixtures/routes.rb.txt b/spec/syntax_suggest/fixtures/routes.rb.txt new file mode 100644 index 0000000000..86733821c0 --- /dev/null +++ b/spec/syntax_suggest/fixtures/routes.rb.txt @@ -0,0 +1,121 @@ +Rails.application.routes.draw do + constraints -> { Rails.application.config.non_production } do + namespace :foo do + resource :bar + end + end + constraints -> { Rails.application.config.non_production } do + namespace :bar do + resource :baz + end + end + constraints -> { Rails.application.config.non_production } do + namespace :bar do + resource :baz + end + end + constraints -> { Rails.application.config.non_production } do + namespace :bar do + resource :baz + end + end + constraints -> { Rails.application.config.non_production } do + namespace :bar do + resource :baz + end + end + constraints -> { Rails.application.config.non_production } do + namespace :bar do + resource :baz + end + end + constraints -> { Rails.application.config.non_production } do + namespace :bar do + resource :baz + end + end + constraints -> { Rails.application.config.non_production } do + namespace :bar do + resource :baz + end + end + constraints -> { Rails.application.config.non_production } do + namespace :bar do + resource :baz + end + end + constraints -> { Rails.application.config.non_production } do + namespace :bar do + resource :baz + end + end + constraints -> { Rails.application.config.non_production } do + namespace :bar do + resource :baz + end + end + constraints -> { Rails.application.config.non_production } do + namespace :bar do + resource :baz + end + end + constraints -> { Rails.application.config.non_production } do + namespace :bar do + resource :baz + end + end + constraints -> { Rails.application.config.non_production } do + namespace :bar do + resource :baz + end + end + constraints -> { Rails.application.config.non_production } do + namespace :bar do + resource :baz + end + end + constraints -> { Rails.application.config.non_production } do + namespace :bar do + resource :baz + end + end + constraints -> { Rails.application.config.non_production } do + namespace :bar do + resource :baz + end + end + constraints -> { Rails.application.config.non_production } do + namespace :bar do + resource :baz + end + end + constraints -> { Rails.application.config.non_production } do + namespace :bar do + resource :baz + end + end + constraints -> { Rails.application.config.non_production } do + namespace :bar do + resource :baz + end + end + constraints -> { Rails.application.config.non_production } do + namespace :bar do + resource :baz + end + end + constraints -> { Rails.application.config.non_production } do + namespace :bar do + resource :baz + end + end + + namespace :admin do + resource :session + + match "/foobar(*path)", via: :all, to: redirect { |_params, req| + uri = URI(req.path.gsub("foobar", "foobaz")) + uri.query = req.query_string.presence + uri.to_s + } +end diff --git a/spec/syntax_suggest/fixtures/ruby_buildpack.rb.txt b/spec/syntax_suggest/fixtures/ruby_buildpack.rb.txt new file mode 100644 index 0000000000..9acdbf3a61 --- /dev/null +++ b/spec/syntax_suggest/fixtures/ruby_buildpack.rb.txt @@ -0,0 +1,1344 @@ +require "tmpdir" +require "digest/md5" +require "benchmark" +require "rubygems" +require "language_pack" +require "language_pack/base" +require "language_pack/ruby_version" +require "language_pack/helpers/nodebin" +require "language_pack/helpers/node_installer" +require "language_pack/helpers/yarn_installer" +require "language_pack/helpers/layer" +require "language_pack/helpers/binstub_check" +require "language_pack/version" + +# base Ruby Language Pack. This is for any base ruby app. +class LanguagePack::Ruby < LanguagePack::Base + NAME = "ruby" + LIBYAML_VERSION = "0.1.7" + LIBYAML_PATH = "libyaml-#{LIBYAML_VERSION}" + RBX_BASE_URL = "http://binaries.rubini.us/heroku" + NODE_BP_PATH = "vendor/node/bin" + + Layer = LanguagePack::Helpers::Layer + + # detects if this is a valid Ruby app + # @return [Boolean] true if it's a Ruby app + def self.use? + instrument "ruby.use" do + File.exist?("Gemfile") + end + end + + def self.bundler + @@bundler ||= LanguagePack::Helpers::BundlerWrapper.new.install + end + + def bundler + self.class.bundler + end + + def initialize(*args) + super(*args) + @fetchers[:mri] = LanguagePack::Fetcher.new(VENDOR_URL, @stack) + @fetchers[:rbx] = LanguagePack::Fetcher.new(RBX_BASE_URL, @stack) + @node_installer = LanguagePack::Helpers::NodeInstaller.new + @yarn_installer = LanguagePack::Helpers::YarnInstaller.new + end + + def name + "Ruby" + end + + def default_addons + instrument "ruby.default_addons" do + add_dev_database_addon + end + end + + def default_config_vars + instrument "ruby.default_config_vars" do + vars = { + "LANG" => env("LANG") || "en_US.UTF-8", + } + + ruby_version.jruby? ? vars.merge({ + "JRUBY_OPTS" => default_jruby_opts + }) : vars + end + end + + def default_process_types + instrument "ruby.default_process_types" do + { + "rake" => "bundle exec rake", + "console" => "bundle exec irb" + } + end + end + + def best_practice_warnings + if bundler.has_gem?("asset_sync") + warn(<<-WARNING) +You are using the `asset_sync` gem. +This is not recommended. +See https://devcenter.heroku.com/articles/please-do-not-use-asset-sync for more information. +WARNING + end + end + + def compile + instrument 'ruby.compile' do + # check for new app at the beginning of the compile + new_app? + Dir.chdir(build_path) + remove_vendor_bundle + warn_bundler_upgrade + warn_bad_binstubs + install_ruby(slug_vendor_ruby, build_ruby_path) + setup_language_pack_environment( + ruby_layer_path: File.expand_path("."), + gem_layer_path: File.expand_path("."), + bundle_path: "vendor/bundle", + bundle_default_without: "development:test" + ) + allow_git do + install_bundler_in_app(slug_vendor_base) + load_bundler_cache + build_bundler + post_bundler + create_database_yml + install_binaries + run_assets_precompile_rake_task + end + config_detect + best_practice_warnings + warn_outdated_ruby + setup_profiled(ruby_layer_path: "$HOME", gem_layer_path: "$HOME") # $HOME is set to /app at run time + setup_export + cleanup + super + end + rescue => e + warn_outdated_ruby + raise e + end + + + def build + new_app? + remove_vendor_bundle + warn_bad_binstubs + ruby_layer = Layer.new(@layer_dir, "ruby", launch: true) + install_ruby("#{ruby_layer.path}/#{slug_vendor_ruby}") + ruby_layer.metadata[:version] = ruby_version.version + ruby_layer.metadata[:patchlevel] = ruby_version.patchlevel if ruby_version.patchlevel + ruby_layer.metadata[:engine] = ruby_version.engine.to_s + ruby_layer.metadata[:engine_version] = ruby_version.engine_version + ruby_layer.write + + gem_layer = Layer.new(@layer_dir, "gems", launch: true, cache: true, build: true) + setup_language_pack_environment( + ruby_layer_path: ruby_layer.path, + gem_layer_path: gem_layer.path, + bundle_path: "#{gem_layer.path}/vendor/bundle", + bundle_default_without: "development:test" + ) + allow_git do + # TODO install bundler in separate layer + topic "Loading Bundler Cache" + gem_layer.validate! do |metadata| + valid_bundler_cache?(gem_layer.path, gem_layer.metadata) + end + install_bundler_in_app("#{gem_layer.path}/#{slug_vendor_base}") + build_bundler + # TODO post_bundler might need to be done in a new layer + bundler.clean + gem_layer.metadata[:gems] = Digest::SHA2.hexdigest(File.read("Gemfile.lock")) + gem_layer.metadata[:stack] = @stack + gem_layer.metadata[:ruby_version] = run_stdout(%q(ruby -v)).strip + gem_layer.metadata[:rubygems_version] = run_stdout(%q(gem -v)).strip + gem_layer.metadata[:buildpack_version] = BUILDPACK_VERSION + gem_layer.write + + create_database_yml + # TODO replace this with multibuildpack stuff? put binaries in their own layer? + install_binaries + run_assets_precompile_rake_task + end + setup_profiled(ruby_layer_path: ruby_layer.path, gem_layer_path: gem_layer.path) + setup_export(gem_layer) + config_detect + best_practice_warnings + cleanup + + super + end + + def cleanup + end + + def config_detect + end + +private + + # A bad shebang line looks like this: + # + # ``` + # #!/usr/bin/env ruby2.5 + # ``` + # + # Since `ruby2.5` is not a valid binary name + # + def warn_bad_binstubs + check = LanguagePack::Helpers::BinstubCheck.new(app_root_dir: Dir.pwd, warn_object: self) + check.call + end + + def default_malloc_arena_max? + return true if @metadata.exists?("default_malloc_arena_max") + return @metadata.touch("default_malloc_arena_max") if new_app? + + return false + end + + def warn_bundler_upgrade + old_bundler_version = @metadata.read("bundler_version").strip if @metadata.exists?("bundler_version") + + if old_bundler_version && old_bundler_version != bundler.version + warn(<<-WARNING, inline: true) +Your app was upgraded to bundler #{ bundler.version }. +Previously you had a successful deploy with bundler #{ old_bundler_version }. + +If you see problems related to the bundler version please refer to: +https://devcenter.heroku.com/articles/bundler-version#known-upgrade-issues + +WARNING + end + end + + # For example "vendor/bundle/ruby/2.6.0" + def self.slug_vendor_base + @slug_vendor_base ||= begin + command = %q(ruby -e "require 'rbconfig';puts \"vendor/bundle/#{RUBY_ENGINE}/#{RbConfig::CONFIG['ruby_version']}\"") + out = run_no_pipe(command, user_env: true).strip + error "Problem detecting bundler vendor directory: #{out}" unless $?.success? + out + end + end + + # the relative path to the bundler directory of gems + # @return [String] resulting path + def slug_vendor_base + instrument 'ruby.slug_vendor_base' do + @slug_vendor_base ||= self.class.slug_vendor_base + end + end + + # the relative path to the vendored ruby directory + # @return [String] resulting path + def slug_vendor_ruby + "vendor/#{ruby_version.version_without_patchlevel}" + end + + # the absolute path of the build ruby to use during the buildpack + # @return [String] resulting path + def build_ruby_path + "/tmp/#{ruby_version.version_without_patchlevel}" + end + + # fetch the ruby version from bundler + # @return [String, nil] returns the ruby version if detected or nil if none is detected + def ruby_version + instrument 'ruby.ruby_version' do + return @ruby_version if @ruby_version + new_app = !File.exist?("vendor/heroku") + last_version_file = "buildpack_ruby_version" + last_version = nil + last_version = @metadata.read(last_version_file).strip if @metadata.exists?(last_version_file) + + @ruby_version = LanguagePack::RubyVersion.new(bundler.ruby_version, + is_new: new_app, + last_version: last_version) + return @ruby_version + end + end + + def set_default_web_concurrency + <<-EOF +case $(ulimit -u) in +256) + export HEROKU_RAM_LIMIT_MB=${HEROKU_RAM_LIMIT_MB:-512} + export WEB_CONCURRENCY=${WEB_CONCURRENCY:-2} + ;; +512) + export HEROKU_RAM_LIMIT_MB=${HEROKU_RAM_LIMIT_MB:-1024} + export WEB_CONCURRENCY=${WEB_CONCURRENCY:-4} + ;; +16384) + export HEROKU_RAM_LIMIT_MB=${HEROKU_RAM_LIMIT_MB:-2560} + export WEB_CONCURRENCY=${WEB_CONCURRENCY:-8} + ;; +32768) + export HEROKU_RAM_LIMIT_MB=${HEROKU_RAM_LIMIT_MB:-6144} + export WEB_CONCURRENCY=${WEB_CONCURRENCY:-16} + ;; +*) + ;; +esac +EOF + end + + # default JRUBY_OPTS + # return [String] string of JRUBY_OPTS + def default_jruby_opts + "-Xcompile.invokedynamic=false" + end + + # sets up the environment variables for the build process + def setup_language_pack_environment(ruby_layer_path:, gem_layer_path:, bundle_path:, bundle_default_without:) + instrument 'ruby.setup_language_pack_environment' do + if ruby_version.jruby? + ENV["PATH"] += ":bin" + ENV["JRUBY_OPTS"] = env('JRUBY_BUILD_OPTS') || env('JRUBY_OPTS') + end + setup_ruby_install_env(ruby_layer_path) + + # By default Node can address 1.5GB of memory, a limitation it inherits from + # the underlying v8 engine. This can occasionally cause issues during frontend + # builds where memory use can exceed this threshold. + # + # This passes an argument to all Node processes during the build, so that they + # can take advantage of all available memory on the build dynos. + ENV["NODE_OPTIONS"] ||= "--max_old_space_size=2560" + + # TODO when buildpack-env-args rolls out, we can get rid of + # ||= and the manual setting below + default_config_vars.each do |key, value| + ENV[key] ||= value + end + + paths = [] + gem_path = "#{gem_layer_path}/#{slug_vendor_base}" + ENV["GEM_PATH"] = gem_path + ENV["GEM_HOME"] = gem_path + + ENV["DISABLE_SPRING"] = "1" + + # Rails has a binstub for yarn that doesn't work for all applications + # we need to ensure that yarn comes before local bin dir for that case + paths << yarn_preinstall_bin_path if yarn_preinstalled? + + # Need to remove `./bin` folder since it links to the wrong --prefix ruby binstubs breaking require in Ruby 1.9.2 and 1.8.7. + # Because for 1.9.2 and 1.8.7 there is a "build" ruby and a non-"build" Ruby + paths << "#{File.expand_path(".")}/bin" unless ruby_version.ruby_192_or_lower? + + paths << "#{gem_layer_path}/#{bundler_binstubs_path}" # Binstubs from bundler, eg. vendor/bundle/bin + paths << "#{gem_layer_path}/#{slug_vendor_base}/bin" # Binstubs from rubygems, eg. vendor/bundle/ruby/2.6.0/bin + paths << ENV["PATH"] + + ENV["PATH"] = paths.join(":") + + ENV["BUNDLE_WITHOUT"] = env("BUNDLE_WITHOUT") || bundle_default_without + if ENV["BUNDLE_WITHOUT"].include?(' ') + ENV["BUNDLE_WITHOUT"] = ENV["BUNDLE_WITHOUT"].tr(' ', ':') + + warn("Your BUNDLE_WITHOUT contains a space, we are converting it to a colon `:` BUNDLE_WITHOUT=#{ENV["BUNDLE_WITHOUT"]}", inline: true) + end + ENV["BUNDLE_PATH"] = bundle_path + ENV["BUNDLE_BIN"] = bundler_binstubs_path + ENV["BUNDLE_DEPLOYMENT"] = "1" + ENV["BUNDLE_GLOBAL_PATH_APPENDS_RUBY_SCOPE"] = "1" if bundler.needs_ruby_global_append_path? + end + end + + # Sets up the environment variables for subsequent processes run by + # muiltibuildpack. We can't use profile.d because $HOME isn't set up + def setup_export(layer = nil) + instrument 'ruby.setup_export' do + if layer + paths = ENV["PATH"] + else + paths = ENV["PATH"].split(":").map do |path| + /^\/.*/ !~ path ? "#{build_path}/#{path}" : path + end.join(":") + end + + # TODO ensure path exported is correct + set_export_path "PATH", paths, layer + + if layer + gem_path = "#{layer.path}/#{slug_vendor_base}" + else + gem_path = "#{build_path}/#{slug_vendor_base}" + end + set_export_path "GEM_PATH", gem_path, layer + set_export_default "LANG", "en_US.UTF-8", layer + + # TODO handle jruby + if ruby_version.jruby? + set_export_default "JRUBY_OPTS", default_jruby_opts + end + + set_export_default "BUNDLE_PATH", ENV["BUNDLE_PATH"], layer + set_export_default "BUNDLE_WITHOUT", ENV["BUNDLE_WITHOUT"], layer + set_export_default "BUNDLE_BIN", ENV["BUNDLE_BIN"], layer + set_export_default "BUNDLE_GLOBAL_PATH_APPENDS_RUBY_SCOPE", ENV["BUNDLE_GLOBAL_PATH_APPENDS_RUBY_SCOPE"], layer if bundler.needs_ruby_global_append_path? + set_export_default "BUNDLE_DEPLOYMENT", ENV["BUNDLE_DEPLOYMENT"], layer if ENV["BUNDLE_DEPLOYMENT"] # Unset on windows since we delete the Gemfile.lock + end + end + + # sets up the profile.d script for this buildpack + def setup_profiled(ruby_layer_path: , gem_layer_path: ) + instrument 'setup_profiled' do + profiled_path = [] + + # Rails has a binstub for yarn that doesn't work for all applications + # we need to ensure that yarn comes before local bin dir for that case + if yarn_preinstalled? + profiled_path << yarn_preinstall_bin_path.gsub(File.expand_path("."), "$HOME") + elsif has_yarn_binary? + profiled_path << "#{ruby_layer_path}/vendor/#{@yarn_installer.binary_path}" + end + profiled_path << "$HOME/bin" # /app in production + profiled_path << "#{gem_layer_path}/#{bundler_binstubs_path}" # Binstubs from bundler, eg. vendor/bundle/bin + profiled_path << "#{gem_layer_path}/#{slug_vendor_base}/bin" # Binstubs from rubygems, eg. vendor/bundle/ruby/2.6.0/bin + profiled_path << "$PATH" + + set_env_default "LANG", "en_US.UTF-8" + set_env_override "GEM_PATH", "#{gem_layer_path}/#{slug_vendor_base}:$GEM_PATH" + set_env_override "PATH", profiled_path.join(":") + set_env_override "DISABLE_SPRING", "1" + + set_env_default "MALLOC_ARENA_MAX", "2" if default_malloc_arena_max? + + web_concurrency = env("SENSIBLE_DEFAULTS") ? set_default_web_concurrency : "" + add_to_profiled(web_concurrency, filename: "WEB_CONCURRENCY.sh", mode: "w") # always write that file, even if its empty (meaning no defaults apply), for interop with other buildpacks - and we overwrite the file rather than appending (which is the default) + + # TODO handle JRUBY + if ruby_version.jruby? + set_env_default "JRUBY_OPTS", default_jruby_opts + end + + set_env_default "BUNDLE_PATH", ENV["BUNDLE_PATH"] + set_env_default "BUNDLE_WITHOUT", ENV["BUNDLE_WITHOUT"] + set_env_default "BUNDLE_BIN", ENV["BUNDLE_BIN"] + set_env_default "BUNDLE_GLOBAL_PATH_APPENDS_RUBY_SCOPE", ENV["BUNDLE_GLOBAL_PATH_APPENDS_RUBY_SCOPE"] if bundler.needs_ruby_global_append_path? + set_env_default "BUNDLE_DEPLOYMENT", ENV["BUNDLE_DEPLOYMENT"] if ENV["BUNDLE_DEPLOYMENT"] # Unset on windows since we delete the Gemfile.lock + end + end + + def warn_outdated_ruby + return unless defined?(@outdated_version_check) + + @warn_outdated ||= begin + @outdated_version_check.join + + warn_outdated_minor + warn_outdated_eol + warn_stack_upgrade + true + end + end + + def warn_stack_upgrade + return unless defined?(@ruby_download_check) + return unless @ruby_download_check.next_stack(current_stack: stack) + return if @ruby_download_check.exists_on_next_stack?(current_stack: stack) + + warn(<<~WARNING) + Your Ruby version is not present on the next stack + + You are currently using #{ruby_version.version_for_download} on #{stack} stack. + This version does not exist on #{@ruby_download_check.next_stack(current_stack: stack)}. In order to upgrade your stack you will + need to upgrade to a supported Ruby version. + + For a list of supported Ruby versions see: + https://devcenter.heroku.com/articles/ruby-support#supported-runtimes + + For a list of the oldest Ruby versions present on a given stack see: + https://devcenter.heroku.com/articles/ruby-support#oldest-available-runtimes + WARNING + end + + def warn_outdated_eol + return unless @outdated_version_check.maybe_eol? + + if @outdated_version_check.eol? + warn(<<~WARNING) + EOL Ruby Version + + You are using a Ruby version that has reached its End of Life (EOL) + + We strongly suggest you upgrade to Ruby #{@outdated_version_check.suggest_ruby_eol_version} or later + + Your current Ruby version no longer receives security updates from + Ruby Core and may have serious vulnerabilities. While you will continue + to be able to deploy on Heroku with this Ruby version you must upgrade + to a non-EOL version to be eligible to receive support. + + Upgrade your Ruby version as soon as possible. + + For a list of supported Ruby versions see: + https://devcenter.heroku.com/articles/ruby-support#supported-runtimes + WARNING + else + # Maybe EOL + warn(<<~WARNING) + Potential EOL Ruby Version + + You are using a Ruby version that has either reached its End of Life (EOL) + or will reach its End of Life on December 25th of this year. + + We suggest you upgrade to Ruby #{@outdated_version_check.suggest_ruby_eol_version} or later + + Once a Ruby version becomes EOL, it will no longer receive + security updates from Ruby core and may have serious vulnerabilities. + + Please upgrade your Ruby version. + + For a list of supported Ruby versions see: + https://devcenter.heroku.com/articles/ruby-support#supported-runtimes + WARNING + end + end + + def warn_outdated_minor + return if @outdated_version_check.latest_minor_version? + + warn(<<~WARNING) + There is a more recent Ruby version available for you to use: + + #{@outdated_version_check.suggested_ruby_minor_version} + + The latest version will include security and bug fixes. We always recommend + running the latest version of your minor release. + + Please upgrade your Ruby version. + + For all available Ruby versions see: + https://devcenter.heroku.com/articles/ruby-support#supported-runtimes + WARNING + end + + # install the vendored ruby + # @return [Boolean] true if it installs the vendored ruby and false otherwise + def install_ruby(install_path, build_ruby_path = nil) + instrument 'ruby.install_ruby' do + # Could do a compare operation to avoid re-downloading ruby + return false unless ruby_version + installer = LanguagePack::Installers::RubyInstaller.installer(ruby_version).new(@stack) + + @ruby_download_check = LanguagePack::Helpers::DownloadPresence.new(ruby_version.file_name) + @ruby_download_check.call + + if ruby_version.build? + installer.fetch_unpack(ruby_version, build_ruby_path, true) + end + + installer.install(ruby_version, install_path) + + @outdated_version_check = LanguagePack::Helpers::OutdatedRubyVersion.new( + current_ruby_version: ruby_version, + fetcher: installer.fetcher + ) + @outdated_version_check.call + + @metadata.write("buildpack_ruby_version", ruby_version.version_for_download) + + topic "Using Ruby version: #{ruby_version.version_for_download}" + if !ruby_version.set + warn(<<~WARNING) + You have not declared a Ruby version in your Gemfile. + + To declare a Ruby version add this line to your Gemfile: + + ``` + ruby "#{LanguagePack::RubyVersion::DEFAULT_VERSION_NUMBER}" + ``` + + For more information see: + https://devcenter.heroku.com/articles/ruby-versions + WARNING + end + + if ruby_version.warn_ruby_26_bundler? + warn(<<~WARNING, inline: true) + There is a known bundler bug with your version of Ruby + + Your version of Ruby contains a problem with the built-in integration of bundler. If + you encounter a bundler error you need to upgrade your Ruby version. We suggest you upgrade to: + + #{@outdated_version_check.suggested_ruby_minor_version} + + For more information see: + https://devcenter.heroku.com/articles/bundler-version#known-upgrade-issues + WARNING + end + end + + true + rescue LanguagePack::Fetcher::FetchError + if @ruby_download_check.does_not_exist? + message = <<~ERROR + The Ruby version you are trying to install does not exist: #{ruby_version.version_for_download} + ERROR + else + message = <<~ERROR + The Ruby version you are trying to install does not exist on this stack. + + You are trying to install #{ruby_version.version_for_download} on #{stack}. + + Ruby #{ruby_version.version_for_download} is present on the following stacks: + + - #{@ruby_download_check.valid_stack_list.join("\n - ")} + ERROR + + if env("CI") + message << <<~ERROR + + On Heroku CI you can set your stack in the `app.json`. For example: + + ``` + "stack": "heroku-20" + ``` + ERROR + end + end + + message << <<~ERROR + + Heroku recommends you use the latest supported Ruby version listed here: + https://devcenter.heroku.com/articles/ruby-support#supported-runtimes + + For more information on syntax for declaring a Ruby version see: + https://devcenter.heroku.com/articles/ruby-versions + ERROR + + error message + end + + # TODO make this compatible with CNB + def new_app? + @new_app ||= !File.exist?("vendor/heroku") + end + + # find the ruby install path for its binstubs during build + # @return [String] resulting path or empty string if ruby is not vendored + def ruby_install_binstub_path(ruby_layer_path = ".") + @ruby_install_binstub_path ||= + if ruby_version.build? + "#{build_ruby_path}/bin" + elsif ruby_version + "#{ruby_layer_path}/#{slug_vendor_ruby}/bin" + else + "" + end + end + + # setup the environment so we can use the vendored ruby + def setup_ruby_install_env(ruby_layer_path = ".") + instrument 'ruby.setup_ruby_install_env' do + ENV["PATH"] = "#{File.expand_path(ruby_install_binstub_path(ruby_layer_path))}:#{ENV["PATH"]}" + end + end + + # installs vendored gems into the slug + def install_bundler_in_app(bundler_dir) + instrument 'ruby.install_language_pack_gems' do + FileUtils.mkdir_p(bundler_dir) + Dir.chdir(bundler_dir) do |dir| + `cp -R #{bundler.bundler_path}/. .` + end + + # write bundler shim, so we can control the version bundler used + # Ruby 2.6.0 started vendoring bundler + write_bundler_shim("vendor/bundle/bin") if ruby_version.vendored_bundler? + end + end + + # default set of binaries to install + # @return [Array] resulting list + def binaries + add_node_js_binary + add_yarn_binary + end + + # vendors binaries into the slug + def install_binaries + instrument 'ruby.install_binaries' do + binaries.each {|binary| install_binary(binary) } + Dir["bin/*"].each {|path| run("chmod +x #{path}") } + end + end + + # vendors individual binary into the slug + # @param [String] name of the binary package from S3. + # Example: https://s3.amazonaws.com/language-pack-ruby/node-0.4.7.tgz, where name is "node-0.4.7" + def install_binary(name) + topic "Installing #{name}" + bin_dir = "bin" + FileUtils.mkdir_p bin_dir + Dir.chdir(bin_dir) do |dir| + if name.match(/^node\-/) + @node_installer.install + # need to set PATH here b/c `node-gyp` can change the CWD, but still depends on executing node. + # the current PATH is relative, but it needs to be absolute for this. + # doing this here also prevents it from being exported during runtime + node_bin_path = File.absolute_path(".") + # this needs to be set after so other binaries in bin/ don't take precedence" + ENV["PATH"] = "#{ENV["PATH"]}:#{node_bin_path}" + elsif name.match(/^yarn\-/) + FileUtils.mkdir_p("../vendor") + Dir.chdir("../vendor") do |vendor_dir| + @yarn_installer.install + yarn_path = File.absolute_path("#{vendor_dir}/#{@yarn_installer.binary_path}") + ENV["PATH"] = "#{yarn_path}:#{ENV["PATH"]}" + end + else + @fetchers[:buildpack].fetch_untar("#{name}.tgz") + end + end + end + + # removes a binary from the slug + # @param [String] relative path of the binary on the slug + def uninstall_binary(path) + FileUtils.rm File.join('bin', File.basename(path)), :force => true + end + + def load_default_cache? + new_app? && ruby_version.default? + end + + # loads a default bundler cache for new apps to speed up initial bundle installs + def load_default_cache + instrument "ruby.load_default_cache" do + if false # load_default_cache? + puts "New app detected loading default bundler cache" + patchlevel = run("ruby -e 'puts RUBY_PATCHLEVEL'").strip + cache_name = "#{LanguagePack::RubyVersion::DEFAULT_VERSION}-p#{patchlevel}-default-cache" + @fetchers[:buildpack].fetch_untar("#{cache_name}.tgz") + end + end + end + + # remove `vendor/bundle` that comes from the git repo + # in case there are native ext. + # users should be using `bundle pack` instead. + # https://github.com/heroku/heroku-buildpack-ruby/issues/21 + def remove_vendor_bundle + if File.exists?("vendor/bundle") + warn(<<-WARNING) +Removing `vendor/bundle`. +Checking in `vendor/bundle` is not supported. Please remove this directory +and add it to your .gitignore. To vendor your gems with Bundler, use +`bundle pack` instead. +WARNING + FileUtils.rm_rf("vendor/bundle") + end + end + + def bundler_binstubs_path + "vendor/bundle/bin" + end + + def bundler_path + @bundler_path ||= "#{slug_vendor_base}/gems/#{bundler.dir_name}" + end + + def write_bundler_shim(path) + FileUtils.mkdir_p(path) + shim_path = "#{path}/bundle" + File.open(shim_path, "w") do |file| + file.print <<-BUNDLE +#!/usr/bin/env ruby +require 'rubygems' + +version = "#{bundler.version}" + +if ARGV.first + str = ARGV.first + str = str.dup.force_encoding("BINARY") if str.respond_to? :force_encoding + if str =~ /\A_(.*)_\z/ and Gem::Version.correct?($1) then + version = $1 + ARGV.shift + end +end + +if Gem.respond_to?(:activate_bin_path) +load Gem.activate_bin_path('bundler', 'bundle', version) +else +gem "bundler", version +load Gem.bin_path("bundler", "bundle", version) +end +BUNDLE + end + FileUtils.chmod(0755, shim_path) + end + + # runs bundler to install the dependencies + def build_bundler + instrument 'ruby.build_bundler' do + log("bundle") do + if File.exist?("#{Dir.pwd}/.bundle/config") + warn(<<~WARNING, inline: true) + You have the `.bundle/config` file checked into your repository + It contains local state like the location of the installed bundle + as well as configured git local gems, and other settings that should + not be shared between multiple checkouts of a single repo. Please + remove the `.bundle/` folder from your repo and add it to your `.gitignore` file. + + https://devcenter.heroku.com/articles/bundler-configuration + WARNING + end + + if bundler.windows_gemfile_lock? + log("bundle", "has_windows_gemfile_lock") + + File.unlink("Gemfile.lock") + ENV.delete("BUNDLE_DEPLOYMENT") + + warn(<<~WARNING, inline: true) + Removing `Gemfile.lock` because it was generated on Windows. + Bundler will do a full resolve so native gems are handled properly. + This may result in unexpected gem versions being used in your app. + In rare occasions Bundler may not be able to resolve your dependencies at all. + + https://devcenter.heroku.com/articles/bundler-windows-gemfile + WARNING + end + + bundle_command = String.new("") + bundle_command << "BUNDLE_WITHOUT='#{ENV["BUNDLE_WITHOUT"]}' " + bundle_command << "BUNDLE_PATH=#{ENV["BUNDLE_PATH"]} " + bundle_command << "BUNDLE_BIN=#{ENV["BUNDLE_BIN"]} " + bundle_command << "BUNDLE_DEPLOYMENT=#{ENV["BUNDLE_DEPLOYMENT"]} " if ENV["BUNDLE_DEPLOYMENT"] # Unset on windows since we delete the Gemfile.lock + bundle_command << "BUNDLE_GLOBAL_PATH_APPENDS_RUBY_SCOPE=#{ENV["BUNDLE_GLOBAL_PATH_APPENDS_RUBY_SCOPE"]} " if bundler.needs_ruby_global_append_path? + bundle_command << "bundle install -j4" + + topic("Installing dependencies using bundler #{bundler.version}") + + bundler_output = String.new("") + bundle_time = nil + env_vars = {} + Dir.mktmpdir("libyaml-") do |tmpdir| + libyaml_dir = "#{tmpdir}/#{LIBYAML_PATH}" + + # need to setup compile environment for the psych gem + yaml_include = File.expand_path("#{libyaml_dir}/include").shellescape + yaml_lib = File.expand_path("#{libyaml_dir}/lib").shellescape + pwd = Dir.pwd + bundler_path = "#{pwd}/#{slug_vendor_base}/gems/#{bundler.dir_name}/lib" + + # we need to set BUNDLE_CONFIG and BUNDLE_GEMFILE for + # codon since it uses bundler. + env_vars["BUNDLE_GEMFILE"] = "#{pwd}/Gemfile" + env_vars["BUNDLE_CONFIG"] = "#{pwd}/.bundle/config" + env_vars["CPATH"] = noshellescape("#{yaml_include}:$CPATH") + env_vars["CPPATH"] = noshellescape("#{yaml_include}:$CPPATH") + env_vars["LIBRARY_PATH"] = noshellescape("#{yaml_lib}:$LIBRARY_PATH") + env_vars["RUBYOPT"] = syck_hack + env_vars["NOKOGIRI_USE_SYSTEM_LIBRARIES"] = "true" + env_vars["BUNDLE_DISABLE_VERSION_CHECK"] = "true" + env_vars["BUNDLER_LIB_PATH"] = "#{bundler_path}" if ruby_version.ruby_version == "1.8.7" + env_vars["BUNDLE_DISABLE_VERSION_CHECK"] = "true" + + puts "Running: #{bundle_command}" + instrument "ruby.bundle_install" do + bundle_time = Benchmark.realtime do + bundler_output << pipe("#{bundle_command} --no-clean", out: "2>&1", env: env_vars, user_env: true) + end + end + end + + if $?.success? + puts "Bundle completed (#{"%.2f" % bundle_time}s)" + log "bundle", :status => "success" + puts "Cleaning up the bundler cache." + instrument "ruby.bundle_clean" do + # Only show bundle clean output when not using default cache + if load_default_cache? + run("bundle clean > /dev/null", user_env: true, env: env_vars) + else + pipe("bundle clean", out: "2> /dev/null", user_env: true, env: env_vars) + end + end + @bundler_cache.store + + # Keep gem cache out of the slug + FileUtils.rm_rf("#{slug_vendor_base}/cache") + else + mcount "fail.bundle.install" + log "bundle", :status => "failure" + error_message = "Failed to install gems via Bundler." + puts "Bundler Output: #{bundler_output}" + if bundler_output.match(/An error occurred while installing sqlite3/) + mcount "fail.sqlite3" + error_message += <<~ERROR + + Detected sqlite3 gem which is not supported on Heroku: + https://devcenter.heroku.com/articles/sqlite3 + ERROR + end + + if bundler_output.match(/but your Gemfile specified/) + mcount "fail.ruby_version_mismatch" + error_message += <<~ERROR + + Detected a mismatch between your Ruby version installed and + Ruby version specified in Gemfile or Gemfile.lock. You can + correct this by running: + + $ bundle update --ruby + $ git add Gemfile.lock + $ git commit -m "update ruby version" + + If this does not solve the issue please see this documentation: + + https://devcenter.heroku.com/articles/ruby-versions#your-ruby-version-is-x-but-your-gemfile-specified-y + ERROR + end + + error error_message + end + end + end + end + + def post_bundler + instrument "ruby.post_bundler" do + Dir[File.join(slug_vendor_base, "**", ".git")].each do |dir| + FileUtils.rm_rf(dir) + end + bundler.clean + end + end + + # RUBYOPT line that requires syck_hack file + # @return [String] require string if needed or else an empty string + def syck_hack + instrument "ruby.syck_hack" do + syck_hack_file = File.expand_path(File.join(File.dirname(__FILE__), "../../vendor/syck_hack")) + rv = run_stdout('ruby -e "puts RUBY_VERSION"').strip + # < 1.9.3 includes syck, so we need to use the syck hack + if Gem::Version.new(rv) < Gem::Version.new("1.9.3") + "-r#{syck_hack_file}" + else + "" + end + end + end + + # writes ERB based database.yml for Rails. The database.yml uses the DATABASE_URL from the environment during runtime. + def create_database_yml + instrument 'ruby.create_database_yml' do + return false unless File.directory?("config") + return false if bundler.has_gem?('activerecord') && bundler.gem_version('activerecord') >= Gem::Version.new('4.1.0.beta1') + + log("create_database_yml") do + topic("Writing config/database.yml to read from DATABASE_URL") + File.open("config/database.yml", "w") do |file| + file.puts <<-DATABASE_YML +<% + +require 'cgi' +require 'uri' + +begin + uri = URI.parse(ENV["DATABASE_URL"]) +rescue URI::InvalidURIError + raise "Invalid DATABASE_URL" +end + +raise "No RACK_ENV or RAILS_ENV found" unless ENV["RAILS_ENV"] || ENV["RACK_ENV"] + +def attribute(name, value, force_string = false) + if value + value_string = + if force_string + '"' + value + '"' + else + value + end + "\#{name}: \#{value_string}" + else + "" + end +end + +adapter = uri.scheme +adapter = "postgresql" if adapter == "postgres" + +database = (uri.path || "").split("/")[1] + +username = uri.user +password = uri.password + +host = uri.host +port = uri.port + +params = CGI.parse(uri.query || "") + +%> + +<%= ENV["RAILS_ENV"] || ENV["RACK_ENV"] %>: + <%= attribute "adapter", adapter %> + <%= attribute "database", database %> + <%= attribute "username", username %> + <%= attribute "password", password, true %> + <%= attribute "host", host %> + <%= attribute "port", port %> + +<% params.each do |key, value| %> + <%= key %>: <%= value.first %> +<% end %> + DATABASE_YML + end + end + end + end + + def rake + @rake ||= begin + rake_gem_available = bundler.has_gem?("rake") || ruby_version.rake_is_vendored? + raise_on_fail = bundler.gem_version('railties') && bundler.gem_version('railties') > Gem::Version.new('3.x') + + topic "Detecting rake tasks" + rake = LanguagePack::Helpers::RakeRunner.new(rake_gem_available) + rake.load_rake_tasks!({ env: rake_env }, raise_on_fail) + rake + end + end + + def rake_env + if database_url + { "DATABASE_URL" => database_url } + else + {} + end.merge(user_env_hash) + end + + def database_url + env("DATABASE_URL") if env("DATABASE_URL") + end + + # executes the block with GIT_DIR environment variable removed since it can mess with the current working directory git thinks it's in + # @param [block] block to be executed in the GIT_DIR free context + def allow_git(&blk) + git_dir = ENV.delete("GIT_DIR") # can mess with bundler + blk.call + ENV["GIT_DIR"] = git_dir + end + + # decides if we need to enable the dev database addon + # @return [Array] the database addon if the pg gem is detected or an empty Array if it isn't. + def add_dev_database_addon + pg_adapters.any? {|a| bundler.has_gem?(a) } ? ['heroku-postgresql'] : [] + end + + def pg_adapters + [ + "pg", + "activerecord-jdbcpostgresql-adapter", + "jdbc-postgres", + "jdbc-postgresql", + "jruby-pg", + "rjack-jdbc-postgres", + "tgbyte-activerecord-jdbcpostgresql-adapter" + ] + end + + # decides if we need to install the node.js binary + # @note execjs will blow up if no JS RUNTIME is detected and is loaded. + # @return [Array] the node.js binary path if we need it or an empty Array + def add_node_js_binary + return [] if node_js_preinstalled? + + if Pathname(build_path).join("package.json").exist? || + bundler.has_gem?('execjs') || + bundler.has_gem?('webpacker') + [@node_installer.binary_path] + else + [] + end + end + + def add_yarn_binary + return [] if yarn_preinstalled? +| + if Pathname(build_path).join("yarn.lock").exist? || bundler.has_gem?('webpacker') + [@yarn_installer.name] + else + [] + end + end + + def has_yarn_binary? + add_yarn_binary.any? + end + + # checks if node.js is installed via the official heroku-buildpack-nodejs using multibuildpack + # @return String if it's detected and false if it isn't + def node_preinstall_bin_path + return @node_preinstall_bin_path if defined?(@node_preinstall_bin_path) + + legacy_path = "#{Dir.pwd}/#{NODE_BP_PATH}" + path = run("which node").strip + if path && $?.success? + @node_preinstall_bin_path = path + elsif run("#{legacy_path}/node -v") && $?.success? + @node_preinstall_bin_path = legacy_path + else + @node_preinstall_bin_path = false + end + end + alias :node_js_preinstalled? :node_preinstall_bin_path + + def node_not_preinstalled? + !node_js_preinstalled? + end + + # Example: tmp/build_8523f77fb96a956101d00988dfeed9d4/.heroku/yarn/bin/ (without the `yarn` at the end) + def yarn_preinstall_bin_path + (yarn_preinstall_binary_path || "").chomp("/yarn") + end + + # Example `tmp/build_8523f77fb96a956101d00988dfeed9d4/.heroku/yarn/bin/yarn` + def yarn_preinstall_binary_path + return @yarn_preinstall_binary_path if defined?(@yarn_preinstall_binary_path) + + path = run("which yarn").strip + if path && $?.success? + @yarn_preinstall_binary_path = path + else + @yarn_preinstall_binary_path = false + end + end + + def yarn_preinstalled? + yarn_preinstall_binary_path + end + + def yarn_not_preinstalled? + !yarn_preinstalled? + end + + def run_assets_precompile_rake_task + instrument 'ruby.run_assets_precompile_rake_task' do + + precompile = rake.task("assets:precompile") + return true unless precompile.is_defined? + + topic "Precompiling assets" + precompile.invoke(env: rake_env) + if precompile.success? + puts "Asset precompilation completed (#{"%.2f" % precompile.time}s)" + else + precompile_fail(precompile.output) + end + end + end + + def precompile_fail(output) + mcount "fail.assets_precompile" + log "assets_precompile", :status => "failure" + msg = "Precompiling assets failed.\n" + if output.match(/(127\.0\.0\.1)|(org\.postgresql\.util)/) + msg << "Attempted to access a nonexistent database:\n" + msg << "https://devcenter.heroku.com/articles/pre-provision-database\n" + end + + sprockets_version = bundler.gem_version('sprockets') + if output.match(/Sprockets::FileNotFound/) && (sprockets_version < Gem::Version.new('4.0.0.beta7') && sprockets_version > Gem::Version.new('4.0.0.beta4')) + mcount "fail.assets_precompile.file_not_found_beta" + msg << "If you have this file in your project\n" + msg << "try upgrading to Sprockets 4.0.0.beta7 or later:\n" + msg << "https://github.com/rails/sprockets/pull/547\n" + end + + error msg + end + + def bundler_cache + "vendor/bundle" + end + + def valid_bundler_cache?(path, metadata) + full_ruby_version = run_stdout(%q(ruby -v)).strip + rubygems_version = run_stdout(%q(gem -v)).strip + old_rubygems_version = nil + + old_rubygems_version = metadata[:ruby_version] + old_stack = metadata[:stack] + old_stack ||= DEFAULT_LEGACY_STACK + + stack_change = old_stack != @stack + if !new_app? && stack_change + return [false, "Purging Cache. Changing stack from #{old_stack} to #{@stack}"] + end + + # fix bug from v37 deploy + if File.exists?("#{path}/vendor/ruby_version") + puts "Broken cache detected. Purging build cache." + cache.clear("vendor") + FileUtils.rm_rf("#{path}/vendor/ruby_version") + return [false, "Broken cache detected. Purging build cache."] + # fix bug introduced in v38 + elsif !metadata.include?(:buildpack_version) && metadata.include?(:ruby_version) + puts "Broken cache detected. Purging build cache." + return [false, "Broken cache detected. Purging build cache."] + elsif (@bundler_cache.exists? || @bundler_cache.old?) && full_ruby_version != metadata[:ruby_version] + return [false, <<-MESSAGE] +Ruby version change detected. Clearing bundler cache. +Old: #{metadata[:ruby_version]} +New: #{full_ruby_version} +MESSAGE + end + + # fix git gemspec bug from Bundler 1.3.0+ upgrade + if File.exists?(bundler_cache) && !metadata.include?(:bundler_version) && !run("find #{path}/vendor/bundle/*/*/bundler/gems/*/ -name *.gemspec").include?("No such file or directory") + return [false, "Old bundler cache detected. Clearing bundler cache."] + end + + # fix for https://github.com/heroku/heroku-buildpack-ruby/issues/86 + if (!metadata.include?(:rubygems_version) || + (old_rubygems_version == "2.0.0" && old_rubygems_version != rubygems_version)) && + metadata.include?(:ruby_version) && metadata[:ruby_version].strip.include?("ruby 2.0.0p0") + return [false, "Updating to rubygems #{rubygems_version}. Clearing bundler cache."] + end + + # fix for https://github.com/sparklemotion/nokogiri/issues/923 + if metadata.include?(:buildpack_version) && (bv = metadata[:buildpack_version].sub('v', '').to_i) && bv != 0 && bv <= 76 + return [false, <<-MESSAGE] +Fixing nokogiri install. Clearing bundler cache. +See https://github.com/sparklemotion/nokogiri/issues/923. +MESSAGE + end + + # recompile nokogiri to use new libyaml + if metadata.include?(:buildpack_version) && (bv = metadata[:buildpack_version].sub('v', '').to_i) && bv != 0 && bv <= 99 && bundler.has_gem?("psych") + return [false, <<-MESSAGE] +Need to recompile psych for CVE-2013-6393. Clearing bundler cache. +See http://bugs.debian.org/cgi-bin/bugreport.cgi?bug=737076. +MESSAGE + end + + # recompile gems for libyaml 0.1.7 update + if metadata.include?(:buildpack_version) && (bv = metadata[:buildpack_version].sub('v', '').to_i) && bv != 0 && bv <= 147 && + (metadata.include?(:ruby_version) && metadata[:ruby_version].match(/ruby 2\.1\.(9|10)/) || + bundler.has_gem?("psych") + ) + return [false, <<-MESSAGE] +Need to recompile gems for CVE-2014-2014-9130. Clearing bundler cache. +See https://devcenter.heroku.com/changelog-items/1016. +MESSAGE + end + + true + end + + def load_bundler_cache + instrument "ruby.load_bundler_cache" do + cache.load "vendor" + + full_ruby_version = run_stdout(%q(ruby -v)).strip + rubygems_version = run_stdout(%q(gem -v)).strip + heroku_metadata = "vendor/heroku" + old_rubygems_version = nil + ruby_version_cache = "ruby_version" + buildpack_version_cache = "buildpack_version" + bundler_version_cache = "bundler_version" + rubygems_version_cache = "rubygems_version" + stack_cache = "stack" + + # bundle clean does not remove binstubs + FileUtils.rm_rf("vendor/bundler/bin") + + old_rubygems_version = @metadata.read(ruby_version_cache).strip if @metadata.exists?(ruby_version_cache) + old_stack = @metadata.read(stack_cache).strip if @metadata.exists?(stack_cache) + old_stack ||= DEFAULT_LEGACY_STACK + + stack_change = old_stack != @stack + convert_stack = @bundler_cache.old? + @bundler_cache.convert_stack(stack_change) if convert_stack + if !new_app? && stack_change + puts "Purging Cache. Changing stack from #{old_stack} to #{@stack}" + purge_bundler_cache(old_stack) + elsif !new_app? && !convert_stack + @bundler_cache.load + end + + # fix bug from v37 deploy + if File.exists?("vendor/ruby_version") + puts "Broken cache detected. Purging build cache." + cache.clear("vendor") + FileUtils.rm_rf("vendor/ruby_version") + purge_bundler_cache + # fix bug introduced in v38 + elsif !@metadata.include?(buildpack_version_cache) && @metadata.exists?(ruby_version_cache) + puts "Broken cache detected. Purging build cache." + purge_bundler_cache + elsif (@bundler_cache.exists? || @bundler_cache.old?) && @metadata.exists?(ruby_version_cache) && full_ruby_version != @metadata.read(ruby_version_cache).strip + puts "Ruby version change detected. Clearing bundler cache." + puts "Old: #{@metadata.read(ruby_version_cache).strip}" + puts "New: #{full_ruby_version}" + purge_bundler_cache + end + + # fix git gemspec bug from Bundler 1.3.0+ upgrade + if File.exists?(bundler_cache) && !@metadata.exists?(bundler_version_cache) && !run("find vendor/bundle/*/*/bundler/gems/*/ -name *.gemspec").include?("No such file or directory") + puts "Old bundler cache detected. Clearing bundler cache." + purge_bundler_cache + end + + # fix for https://github.com/heroku/heroku-buildpack-ruby/issues/86 + if (!@metadata.exists?(rubygems_version_cache) || + (old_rubygems_version == "2.0.0" && old_rubygems_version != rubygems_version)) && + @metadata.exists?(ruby_version_cache) && @metadata.read(ruby_version_cache).strip.include?("ruby 2.0.0p0") + puts "Updating to rubygems #{rubygems_version}. Clearing bundler cache." + purge_bundler_cache + end + + # fix for https://github.com/sparklemotion/nokogiri/issues/923 + if @metadata.exists?(buildpack_version_cache) && (bv = @metadata.read(buildpack_version_cache).sub('v', '').to_i) && bv != 0 && bv <= 76 + puts "Fixing nokogiri install. Clearing bundler cache." + puts "See https://github.com/sparklemotion/nokogiri/issues/923." + purge_bundler_cache + end + + # recompile nokogiri to use new libyaml + if @metadata.exists?(buildpack_version_cache) && (bv = @metadata.read(buildpack_version_cache).sub('v', '').to_i) && bv != 0 && bv <= 99 && bundler.has_gem?("psych") + puts "Need to recompile psych for CVE-2013-6393. Clearing bundler cache." + puts "See http://bugs.debian.org/cgi-bin/bugreport.cgi?bug=737076." + purge_bundler_cache + end + + # recompile gems for libyaml 0.1.7 update + if @metadata.exists?(buildpack_version_cache) && (bv = @metadata.read(buildpack_version_cache).sub('v', '').to_i) && bv != 0 && bv <= 147 && + (@metadata.exists?(ruby_version_cache) && @metadata.read(ruby_version_cache).strip.match(/ruby 2\.1\.(9|10)/) || + bundler.has_gem?("psych") + ) + puts "Need to recompile gems for CVE-2014-2014-9130. Clearing bundler cache." + puts "See https://devcenter.heroku.com/changelog-items/1016." + purge_bundler_cache + end + + FileUtils.mkdir_p(heroku_metadata) + @metadata.write(ruby_version_cache, full_ruby_version, false) + @metadata.write(buildpack_version_cache, BUILDPACK_VERSION, false) + @metadata.write(bundler_version_cache, bundler.version, false) + @metadata.write(rubygems_version_cache, rubygems_version, false) + @metadata.write(stack_cache, @stack, false) + @metadata.save + end + end + + def purge_bundler_cache(stack = nil) + instrument "ruby.purge_bundler_cache" do + @bundler_cache.clear(stack) + # need to reinstall language pack gems + install_bundler_in_app(slug_vendor_base) + end + end +end diff --git a/spec/syntax_suggest/fixtures/syntax_tree.rb.txt b/spec/syntax_suggest/fixtures/syntax_tree.rb.txt new file mode 100644 index 0000000000..1c110783f9 --- /dev/null +++ b/spec/syntax_suggest/fixtures/syntax_tree.rb.txt @@ -0,0 +1,9234 @@ +# frozen_string_literal: true + +require 'ripper' +require_relative 'syntax_tree/version' + +class SyntaxTree < Ripper + # Represents a line in the source. If this class is being used, it means that + # every character in the string is 1 byte in length, so we can just return the + # start of the line + the index. + class SingleByteString + def initialize(start) + @start = start + end + + def [](byteindex) + @start + byteindex + end + end + + # Represents a line in the source. If this class is being used, it means that + # there are characters in the string that are multi-byte, so we will build up + # an array of indices, such that array[byteindex] will be equal to the index + # of the character within the string. + class MultiByteString + def initialize(start, line) + @indices = [] + + line + .each_char + .with_index(start) do |char, index| + char.bytesize.times { @indices << index } + end + end + + def [](byteindex) + @indices[byteindex] + end + end + + # Represents the location of a node in the tree from the source code. + class Location + attr_reader :start_line, :start_char, :end_line, :end_char + + def initialize(start_line:, start_char:, end_line:, end_char:) + @start_line = start_line + @start_char = start_char + @end_line = end_line + @end_char = end_char + end + + def ==(other) + other.is_a?(Location) && start_line == other.start_line && + start_char == other.start_char && end_line == other.end_line && + end_char == other.end_char + end + + def to(other) + Location.new( + start_line: start_line, + start_char: start_char, + end_line: other.end_line, + end_char: other.end_char + ) + end + + def to_json(*opts) + [start_line, start_char, end_line, end_char].to_json(*opts) + end + + def self.token(line:, char:, size:) + new( + start_line: line, + start_char: char, + end_line: line, + end_char: char + size + ) + end + + def self.fixed(line:, char:) + new(start_line: line, start_char: char, end_line: line, end_char: char) + end + end + + # A special parser error so that we can get nice syntax displays on the error + # message when prettier prints out the results. + class ParseError < StandardError + attr_reader :lineno, :column + + def initialize(error, lineno, column) + super(error) + @lineno = lineno + @column = column + end + end + + attr_reader :source, :lines, :tokens + + # This is an attr_accessor so Stmts objects can grab comments out of this + # array and attach them to themselves. + attr_accessor :comments + + def initialize(source, *) + super + + # We keep the source around so that we can refer back to it when we're + # generating the AST. Sometimes it's easier to just reference the source + # string when you want to check if it contains a certain character, for + # example. + @source = source + + # Similarly, we keep the lines of the source string around to be able to + # check if certain lines contain certain characters. For example, we'll use + # this to generate the content that goes after the __END__ keyword. Or we'll + # use this to check if a comment has other content on its line. + @lines = source.split("\n") + + # This is the full set of comments that have been found by the parser. It's + # a running list. At the end of every block of statements, they will go in + # and attempt to grab any comments that are on their own line and turn them + # into regular statements. So at the end of parsing the only comments left + # in here will be comments on lines that also contain code. + @comments = [] + + # This is the current embdoc (comments that start with =begin and end with + # =end). Since they can't be nested, there's no need for a stack here, as + # there can only be one active. These end up getting dumped into the + # comments list before getting picked up by the statements that surround + # them. + @embdoc = nil + + # This is an optional node that can be present if the __END__ keyword is + # used in the file. In that case, this will represent the content after that + # keyword. + @__end__ = nil + + # Heredocs can actually be nested together if you're using interpolation, so + # this is a stack of heredoc nodes that are currently being created. When we + # get to the token that finishes off a heredoc node, we pop the top + # one off. If there are others surrounding it, then the body events will now + # be added to the correct nodes. + @heredocs = [] + + # This is a running list of tokens that have fired. It's useful + # mostly for maintaining location information. For example, if you're inside + # the handle of a def event, then in order to determine where the AST node + # started, you need to look backward in the tokens to find a def + # keyword. Most of the time, when a parser event consumes one of these + # events, it will be deleted from the list. So ideally, this list stays + # pretty short over the course of parsing a source string. + @tokens = [] + + # Here we're going to build up a list of SingleByteString or MultiByteString + # objects. They're each going to represent a string in the source. They are + # used by the `char_pos` method to determine where we are in the source + # string. + @line_counts = [] + last_index = 0 + + @source.lines.each do |line| + if line.size == line.bytesize + @line_counts << SingleByteString.new(last_index) + else + @line_counts << MultiByteString.new(last_index, line) + end + + last_index += line.size + end + end + + def self.parse(source) + parser = new(source) + response = parser.parse + response unless parser.error? + end + + private + + # ---------------------------------------------------------------------------- + # :section: Helper methods + # The following methods are used by the ripper event handlers to either + # determine their bounds or query other nodes. + # ---------------------------------------------------------------------------- + + # This represents the current place in the source string that we've gotten to + # so far. We have a memoized line_counts object that we can use to get the + # number of characters that we've had to go through to get to the beginning of + # this line, then we add the number of columns into this line that we've gone + # through. + def char_pos + @line_counts[lineno - 1][column] + end + + # As we build up a list of tokens, we'll periodically need to go backwards and + # find the ones that we've already hit in order to determine the location + # information for nodes that use them. For example, if you have a module node + # then you'll look backward for a kw token to determine your start location. + # + # This works with nesting since we're deleting tokens from the list once + # they've been used up. For example if you had nested module declarations then + # the innermost declaration would grab the last kw node that matches "module" + # (which would happen to be the innermost keyword). Then the outer one would + # only be able to grab the first one. In this way all of the tokens act as + # their own stack. + def find_token(type, value = :any, consume: true) + index = + tokens.rindex do |token| + token.is_a?(type) && (value == :any || (token.value == value)) + end + + if consume + # If we're expecting to be able to find a token and consume it, + # but can't actually find it, then we need to raise an error. This is + # _usually_ caused by a syntax error in the source that we're printing. It + # could also be caused by accidentally attempting to consume a token twice + # by two different parser event handlers. + unless index + message = "Cannot find expected #{value == :any ? type : value}" + raise ParseError.new(message, lineno, column) + end + + tokens.delete_at(index) + elsif index + tokens[index] + end + end + + # A helper function to find a :: operator. We do special handling instead of + # using find_token here because we don't pop off all of the :: + # operators so you could end up getting the wrong information if you have for + # instance ::X::Y::Z. + def find_colon2_before(const) + index = + tokens.rindex do |token| + token.is_a?(Op) && token.value == '::' && + token.location.start_char < const.location.start_char + end + + tokens[index] + end + + # Finds the next position in the source string that begins a statement. This + # is used to bind statements lists and make sure they don't include a + # preceding comment. For example, we want the following comment to be attached + # to the class node and not the statement node: + # + # class Foo # :nodoc: + # ... + # end + # + # By finding the next non-space character, we can make sure that the bounds of + # the statement list are correct. + def find_next_statement_start(position) + remaining = source[position..-1] + + if remaining.sub(/\A +/, '')[0] == '#' + return position + remaining.index("\n") + end + + position + end + + # ---------------------------------------------------------------------------- + # :section: Ripper event handlers + # The following methods all handle a dispatched ripper event. + # ---------------------------------------------------------------------------- + + # BEGINBlock represents the use of the +BEGIN+ keyword, which hooks into the + # lifecycle of the interpreter. Whatever is inside the block will get executed + # when the program starts. + # + # BEGIN { + # } + # + # Interestingly, the BEGIN keyword doesn't allow the do and end keywords for + # the block. Only braces are permitted. + class BEGINBlock + # [LBrace] the left brace that is seen after the keyword + attr_reader :lbrace + + # [Statements] the expressions to be executed + attr_reader :statements + + # [Location] the location of this node + attr_reader :location + + def initialize(lbrace:, statements:, location:) + @lbrace = lbrace + @statements = statements + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('BEGIN') + q.breakable + q.pp(statements) + end + end + + def to_json(*opts) + { + type: :BEGIN, + lbrace: lbrace, + stmts: statements, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_BEGIN: (Statements statements) -> BEGINBlock + def on_BEGIN(statements) + lbrace = find_token(LBrace) + rbrace = find_token(RBrace) + + statements.bind( + find_next_statement_start(lbrace.location.end_char), + rbrace.location.start_char + ) + + keyword = find_token(Kw, 'BEGIN') + + BEGINBlock.new( + lbrace: lbrace, + statements: statements, + location: keyword.location.to(rbrace.location) + ) + end + + # CHAR irepresents a single codepoint in the script encoding. + # + # ?a + # + # In the example above, the CHAR node represents the string literal "a". You + # can use control characters with this as well, as in ?\C-a. + class CHAR + # [String] the value of the character literal + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('CHAR') + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :CHAR, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_CHAR: (String value) -> CHAR + def on_CHAR(value) + node = + CHAR.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # ENDBlock represents the use of the +END+ keyword, which hooks into the + # lifecycle of the interpreter. Whatever is inside the block will get executed + # when the program ends. + # + # END { + # } + # + # Interestingly, the END keyword doesn't allow the do and end keywords for the + # block. Only braces are permitted. + class ENDBlock + # [LBrace] the left brace that is seen after the keyword + attr_reader :lbrace + + # [Statements] the expressions to be executed + attr_reader :statements + + # [Location] the location of this node + attr_reader :location + + def initialize(lbrace:, statements:, location:) + @lbrace = lbrace + @statements = statements + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('END') + q.breakable + q.pp(statements) + end + end + + def to_json(*opts) + { type: :END, lbrace: lbrace, stmts: statements, loc: location }.to_json( + *opts + ) + end + end + + # :call-seq: + # on_END: (Statements statements) -> ENDBlock + def on_END(statements) + lbrace = find_token(LBrace) + rbrace = find_token(RBrace) + + statements.bind( + find_next_statement_start(lbrace.location.end_char), + rbrace.location.start_char + ) + + keyword = find_token(Kw, 'END') + + ENDBlock.new( + lbrace: lbrace, + statements: statements, + location: keyword.location.to(rbrace.location) + ) + end + + # EndContent represents the use of __END__ syntax, which allows individual + # scripts to keep content after the main ruby code that can be read through + # the DATA constant. + # + # puts DATA.read + # + # __END__ + # some other content that is not executed by the program + # + class EndContent + # [String] the content after the script + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('__end__') + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :__end__, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on___end__: (String value) -> EndContent + def on___end__(value) + @__end__ = + EndContent.new( + value: lines[lineno..-1].join("\n"), + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + end + + # Alias represents the use of the +alias+ keyword with regular arguments (not + # global variables). The +alias+ keyword is used to make a method respond to + # another name as well as the current one. + # + # alias aliased_name name + # + # For the example above, in the current context you can now call aliased_name + # and it will execute the name method. When you're aliasing two methods, you + # can either provide bare words (like the example above) or you can provide + # symbols (note that this includes dynamic symbols like + # :"left-#{middle}-right"). + class Alias + # [DynaSymbol | SymbolLiteral] the new name of the method + attr_reader :left + + # [DynaSymbol | SymbolLiteral] the old name of the method + attr_reader :right + + # [Location] the location of this node + attr_reader :location + + def initialize(left:, right:, location:) + @left = left + @right = right + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('alias') + q.breakable + q.pp(left) + q.breakable + q.pp(right) + end + end + + def to_json(*opts) + { type: :alias, left: left, right: right, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_alias: ( + # (DynaSymbol | SymbolLiteral) left, + # (DynaSymbol | SymbolLiteral) right + # ) -> Alias + def on_alias(left, right) + keyword = find_token(Kw, 'alias') + + Alias.new( + left: left, + right: right, + location: keyword.location.to(right.location) + ) + end + + # ARef represents when you're pulling a value out of a collection at a + # specific index. Put another way, it's any time you're calling the method + # #[]. + # + # collection[index] + # + # The nodes usually contains two children, the collection and the index. In + # some cases, you don't necessarily have the second child node, because you + # can call procs with a pretty esoteric syntax. In the following example, you + # wouldn't have a second child node: + # + # collection[] + # + class ARef + # [untyped] the value being indexed + attr_reader :collection + + # [nil | Args | ArgsAddBlock] the value being passed within the brackets + attr_reader :index + + # [Location] the location of this node + attr_reader :location + + def initialize(collection:, index:, location:) + @collection = collection + @index = index + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('aref') + q.breakable + q.pp(collection) + q.breakable + q.pp(index) + end + end + + def to_json(*opts) + { + type: :aref, + collection: collection, + index: index, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_aref: (untyped collection, (nil | Args | ArgsAddBlock) index) -> ARef + def on_aref(collection, index) + find_token(LBracket) + rbracket = find_token(RBracket) + + ARef.new( + collection: collection, + index: index, + location: collection.location.to(rbracket.location) + ) + end + + # ARefField represents assigning values into collections at specific indices. + # Put another way, it's any time you're calling the method #[]=. The + # ARefField node itself is just the left side of the assignment, and they're + # always wrapped in assign nodes. + # + # collection[index] = value + # + class ARefField + # [untyped] the value being indexed + attr_reader :collection + + # [nil | ArgsAddBlock] the value being passed within the brackets + attr_reader :index + + # [Location] the location of this node + attr_reader :location + + def initialize(collection:, index:, location:) + @collection = collection + @index = index + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('aref_field') + q.breakable + q.pp(collection) + q.breakable + q.pp(index) + end + end + + def to_json(*opts) + { + type: :aref_field, + collection: collection, + index: index, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_aref_field: ( + # untyped collection, + # (nil | ArgsAddBlock) index + # ) -> ARefField + def on_aref_field(collection, index) + find_token(LBracket) + rbracket = find_token(RBracket) + + ARefField.new( + collection: collection, + index: index, + location: collection.location.to(rbracket.location) + ) + end + + # def on_arg_ambiguous(value) + # value + # end + + # ArgParen represents wrapping arguments to a method inside a set of + # parentheses. + # + # method(argument) + # + # In the example above, there would be an ArgParen node around the + # ArgsAddBlock node that represents the set of arguments being sent to the + # method method. The argument child node can be +nil+ if no arguments were + # passed, as in: + # + # method() + # + class ArgParen + # [nil | Args | ArgsAddBlock | ArgsForward] the arguments inside the + # parentheses + attr_reader :arguments + + # [Location] the location of this node + attr_reader :location + + def initialize(arguments:, location:) + @arguments = arguments + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('arg_paren') + q.breakable + q.pp(arguments) + end + end + + def to_json(*opts) + { type: :arg_paren, args: arguments, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_arg_paren: ( + # (nil | Args | ArgsAddBlock | ArgsForward) arguments + # ) -> ArgParen + def on_arg_paren(arguments) + lparen = find_token(LParen) + rparen = find_token(RParen) + + # If the arguments exceed the ending of the parentheses, then we know we + # have a heredoc in the arguments, and we need to use the bounds of the + # arguments to determine how large the arg_paren is. + ending = + if arguments && arguments.location.end_line > rparen.location.end_line + arguments + else + rparen + end + + ArgParen.new( + arguments: arguments, + location: lparen.location.to(ending.location) + ) + end + + # Args represents a list of arguments being passed to a method call or array + # literal. + # + # method(first, second, third) + # + class Args + # [Array[ untyped ]] the arguments that this node wraps + attr_reader :parts + + # [Location] the location of this node + attr_reader :location + + def initialize(parts:, location:) + @parts = parts + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('args') + q.breakable + q.group(2, '(', ')') { q.seplist(parts) { |part| q.pp(part) } } + end + end + + def to_json(*opts) + { type: :args, parts: parts, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_args_add: (Args arguments, untyped argument) -> Args + def on_args_add(arguments, argument) + if arguments.parts.empty? + # If this is the first argument being passed into the list of arguments, + # then we're going to use the bounds of the argument to override the + # parent node's location since this will be more accurate. + Args.new(parts: [argument], location: argument.location) + else + # Otherwise we're going to update the existing list with the argument + # being added as well as the new end bounds. + Args.new( + parts: arguments.parts << argument, + location: arguments.location.to(argument.location) + ) + end + end + + # ArgsAddBlock represents a list of arguments and potentially a block + # argument. ArgsAddBlock is commonly seen being passed to any method where you + # use parentheses (wrapped in an ArgParen node). It’s also used to pass + # arguments to the various control-flow keywords like +return+. + # + # method(argument, &block) + # + class ArgsAddBlock + # [Args] the arguments before the optional block + attr_reader :arguments + + # [nil | untyped] the optional block argument + attr_reader :block + + # [Location] the location of this node + attr_reader :location + + def initialize(arguments:, block:, location:) + @arguments = arguments + @block = block + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('args_add_block') + q.breakable + q.pp(arguments) + q.breakable + q.pp(block) + end + end + + def to_json(*opts) + { + type: :args_add_block, + args: arguments, + block: block, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_args_add_block: ( + # Args arguments, + # (false | untyped) block + # ) -> ArgsAddBlock + def on_args_add_block(arguments, block) + ending = block || arguments + + ArgsAddBlock.new( + arguments: arguments, + block: block || nil, + location: arguments.location.to(ending.location) + ) + end + + # Star represents using a splat operator on an expression. + # + # method(*arguments) + # + class ArgStar + # [untyped] the expression being splatted + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('arg_star') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :arg_star, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_args_add_star: (Args arguments, untyped star) -> Args + def on_args_add_star(arguments, argument) + beginning = find_token(Op, '*') + ending = argument || beginning + + location = + if arguments.parts.empty? + ending.location + else + arguments.location.to(ending.location) + end + + arg_star = + ArgStar.new( + value: argument, + location: beginning.location.to(ending.location) + ) + + Args.new(parts: arguments.parts << arg_star, location: location) + end + + # ArgsForward represents forwarding all kinds of arguments onto another method + # call. + # + # def request(method, path, **headers, &block); end + # + # def get(...) + # request(:GET, ...) + # end + # + # def post(...) + # request(:POST, ...) + # end + # + # In the example above, both the get and post methods are forwarding all of + # their arguments (positional, keyword, and block) on to the request method. + # The ArgsForward node appears in both the caller (the request method calls) + # and the callee (the get and post definitions). + class ArgsForward + # [String] the value of the operator + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('args_forward') + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :args_forward, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_args_forward: () -> ArgsForward + def on_args_forward + op = find_token(Op, '...') + + ArgsForward.new(value: op.value, location: op.location) + end + + # :call-seq: + # on_args_new: () -> Args + def on_args_new + Args.new(parts: [], location: Location.fixed(line: lineno, char: char_pos)) + end + + # ArrayLiteral represents any form of an array literal, and contains myriad + # child nodes because of the special array literal syntax like %w and %i. + # + # [] + # [one, two, three] + # [*one_two_three] + # %i[one two three] + # %w[one two three] + # %I[one two three] + # %W[one two three] + # + # Every line in the example above produces an ArrayLiteral node. In order, the + # child contents node of this ArrayLiteral node would be nil, Args, QSymbols, + # QWords, Symbols, and Words. + class ArrayLiteral + # [nil | Args | QSymbols | QWords | Symbols | Words] the + # contents of the array + attr_reader :contents + + # [Location] the location of this node + attr_reader :location + + def initialize(contents:, location:) + @contents = contents + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('array') + q.breakable + q.pp(contents) + end + end + + def to_json(*opts) + { type: :array, cnts: contents, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_array: ( + # (nil | Args | QSymbols | QWords | Symbols | Words) contents + # ) -> ArrayLiteral + def on_array(contents) + if !contents || contents.is_a?(Args) + lbracket = find_token(LBracket) + rbracket = find_token(RBracket) + + ArrayLiteral.new( + contents: contents, + location: lbracket.location.to(rbracket.location) + ) + else + tstring_end = find_token(TStringEnd) + contents = + contents.class.new( + elements: contents.elements, + location: contents.location.to(tstring_end.location) + ) + + ArrayLiteral.new(contents: contents, location: contents.location) + end + end + + # AryPtn represents matching against an array pattern using the Ruby 2.7+ + # pattern matching syntax. It’s one of the more complicated nodes, because + # the four parameters that it accepts can almost all be nil. + # + # case [1, 2, 3] + # in [Integer, Integer] + # "matched" + # in Container[Integer, Integer] + # "matched" + # in [Integer, *, Integer] + # "matched" + # end + # + # An AryPtn node is created with four parameters: an optional constant + # wrapper, an array of positional matches, an optional splat with identifier, + # and an optional array of positional matches that occur after the splat. + # All of the in clauses above would create an AryPtn node. + class AryPtn + # [nil | VarRef] the optional constant wrapper + attr_reader :constant + + # [Array[ untyped ]] the regular positional arguments that this array + # pattern is matching against + attr_reader :requireds + + # [nil | VarField] the optional starred identifier that grabs up a list of + # positional arguments + attr_reader :rest + + # [Array[ untyped ]] the list of positional arguments occurring after the + # optional star if there is one + attr_reader :posts + + # [Location] the location of this node + attr_reader :location + + def initialize(constant:, requireds:, rest:, posts:, location:) + @constant = constant + @requireds = requireds + @rest = rest + @posts = posts + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('aryptn') + + if constant + q.breakable + q.pp(constant) + end + + if requireds.any? + q.breakable + q.group(2, '(', ')') do + q.seplist(requireds) { |required| q.pp(required) } + end + end + + if rest + q.breakable + q.pp(rest) + end + + if posts.any? + q.breakable + q.group(2, '(', ')') { q.seplist(posts) { |post| q.pp(post) } } + end + end + end + + def to_json(*opts) + { + type: :aryptn, + constant: constant, + reqs: requireds, + rest: rest, + posts: posts, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_aryptn: ( + # (nil | VarRef) constant, + # (nil | Array[untyped]) requireds, + # (nil | VarField) rest, + # (nil | Array[untyped]) posts + # ) -> AryPtn + def on_aryptn(constant, requireds, rest, posts) + parts = [constant, *requireds, rest, *posts].compact + + AryPtn.new( + constant: constant, + requireds: requireds || [], + rest: rest, + posts: posts || [], + location: parts[0].location.to(parts[-1].location) + ) + end + + # Assign represents assigning something to a variable or constant. Generally, + # the left side of the assignment is going to be any node that ends with the + # name "Field". + # + # variable = value + # + class Assign + # [ARefField | ConstPathField | Field | TopConstField | VarField] the target + # to assign the result of the expression to + attr_reader :target + + # [untyped] the expression to be assigned + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(target:, value:, location:) + @target = target + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('assign') + q.breakable + q.pp(target) + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :assign, target: target, value: value, loc: location }.to_json( + *opts + ) + end + end + + # :call-seq: + # on_assign: ( + # (ARefField | ConstPathField | Field | TopConstField | VarField) target, + # untyped value + # ) -> Assign + def on_assign(target, value) + Assign.new( + target: target, + value: value, + location: target.location.to(value.location) + ) + end + + # Assoc represents a key-value pair within a hash. It is a child node of + # either an AssocListFromArgs or a BareAssocHash. + # + # { key1: value1, key2: value2 } + # + # In the above example, the would be two AssocNew nodes. + class Assoc + # [untyped] the key of this pair + attr_reader :key + + # [untyped] the value of this pair + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(key:, value:, location:) + @key = key + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('assoc') + q.breakable + q.pp(key) + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :assoc, key: key, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_assoc_new: (untyped key, untyped value) -> Assoc + def on_assoc_new(key, value) + Assoc.new( + key: key, + value: value, + location: key.location.to(value.location) + ) + end + + # AssocSplat represents double-splatting a value into a hash (either a hash + # literal or a bare hash in a method call). + # + # { **pairs } + # + class AssocSplat + # [untyped] the expression that is being splatted + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('assoc_splat') + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :assoc_splat, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_assoc_splat: (untyped value) -> AssocSplat + def on_assoc_splat(value) + operator = find_token(Op, '**') + + AssocSplat.new(value: value, location: operator.location.to(value.location)) + end + + # AssocListFromArgs represents the key-value pairs of a hash literal. Its + # parent node is always a hash. + # + # { key1: value1, key2: value2 } + # + class AssocListFromArgs + # [Array[ AssocNew | AssocSplat ]] + attr_reader :assocs + + # [Location] the location of this node + attr_reader :location + + def initialize(assocs:, location:) + @assocs = assocs + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('assoclist_from_args') + q.breakable + q.group(2, '(', ')') { q.seplist(assocs) { |assoc| q.pp(assoc) } } + end + end + + def to_json(*opts) + { type: :assoclist_from_args, assocs: assocs, loc: location }.to_json( + *opts + ) + end + end + + # :call-seq: + # on_assoclist_from_args: ( + # Array[AssocNew | AssocSplat] assocs + # ) -> AssocListFromArgs + def on_assoclist_from_args(assocs) + AssocListFromArgs.new( + assocs: assocs, + location: assocs[0].location.to(assocs[-1].location) + ) + end + + # Backref represents a global variable referencing a matched value. It comes + # in the form of a $ followed by a positive integer. + # + # $1 + # + class Backref + # [String] the name of the global backreference variable + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('backref') + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :backref, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_backref: (String value) -> Backref + def on_backref(value) + node = + Backref.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # Backtick represents the use of the ` operator. It's usually found being used + # for an XStringLiteral, but could also be found as the name of a method being + # defined. + class Backtick + # [String] the backtick in the string + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('backtick') + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :backtick, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_backtick: (String value) -> Backtick + def on_backtick(value) + node = + Backtick.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # BareAssocHash represents a hash of contents being passed as a method + # argument (and therefore has omitted braces). It's very similar to an + # AssocListFromArgs node. + # + # method(key1: value1, key2: value2) + # + class BareAssocHash + # [Array[ AssocNew | AssocSplat ]] + attr_reader :assocs + + # [Location] the location of this node + attr_reader :location + + def initialize(assocs:, location:) + @assocs = assocs + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('bare_assoc_hash') + q.breakable + q.group(2, '(', ')') { q.seplist(assocs) { |assoc| q.pp(assoc) } } + end + end + + def to_json(*opts) + { type: :bare_assoc_hash, assocs: assocs, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_bare_assoc_hash: (Array[AssocNew | AssocSplat] assocs) -> BareAssocHash + def on_bare_assoc_hash(assocs) + BareAssocHash.new( + assocs: assocs, + location: assocs[0].location.to(assocs[-1].location) + ) + end + + # Begin represents a begin..end chain. + # + # begin + # value + # end + # + class Begin + # [BodyStmt] the bodystmt that contains the contents of this begin block + attr_reader :bodystmt + + # [Location] the location of this node + attr_reader :location + + def initialize(bodystmt:, location:) + @bodystmt = bodystmt + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('begin') + q.breakable + q.pp(bodystmt) + end + end + + def to_json(*opts) + { type: :begin, bodystmt: bodystmt, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_begin: (BodyStmt bodystmt) -> Begin + def on_begin(bodystmt) + keyword = find_token(Kw, 'begin') + end_char = + if bodystmt.rescue_clause || bodystmt.ensure_clause || + bodystmt.else_clause + bodystmt.location.end_char + else + find_token(Kw, 'end').location.end_char + end + + bodystmt.bind(keyword.location.end_char, end_char) + + Begin.new( + bodystmt: bodystmt, + location: keyword.location.to(bodystmt.location) + ) + end + + # Binary represents any expression that involves two sub-expressions with an + # operator in between. This can be something that looks like a mathematical + # operation: + # + # 1 + 1 + # + # but can also be something like pushing a value onto an array: + # + # array << value + # + class Binary + # [untyped] the left-hand side of the expression + attr_reader :left + + # [String] the operator used between the two expressions + attr_reader :operator + + # [untyped] the right-hand side of the expression + attr_reader :right + + # [Location] the location of this node + attr_reader :location + + def initialize(left:, operator:, right:, location:) + @left = left + @operator = operator + @right = right + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('binary') + q.breakable + q.pp(left) + q.breakable + q.text(operator) + q.breakable + q.pp(right) + end + end + + def to_json(*opts) + { + type: :binary, + left: left, + op: operator, + right: right, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_binary: (untyped left, (Op | Symbol) operator, untyped right) -> Binary + def on_binary(left, operator, right) + # On most Ruby implementations, operator is a Symbol that represents that + # operation being performed. For instance in the example `1 < 2`, the + # `operator` object would be `:<`. However, on JRuby, it's an `@op` node, + # so here we're going to explicitly convert it into the same normalized + # form. + operator = tokens.delete(operator).value unless operator.is_a?(Symbol) + + Binary.new( + left: left, + operator: operator, + right: right, + location: left.location.to(right.location) + ) + end + + # BlockVar represents the parameters being declared for a block. Effectively + # this node is everything contained within the pipes. This includes all of the + # various parameter types, as well as block-local variable declarations. + # + # method do |positional, optional = value, keyword:, █ local| + # end + # + class BlockVar + # [Params] the parameters being declared with the block + attr_reader :params + + # [Array[ Ident ]] the list of block-local variable declarations + attr_reader :locals + + # [Location] the location of this node + attr_reader :location + + def initialize(params:, locals:, location:) + @params = params + @locals = locals + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('block_var') + q.breakable + q.pp(params) + + if locals.any? + q.breakable + q.group(2, '(', ')') { q.seplist(locals) { |local| q.pp(local) } } + end + end + end + + def to_json(*opts) + { + type: :block_var, + params: params, + locals: locals, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_block_var: (Params params, (nil | Array[Ident]) locals) -> BlockVar + def on_block_var(params, locals) + index = + tokens.rindex do |node| + node.is_a?(Op) && %w[| ||].include?(node.value) && + node.location.start_char < params.location.start_char + end + + beginning = tokens[index] + ending = tokens[-1] + + BlockVar.new( + params: params, + locals: locals || [], + location: beginning.location.to(ending.location) + ) + end + + # BlockArg represents declaring a block parameter on a method definition. + # + # def method(&block); end + # + class BlockArg + # [Ident] the name of the block argument + attr_reader :name + + # [Location] the location of this node + attr_reader :location + + def initialize(name:, location:) + @name = name + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('blockarg') + q.breakable + q.pp(name) + end + end + + def to_json(*opts) + { type: :blockarg, name: name, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_blockarg: (Ident name) -> BlockArg + def on_blockarg(name) + operator = find_token(Op, '&') + + BlockArg.new(name: name, location: operator.location.to(name.location)) + end + + # bodystmt can't actually determine its bounds appropriately because it + # doesn't necessarily know where it started. So the parent node needs to + # report back down into this one where it goes. + class BodyStmt + # [Statements] the list of statements inside the begin clause + attr_reader :statements + + # [nil | Rescue] the optional rescue chain attached to the begin clause + attr_reader :rescue_clause + + # [nil | Statements] the optional set of statements inside the else clause + attr_reader :else_clause + + # [nil | Ensure] the optional ensure clause + attr_reader :ensure_clause + + # [Location] the location of this node + attr_reader :location + + def initialize( + statements:, + rescue_clause:, + else_clause:, + ensure_clause:, + location: + ) + @statements = statements + @rescue_clause = rescue_clause + @else_clause = else_clause + @ensure_clause = ensure_clause + @location = location + end + + def bind(start_char, end_char) + @location = + Location.new( + start_line: location.start_line, + start_char: start_char, + end_line: location.end_line, + end_char: end_char + ) + + parts = [rescue_clause, else_clause, ensure_clause] + + # Here we're going to determine the bounds for the statements + consequent = parts.compact.first + statements.bind( + start_char, + consequent ? consequent.location.start_char : end_char + ) + + # Next we're going to determine the rescue clause if there is one + if rescue_clause + consequent = parts.drop(1).compact.first + rescue_clause.bind_end( + consequent ? consequent.location.start_char : end_char + ) + end + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('bodystmt') + q.breakable + q.pp(statements) + + if rescue_clause + q.breakable + q.pp(rescue_clause) + end + + if else_clause + q.breakable + q.pp(else_clause) + end + + if ensure_clause + q.breakable + q.pp(ensure_clause) + end + end + end + + def to_json(*opts) + { + type: :bodystmt, + stmts: statements, + rsc: rescue_clause, + els: else_clause, + ens: ensure_clause, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_bodystmt: ( + # Statements statements, + # (nil | Rescue) rescue_clause, + # (nil | Statements) else_clause, + # (nil | Ensure) ensure_clause + # ) -> BodyStmt + def on_bodystmt(statements, rescue_clause, else_clause, ensure_clause) + BodyStmt.new( + statements: statements, + rescue_clause: rescue_clause, + else_clause: else_clause, + ensure_clause: ensure_clause, + location: Location.fixed(line: lineno, char: char_pos) + ) + end + + # BraceBlock represents passing a block to a method call using the { } + # operators. + # + # method { |variable| variable + 1 } + # + class BraceBlock + # [LBrace] the left brace that opens this block + attr_reader :lbrace + + # [nil | BlockVar] the optional set of parameters to the block + attr_reader :block_var + + # [Statements] the list of expressions to evaluate within the block + attr_reader :statements + + # [Location] the location of this node + attr_reader :location + + def initialize(lbrace:, block_var:, statements:, location:) + @lbrace = lbrace + @block_var = block_var + @statements = statements + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('brace_block') + + if block_var + q.breakable + q.pp(block_var) + end + + q.breakable + q.pp(statements) + end + end + + def to_json(*opts) + { + type: :brace_block, + lbrace: lbrace, + block_var: block_var, + stmts: statements, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_brace_block: ( + # (nil | BlockVar) block_var, + # Statements statements + # ) -> BraceBlock + def on_brace_block(block_var, statements) + lbrace = find_token(LBrace) + rbrace = find_token(RBrace) + + statements.bind( + find_next_statement_start((block_var || lbrace).location.end_char), + rbrace.location.start_char + ) + + location = + Location.new( + start_line: lbrace.location.start_line, + start_char: lbrace.location.start_char, + end_line: [rbrace.location.end_line, statements.location.end_line].max, + end_char: rbrace.location.end_char + ) + + BraceBlock.new( + lbrace: lbrace, + block_var: block_var, + statements: statements, + location: location + ) + end + + # Break represents using the +break+ keyword. + # + # break + # + # It can also optionally accept arguments, as in: + # + # break 1 + # + class Break + # [Args | ArgsAddBlock] the arguments being sent to the keyword + attr_reader :arguments + + # [Location] the location of this node + attr_reader :location + + def initialize(arguments:, location:) + @arguments = arguments + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('break') + q.breakable + q.pp(arguments) + end + end + + def to_json(*opts) + { type: :break, args: arguments, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_break: ((Args | ArgsAddBlock) arguments) -> Break + def on_break(arguments) + keyword = find_token(Kw, 'break') + + location = keyword.location + location = location.to(arguments.location) unless arguments.is_a?(Args) + + Break.new(arguments: arguments, location: location) + end + + # Call represents a method call. This node doesn't contain the arguments being + # passed (if arguments are passed, this node will get nested under a + # MethodAddArg node). + # + # receiver.message + # + class Call + # [untyped] the receiver of the method call + attr_reader :receiver + + # [:"::" | Op | Period] the operator being used to send the message + attr_reader :operator + + # [:call | Backtick | Const | Ident | Op] the message being sent + attr_reader :message + + # [Location] the location of this node + attr_reader :location + + def initialize(receiver:, operator:, message:, location:) + @receiver = receiver + @operator = operator + @message = message + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('call') + q.breakable + q.pp(receiver) + q.breakable + q.pp(operator) + q.breakable + q.pp(message) + end + end + + def to_json(*opts) + { + type: :call, + receiver: receiver, + op: operator, + message: message, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_call: ( + # untyped receiver, + # (:"::" | Op | Period) operator, + # (:call | Backtick | Const | Ident | Op) message + # ) -> Call + def on_call(receiver, operator, message) + ending = message + ending = operator if message == :call + + Call.new( + receiver: receiver, + operator: operator, + message: message, + location: + Location.new( + start_line: receiver.location.start_line, + start_char: receiver.location.start_char, + end_line: [ending.location.end_line, receiver.location.end_line].max, + end_char: ending.location.end_char + ) + ) + end + + # Case represents the beginning of a case chain. + # + # case value + # when 1 + # "one" + # when 2 + # "two" + # else + # "number" + # end + # + class Case + # [nil | untyped] optional value being switched on + attr_reader :value + + # [In | When] the next clause in the chain + attr_reader :consequent + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, consequent:, location:) + @value = value + @consequent = consequent + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('case') + + if value + q.breakable + q.pp(value) + end + + q.breakable + q.pp(consequent) + end + end + + def to_json(*opts) + { type: :case, value: value, cons: consequent, loc: location }.to_json( + *opts + ) + end + end + + # RAssign represents a single-line pattern match. + # + # value in pattern + # value => pattern + # + class RAssign + # [untyped] the left-hand expression + attr_reader :value + + # [Kw | Op] the operator being used to match against the pattern, which is + # either => or in + attr_reader :operator + + # [untyped] the pattern on the right-hand side of the expression + attr_reader :pattern + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, operator:, pattern:, location:) + @value = value + @operator = operator + @pattern = pattern + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('rassign') + + q.breakable + q.pp(value) + + q.breakable + q.pp(operator) + + q.breakable + q.pp(pattern) + end + end + + def to_json(*opts) + { + type: :rassign, + value: value, + op: operator, + pattern: pattern, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_case: (untyped value, untyped consequent) -> Case | RAssign + def on_case(value, consequent) + if keyword = find_token(Kw, 'case', consume: false) + tokens.delete(keyword) + + Case.new( + value: value, + consequent: consequent, + location: keyword.location.to(consequent.location) + ) + else + operator = find_token(Kw, 'in', consume: false) || find_token(Op, '=>') + + RAssign.new( + value: value, + operator: operator, + pattern: consequent, + location: value.location.to(consequent.location) + ) + end + end + + # Class represents defining a class using the +class+ keyword. + # + # class Container + # end + # + # Classes can have path names as their class name in case it's being nested + # under a namespace, as in: + # + # class Namespace::Container + # end + # + # Classes can also be defined as a top-level path, in the case that it's + # already in a namespace but you want to define it at the top-level instead, + # as in: + # + # module OtherNamespace + # class ::Namespace::Container + # end + # end + # + # All of these declarations can also have an optional superclass reference, as + # in: + # + # class Child < Parent + # end + # + # That superclass can actually be any Ruby expression, it doesn't necessarily + # need to be a constant, as in: + # + # class Child < method + # end + # + class ClassDeclaration + # [ConstPathRef | ConstRef | TopConstRef] the name of the class being + # defined + attr_reader :constant + + # [nil | untyped] the optional superclass declaration + attr_reader :superclass + + # [BodyStmt] the expressions to execute within the context of the class + attr_reader :bodystmt + + # [Location] the location of this node + attr_reader :location + + def initialize(constant:, superclass:, bodystmt:, location:) + @constant = constant + @superclass = superclass + @bodystmt = bodystmt + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('class') + + q.breakable + q.pp(constant) + + if superclass + q.breakable + q.pp(superclass) + end + + q.breakable + q.pp(bodystmt) + end + end + + def to_json(*opts) + { + type: :class, + constant: constant, + superclass: superclass, + bodystmt: bodystmt, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_class: ( + # (ConstPathRef | ConstRef | TopConstRef) constant, + # untyped superclass, + # BodyStmt bodystmt + # ) -> ClassDeclaration + def on_class(constant, superclass, bodystmt) + beginning = find_token(Kw, 'class') + ending = find_token(Kw, 'end') + + bodystmt.bind( + find_next_statement_start((superclass || constant).location.end_char), + ending.location.start_char + ) + + ClassDeclaration.new( + constant: constant, + superclass: superclass, + bodystmt: bodystmt, + location: beginning.location.to(ending.location) + ) + end + + # Comma represents the use of the , operator. + class Comma + # [String] the comma in the string + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + end + + # :call-seq: + # on_comma: (String value) -> Comma + def on_comma(value) + node = + Comma.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # Command represents a method call with arguments and no parentheses. Note + # that Command nodes only happen when there is no explicit receiver for this + # method. + # + # method argument + # + class Command + # [Const | Ident] the message being sent to the implicit receiver + attr_reader :message + + # [Args | ArgsAddBlock] the arguments being sent with the message + attr_reader :arguments + + # [Location] the location of this node + attr_reader :location + + def initialize(message:, arguments:, location:) + @message = message + @arguments = arguments + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('command') + + q.breakable + q.pp(message) + + q.breakable + q.pp(arguments) + end + end + + def to_json(*opts) + { + type: :command, + message: message, + args: arguments, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_command: ( + # (Const | Ident) message, + # (Args | ArgsAddBlock) arguments + # ) -> Command + def on_command(message, arguments) + Command.new( + message: message, + arguments: arguments, + location: message.location.to(arguments.location) + ) + end + + # CommandCall represents a method call on an object with arguments and no + # parentheses. + # + # object.method argument + # + class CommandCall + # [untyped] the receiver of the message + attr_reader :receiver + + # [:"::" | Op | Period] the operator used to send the message + attr_reader :operator + + # [Const | Ident | Op] the message being send + attr_reader :message + + # [Args | ArgsAddBlock] the arguments going along with the message + attr_reader :arguments + + # [Location] the location of this node + attr_reader :location + + def initialize(receiver:, operator:, message:, arguments:, location:) + @receiver = receiver + @operator = operator + @message = message + @arguments = arguments + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('command_call') + + q.breakable + q.pp(receiver) + + q.breakable + q.pp(operator) + + q.breakable + q.pp(message) + + q.breakable + q.pp(arguments) + end + end + + def to_json(*opts) + { + type: :command_call, + receiver: receiver, + op: operator, + message: message, + args: arguments, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_command_call: ( + # untyped receiver, + # (:"::" | Op | Period) operator, + # (Const | Ident | Op) message, + # (Args | ArgsAddBlock) arguments + # ) -> CommandCall + def on_command_call(receiver, operator, message, arguments) + ending = arguments || message + + CommandCall.new( + receiver: receiver, + operator: operator, + message: message, + arguments: arguments, + location: receiver.location.to(ending.location) + ) + end + + # Comment represents a comment in the source. + # + # # comment + # + class Comment + # [String] the contents of the comment + attr_reader :value + + # [boolean] whether or not there is code on the same line as this comment. + # If there is, then inline will be true. + attr_reader :inline + alias inline? inline + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, inline:, location:) + @value = value + @inline = inline + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('comment') + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { + type: :comment, + value: value.force_encoding('UTF-8'), + inline: inline, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_comment: (String value) -> Comment + def on_comment(value) + line = lineno + comment = + Comment.new( + value: value[1..-1].chomp, + inline: value.strip != lines[line - 1], + location: + Location.token(line: line, char: char_pos, size: value.size - 1) + ) + + @comments << comment + comment + end + + # Const represents a literal value that _looks_ like a constant. This could + # actually be a reference to a constant: + # + # Constant + # + # It could also be something that looks like a constant in another context, as + # in a method call to a capitalized method: + # + # object.Constant + # + # or a symbol that starts with a capital letter: + # + # :Constant + # + class Const + # [String] the name of the constant + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('const') + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :const, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_const: (String value) -> Const + def on_const(value) + node = + Const.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # ConstPathField represents the child node of some kind of assignment. It + # represents when you're assigning to a constant that is being referenced as + # a child of another variable. + # + # object::Const = value + # + class ConstPathField + # [untyped] the source of the constant + attr_reader :parent + + # [Const] the constant itself + attr_reader :constant + + # [Location] the location of this node + attr_reader :location + + def initialize(parent:, constant:, location:) + @parent = parent + @constant = constant + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('const_path_field') + + q.breakable + q.pp(parent) + + q.breakable + q.pp(constant) + end + end + + def to_json(*opts) + { + type: :const_path_field, + parent: parent, + constant: constant, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_const_path_field: (untyped parent, Const constant) -> ConstPathField + def on_const_path_field(parent, constant) + ConstPathField.new( + parent: parent, + constant: constant, + location: parent.location.to(constant.location) + ) + end + + # ConstPathRef represents referencing a constant by a path. + # + # object::Const + # + class ConstPathRef + # [untyped] the source of the constant + attr_reader :parent + + # [Const] the constant itself + attr_reader :constant + + # [Location] the location of this node + attr_reader :location + + def initialize(parent:, constant:, location:) + @parent = parent + @constant = constant + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('const_path_ref') + + q.breakable + q.pp(parent) + + q.breakable + q.pp(constant) + end + end + + def to_json(*opts) + { + type: :const_path_ref, + parent: parent, + constant: constant, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_const_path_ref: (untyped parent, Const constant) -> ConstPathRef + def on_const_path_ref(parent, constant) + ConstPathRef.new( + parent: parent, + constant: constant, + location: parent.location.to(constant.location) + ) + end + + # ConstRef represents the name of the constant being used in a class or module + # declaration. + # + # class Container + # end + # + class ConstRef + # [Const] the constant itself + attr_reader :constant + + # [Location] the location of this node + attr_reader :location + + def initialize(constant:, location:) + @constant = constant + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('const_ref') + + q.breakable + q.pp(constant) + end + end + + def to_json(*opts) + { type: :const_ref, constant: constant, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_const_ref: (Const constant) -> ConstRef + def on_const_ref(constant) + ConstRef.new(constant: constant, location: constant.location) + end + + # CVar represents the use of a class variable. + # + # @@variable + # + class CVar + # [String] the name of the class variable + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('cvar') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :cvar, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_cvar: (String value) -> CVar + def on_cvar(value) + node = + CVar.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # Def represents defining a regular method on the current self object. + # + # def method(param) result end + # + class Def + # [Backtick | Const | Ident | Kw | Op] the name of the method + attr_reader :name + + # [Params | Paren] the parameter declaration for the method + attr_reader :params + + # [BodyStmt] the expressions to be executed by the method + attr_reader :bodystmt + + # [Location] the location of this node + attr_reader :location + + def initialize(name:, params:, bodystmt:, location:) + @name = name + @params = params + @bodystmt = bodystmt + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('def') + + q.breakable + q.pp(name) + + q.breakable + q.pp(params) + + q.breakable + q.pp(bodystmt) + end + end + + def to_json(*opts) + { + type: :def, + name: name, + params: params, + bodystmt: bodystmt, + loc: location + }.to_json(*opts) + end + end + + # DefEndless represents defining a single-line method since Ruby 3.0+. + # + # def method = result + # + class DefEndless + # [Backtick | Const | Ident | Kw | Op] the name of the method + attr_reader :name + + # [Paren] the parameter declaration for the method + attr_reader :paren + + # [untyped] the expression to be executed by the method + attr_reader :statement + + # [Location] the location of this node + attr_reader :location + + def initialize(name:, paren:, statement:, location:) + @name = name + @paren = paren + @statement = statement + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('def_endless') + + q.breakable + q.pp(name) + + q.breakable + q.pp(paren) + + q.breakable + q.pp(statement) + end + end + + def to_json(*opts) + { + type: :def_endless, + name: name, + paren: paren, + stmt: statement, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_def: ( + # (Backtick | Const | Ident | Kw | Op) name, + # (Params | Paren) params, + # untyped bodystmt + # ) -> Def | DefEndless + def on_def(name, params, bodystmt) + # Make sure to delete this token in case you're defining something like def + # class which would lead to this being a kw and causing all kinds of trouble + tokens.delete(name) + + # Find the beginning of the method definition, which works for single-line + # and normal method definitions. + beginning = find_token(Kw, 'def') + + # If we don't have a bodystmt node, then we have a single-line method + unless bodystmt.is_a?(BodyStmt) + node = + DefEndless.new( + name: name, + paren: params, + statement: bodystmt, + location: beginning.location.to(bodystmt.location) + ) + + return node + end + + # If there aren't any params then we need to correct the params node + # location information + if params.is_a?(Params) && params.empty? + end_char = name.location.end_char + location = + Location.new( + start_line: params.location.start_line, + start_char: end_char, + end_line: params.location.end_line, + end_char: end_char + ) + + params = Params.new(location: location) + end + + ending = find_token(Kw, 'end') + bodystmt.bind( + find_next_statement_start(params.location.end_char), + ending.location.start_char + ) + + Def.new( + name: name, + params: params, + bodystmt: bodystmt, + location: beginning.location.to(ending.location) + ) + end + + # Defined represents the use of the +defined?+ operator. It can be used with + # and without parentheses. + # + # defined?(variable) + # + class Defined + # [untyped] the value being sent to the keyword + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('defined') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :defined, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_defined: (untyped value) -> Defined + def on_defined(value) + beginning = find_token(Kw, 'defined?') + ending = value + + range = beginning.location.end_char...value.location.start_char + if source[range].include?('(') + find_token(LParen) + ending = find_token(RParen) + end + + Defined.new(value: value, location: beginning.location.to(ending.location)) + end + + # Defs represents defining a singleton method on an object. + # + # def object.method(param) result end + # + class Defs + # [untyped] the target where the method is being defined + attr_reader :target + + # [Op | Period] the operator being used to declare the method + attr_reader :operator + + # [Backtick | Const | Ident | Kw | Op] the name of the method + attr_reader :name + + # [Params | Paren] the parameter declaration for the method + attr_reader :params + + # [BodyStmt] the expressions to be executed by the method + attr_reader :bodystmt + + # [Location] the location of this node + attr_reader :location + + def initialize(target:, operator:, name:, params:, bodystmt:, location:) + @target = target + @operator = operator + @name = name + @params = params + @bodystmt = bodystmt + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('defs') + + q.breakable + q.pp(target) + + q.breakable + q.pp(operator) + + q.breakable + q.pp(name) + + q.breakable + q.pp(params) + + q.breakable + q.pp(bodystmt) + end + end + + def to_json(*opts) + { + type: :defs, + target: target, + op: operator, + name: name, + params: params, + bodystmt: bodystmt, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_defs: ( + # untyped target, + # (Op | Period) operator, + # (Backtick | Const | Ident | Kw | Op) name, + # (Params | Paren) params, + # BodyStmt bodystmt + # ) -> Defs + def on_defs(target, operator, name, params, bodystmt) + # Make sure to delete this token in case you're defining something + # like def class which would lead to this being a kw and causing all kinds + # of trouble + tokens.delete(name) + + # If there aren't any params then we need to correct the params node + # location information + if params.is_a?(Params) && params.empty? + end_char = name.location.end_char + location = + Location.new( + start_line: params.location.start_line, + start_char: end_char, + end_line: params.location.end_line, + end_char: end_char + ) + + params = Params.new(location: location) + end + + beginning = find_token(Kw, 'def') + ending = find_token(Kw, 'end') + + bodystmt.bind( + find_next_statement_start(params.location.end_char), + ending.location.start_char + ) + + Defs.new( + target: target, + operator: operator, + name: name, + params: params, + bodystmt: bodystmt, + location: beginning.location.to(ending.location) + ) + end + + # DoBlock represents passing a block to a method call using the +do+ and +end+ + # keywords. + # + # method do |value| + # end + # + class DoBlock + # [Kw] the do keyword that opens this block + attr_reader :keyword + + # [nil | BlockVar] the optional variable declaration within this block + attr_reader :block_var + + # [BodyStmt] the expressions to be executed within this block + attr_reader :bodystmt + + # [Location] the location of this node + attr_reader :location + + def initialize(keyword:, block_var:, bodystmt:, location:) + @keyword = keyword + @block_var = block_var + @bodystmt = bodystmt + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('do_block') + + if block_var + q.breakable + q.pp(block_var) + end + + q.breakable + q.pp(bodystmt) + end + end + + def to_json(*opts) + { + type: :do_block, + keyword: keyword, + block_var: block_var, + bodystmt: bodystmt, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_do_block: (BlockVar block_var, BodyStmt bodystmt) -> DoBlock + def on_do_block(block_var, bodystmt) + beginning = find_token(Kw, 'do') + ending = find_token(Kw, 'end') + + bodystmt.bind( + find_next_statement_start((block_var || beginning).location.end_char), + ending.location.start_char + ) + + DoBlock.new( + keyword: beginning, + block_var: block_var, + bodystmt: bodystmt, + location: beginning.location.to(ending.location) + ) + end + + # Dot2 represents using the .. operator between two expressions. Usually this + # is to create a range object. + # + # 1..2 + # + # Sometimes this operator is used to create a flip-flop. + # + # if value == 5 .. value == 10 + # end + # + # One of the sides of the expression may be nil, but not both. + class Dot2 + # [nil | untyped] the left side of the expression + attr_reader :left + + # [nil | untyped] the right side of the expression + attr_reader :right + + # [Location] the location of this node + attr_reader :location + + def initialize(left:, right:, location:) + @left = left + @right = right + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('dot2') + + if left + q.breakable + q.pp(left) + end + + if right + q.breakable + q.pp(right) + end + end + end + + def to_json(*opts) + { type: :dot2, left: left, right: right, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_dot2: ((nil | untyped) left, (nil | untyped) right) -> Dot2 + def on_dot2(left, right) + operator = find_token(Op, '..') + + beginning = left || operator + ending = right || operator + + Dot2.new( + left: left, + right: right, + location: beginning.location.to(ending.location) + ) + end + + # Dot3 represents using the ... operator between two expressions. Usually this + # is to create a range object. It's effectively the same event as the Dot2 + # node but with this operator you're asking Ruby to omit the final value. + # + # 1...2 + # + # Like Dot2 it can also be used to create a flip-flop. + # + # if value == 5 ... value == 10 + # end + # + # One of the sides of the expression may be nil, but not both. + class Dot3 + # [nil | untyped] the left side of the expression + attr_reader :left + + # [nil | untyped] the right side of the expression + attr_reader :right + + # [Location] the location of this node + attr_reader :location + + def initialize(left:, right:, location:) + @left = left + @right = right + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('dot3') + + if left + q.breakable + q.pp(left) + end + + if right + q.breakable + q.pp(right) + end + end + end + + def to_json(*opts) + { type: :dot3, left: left, right: right, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_dot3: ((nil | untyped) left, (nil | untyped) right) -> Dot3 + def on_dot3(left, right) + operator = find_token(Op, '...') + + beginning = left || operator + ending = right || operator + + Dot3.new( + left: left, + right: right, + location: beginning.location.to(ending.location) + ) + end + + # DynaSymbol represents a symbol literal that uses quotes to dynamically + # define its value. + # + # :"#{variable}" + # + # They can also be used as a special kind of dynamic hash key, as in: + # + # { "#{key}": value } + # + class DynaSymbol + # [Array[ StringDVar | StringEmbExpr | TStringContent ]] the parts of the + # dynamic symbol + attr_reader :parts + + # [String] the quote used to delimit the dynamic symbol + attr_reader :quote + + # [Location] the location of this node + attr_reader :location + + def initialize(parts:, quote:, location:) + @parts = parts + @quote = quote + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('dyna_symbol') + + q.breakable + q.group(2, '(', ')') { q.seplist(parts) { |part| q.pp(part) } } + end + end + + def to_json(*opts) + { type: :dyna_symbol, parts: parts, quote: quote, loc: location }.to_json( + *opts + ) + end + end + + # :call-seq: + # on_dyna_symbol: (StringContent string_content) -> DynaSymbol + def on_dyna_symbol(string_content) + if find_token(SymBeg, consume: false) + # A normal dynamic symbol + symbeg = find_token(SymBeg) + tstring_end = find_token(TStringEnd) + + DynaSymbol.new( + quote: symbeg.value, + parts: string_content.parts, + location: symbeg.location.to(tstring_end.location) + ) + else + # A dynamic symbol as a hash key + tstring_beg = find_token(TStringBeg) + label_end = find_token(LabelEnd) + + DynaSymbol.new( + parts: string_content.parts, + quote: label_end.value[0], + location: tstring_beg.location.to(label_end.location) + ) + end + end + + # Else represents the end of an +if+, +unless+, or +case+ chain. + # + # if variable + # else + # end + # + class Else + # [Statements] the expressions to be executed + attr_reader :statements + + # [Location] the location of this node + attr_reader :location + + def initialize(statements:, location:) + @statements = statements + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('else') + + q.breakable + q.pp(statements) + end + end + + def to_json(*opts) + { type: :else, stmts: statements, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_else: (Statements statements) -> Else + def on_else(statements) + beginning = find_token(Kw, 'else') + + # else can either end with an end keyword (in which case we'll want to + # consume that event) or it can end with an ensure keyword (in which case + # we'll leave that to the ensure to handle). + index = + tokens.rindex do |token| + token.is_a?(Kw) && %w[end ensure].include?(token.value) + end + + node = tokens[index] + ending = node.value == 'end' ? tokens.delete_at(index) : node + + statements.bind(beginning.location.end_char, ending.location.start_char) + + Else.new( + statements: statements, + location: beginning.location.to(ending.location) + ) + end + + # Elsif represents another clause in an +if+ or +unless+ chain. + # + # if variable + # elsif other_variable + # end + # + class Elsif + # [untyped] the expression to be checked + attr_reader :predicate + + # [Statements] the expressions to be executed + attr_reader :statements + + # [nil | Elsif | Else] the next clause in the chain + attr_reader :consequent + + # [Location] the location of this node + attr_reader :location + + def initialize(predicate:, statements:, consequent:, location:) + @predicate = predicate + @statements = statements + @consequent = consequent + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('elsif') + + q.breakable + q.pp(predicate) + + q.breakable + q.pp(statements) + + if consequent + q.breakable + q.pp(consequent) + end + end + end + + def to_json(*opts) + { + type: :elsif, + pred: predicate, + stmts: statements, + cons: consequent, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_elsif: ( + # untyped predicate, + # Statements statements, + # (nil | Elsif | Else) consequent + # ) -> Elsif + def on_elsif(predicate, statements, consequent) + beginning = find_token(Kw, 'elsif') + ending = consequent || find_token(Kw, 'end') + + statements.bind(predicate.location.end_char, ending.location.start_char) + + Elsif.new( + predicate: predicate, + statements: statements, + consequent: consequent, + location: beginning.location.to(ending.location) + ) + end + + # EmbDoc represents a multi-line comment. + # + # =begin + # first line + # second line + # =end + # + class EmbDoc + # [String] the contents of the comment + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def inline? + false + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('embdoc') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :embdoc, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_embdoc: (String value) -> EmbDoc + def on_embdoc(value) + @embdoc.value << value + @embdoc + end + + # :call-seq: + # on_embdoc_beg: (String value) -> EmbDoc + def on_embdoc_beg(value) + @embdoc = + EmbDoc.new( + value: value, + location: Location.fixed(line: lineno, char: char_pos) + ) + end + + # :call-seq: + # on_embdoc_end: (String value) -> EmbDoc + def on_embdoc_end(value) + location = @embdoc.location + embdoc = + EmbDoc.new( + value: @embdoc.value << value.chomp, + location: + Location.new( + start_line: location.start_line, + start_char: location.start_char, + end_line: lineno, + end_char: char_pos + value.length - 1 + ) + ) + + @comments << embdoc + @embdoc = nil + + embdoc + end + + # EmbExprBeg represents the beginning token for using interpolation inside of + # a parent node that accepts string content (like a string or regular + # expression). + # + # "Hello, #{person}!" + # + class EmbExprBeg + # [String] the #{ used in the string + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + end + + # :call-seq: + # on_embexpr_beg: (String value) -> EmbExprBeg + def on_embexpr_beg(value) + node = + EmbExprBeg.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # EmbExprEnd represents the ending token for using interpolation inside of a + # parent node that accepts string content (like a string or regular + # expression). + # + # "Hello, #{person}!" + # + class EmbExprEnd + # [String] the } used in the string + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + end + + # :call-seq: + # on_embexpr_end: (String value) -> EmbExprEnd + def on_embexpr_end(value) + node = + EmbExprEnd.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # EmbVar represents the use of shorthand interpolation for an instance, class, + # or global variable into a parent node that accepts string content (like a + # string or regular expression). + # + # "#@variable" + # + # In the example above, an EmbVar node represents the # because it forces + # @variable to be interpolated. + class EmbVar + # [String] the # used in the string + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + end + + # :call-seq: + # on_embvar: (String value) -> EmbVar + def on_embvar(value) + node = + EmbVar.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # Ensure represents the use of the +ensure+ keyword and its subsequent + # statements. + # + # begin + # ensure + # end + # + class Ensure + # [Kw] the ensure keyword that began this node + attr_reader :keyword + + # [Statements] the expressions to be executed + attr_reader :statements + + # [Location] the location of this node + attr_reader :location + + def initialize(keyword:, statements:, location:) + @keyword = keyword + @statements = statements + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('ensure') + + q.breakable + q.pp(statements) + end + end + + def to_json(*opts) + { + type: :ensure, + keyword: keyword, + stmts: statements, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_ensure: (Statements statements) -> Ensure + def on_ensure(statements) + keyword = find_token(Kw, 'ensure') + + # We don't want to consume the :@kw event, because that would break + # def..ensure..end chains. + ending = find_token(Kw, 'end', consume: false) + statements.bind( + find_next_statement_start(keyword.location.end_char), + ending.location.start_char + ) + + Ensure.new( + keyword: keyword, + statements: statements, + location: keyword.location.to(ending.location) + ) + end + + # ExcessedComma represents a trailing comma in a list of block parameters. It + # changes the block parameters such that they will destructure. + # + # [[1, 2, 3], [2, 3, 4]].each do |first, second,| + # end + # + # In the above example, an ExcessedComma node would appear in the third + # position of the Params node that is used to declare that block. The third + # position typically represents a rest-type parameter, but in this case is + # used to indicate that a trailing comma was used. + class ExcessedComma + # [String] the comma + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('excessed_comma') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :excessed_comma, value: value, loc: location }.to_json(*opts) + end + end + + # The handler for this event accepts no parameters (though in previous + # versions of Ruby it accepted a string literal with a value of ","). + # + # :call-seq: + # on_excessed_comma: () -> ExcessedComma + def on_excessed_comma(*) + comma = find_token(Comma) + + ExcessedComma.new(value: comma.value, location: comma.location) + end + + # FCall represents the piece of a method call that comes before any arguments + # (i.e., just the name of the method). It is used in places where the parser + # is sure that it is a method call and not potentially a local variable. + # + # method(argument) + # + # In the above example, it's referring to the +method+ segment. + class FCall + # [Const | Ident] the name of the method + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('fcall') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :fcall, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_fcall: ((Const | Ident) value) -> FCall + def on_fcall(value) + FCall.new(value: value, location: value.location) + end + + # Field is always the child of an assignment. It represents assigning to a + # “field” on an object. + # + # object.variable = value + # + class Field + # [untyped] the parent object that owns the field being assigned + attr_reader :parent + + # [:"::" | Op | Period] the operator being used for the assignment + attr_reader :operator + + # [Const | Ident] the name of the field being assigned + attr_reader :name + + # [Location] the location of this node + attr_reader :location + + def initialize(parent:, operator:, name:, location:) + @parent = parent + @operator = operator + @name = name + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('field') + + q.breakable + q.pp(parent) + + q.breakable + q.pp(operator) + + q.breakable + q.pp(name) + end + end + + def to_json(*opts) + { + type: :field, + parent: parent, + op: operator, + name: name, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_field: ( + # untyped parent, + # (:"::" | Op | Period) operator + # (Const | Ident) name + # ) -> Field + def on_field(parent, operator, name) + Field.new( + parent: parent, + operator: operator, + name: name, + location: parent.location.to(name.location) + ) + end + + # FloatLiteral represents a floating point number literal. + # + # 1.0 + # + class FloatLiteral + # [String] the value of the floating point number literal + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('float') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :float, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_float: (String value) -> FloatLiteral + def on_float(value) + node = + FloatLiteral.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # FndPtn represents matching against a pattern where you find a pattern in an + # array using the Ruby 3.0+ pattern matching syntax. + # + # case value + # in [*, 7, *] + # end + # + class FndPtn + # [nil | untyped] the optional constant wrapper + attr_reader :constant + + # [VarField] the splat on the left-hand side + attr_reader :left + + # [Array[ untyped ]] the list of positional expressions in the pattern that + # are being matched + attr_reader :values + + # [VarField] the splat on the right-hand side + attr_reader :right + + # [Location] the location of this node + attr_reader :location + + def initialize(constant:, left:, values:, right:, location:) + @constant = constant + @left = left + @values = values + @right = right + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('fndptn') + + if constant + q.breakable + q.pp(constant) + end + + q.breakable + q.pp(left) + + q.breakable + q.group(2, '(', ')') { q.seplist(values) { |value| q.pp(value) } } + + q.breakable + q.pp(right) + end + end + + def to_json(*opts) + { + type: :fndptn, + constant: constant, + left: left, + values: values, + right: right, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_fndptn: ( + # (nil | untyped) constant, + # VarField left, + # Array[untyped] values, + # VarField right + # ) -> FndPtn + def on_fndptn(constant, left, values, right) + beginning = constant || find_token(LBracket) + ending = find_token(RBracket) + + FndPtn.new( + constant: constant, + left: left, + values: values, + right: right, + location: beginning.location.to(ending.location) + ) + end + + # For represents using a +for+ loop. + # + # for value in list do + # end + # + class For + # [MLHS | MLHSAddStar | VarField] the variable declaration being used to + # pull values out of the object being enumerated + attr_reader :index + + # [untyped] the object being enumerated in the loop + attr_reader :collection + + # [Statements] the statements to be executed + attr_reader :statements + + # [Location] the location of this node + attr_reader :location + + def initialize(index:, collection:, statements:, location:) + @index = index + @collection = collection + @statements = statements + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('for') + + q.breakable + q.pp(index) + + q.breakable + q.pp(collection) + + q.breakable + q.pp(statements) + end + end + + def to_json(*opts) + { + type: :for, + index: index, + collection: collection, + stmts: statements, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_for: ( + # (MLHS | MLHSAddStar | VarField) value, + # untyped collection, + # Statements statements + # ) -> For + def on_for(index, collection, statements) + beginning = find_token(Kw, 'for') + ending = find_token(Kw, 'end') + + # Consume the do keyword if it exists so that it doesn't get confused for + # some other block + keyword = find_token(Kw, 'do', consume: false) + if keyword && keyword.location.start_char > collection.location.end_char && + keyword.location.end_char < ending.location.start_char + tokens.delete(keyword) + end + + statements.bind( + (keyword || collection).location.end_char, + ending.location.start_char + ) + + For.new( + index: index, + collection: collection, + statements: statements, + location: beginning.location.to(ending.location) + ) + end + + # GVar represents a global variable literal. + # + # $variable + # + class GVar + # [String] the name of the global variable + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('gvar') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :gvar, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_gvar: (String value) -> GVar + def on_gvar(value) + node = + GVar.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # HashLiteral represents a hash literal. + # + # { key => value } + # + class HashLiteral + # [nil | AssocListFromArgs] the contents of the hash + attr_reader :contents + + # [Location] the location of this node + attr_reader :location + + def initialize(contents:, location:) + @contents = contents + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('hash') + + q.breakable + q.pp(contents) + end + end + + def to_json(*opts) + { type: :hash, cnts: contents, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_hash: ((nil | AssocListFromArgs) contents) -> HashLiteral + def on_hash(contents) + lbrace = find_token(LBrace) + rbrace = find_token(RBrace) + + if contents + # Here we're going to expand out the location information for the contents + # node so that it can grab up any remaining comments inside the hash. + location = + Location.new( + start_line: contents.location.start_line, + start_char: lbrace.location.end_char, + end_line: contents.location.end_line, + end_char: rbrace.location.start_char + ) + + contents = contents.class.new(assocs: contents.assocs, location: location) + end + + HashLiteral.new( + contents: contents, + location: lbrace.location.to(rbrace.location) + ) + end + + # Heredoc represents a heredoc string literal. + # + # <<~DOC + # contents + # DOC + # + class Heredoc + # [HeredocBeg] the opening of the heredoc + attr_reader :beginning + + # [String] the ending of the heredoc + attr_reader :ending + + # [Array[ StringEmbExpr | StringDVar | TStringContent ]] the parts of the + # heredoc string literal + attr_reader :parts + + # [Location] the location of this node + attr_reader :location + + def initialize(beginning:, ending: nil, parts: [], location:) + @beginning = beginning + @ending = ending + @parts = parts + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('heredoc') + + q.breakable + q.group(2, '(', ')') { q.seplist(parts) { |part| q.pp(part) } } + end + end + + def to_json(*opts) + { + type: :heredoc, + beging: beginning, + ending: ending, + parts: parts, + loc: location + }.to_json(*opts) + end + end + + # HeredocBeg represents the beginning declaration of a heredoc. + # + # <<~DOC + # contents + # DOC + # + # In the example above the HeredocBeg node represents <<~DOC. + class HeredocBeg + # [String] the opening declaration of the heredoc + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('heredoc_beg') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :heredoc_beg, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_heredoc_beg: (String value) -> HeredocBeg + def on_heredoc_beg(value) + location = + Location.token(line: lineno, char: char_pos, size: value.size + 1) + + # Here we're going to artificially create an extra node type so that if + # there are comments after the declaration of a heredoc, they get printed. + beginning = HeredocBeg.new(value: value, location: location) + @heredocs << Heredoc.new(beginning: beginning, location: location) + + beginning + end + + # :call-seq: + # on_heredoc_dedent: (StringContent string, Integer width) -> Heredoc + def on_heredoc_dedent(string, width) + heredoc = @heredocs[-1] + + @heredocs[-1] = + Heredoc.new( + beginning: heredoc.beginning, + ending: heredoc.ending, + parts: string.parts, + location: heredoc.location + ) + end + + # :call-seq: + # on_heredoc_end: (String value) -> Heredoc + def on_heredoc_end(value) + heredoc = @heredocs[-1] + + @heredocs[-1] = + Heredoc.new( + beginning: heredoc.beginning, + ending: value.chomp, + parts: heredoc.parts, + location: + Location.new( + start_line: heredoc.location.start_line, + start_char: heredoc.location.start_char, + end_line: lineno, + end_char: char_pos + ) + ) + end + + # HshPtn represents matching against a hash pattern using the Ruby 2.7+ + # pattern matching syntax. + # + # case value + # in { key: } + # end + # + class HshPtn + # [nil | untyped] the optional constant wrapper + attr_reader :constant + + # [Array[ [Label, untyped] ]] the set of tuples representing the keywords + # that should be matched against in the pattern + attr_reader :keywords + + # [nil | VarField] an optional parameter to gather up all remaining keywords + attr_reader :keyword_rest + + # [Location] the location of this node + attr_reader :location + + def initialize(constant:, keywords:, keyword_rest:, location:) + @constant = constant + @keywords = keywords + @keyword_rest = keyword_rest + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('hshptn') + + if constant + q.breakable + q.pp(constant) + end + + if keywords.any? + q.breakable + q.group(2, '(', ')') do + q.seplist(keywords) { |keyword| q.pp(keyword) } + end + end + + if keyword_rest + q.breakable + q.pp(keyword_rest) + end + end + end + + def to_json(*opts) + { + type: :hshptn, + constant: constant, + keywords: keywords, + kwrest: keyword_rest, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_hshptn: ( + # (nil | untyped) constant, + # Array[[Label, untyped]] keywords, + # (nil | VarField) keyword_rest + # ) -> HshPtn + def on_hshptn(constant, keywords, keyword_rest) + parts = [constant, keywords, keyword_rest].flatten(2).compact + + HshPtn.new( + constant: constant, + keywords: keywords, + keyword_rest: keyword_rest, + location: parts[0].location.to(parts[-1].location) + ) + end + + # Ident represents an identifier anywhere in code. It can represent a very + # large number of things, depending on where it is in the syntax tree. + # + # value + # + class Ident + # [String] the value of the identifier + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('ident') + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { + type: :ident, + value: value.force_encoding('UTF-8'), + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_ident: (String value) -> Ident + def on_ident(value) + node = + Ident.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # If represents the first clause in an +if+ chain. + # + # if predicate + # end + # + class If + # [untyped] the expression to be checked + attr_reader :predicate + + # [Statements] the expressions to be executed + attr_reader :statements + + # [nil, Elsif, Else] the next clause in the chain + attr_reader :consequent + + # [Location] the location of this node + attr_reader :location + + def initialize(predicate:, statements:, consequent:, location:) + @predicate = predicate + @statements = statements + @consequent = consequent + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('if') + + q.breakable + q.pp(predicate) + + q.breakable + q.pp(statements) + + if consequent + q.breakable + q.pp(consequent) + end + end + end + + def to_json(*opts) + { + type: :if, + pred: predicate, + stmts: statements, + cons: consequent, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_if: ( + # untyped predicate, + # Statements statements, + # (nil | Elsif | Else) consequent + # ) -> If + def on_if(predicate, statements, consequent) + beginning = find_token(Kw, 'if') + ending = consequent || find_token(Kw, 'end') + + statements.bind(predicate.location.end_char, ending.location.start_char) + + If.new( + predicate: predicate, + statements: statements, + consequent: consequent, + location: beginning.location.to(ending.location) + ) + end + + # IfOp represents a ternary clause. + # + # predicate ? truthy : falsy + # + class IfOp + # [untyped] the expression to be checked + attr_reader :predicate + + # [untyped] the expression to be executed if the predicate is truthy + attr_reader :truthy + + # [untyped] the expression to be executed if the predicate is falsy + attr_reader :falsy + + # [Location] the location of this node + attr_reader :location + + def initialize(predicate:, truthy:, falsy:, location:) + @predicate = predicate + @truthy = truthy + @falsy = falsy + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('ifop') + + q.breakable + q.pp(predicate) + + q.breakable + q.pp(truthy) + + q.breakable + q.pp(falsy) + end + end + + def to_json(*opts) + { + type: :ifop, + pred: predicate, + tthy: truthy, + flsy: falsy, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_ifop: (untyped predicate, untyped truthy, untyped falsy) -> IfOp + def on_ifop(predicate, truthy, falsy) + IfOp.new( + predicate: predicate, + truthy: truthy, + falsy: falsy, + location: predicate.location.to(falsy.location) + ) + end + + # IfMod represents the modifier form of an +if+ statement. + # + # expression if predicate + # + class IfMod + # [untyped] the expression to be executed + attr_reader :statement + + # [untyped] the expression to be checked + attr_reader :predicate + + # [Location] the location of this node + attr_reader :location + + def initialize(statement:, predicate:, location:) + @statement = statement + @predicate = predicate + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('if_mod') + + q.breakable + q.pp(statement) + + q.breakable + q.pp(predicate) + end + end + + def to_json(*opts) + { + type: :if_mod, + stmt: statement, + pred: predicate, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_if_mod: (untyped predicate, untyped statement) -> IfMod + def on_if_mod(predicate, statement) + find_token(Kw, 'if') + + IfMod.new( + statement: statement, + predicate: predicate, + location: statement.location.to(predicate.location) + ) + end + + # def on_ignored_nl(value) + # value + # end + + # def on_ignored_sp(value) + # value + # end + + # Imaginary represents an imaginary number literal. + # + # 1i + # + class Imaginary + # [String] the value of the imaginary number literal + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('imaginary') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :imaginary, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_imaginary: (String value) -> Imaginary + def on_imaginary(value) + node = + Imaginary.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # In represents using the +in+ keyword within the Ruby 2.7+ pattern matching + # syntax. + # + # case value + # in pattern + # end + # + class In + # [untyped] the pattern to check against + attr_reader :pattern + + # [Statements] the expressions to execute if the pattern matched + attr_reader :statements + + # [nil | In | Else] the next clause in the chain + attr_reader :consequent + + # [Location] the location of this node + attr_reader :location + + def initialize(pattern:, statements:, consequent:, location:) + @pattern = pattern + @statements = statements + @consequent = consequent + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('in') + + q.breakable + q.pp(pattern) + + q.breakable + q.pp(statements) + + if consequent + q.breakable + q.pp(consequent) + end + end + end + + def to_json(*opts) + { + type: :in, + pattern: pattern, + stmts: statements, + cons: consequent, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_in: (RAssign pattern, nil statements, nil consequent) -> RAssign + # | ( + # untyped pattern, + # Statements statements, + # (nil | In | Else) consequent + # ) -> In + def on_in(pattern, statements, consequent) + # Here we have a rightward assignment + return pattern unless statements + + beginning = find_token(Kw, 'in') + ending = consequent || find_token(Kw, 'end') + + statements.bind(beginning.location.end_char, ending.location.start_char) + + In.new( + pattern: pattern, + statements: statements, + consequent: consequent, + location: beginning.location.to(ending.location) + ) + end + + # Int represents an integer number literal. + # + # 1 + # + class Int + # [String] the value of the integer + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('int') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :int, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_int: (String value) -> Int + def on_int(value) + node = + Int.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # IVar represents an instance variable literal. + # + # @variable + # + class IVar + # [String] the name of the instance variable + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('ivar') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :ivar, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_ivar: (String value) -> IVar + def on_ivar(value) + node = + IVar.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # Kw represents the use of a keyword. It can be almost anywhere in the syntax + # tree, so you end up seeing it quite a lot. + # + # if value + # end + # + # In the above example, there would be two Kw nodes: one for the if and one + # for the end. Note that anything that matches the list of keywords in Ruby + # will use a Kw, so if you use a keyword in a symbol literal for instance: + # + # :if + # + # then the contents of the symbol node will contain a Kw node. + class Kw + # [String] the value of the keyword + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('kw') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :kw, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_kw: (String value) -> Kw + def on_kw(value) + node = + Kw.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # KwRestParam represents defining a parameter in a method definition that + # accepts all remaining keyword parameters. + # + # def method(**kwargs) end + # + class KwRestParam + # [nil | Ident] the name of the parameter + attr_reader :name + + # [Location] the location of this node + attr_reader :location + + def initialize(name:, location:) + @name = name + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('kwrest_param') + + q.breakable + q.pp(name) + end + end + + def to_json(*opts) + { type: :kwrest_param, name: name, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_kwrest_param: ((nil | Ident) name) -> KwRestParam + def on_kwrest_param(name) + location = find_token(Op, '**').location + location = location.to(name.location) if name + + KwRestParam.new(name: name, location: location) + end + + # Label represents the use of an identifier to associate with an object. You + # can find it in a hash key, as in: + # + # { key: value } + # + # In this case "key:" would be the body of the label. You can also find it in + # pattern matching, as in: + # + # case value + # in key: + # end + # + # In this case "key:" would be the body of the label. + class Label + # [String] the value of the label + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('label') + + q.breakable + q.text(':') + q.text(value[0...-1]) + end + end + + def to_json(*opts) + { type: :label, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_label: (String value) -> Label + def on_label(value) + node = + Label.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # LabelEnd represents the end of a dynamic symbol. + # + # { "key": value } + # + # In the example above, LabelEnd represents the "\":" token at the end of the + # hash key. This node is important for determining the type of quote being + # used by the label. + class LabelEnd + # [String] the end of the label + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + end + + # :call-seq: + # on_label_end: (String value) -> LabelEnd + def on_label_end(value) + node = + LabelEnd.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # Lambda represents using a lambda literal (not the lambda method call). + # + # ->(value) { value * 2 } + # + class Lambda + # [Params | Paren] the parameter declaration for this lambda + attr_reader :params + + # [BodyStmt | Statements] the expressions to be executed in this lambda + attr_reader :statements + + # [Location] the location of this node + attr_reader :location + + def initialize(params:, statements:, location:) + @params = params + @statements = statements + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('lambda') + + q.breakable + q.pp(params) + + q.breakable + q.pp(statements) + end + end + + def to_json(*opts) + { + type: :lambda, + params: params, + stmts: statements, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_lambda: ( + # (Params | Paren) params, + # (BodyStmt | Statements) statements + # ) -> Lambda + def on_lambda(params, statements) + beginning = find_token(TLambda) + + if token = find_token(TLamBeg, consume: false) + opening = tokens.delete(token) + closing = find_token(RBrace) + else + opening = find_token(Kw, 'do') + closing = find_token(Kw, 'end') + end + + statements.bind(opening.location.end_char, closing.location.start_char) + + Lambda.new( + params: params, + statements: statements, + location: beginning.location.to(closing.location) + ) + end + + # LBrace represents the use of a left brace, i.e., {. + class LBrace + # [String] the left brace + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('lbrace') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :lbrace, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_lbrace: (String value) -> LBrace + def on_lbrace(value) + node = + LBrace.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # LBracket represents the use of a left bracket, i.e., [. + class LBracket + # [String] the left bracket + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + end + + # :call-seq: + # on_lbracket: (String value) -> LBracket + def on_lbracket(value) + node = + LBracket.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # LParen represents the use of a left parenthesis, i.e., (. + class LParen + # [String] the left parenthesis + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('lparen') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :lparen, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_lparen: (String value) -> LParen + def on_lparen(value) + node = + LParen.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # def on_magic_comment(key, value) + # [key, value] + # end + + # MAssign is a parent node of any kind of multiple assignment. This includes + # splitting out variables on the left like: + # + # first, second, third = value + # + # as well as splitting out variables on the right, as in: + # + # value = first, second, third + # + # Both sides support splats, as well as variables following them. There's also + # destructuring behavior that you can achieve with the following: + # + # first, = value + # + class MAssign + # [Mlhs | MlhsAddPost | MlhsAddStar | MlhsParen] the target of the multiple + # assignment + attr_reader :target + + # [untyped] the value being assigned + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(target:, value:, location:) + @target = target + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('massign') + + q.breakable + q.pp(target) + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :massign, target: target, value: value, loc: location }.to_json( + *opts + ) + end + end + + # :call-seq: + # on_massign: ( + # (Mlhs | MlhsAddPost | MlhsAddStar | MlhsParen) target, + # untyped value + # ) -> MAssign + def on_massign(target, value) + comma_range = target.location.end_char...value.location.start_char + target.comma = true if source[comma_range].strip.start_with?(',') + + MAssign.new( + target: target, + value: value, + location: target.location.to(value.location) + ) + end + + # MethodAddArg represents a method call with arguments and parentheses. + # + # method(argument) + # + # MethodAddArg can also represent with a method on an object, as in: + # + # object.method(argument) + # + # Finally, MethodAddArg can represent calling a method with no receiver that + # ends in a ?. In this case, the parser knows it's a method call and not a + # local variable, so it uses a MethodAddArg node as opposed to a VCall node, + # as in: + # + # method? + # + class MethodAddArg + # [Call | FCall] the method call + attr_reader :call + + # [ArgParen | Args | ArgsAddBlock] the arguments to the method call + attr_reader :arguments + + # [Location] the location of this node + attr_reader :location + + def initialize(call:, arguments:, location:) + @call = call + @arguments = arguments + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('method_add_arg') + + q.breakable + q.pp(call) + + q.breakable + q.pp(arguments) + end + end + + def to_json(*opts) + { + type: :method_add_arg, + call: call, + args: arguments, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_method_add_arg: ( + # (Call | FCall) call, + # (ArgParen | Args | ArgsAddBlock) arguments + # ) -> MethodAddArg + def on_method_add_arg(call, arguments) + location = call.location + + location = location.to(arguments.location) unless arguments.is_a?(Args) + + MethodAddArg.new(call: call, arguments: arguments, location: location) + end + + # MethodAddBlock represents a method call with a block argument. + # + # method {} + # + class MethodAddBlock + # [Call | Command | CommandCall | FCall | MethodAddArg] the method call + attr_reader :call + + # [BraceBlock | DoBlock] the block being sent with the method call + attr_reader :block + + # [Location] the location of this node + attr_reader :location + + def initialize(call:, block:, location:) + @call = call + @block = block + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('method_add_block') + + q.breakable + q.pp(call) + + q.breakable + q.pp(block) + end + end + + def to_json(*opts) + { + type: :method_add_block, + call: call, + block: block, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_method_add_block: ( + # (Call | Command | CommandCall | FCall | MethodAddArg) call, + # (BraceBlock | DoBlock) block + # ) -> MethodAddBlock + def on_method_add_block(call, block) + MethodAddBlock.new( + call: call, + block: block, + location: call.location.to(block.location) + ) + end + + # MLHS represents a list of values being destructured on the left-hand side + # of a multiple assignment. + # + # first, second, third = value + # + class MLHS + # Array[ARefField | Field | Ident | MlhsParen | VarField] the parts of + # the left-hand side of a multiple assignment + attr_reader :parts + + # [boolean] whether or not there is a trailing comma at the end of this + # list, which impacts destructuring. It's an attr_accessor so that while + # the syntax tree is being built it can be set by its parent node + attr_accessor :comma + + # [Location] the location of this node + attr_reader :location + + def initialize(parts:, comma: false, location:) + @parts = parts + @comma = comma + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('mlhs') + + q.breakable + q.group(2, '(', ')') { q.seplist(parts) { |part| q.pp(part) } } + end + end + + def to_json(*opts) + { type: :mlhs, parts: parts, comma: comma, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_mlhs_add: ( + # MLHS mlhs, + # (ARefField | Field | Ident | MlhsParen | VarField) part + # ) -> MLHS + def on_mlhs_add(mlhs, part) + if mlhs.parts.empty? + MLHS.new(parts: [part], location: part.location) + else + MLHS.new( + parts: mlhs.parts << part, + location: mlhs.location.to(part.location) + ) + end + end + + # MLHSAddPost represents adding another set of variables onto a list of + # assignments after a splat variable within a multiple assignment. + # + # left, *middle, right = values + # + class MLHSAddPost + # [MlhsAddStar] the value being starred + attr_reader :star + + # [Mlhs] the values after the star + attr_reader :mlhs + + # [Location] the location of this node + attr_reader :location + + def initialize(star:, mlhs:, location:) + @star = star + @mlhs = mlhs + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('mlhs_add_post') + + q.breakable + q.pp(star) + + q.breakable + q.pp(mlhs) + end + end + + def to_json(*opts) + { type: :mlhs_add_post, star: star, mlhs: mlhs, loc: location }.to_json( + *opts + ) + end + end + + # :call-seq: + # on_mlhs_add_post: (MLHSAddStar star, MLHS mlhs) -> MLHSAddPost + def on_mlhs_add_post(star, mlhs) + MLHSAddPost.new( + star: star, + mlhs: mlhs, + location: star.location.to(mlhs.location) + ) + end + + # MLHSAddStar represents a splatted variable inside of a multiple assignment + # on the left hand side. + # + # first, *rest = values + # + class MLHSAddStar + # [MLHS] the values before the starred expression + attr_reader :mlhs + + # [nil | ARefField | Field | Ident | VarField] the expression being + # splatted + attr_reader :star + + # [Location] the location of this node + attr_reader :location + + def initialize(mlhs:, star:, location:) + @mlhs = mlhs + @star = star + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('mlhs_add_star') + + q.breakable + q.pp(mlhs) + + q.breakable + q.pp(star) + end + end + + def to_json(*opts) + { type: :mlhs_add_star, mlhs: mlhs, star: star, loc: location }.to_json( + *opts + ) + end + end + + # :call-seq: + # on_mlhs_add_star: ( + # MLHS mlhs, + # (nil | ARefField | Field | Ident | VarField) part + # ) -> MLHSAddStar + def on_mlhs_add_star(mlhs, part) + beginning = find_token(Op, '*') + ending = part || beginning + + MLHSAddStar.new( + mlhs: mlhs, + star: part, + location: beginning.location.to(ending.location) + ) + end + + # :call-seq: + # on_mlhs_new: () -> MLHS + def on_mlhs_new + MLHS.new(parts: [], location: Location.fixed(line: lineno, char: char_pos)) + end + + # MLHSParen represents parentheses being used to destruct values in a multiple + # assignment on the left hand side. + # + # (left, right) = value + # + class MLHSParen + # [Mlhs | MlhsAddPost | MlhsAddStar | MlhsParen] the contents inside of the + # parentheses + attr_reader :contents + + # [Location] the location of this node + attr_reader :location + + def initialize(contents:, location:) + @contents = contents + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('mlhs_paren') + + q.breakable + q.pp(contents) + end + end + + def to_json(*opts) + { type: :mlhs_paren, cnts: contents, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_mlhs_paren: ( + # (Mlhs | MlhsAddPost | MlhsAddStar | MlhsParen) contents + # ) -> MLHSParen + def on_mlhs_paren(contents) + lparen = find_token(LParen) + rparen = find_token(RParen) + + comma_range = lparen.location.end_char...rparen.location.start_char + contents.comma = true if source[comma_range].strip.end_with?(',') + + MLHSParen.new( + contents: contents, + location: lparen.location.to(rparen.location) + ) + end + + # ModuleDeclaration represents defining a module using the +module+ keyword. + # + # module Namespace + # end + # + class ModuleDeclaration + # [ConstPathRef | ConstRef | TopConstRef] the name of the module + attr_reader :constant + + # [BodyStmt] the expressions to be executed in the context of the module + attr_reader :bodystmt + + # [Location] the location of this node + attr_reader :location + + def initialize(constant:, bodystmt:, location:) + @constant = constant + @bodystmt = bodystmt + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('module') + + q.breakable + q.pp(constant) + + q.breakable + q.pp(bodystmt) + end + end + + def to_json(*opts) + { + type: :module, + constant: constant, + bodystmt: bodystmt, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_module: ( + # (ConstPathRef | ConstRef | TopConstRef) constant, + # BodyStmt bodystmt + # ) -> ModuleDeclaration + def on_module(constant, bodystmt) + beginning = find_token(Kw, 'module') + ending = find_token(Kw, 'end') + + bodystmt.bind( + find_next_statement_start(constant.location.end_char), + ending.location.start_char + ) + + ModuleDeclaration.new( + constant: constant, + bodystmt: bodystmt, + location: beginning.location.to(ending.location) + ) + end + + # MRHS represents the values that are being assigned on the right-hand side of + # a multiple assignment. + # + # values = first, second, third + # + class MRHS + # Array[untyped] the parts that are being assigned + attr_reader :parts + + # [Location] the location of this node + attr_reader :location + + def initialize(parts:, location:) + @parts = parts + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('mrhs') + + q.breakable + q.group(2, '(', ')') { q.seplist(parts) { |part| q.pp(part) } } + end + end + + def to_json(*opts) + { type: :mrhs, parts: parts, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_mrhs_new: () -> MRHS + def on_mrhs_new + MRHS.new(parts: [], location: Location.fixed(line: lineno, char: char_pos)) + end + + # :call-seq: + # on_mrhs_add: (MRHS mrhs, untyped part) -> MRHS + def on_mrhs_add(mrhs, part) + if mrhs.is_a?(MRHSNewFromArgs) + MRHS.new( + parts: [*mrhs.arguments.parts, part], + location: mrhs.location.to(part.location) + ) + elsif mrhs.parts.empty? + MRHS.new(parts: [part], location: mrhs.location) + else + MRHS.new(parts: mrhs.parts << part, loc: mrhs.location.to(part.location)) + end + end + + # MRHSAddStar represents using the splat operator to expand out a value on the + # right hand side of a multiple assignment. + # + # values = first, *rest + # + class MRHSAddStar + # [MRHS | MRHSNewFromArgs] the values before the splatted expression + attr_reader :mrhs + + # [untyped] the splatted expression + attr_reader :star + + # [Location] the location of this node + attr_reader :location + + def initialize(mrhs:, star:, location:) + @mrhs = mrhs + @star = star + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('mrhs_add_star') + + q.breakable + q.pp(mrhs) + + q.breakable + q.pp(star) + end + end + + def to_json(*opts) + { type: :mrhs_add_star, mrhs: mrhs, star: star, loc: location }.to_json( + *opts + ) + end + end + + # :call-seq: + # on_mrhs_add_star: ( + # (MRHS | MRHSNewFromArgs) mrhs, + # untyped star + # ) -> MRHSAddStar + def on_mrhs_add_star(mrhs, star) + beginning = find_token(Op, '*') + ending = star || beginning + + MRHSAddStar.new( + mrhs: mrhs, + star: star, + location: beginning.location.to(ending.location) + ) + end + + # MRHSNewFromArgs represents the shorthand of a multiple assignment that + # allows you to assign values using just commas as opposed to assigning from + # an array. + # + # values = first, second, third + # + class MRHSNewFromArgs + # [Args] the arguments being used in the assignment + attr_reader :arguments + + # [Location] the location of this node + attr_reader :location + + def initialize(arguments:, location:) + @arguments = arguments + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('mrhs_new_from_args') + + q.breakable + q.pp(arguments) + end + end + + def to_json(*opts) + { type: :mrhs_new_from_args, args: arguments, loc: location }.to_json( + *opts + ) + end + end + + # :call-seq: + # on_mrhs_new_from_args: (Args arguments) -> MRHSNewFromArgs + def on_mrhs_new_from_args(arguments) + MRHSNewFromArgs.new(arguments: arguments, location: arguments.location) + end + + # Next represents using the +next+ keyword. + # + # next + # + # The +next+ keyword can also optionally be called with an argument: + # + # next value + # + # +next+ can even be called with multiple arguments, but only if parentheses + # are omitted, as in: + # + # next first, second, third + # + # If a single value is being given, parentheses can be used, as in: + # + # next(value) + # + class Next + # [Args | ArgsAddBlock] the arguments passed to the next keyword + attr_reader :arguments + + # [Location] the location of this node + attr_reader :location + + def initialize(arguments:, location:) + @arguments = arguments + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('next') + + q.breakable + q.pp(arguments) + end + end + + def to_json(*opts) + { type: :next, args: arguments, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_next: ((Args | ArgsAddBlock) arguments) -> Next + def on_next(arguments) + keyword = find_token(Kw, 'next') + + location = keyword.location + location = location.to(arguments.location) unless arguments.is_a?(Args) + + Next.new(arguments: arguments, location: location) + end + + # def on_nl(value) + # value + # end + + # def on_nokw_param(value) + # value + # end + + # Op represents an operator literal in the source. + # + # 1 + 2 + # + # In the example above, the Op node represents the + operator. + class Op + # [String] the operator + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('op') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :op, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_op: (String value) -> Op + def on_op(value) + node = + Op.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # OpAssign represents assigning a value to a variable or constant using an + # operator like += or ||=. + # + # variable += value + # + class OpAssign + # [ARefField | ConstPathField | Field | TopConstField | VarField] the target + # to assign the result of the expression to + attr_reader :target + + # [Op] the operator being used for the assignment + attr_reader :operator + + # [untyped] the expression to be assigned + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(target:, operator:, value:, location:) + @target = target + @operator = operator + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('opassign') + + q.breakable + q.pp(target) + + q.breakable + q.pp(operator) + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { + type: :opassign, + target: target, + op: operator, + value: value, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_opassign: ( + # (ARefField | ConstPathField | Field | TopConstField | VarField) target, + # Op operator, + # untyped value + # ) -> OpAssign + def on_opassign(target, operator, value) + OpAssign.new( + target: target, + operator: operator, + value: value, + location: target.location.to(value.location) + ) + end + + # def on_operator_ambiguous(value) + # value + # end + + # Params represents defining parameters on a method or lambda. + # + # def method(param) end + # + class Params + # [Array[ Ident ]] any required parameters + attr_reader :requireds + + # [Array[ [ Ident, untyped ] ]] any optional parameters and their default + # values + attr_reader :optionals + + # [nil | ArgsForward | ExcessedComma | RestParam] the optional rest + # parameter + attr_reader :rest + + # [Array[ Ident ]] any positional parameters that exist after a rest + # parameter + attr_reader :posts + + # [Array[ [ Ident, nil | untyped ] ]] any keyword parameters and their + # optional default values + attr_reader :keywords + + # [nil | :nil | KwRestParam] the optional keyword rest parameter + attr_reader :keyword_rest + + # [nil | BlockArg] the optional block parameter + attr_reader :block + + # [Location] the location of this node + attr_reader :location + + def initialize( + requireds: [], + optionals: [], + rest: nil, + posts: [], + keywords: [], + keyword_rest: nil, + block: nil, + location: + ) + @requireds = requireds + @optionals = optionals + @rest = rest + @posts = posts + @keywords = keywords + @keyword_rest = keyword_rest + @block = block + @location = location + end + + # Params nodes are the most complicated in the tree. Occasionally you want + # to know if they are "empty", which means not having any parameters + # declared. This logic accesses every kind of parameter and determines if + # it's missing. + def empty? + requireds.empty? && optionals.empty? && !rest && posts.empty? && + keywords.empty? && !keyword_rest && !block + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('params') + + if requireds.any? + q.breakable + q.group(2, '(', ')') { q.seplist(requireds) { |name| q.pp(name) } } + end + + if optionals.any? + q.breakable + q.group(2, '(', ')') do + q.seplist(optionals) do |(name, default)| + q.pp(name) + q.text('=') + q.group(2) do + q.breakable('') + q.pp(default) + end + end + end + end + + if rest + q.breakable + q.pp(rest) + end + + if posts.any? + q.breakable + q.group(2, '(', ')') { q.seplist(posts) { |value| q.pp(value) } } + end + + if keywords.any? + q.breakable + q.group(2, '(', ')') do + q.seplist(keywords) do |(name, default)| + q.pp(name) + + if default + q.text('=') + q.group(2) do + q.breakable('') + q.pp(default) + end + end + end + end + end + + if keyword_rest + q.breakable + q.pp(keyword_rest) + end + + if block + q.breakable + q.pp(block) + end + end + end + + def to_json(*opts) + { + type: :params, + reqs: requireds, + opts: optionals, + rest: rest, + posts: posts, + keywords: keywords, + kwrest: keyword_rest, + block: block, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_params: ( + # (nil | Array[Ident]) requireds, + # (nil | Array[[Ident, untyped]]) optionals, + # (nil | ArgsForward | ExcessedComma | RestParam) rest, + # (nil | Array[Ident]) posts, + # (nil | Array[[Ident, nil | untyped]]) keywords, + # (nil | :nil | KwRestParam) keyword_rest, + # (nil | BlockArg) block + # ) -> Params + def on_params( + requireds, + optionals, + rest, + posts, + keywords, + keyword_rest, + block + ) + parts = [ + *requireds, + *optionals&.flatten(1), + rest, + *posts, + *keywords&.flat_map { |(key, value)| [key, value || nil] }, + (keyword_rest if keyword_rest != :nil), + block + ].compact + + location = + if parts.any? + parts[0].location.to(parts[-1].location) + else + Location.fixed(line: lineno, char: char_pos) + end + + Params.new( + requireds: requireds || [], + optionals: optionals || [], + rest: rest, + posts: posts || [], + keywords: keywords || [], + keyword_rest: keyword_rest, + block: block, + location: location + ) + end + + # Paren represents using balanced parentheses in a couple places in a Ruby + # program. In general parentheses can be used anywhere a Ruby expression can + # be used. + # + # (1 + 2) + # + class Paren + # [LParen] the left parenthesis that opened this statement + attr_reader :lparen + + # [untyped] the expression inside the parentheses + attr_reader :contents + + # [Location] the location of this node + attr_reader :location + + def initialize(lparen:, contents:, location:) + @lparen = lparen + @contents = contents + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('paren') + + q.breakable + q.pp(contents) + end + end + + def to_json(*opts) + { type: :paren, lparen: lparen, cnts: contents, loc: location }.to_json( + *opts + ) + end + end + + # :call-seq: + # on_paren: (untyped contents) -> Paren + def on_paren(contents) + lparen = find_token(LParen) + rparen = find_token(RParen) + + if contents && contents.is_a?(Params) + location = contents.location + location = + Location.new( + start_line: location.start_line, + start_char: find_next_statement_start(lparen.location.end_char), + end_line: location.end_line, + end_char: rparen.location.start_char + ) + + contents = + Params.new( + requireds: contents.requireds, + optionals: contents.optionals, + rest: contents.rest, + posts: contents.posts, + keywords: contents.keywords, + keyword_rest: contents.keyword_rest, + block: contents.block, + location: location + ) + end + + Paren.new( + lparen: lparen, + contents: contents, + location: lparen.location.to(rparen.location) + ) + end + + # If we encounter a parse error, just immediately bail out so that our runner + # can catch it. + def on_parse_error(error, *) + raise ParseError.new(error, lineno, column) + end + alias on_alias_error on_parse_error + alias on_assign_error on_parse_error + alias on_class_name_error on_parse_error + alias on_param_error on_parse_error + + # Period represents the use of the +.+ operator. It is usually found in method + # calls. + class Period + # [String] the period + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('period') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :period, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_period: (String value) -> Period + def on_period(value) + Period.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + end + + # Program represents the overall syntax tree. + class Program + # [Statements] the top-level expressions of the program + attr_reader :statements + + # [Array[ Comment | EmbDoc ]] the comments inside the program + attr_reader :comments + + # [Location] the location of this node + attr_reader :location + + def initialize(statements:, comments:, location:) + @statements = statements + @comments = comments + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('program') + + q.breakable + q.pp(statements) + end + end + + def to_json(*opts) + { + type: :program, + stmts: statements, + comments: comments, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_program: (Statements statements) -> Program + def on_program(statements) + location = + Location.new( + start_line: 1, + start_char: 0, + end_line: lines.length, + end_char: source.length + ) + + statements.body << @__end__ if @__end__ + statements.bind(0, source.length) + + Program.new(statements: statements, comments: @comments, location: location) + end + + # QSymbols represents a symbol literal array without interpolation. + # + # %i[one two three] + # + class QSymbols + # [Array[ TStringContent ]] the elements of the array + attr_reader :elements + + # [Location] the location of this node + attr_reader :location + + def initialize(elements:, location:) + @elements = elements + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('qsymbols') + + q.breakable + q.group(2, '(', ')') { q.seplist(elements) { |element| q.pp(element) } } + end + end + + def to_json(*opts) + { type: :qsymbols, elems: elements, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_qsymbols_add: (QSymbols qsymbols, TStringContent element) -> QSymbols + def on_qsymbols_add(qsymbols, element) + QSymbols.new( + elements: qsymbols.elements << element, + location: qsymbols.location.to(element.location) + ) + end + + # QSymbolsBeg represents the beginning of a symbol literal array. + # + # %i[one two three] + # + # In the snippet above, QSymbolsBeg represents the "%i[" token. Note that + # these kinds of arrays can start with a lot of different delimiter types + # (e.g., %i| or %i<). + class QSymbolsBeg + # [String] the beginning of the array literal + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + end + + # :call-seq: + # on_qsymbols_beg: (String value) -> QSymbolsBeg + def on_qsymbols_beg(value) + node = + QSymbolsBeg.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # :call-seq: + # on_qsymbols_new: () -> QSymbols + def on_qsymbols_new + qsymbols_beg = find_token(QSymbolsBeg) + + QSymbols.new(elements: [], location: qsymbols_beg.location) + end + + # QWords represents a string literal array without interpolation. + # + # %w[one two three] + # + class QWords + # [Array[ TStringContent ]] the elements of the array + attr_reader :elements + + # [Location] the location of this node + attr_reader :location + + def initialize(elements:, location:) + @elements = elements + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('qwords') + + q.breakable + q.group(2, '(', ')') { q.seplist(elements) { |element| q.pp(element) } } + end + end + + def to_json(*opts) + { type: :qwords, elems: elements, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_qwords_add: (QWords qwords, TStringContent element) -> QWords + def on_qwords_add(qwords, element) + QWords.new( + elements: qwords.elements << element, + location: qwords.location.to(element.location) + ) + end + + # QWordsBeg represents the beginning of a string literal array. + # + # %w[one two three] + # + # In the snippet above, QWordsBeg represents the "%w[" token. Note that these + # kinds of arrays can start with a lot of different delimiter types (e.g., + # %w| or %w<). + class QWordsBeg + # [String] the beginning of the array literal + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + end + + # :call-seq: + # on_qwords_beg: (String value) -> QWordsBeg + def on_qwords_beg(value) + node = + QWordsBeg.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # :call-seq: + # on_qwords_new: () -> QWords + def on_qwords_new + qwords_beg = find_token(QWordsBeg) + + QWords.new(elements: [], location: qwords_beg.location) + end + + # RationalLiteral represents the use of a rational number literal. + # + # 1r + # + class RationalLiteral + # [String] the rational number literal + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('rational') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :rational, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_rational: (String value) -> RationalLiteral + def on_rational(value) + node = + RationalLiteral.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # RBrace represents the use of a right brace, i.e., +++. + class RBrace + # [String] the right brace + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + end + + # :call-seq: + # on_rbrace: (String value) -> RBrace + def on_rbrace(value) + node = + RBrace.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # RBracket represents the use of a right bracket, i.e., +]+. + class RBracket + # [String] the right bracket + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + end + + # :call-seq: + # on_rbracket: (String value) -> RBracket + def on_rbracket(value) + node = + RBracket.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # Redo represents the use of the +redo+ keyword. + # + # redo + # + class Redo + # [String] the value of the keyword + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('redo') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :redo, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_redo: () -> Redo + def on_redo + keyword = find_token(Kw, 'redo') + + Redo.new(value: keyword.value, location: keyword.location) + end + + # RegexpContent represents the body of a regular expression. + # + # /.+ #{pattern} .+/ + # + # In the example above, a RegexpContent node represents everything contained + # within the forward slashes. + class RegexpContent + # [String] the opening of the regular expression + attr_reader :beginning + + # [Array[ StringDVar | StringEmbExpr | TStringContent ]] the parts of the + # regular expression + attr_reader :parts + + # [Location] the location of this node + attr_reader :location + + def initialize(beginning:, parts:, location:) + @beginning = beginning + @parts = parts + @location = location + end + end + + # :call-seq: + # on_regexp_add: ( + # RegexpContent regexp_content, + # (StringDVar | StringEmbExpr | TStringContent) part + # ) -> RegexpContent + def on_regexp_add(regexp_content, part) + RegexpContent.new( + beginning: regexp_content.beginning, + parts: regexp_content.parts << part, + location: regexp_content.location.to(part.location) + ) + end + + # RegexpBeg represents the start of a regular expression literal. + # + # /.+/ + # + # In the example above, RegexpBeg represents the first / token. Regular + # expression literals can also be declared using the %r syntax, as in: + # + # %r{.+} + # + class RegexpBeg + # [String] the beginning of the regular expression + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + end + + # :call-seq: + # on_regexp_beg: (String value) -> RegexpBeg + def on_regexp_beg(value) + node = + RegexpBeg.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # RegexpEnd represents the end of a regular expression literal. + # + # /.+/m + # + # In the example above, the RegexpEnd event represents the /m at the end of + # the regular expression literal. You can also declare regular expression + # literals using %r, as in: + # + # %r{.+}m + # + class RegexpEnd + # [String] the end of the regular expression + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + end + + # :call-seq: + # on_regexp_end: (String value) -> RegexpEnd + def on_regexp_end(value) + RegexpEnd.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + end + + # RegexpLiteral represents a regular expression literal. + # + # /.+/ + # + class RegexpLiteral + # [String] the beginning of the regular expression literal + attr_reader :beginning + + # [String] the ending of the regular expression literal + attr_reader :ending + + # [Array[ StringEmbExpr | StringDVar | TStringContent ]] the parts of the + # regular expression literal + attr_reader :parts + + # [Locatione] the location of this node + attr_reader :location + + def initialize(beginning:, ending:, parts:, location:) + @beginning = beginning + @ending = ending + @parts = parts + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('regexp_literal') + + q.breakable + q.group(2, '(', ')') { q.seplist(parts) { |part| q.pp(part) } } + end + end + + def to_json(*opts) + { + type: :regexp_literal, + beging: beginning, + ending: ending, + parts: parts, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_regexp_literal: ( + # RegexpContent regexp_content, + # RegexpEnd ending + # ) -> RegexpLiteral + def on_regexp_literal(regexp_content, ending) + RegexpLiteral.new( + beginning: regexp_content.beginning, + ending: ending.value, + parts: regexp_content.parts, + location: regexp_content.location.to(ending.location) + ) + end + + # :call-seq: + # on_regexp_new: () -> RegexpContent + def on_regexp_new + regexp_beg = find_token(RegexpBeg) + + RegexpContent.new( + beginning: regexp_beg.value, + parts: [], + location: regexp_beg.location + ) + end + + # RescueEx represents the list of exceptions being rescued in a rescue clause. + # + # begin + # rescue Exception => exception + # end + # + class RescueEx + # [untyped] the list of exceptions being rescued + attr_reader :exceptions + + # [nil | Field | VarField] the expression being used to capture the raised + # exception + attr_reader :variable + + # [Location] the location of this node + attr_reader :location + + def initialize(exceptions:, variable:, location:) + @exceptions = exceptions + @variable = variable + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('rescue_ex') + + q.breakable + q.pp(exceptions) + + q.breakable + q.pp(variable) + end + end + + def to_json(*opts) + { + type: :rescue_ex, + extns: exceptions, + var: variable, + loc: location + }.to_json(*opts) + end + end + + # Rescue represents the use of the rescue keyword inside of a BodyStmt node. + # + # begin + # rescue + # end + # + class Rescue + # [RescueEx] the exceptions being rescued + attr_reader :exception + + # [Statements] the expressions to evaluate when an error is rescued + attr_reader :statements + + # [nil | Rescue] the optional next clause in the chain + attr_reader :consequent + + # [Location] the location of this node + attr_reader :location + + def initialize(exception:, statements:, consequent:, location:) + @exception = exception + @statements = statements + @consequent = consequent + @location = location + end + + def bind_end(end_char) + @location = + Location.new( + start_line: location.start_line, + start_char: location.start_char, + end_line: location.end_line, + end_char: end_char + ) + + if consequent + consequent.bind_end(end_char) + statements.bind_end(consequent.location.start_char) + else + statements.bind_end(end_char) + end + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('rescue') + + if exception + q.breakable + q.pp(exception) + end + + q.breakable + q.pp(statements) + + if consequent + q.breakable + q.pp(consequent) + end + end + end + + def to_json(*opts) + { + type: :rescue, + extn: exception, + stmts: statements, + cons: consequent, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_rescue: ( + # (nil | [untyped] | MRHS | MRHSAddStar) exceptions, + # (nil | Field | VarField) variable, + # Statements statements, + # (nil | Rescue) consequent + # ) -> Rescue + def on_rescue(exceptions, variable, statements, consequent) + keyword = find_token(Kw, 'rescue') + exceptions = exceptions[0] if exceptions.is_a?(Array) + + last_node = variable || exceptions || keyword + statements.bind( + find_next_statement_start(last_node.location.end_char), + char_pos + ) + + # We add an additional inner node here that ripper doesn't provide so that + # we have a nice place to attach inline comments. But we only need it if we + # have an exception or a variable that we're rescuing. + rescue_ex = + if exceptions || variable + RescueEx.new( + exceptions: exceptions, + variable: variable, + location: + Location.new( + start_line: keyword.location.start_line, + start_char: keyword.location.end_char + 1, + end_line: last_node.location.end_line, + end_char: last_node.location.end_char + ) + ) + end + + Rescue.new( + exception: rescue_ex, + statements: statements, + consequent: consequent, + location: + Location.new( + start_line: keyword.location.start_line, + start_char: keyword.location.start_char, + end_line: lineno, + end_char: char_pos + ) + ) + end + + # RescueMod represents the use of the modifier form of a +rescue+ clause. + # + # expression rescue value + # + class RescueMod + # [untyped] the expression to execute + attr_reader :statement + + # [untyped] the value to use if the executed expression raises an error + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(statement:, value:, location:) + @statement = statement + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('rescue_mod') + + q.breakable + q.pp(statement) + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { + type: :rescue_mod, + stmt: statement, + value: value, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_rescue_mod: (untyped statement, untyped value) -> RescueMod + def on_rescue_mod(statement, value) + find_token(Kw, 'rescue') + + RescueMod.new( + statement: statement, + value: value, + location: statement.location.to(value.location) + ) + end + + # RestParam represents defining a parameter in a method definition that + # accepts all remaining positional parameters. + # + # def method(*rest) end + # + class RestParam + # [nil | Ident] the name of the parameter + attr_reader :name + + # [Location] the location of this node + attr_reader :location + + def initialize(name:, location:) + @name = name + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('rest_param') + + q.breakable + q.pp(name) + end + end + + def to_json(*opts) + { type: :rest_param, name: name, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_rest_param: ((nil | Ident) name) -> RestParam + def on_rest_param(name) + location = find_token(Op, '*').location + location = location.to(name.location) if name + + RestParam.new(name: name, location: location) + end + + # Retry represents the use of the +retry+ keyword. + # + # retry + # + class Retry + # [String] the value of the keyword + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('retry') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :retry, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_retry: () -> Retry + def on_retry + keyword = find_token(Kw, 'retry') + + Retry.new(value: keyword.value, location: keyword.location) + end + + # Return represents using the +return+ keyword with arguments. + # + # return value + # + class Return + # [Args | ArgsAddBlock] the arguments being passed to the keyword + attr_reader :arguments + + # [Location] the location of this node + attr_reader :location + + def initialize(arguments:, location:) + @arguments = arguments + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('return') + + q.breakable + q.pp(arguments) + end + end + + def to_json(*opts) + { type: :return, args: arguments, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_return: ((Args | ArgsAddBlock) arguments) -> Return + def on_return(arguments) + keyword = find_token(Kw, 'return') + + Return.new( + arguments: arguments, + location: keyword.location.to(arguments.location) + ) + end + + # Return0 represents the bare +return+ keyword with no arguments. + # + # return + # + class Return0 + # [String] the value of the keyword + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('return0') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :return0, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_return0: () -> Return0 + def on_return0 + keyword = find_token(Kw, 'return') + + Return0.new(value: keyword.value, location: keyword.location) + end + + # RParen represents the use of a right parenthesis, i.e., +)+. + class RParen + # [String] the parenthesis + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + end + + # :call-seq: + # on_rparen: (String value) -> RParen + def on_rparen(value) + node = + RParen.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # SClass represents a block of statements that should be evaluated within the + # context of the singleton class of an object. It's frequently used to define + # singleton methods. + # + # class << self + # end + # + class SClass + # [untyped] the target of the singleton class to enter + attr_reader :target + + # [BodyStmt] the expressions to be executed + attr_reader :bodystmt + + # [Location] the location of this node + attr_reader :location + + def initialize(target:, bodystmt:, location:) + @target = target + @bodystmt = bodystmt + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('sclass') + + q.breakable + q.pp(target) + + q.breakable + q.pp(bodystmt) + end + end + + def to_json(*opts) + { + type: :sclass, + target: target, + bodystmt: bodystmt, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_sclass: (untyped target, BodyStmt bodystmt) -> SClass + def on_sclass(target, bodystmt) + beginning = find_token(Kw, 'class') + ending = find_token(Kw, 'end') + + bodystmt.bind( + find_next_statement_start(target.location.end_char), + ending.location.start_char + ) + + SClass.new( + target: target, + bodystmt: bodystmt, + location: beginning.location.to(ending.location) + ) + end + + # def on_semicolon(value) + # value + # end + + # def on_sp(value) + # value + # end + + # stmts_add is a parser event that represents a single statement inside a + # list of statements within any lexical block. It accepts as arguments the + # parent stmts node as well as an stmt which can be any expression in + # Ruby. + def on_stmts_add(statements, statement) + statements << statement + end + + # Everything that has a block of code inside of it has a list of statements. + # Normally we would just track those as a node that has an array body, but we + # have some special handling in order to handle empty statement lists. They + # need to have the right location information, so all of the parent node of + # stmts nodes will report back down the location information. We then + # propagate that onto void_stmt nodes inside the stmts in order to make sure + # all comments get printed appropriately. + class Statements + # [SyntaxTree] the parser that created this node + attr_reader :parser + + # [Array[ untyped ]] the list of expressions contained within this node + attr_reader :body + + # [Location] the location of this node + attr_reader :location + + def initialize(parser:, body:, location:) + @parser = parser + @body = body + @location = location + end + + def bind(start_char, end_char) + @location = + Location.new( + start_line: location.start_line, + start_char: start_char, + end_line: location.end_line, + end_char: end_char + ) + + if body[0].is_a?(VoidStmt) + location = body[0].location + location = + Location.new( + start_line: location.start_line, + start_char: start_char, + end_line: location.end_line, + end_char: start_char + ) + + body[0] = VoidStmt.new(location: location) + end + + attach_comments(start_char, end_char) + end + + def bind_end(end_char) + @location = + Location.new( + start_line: location.start_line, + start_char: location.start_char, + end_line: location.end_line, + end_char: end_char + ) + end + + def <<(statement) + @location = + body.any? ? location.to(statement.location) : statement.location + + body << statement + self + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('statements') + + q.breakable + q.seplist(body) { |statement| q.pp(statement) } + end + end + + def to_json(*opts) + { type: :statements, body: body, loc: location }.to_json(*opts) + end + + private + + def attach_comments(start_char, end_char) + attachable = + parser.comments.select do |comment| + !comment.inline? && start_char <= comment.location.start_char && + end_char >= comment.location.end_char && + !comment.value.include?('prettier-ignore') + end + + return if attachable.empty? + + parser.comments -= attachable + @body = (body + attachable).sort_by! { |node| node.location.start_char } + end + end + + # :call-seq: + # on_stmts_new: () -> Statements + def on_stmts_new + Statements.new( + parser: self, + body: [], + location: Location.fixed(line: lineno, char: char_pos) + ) + end + + # StringContent represents the contents of a string-like value. + # + # "string" + # + class StringContent + # [Array[ StringEmbExpr | StringDVar | TStringContent ]] the parts of the + # string + attr_reader :parts + + # [Location] the location of this node + attr_reader :location + + def initialize(parts:, location:) + @parts = parts + @location = location + end + end + + # :call-seq: + # on_string_add: ( + # String string, + # (StringEmbExpr | StringDVar | TStringContent) part + # ) -> StringContent + def on_string_add(string, part) + location = + string.parts.any? ? string.location.to(part.location) : part.location + + StringContent.new(parts: string.parts << part, location: location) + end + + # StringConcat represents concatenating two strings together using a backward + # slash. + # + # "first" \ + # "second" + # + class StringConcat + # [StringConcat | StringLiteral] the left side of the concatenation + attr_reader :left + + # [StringLiteral] the right side of the concatenation + attr_reader :right + + # [Location] the location of this node + attr_reader :location + + def initialize(left:, right:, location:) + @left = left + @right = right + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('string_concat') + + q.breakable + q.pp(left) + + q.breakable + q.pp(right) + end + end + + def to_json(*opts) + { type: :string_concat, left: left, right: right, loc: location }.to_json( + *opts + ) + end + end + + # :call-seq: + # on_string_concat: ( + # (StringConcat | StringLiteral) left, + # StringLiteral right + # ) -> StringConcat + def on_string_concat(left, right) + StringConcat.new( + left: left, + right: right, + location: left.location.to(right.location) + ) + end + + # :call-seq: + # on_string_content: () -> StringContent + def on_string_content + StringContent.new( + parts: [], + location: Location.fixed(line: lineno, char: char_pos) + ) + end + + # StringDVar represents shorthand interpolation of a variable into a string. + # It allows you to take an instance variable, class variable, or global + # variable and omit the braces when interpolating. + # + # "#@variable" + # + class StringDVar + # [Backref | VarRef] the variable being interpolated + attr_reader :variable + + # [Location] the location of this node + attr_reader :location + + def initialize(variable:, location:) + @variable = variable + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('string_dvar') + + q.breakable + q.pp(variable) + end + end + + def to_json(*opts) + { type: :string_dvar, var: variable, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_string_dvar: ((Backref | VarRef) variable) -> StringDVar + def on_string_dvar(variable) + embvar = find_token(EmbVar) + + StringDVar.new( + variable: variable, + location: embvar.location.to(variable.location) + ) + end + + # StringEmbExpr represents interpolated content. It can be contained within a + # couple of different parent nodes, including regular expressions, strings, + # and dynamic symbols. + # + # "string #{expression}" + # + class StringEmbExpr + # [Statements] the expressions to be interpolated + attr_reader :statements + + # [Location] the location of this node + attr_reader :location + + def initialize(statements:, location:) + @statements = statements + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('string_embexpr') + + q.breakable + q.pp(statements) + end + end + + def to_json(*opts) + { type: :string_embexpr, stmts: statements, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_string_embexpr: (Statements statements) -> StringEmbExpr + def on_string_embexpr(statements) + embexpr_beg = find_token(EmbExprBeg) + embexpr_end = find_token(EmbExprEnd) + + statements.bind( + embexpr_beg.location.end_char, + embexpr_end.location.start_char + ) + + StringEmbExpr.new( + statements: statements, + location: embexpr_beg.location.to(embexpr_end.location) + ) + end + + # StringLiteral represents a string literal. + # + # "string" + # + class StringLiteral + # [Array[ StringEmbExpr | StringDVar | TStringContent ]] the parts of the + # string literal + attr_reader :parts + + # [String] which quote was used by the string literal + attr_reader :quote + + # [Location] the location of this node + attr_reader :location + + def initialize(parts:, quote:, location:) + @parts = parts + @quote = quote + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('string_literal') + + q.breakable + q.group(2, '(', ')') { q.seplist(parts) { |part| q.pp(part) } } + end + end + + def to_json(*opts) + { + type: :string_literal, + parts: parts, + quote: quote, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_string_literal: (String string) -> Heredoc | StringLiteral + def on_string_literal(string) + heredoc = @heredocs[-1] + + if heredoc && heredoc.ending + heredoc = @heredocs.pop + + Heredoc.new( + beginning: heredoc.beginning, + ending: heredoc.ending, + parts: string.parts, + location: heredoc.location + ) + else + tstring_beg = find_token(TStringBeg) + tstring_end = find_token(TStringEnd) + + StringLiteral.new( + parts: string.parts, + quote: tstring_beg.value, + location: tstring_beg.location.to(tstring_end.location) + ) + end + end + + # Super represents using the +super+ keyword with arguments. It can optionally + # use parentheses. + # + # super(value) + # + class Super + # [ArgParen | Args | ArgsAddBlock] the arguments to the keyword + attr_reader :arguments + + # [Location] the location of this node + attr_reader :location + + def initialize(arguments:, location:) + @arguments = arguments + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('super') + + q.breakable + q.pp(arguments) + end + end + + def to_json(*opts) + { type: :super, args: arguments, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_super: ((ArgParen | Args | ArgsAddBlock) arguments) -> Super + def on_super(arguments) + keyword = find_token(Kw, 'super') + + Super.new( + arguments: arguments, + location: keyword.location.to(arguments.location) + ) + end + + # SymBeg represents the beginning of a symbol literal. + # + # :symbol + # + # SymBeg is also used for dynamic symbols, as in: + # + # :"symbol" + # + # Finally, SymBeg is also used for symbols using the %s syntax, as in: + # + # %s[symbol] + # + # The value of this node is a string. In most cases (as in the first example + # above) it will contain just ":". In the case of dynamic symbols it will + # contain ":'" or ":\"". In the case of %s symbols, it will contain the start + # of the symbol including the %s and the delimiter. + class SymBeg + # [String] the beginning of the symbol + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + end + + # symbeg is a token that represents the beginning of a symbol literal. + # In most cases it will contain just ":" as in the value, but if its a dynamic + # symbol being defined it will contain ":'" or ":\"". + def on_symbeg(value) + node = + SymBeg.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # SymbolContent represents symbol contents and is always the child of a + # SymbolLiteral node. + # + # :symbol + # + class SymbolContent + # [Backtick | Const | CVar | GVar | Ident | IVar | Kw | Op] the value of the + # symbol + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + end + + # :call-seq: + # on_symbol: ( + # (Backtick | Const | CVar | GVar | Ident | IVar | Kw | Op) value + # ) -> SymbolContent + def on_symbol(value) + tokens.pop + + SymbolContent.new(value: value, location: value.location) + end + + # SymbolLiteral represents a symbol in the system with no interpolation + # (as opposed to a DynaSymbol which has interpolation). + # + # :symbol + # + class SymbolLiteral + # [Backtick | Const | CVar | GVar | Ident | IVar | Kw | Op] the value of the + # symbol + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('symbol_literal') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :symbol_literal, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_symbol_literal: ( + # ( + # Backtick | Const | CVar | GVar | Ident | + # IVar | Kw | Op | SymbolContent + # ) value + # ) -> SymbolLiteral + def on_symbol_literal(value) + if tokens[-1] == value + SymbolLiteral.new(value: tokens.pop, location: value.location) + else + symbeg = find_token(SymBeg) + + SymbolLiteral.new( + value: value.value, + location: symbeg.location.to(value.location) + ) + end + end + + # Symbols represents a symbol array literal with interpolation. + # + # %I[one two three] + # + class Symbols + # [Array[ Word ]] the words in the symbol array literal + attr_reader :elements + + # [Location] the location of this node + attr_reader :location + + def initialize(elements:, location:) + @elements = elements + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('symbols') + + q.breakable + q.group(2, '(', ')') { q.seplist(elements) { |element| q.pp(element) } } + end + end + + def to_json(*opts) + { type: :symbols, elems: elements, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_symbols_add: (Symbols symbols, Word word) -> Symbols + def on_symbols_add(symbols, word) + Symbols.new( + elements: symbols.elements << word, + location: symbols.location.to(word.location) + ) + end + + # SymbolsBeg represents the start of a symbol array literal with + # interpolation. + # + # %I[one two three] + # + # In the snippet above, SymbolsBeg represents the "%I[" token. Note that these + # kinds of arrays can start with a lot of different delimiter types + # (e.g., %I| or %I<). + class SymbolsBeg + # [String] the beginning of the symbol literal array + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + end + + # :call-seq: + # on_symbols_beg: (String value) -> SymbolsBeg + def on_symbols_beg(value) + node = + SymbolsBeg.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # :call-seq: + # on_symbols_new: () -> Symbols + def on_symbols_new + symbols_beg = find_token(SymbolsBeg) + + Symbols.new(elements: [], location: symbols_beg.location) + end + + # TLambda represents the beginning of a lambda literal. + # + # -> { value } + # + # In the example above the TLambda represents the +->+ operator. + class TLambda + # [String] the beginning of the lambda literal + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + end + + # :call-seq: + # on_tlambda: (String value) -> TLambda + def on_tlambda(value) + node = + TLambda.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # TLamBeg represents the beginning of the body of a lambda literal using + # braces. + # + # -> { value } + # + # In the example above the TLamBeg represents the +{+ operator. + class TLamBeg + # [String] the beginning of the body of the lambda literal + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + end + + # :call-seq: + # on_tlambeg: (String value) -> TLamBeg + def on_tlambeg(value) + node = + TLamBeg.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # TopConstField is always the child node of some kind of assignment. It + # represents when you're assigning to a constant that is being referenced at + # the top level. + # + # ::Constant = value + # + class TopConstField + # [Const] the constant being assigned + attr_reader :constant + + # [Location] the location of this node + attr_reader :location + + def initialize(constant:, location:) + @constant = constant + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('top_const_field') + + q.breakable + q.pp(constant) + end + end + + def to_json(*opts) + { type: :top_const_field, constant: constant, loc: location }.to_json( + *opts + ) + end + end + + # :call-seq: + # on_top_const_field: (Const constant) -> TopConstRef + def on_top_const_field(constant) + operator = find_colon2_before(constant) + + TopConstField.new( + constant: constant, + location: operator.location.to(constant.location) + ) + end + + # TopConstRef is very similar to TopConstField except that it is not involved + # in an assignment. + # + # ::Constant + # + class TopConstRef + # [Const] the constant being referenced + attr_reader :constant + + # [Location] the location of this node + attr_reader :location + + def initialize(constant:, location:) + @constant = constant + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('top_const_ref') + + q.breakable + q.pp(constant) + end + end + + def to_json(*opts) + { type: :top_const_ref, constant: constant, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_top_const_ref: (Const constant) -> TopConstRef + def on_top_const_ref(constant) + operator = find_colon2_before(constant) + + TopConstRef.new( + constant: constant, + location: operator.location.to(constant.location) + ) + end + + # TStringBeg represents the beginning of a string literal. + # + # "string" + # + # In the example above, TStringBeg represents the first set of quotes. Strings + # can also use single quotes. They can also be declared using the +%q+ and + # +%Q+ syntax, as in: + # + # %q{string} + # + class TStringBeg + # [String] the beginning of the string + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + end + + # :call-seq: + # on_tstring_beg: (String value) -> TStringBeg + def on_tstring_beg(value) + node = + TStringBeg.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # TStringContent represents plain characters inside of an entity that accepts + # string content like a string, heredoc, command string, or regular + # expression. + # + # "string" + # + # In the example above, TStringContent represents the +string+ token contained + # within the string. + class TStringContent + # [String] the content of the string + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('tstring_content') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { + type: :tstring_content, + value: value.force_encoding('UTF-8'), + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_tstring_content: (String value) -> TStringContent + def on_tstring_content(value) + TStringContent.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + end + + # TStringEnd represents the end of a string literal. + # + # "string" + # + # In the example above, TStringEnd represents the second set of quotes. + # Strings can also use single quotes. They can also be declared using the +%q+ + # and +%Q+ syntax, as in: + # + # %q{string} + # + class TStringEnd + # [String] the end of the string + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + end + + # :call-seq: + # on_tstring_end: (String value) -> TStringEnd + def on_tstring_end(value) + node = + TStringEnd.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # Not represents the unary +not+ method being called on an expression. + # + # not value + # + class Not + # [untyped] the statement on which to operate + attr_reader :statement + + # [boolean] whether or not parentheses were used + attr_reader :parentheses + + # [Location] the location of this node + attr_reader :location + + def initialize(statement:, parentheses:, location:) + @statement = statement + @parentheses = parentheses + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('not') + + q.breakable + q.pp(statement) + end + end + + def to_json(*opts) + { + type: :not, + value: statement, + paren: parentheses, + loc: location + }.to_json(*opts) + end + end + + # Unary represents a unary method being called on an expression, as in +!+ or + # +~+. + # + # !value + # + class Unary + # [String] the operator being used + attr_reader :operator + + # [untyped] the statement on which to operate + attr_reader :statement + + # [Location] the location of this node + attr_reader :location + + def initialize(operator:, statement:, location:) + @operator = operator + @statement = statement + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('unary') + + q.breakable + q.pp(operator) + + q.breakable + q.pp(statement) + end + end + + def to_json(*opts) + { type: :unary, op: operator, value: statement, loc: location }.to_json( + *opts + ) + end + end + + # :call-seq: + # on_unary: (:not operator, untyped statement) -> Not + # | (Symbol operator, untyped statement) -> Unary + def on_unary(operator, statement) + if operator == :not + # We have somewhat special handling of the not operator since if it has + # parentheses they don't get reported as a paren node for some reason. + + beginning = find_token(Kw, 'not') + ending = statement + + range = beginning.location.end_char...statement.location.start_char + paren = source[range].include?('(') + + if paren + find_token(LParen) + ending = find_token(RParen) + end + + Not.new( + statement: statement, + parentheses: paren, + location: beginning.location.to(ending.location) + ) + else + # Special case instead of using find_token here. It turns out that + # if you have a range that goes from a negative number to a negative + # number then you can end up with a .. or a ... that's higher in the + # stack. So we need to explicitly disallow those operators. + index = + tokens.rindex do |token| + token.is_a?(Op) && + token.location.start_char < statement.location.start_char && + !%w[.. ...].include?(token.value) + end + + beginning = tokens.delete_at(index) + + Unary.new( + operator: operator[0], # :+@ -> "+" + statement: statement, + location: beginning.location.to(statement.location) + ) + end + end + + # Undef represents the use of the +undef+ keyword. + # + # undef method + # + class Undef + # [Array[ DynaSymbol | SymbolLiteral ]] the symbols to undefine + attr_reader :symbols + + # [Location] the location of this node + attr_reader :location + + def initialize(symbols:, location:) + @symbols = symbols + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('undef') + + q.breakable + q.group(2, '(', ')') { q.seplist(symbols) { |symbol| q.pp(symbol) } } + end + end + + def to_json(*opts) + { type: :undef, syms: symbols, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_undef: (Array[DynaSymbol | SymbolLiteral] symbols) -> Undef + def on_undef(symbols) + keyword = find_token(Kw, 'undef') + + Undef.new( + symbols: symbols, + location: keyword.location.to(symbols.last.location) + ) + end + + # Unless represents the first clause in an +unless+ chain. + # + # unless predicate + # end + # + class Unless + # [untyped] the expression to be checked + attr_reader :predicate + + # [Statements] the expressions to be executed + attr_reader :statements + + # [nil, Elsif, Else] the next clause in the chain + attr_reader :consequent + + # [Location] the location of this node + attr_reader :location + + def initialize(predicate:, statements:, consequent:, location:) + @predicate = predicate + @statements = statements + @consequent = consequent + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('unless') + + q.breakable + q.pp(predicate) + + q.breakable + q.pp(statements) + + if consequent + q.breakable + q.pp(consequent) + end + end + end + + def to_json(*opts) + { + type: :unless, + pred: predicate, + stmts: statements, + cons: consequent, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_unless: ( + # untyped predicate, + # Statements statements, + # ((nil | Elsif | Else) consequent) + # ) -> Unless + def on_unless(predicate, statements, consequent) + beginning = find_token(Kw, 'unless') + ending = consequent || find_token(Kw, 'end') + + statements.bind(predicate.location.end_char, ending.location.start_char) + + Unless.new( + predicate: predicate, + statements: statements, + consequent: consequent, + location: beginning.location.to(ending.location) + ) + end + + # UnlessMod represents the modifier form of an +unless+ statement. + # + # expression unless predicate + # + class UnlessMod + # [untyped] the expression to be executed + attr_reader :statement + + # [untyped] the expression to be checked + attr_reader :predicate + + # [Location] the location of this node + attr_reader :location + + def initialize(statement:, predicate:, location:) + @statement = statement + @predicate = predicate + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('unless_mod') + + q.breakable + q.pp(statement) + + q.breakable + q.pp(predicate) + end + end + + def to_json(*opts) + { + type: :unless_mod, + stmt: statement, + pred: predicate, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_unless_mod: (untyped predicate, untyped statement) -> UnlessMod + def on_unless_mod(predicate, statement) + find_token(Kw, 'unless') + + UnlessMod.new( + statement: statement, + predicate: predicate, + location: statement.location.to(predicate.location) + ) + end + + # Until represents an +until+ loop. + # + # until predicate + # end + # + class Until + # [untyped] the expression to be checked + attr_reader :predicate + + # [Statements] the expressions to be executed + attr_reader :statements + + # [Location] the location of this node + attr_reader :location + + def initialize(predicate:, statements:, location:) + @predicate = predicate + @statements = statements + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('until') + + q.breakable + q.pp(predicate) + + q.breakable + q.pp(statements) + end + end + + def to_json(*opts) + { + type: :until, + pred: predicate, + stmts: statements, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_until: (untyped predicate, Statements statements) -> Until + def on_until(predicate, statements) + beginning = find_token(Kw, 'until') + ending = find_token(Kw, 'end') + + # Consume the do keyword if it exists so that it doesn't get confused for + # some other block + keyword = find_token(Kw, 'do', consume: false) + if keyword && keyword.location.start_char > predicate.location.end_char && + keyword.location.end_char < ending.location.start_char + tokens.delete(keyword) + end + + # Update the Statements location information + statements.bind(predicate.location.end_char, ending.location.start_char) + + Until.new( + predicate: predicate, + statements: statements, + location: beginning.location.to(ending.location) + ) + end + + # UntilMod represents the modifier form of a +until+ loop. + # + # expression until predicate + # + class UntilMod + # [untyped] the expression to be executed + attr_reader :statement + + # [untyped] the expression to be checked + attr_reader :predicate + + # [Location] the location of this node + attr_reader :location + + def initialize(statement:, predicate:, location:) + @statement = statement + @predicate = predicate + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('until_mod') + + q.breakable + q.pp(statement) + + q.breakable + q.pp(predicate) + end + end + + def to_json(*opts) + { + type: :until_mod, + stmt: statement, + pred: predicate, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_until_mod: (untyped predicate, untyped statement) -> UntilMod + def on_until_mod(predicate, statement) + find_token(Kw, 'until') + + UntilMod.new( + statement: statement, + predicate: predicate, + location: statement.location.to(predicate.location) + ) + end + + # VarAlias represents when you're using the +alias+ keyword with global + # variable arguments. + # + # alias $new $old + # + class VarAlias + # [GVar] the new alias of the variable + attr_reader :left + + # [Backref | GVar] the current name of the variable to be aliased + attr_reader :right + + # [Location] the location of this node + attr_reader :location + + def initialize(left:, right:, location:) + @left = left + @right = right + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('var_alias') + + q.breakable + q.pp(left) + + q.breakable + q.pp(right) + end + end + + def to_json(*opts) + { type: :var_alias, left: left, right: right, loc: location }.to_json( + *opts + ) + end + end + + # :call-seq: + # on_var_alias: (GVar left, (Backref | GVar) right) -> VarAlias + def on_var_alias(left, right) + keyword = find_token(Kw, 'alias') + + VarAlias.new( + left: left, + right: right, + location: keyword.location.to(right.location) + ) + end + + # VarField represents a variable that is being assigned a value. As such, it + # is always a child of an assignment type node. + # + # variable = value + # + # In the example above, the VarField node represents the +variable+ token. + class VarField + # [nil | Const | CVar | GVar | Ident | IVar] the target of this node + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('var_field') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :var_field, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_var_field: ( + # (nil | Const | CVar | GVar | Ident | IVar) value + # ) -> VarField + def on_var_field(value) + location = + if value + value.location + else + # You can hit this pattern if you're assigning to a splat using pattern + # matching syntax in Ruby 2.7+ + Location.fixed(line: lineno, char: char_pos) + end + + VarField.new(value: value, location: location) + end + + # VarRef represents a variable reference. + # + # true + # + # This can be a plain local variable like the example above. It can also be a + # constant, a class variable, a global variable, an instance variable, a + # keyword (like +self+, +nil+, +true+, or +false+), or a numbered block + # variable. + class VarRef + # [Const | CVar | GVar | Ident | IVar | Kw] the value of this node + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('var_ref') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :var_ref, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_var_ref: ((Const | CVar | GVar | Ident | IVar | Kw) value) -> VarRef + def on_var_ref(value) + VarRef.new(value: value, location: value.location) + end + + # AccessCtrl represents a call to a method visibility control, i.e., +public+, + # +protected+, or +private+. + # + # private + # + class AccessCtrl + # [Ident] the value of this expression + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('access_ctrl') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :access_ctrl, value: value, loc: location }.to_json(*opts) + end + end + + # VCall represent any plain named object with Ruby that could be either a + # local variable or a method call. + # + # variable + # + class VCall + # [Ident] the value of this expression + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('vcall') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :vcall, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_vcall: (Ident ident) -> AccessCtrl | VCall + def on_vcall(ident) + @controls ||= %w[private protected public].freeze + + if @controls.include?(ident.value) && ident.value == lines[lineno - 1].strip + # Access controls like private, protected, and public are reported as + # vcall nodes since they're technically method calls. We want to be able + # add new lines around them as necessary, so here we're going to + # explicitly track those as a different node type. + AccessCtrl.new(value: ident, location: ident.location) + else + VCall.new(value: ident, location: ident.location) + end + end + + # VoidStmt represents an empty lexical block of code. + # + # ;; + # + class VoidStmt + # [Location] the location of this node + attr_reader :location + + def initialize(location:) + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') { q.text('void_stmt') } + end + + def to_json(*opts) + { type: :void_stmt, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_void_stmt: () -> VoidStmt + def on_void_stmt + VoidStmt.new(location: Location.fixed(line: lineno, char: char_pos)) + end + + # When represents a +when+ clause in a +case+ chain. + # + # case value + # when predicate + # end + # + class When + # [untyped] the arguments to the when clause + attr_reader :arguments + + # [Statements] the expressions to be executed + attr_reader :statements + + # [nil | Else | When] the next clause in the chain + attr_reader :consequent + + # [Location] the location of this node + attr_reader :location + + def initialize(arguments:, statements:, consequent:, location:) + @arguments = arguments + @statements = statements + @consequent = consequent + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('when') + + q.breakable + q.pp(arguments) + + q.breakable + q.pp(statements) + + if consequent + q.breakable + q.pp(consequent) + end + end + end + + def to_json(*opts) + { + type: :when, + args: arguments, + stmts: statements, + cons: consequent, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_when: ( + # untyped arguments, + # Statements statements, + # (nil | Else | When) consequent + # ) -> When + def on_when(arguments, statements, consequent) + beginning = find_token(Kw, 'when') + ending = consequent || find_token(Kw, 'end') + + statements.bind(arguments.location.end_char, ending.location.start_char) + + When.new( + arguments: arguments, + statements: statements, + consequent: consequent, + location: beginning.location.to(ending.location) + ) + end + + # While represents a +while+ loop. + # + # while predicate + # end + # + class While + # [untyped] the expression to be checked + attr_reader :predicate + + # [Statements] the expressions to be executed + attr_reader :statements + + # [Location] the location of this node + attr_reader :location + + def initialize(predicate:, statements:, location:) + @predicate = predicate + @statements = statements + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('while') + + q.breakable + q.pp(predicate) + + q.breakable + q.pp(statements) + end + end + + def to_json(*opts) + { + type: :while, + pred: predicate, + stmts: statements, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_while: (untyped predicate, Statements statements) -> While + def on_while(predicate, statements) + beginning = find_token(Kw, 'while') + ending = find_token(Kw, 'end') + + # Consume the do keyword if it exists so that it doesn't get confused for + # some other block + keyword = find_token(Kw, 'do', consume: false) + if keyword && keyword.location.start_char > predicate.location.end_char && + keyword.location.end_char < ending.location.start_char + tokens.delete(keyword) + end + + # Update the Statements location information + statements.bind(predicate.location.end_char, ending.location.start_char) + + While.new( + predicate: predicate, + statements: statements, + location: beginning.location.to(ending.location) + ) + end + + # WhileMod represents the modifier form of a +while+ loop. + # + # expression while predicate + # + class WhileMod + # [untyped] the expression to be executed + attr_reader :statement + + # [untyped] the expression to be checked + attr_reader :predicate + + # [Location] the location of this node + attr_reader :location + + def initialize(statement:, predicate:, location:) + @statement = statement + @predicate = predicate + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('while_mod') + + q.breakable + q.pp(statement) + + q.breakable + q.pp(predicate) + end + end + + def to_json(*opts) + { + type: :while_mod, + stmt: statement, + pred: predicate, + loc: location + }.to_json(*opts) + end + end + + # :call-seq: + # on_while_mod: (untyped predicate, untyped statement) -> WhileMod + def on_while_mod(predicate, statement) + find_token(Kw, 'while') + + WhileMod.new( + statement: statement, + predicate: predicate, + location: statement.location.to(predicate.location) + ) + end + + # Word represents an element within a special array literal that accepts + # interpolation. + # + # %W[a#{b}c xyz] + # + # In the example above, there would be two Word nodes within a parent Words + # node. + class Word + # [Array[ StringEmbExpr | StringDVar | TStringContent ]] the parts of the + # word + attr_reader :parts + + # [Location] the location of this node + attr_reader :location + + def initialize(parts:, location:) + @parts = parts + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('word') + + q.breakable + q.group(2, '(', ')') { q.seplist(parts) { |part| q.pp(part) } } + end + end + + def to_json(*opts) + { type: :word, parts: parts, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_word_add: ( + # Word word, + # (StringEmbExpr | StringDVar | TStringContent) part + # ) -> Word + def on_word_add(word, part) + location = + word.parts.empty? ? part.location : word.location.to(part.location) + + Word.new(parts: word.parts << part, location: location) + end + + # :call-seq: + # on_word_new: () -> Word + def on_word_new + Word.new(parts: [], location: Location.fixed(line: lineno, char: char_pos)) + end + + # Words represents a string literal array with interpolation. + # + # %W[one two three] + # + class Words + # [Array[ Word ]] the elements of this array + attr_reader :elements + + # [Location] the location of this node + attr_reader :location + + def initialize(elements:, location:) + @elements = elements + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('words') + + q.breakable + q.group(2, '(', ')') { q.seplist(elements) { |element| q.pp(element) } } + end + end + + def to_json(*opts) + { type: :words, elems: elements, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_words_add: (Words words, Word word) -> Words + def on_words_add(words, word) + Words.new( + elements: words.elements << word, + location: words.location.to(word.location) + ) + end + + # WordsBeg represents the beginning of a string literal array with + # interpolation. + # + # %W[one two three] + # + # In the snippet above, a WordsBeg would be created with the value of "%W[". + # Note that these kinds of arrays can start with a lot of different delimiter + # types (e.g., %W| or %W<). + class WordsBeg + # [String] the start of the word literal array + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + end + + # :call-seq: + # on_words_beg: (String value) -> WordsBeg + def on_words_beg(value) + node = + WordsBeg.new( + value: value, + location: Location.token(line: lineno, char: char_pos, size: value.size) + ) + + tokens << node + node + end + + # :call-seq: + # on_words_new: () -> Words + def on_words_new + words_beg = find_token(WordsBeg) + + Words.new(elements: [], location: words_beg.location) + end + + # def on_words_sep(value) + # value + # end + + # XString represents the contents of an XStringLiteral. + # + # `ls` + # + class XString + # [Array[ StringEmbExpr | StringDVar | TStringContent ]] the parts of the + # xstring + attr_reader :parts + + # [Location] the location of this node + attr_reader :location + + def initialize(parts:, location:) + @parts = parts + @location = location + end + end + + # :call-seq: + # on_xstring_add: ( + # XString xstring, + # (StringEmbExpr | StringDVar | TStringContent) part + # ) -> XString + def on_xstring_add(xstring, part) + XString.new( + parts: xstring.parts << part, + location: xstring.location.to(part.location) + ) + end + + # :call-seq: + # on_xstring_new: () -> XString + def on_xstring_new + heredoc = @heredocs[-1] + + location = + if heredoc && heredoc.beginning.value.include?('`') + heredoc.location + else + find_token(Backtick).location + end + + XString.new(parts: [], location: location) + end + + # XStringLiteral represents a string that gets executed. + # + # `ls` + # + class XStringLiteral + # [Array[ StringEmbExpr | StringDVar | TStringContent ]] the parts of the + # xstring + attr_reader :parts + + # [Location] the location of this node + attr_reader :location + + def initialize(parts:, location:) + @parts = parts + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('xstring_literal') + + q.breakable + q.group(2, '(', ')') { q.seplist(parts) { |part| q.pp(part) } } + end + end + + def to_json(*opts) + { type: :xstring_literal, parts: parts, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_xstring_literal: (XString xstring) -> Heredoc | XStringLiteral + def on_xstring_literal(xstring) + heredoc = @heredocs[-1] + + if heredoc && heredoc.beginning.value.include?('`') + Heredoc.new( + beginning: heredoc.beginning, + ending: heredoc.ending, + parts: xstring.parts, + location: heredoc.location + ) + else + ending = find_token(TStringEnd) + + XStringLiteral.new( + parts: xstring.parts, + location: xstring.location.to(ending.location) + ) + end + end + + # Yield represents using the +yield+ keyword with arguments. + # + # yield value + # + class Yield + # [ArgsAddBlock | Paren] the arguments passed to the yield + attr_reader :arguments + + # [Location] the location of this node + attr_reader :location + + def initialize(arguments:, location:) + @arguments = arguments + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('yield') + + q.breakable + q.pp(arguments) + end + end + + def to_json(*opts) + { type: :yield, args: arguments, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_yield: ((ArgsAddBlock | Paren) arguments) -> Yield + def on_yield(arguments) + keyword = find_token(Kw, 'yield') + + Yield.new( + arguments: arguments, + location: keyword.location.to(arguments.location) + ) + end + + # Yield0 represents the bare +yield+ keyword with no arguments. + # + # yield + # + class Yield0 + # [String] the value of the keyword + attr_reader :value + + # [Location] the location of this node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('yield0') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :yield0, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_yield0: () -> Yield0 + def on_yield0 + keyword = find_token(Kw, 'yield') + + Yield0.new(value: keyword.value, location: keyword.location) + end + + # ZSuper represents the bare +super+ keyword with no arguments. + # + # super + # + class ZSuper + # [String] the value of the keyword + attr_reader :value + + # [Location] the location of the node + attr_reader :location + + def initialize(value:, location:) + @value = value + @location = location + end + + def pretty_print(q) + q.group(2, '(', ')') do + q.text('zsuper') + + q.breakable + q.pp(value) + end + end + + def to_json(*opts) + { type: :zsuper, value: value, loc: location }.to_json(*opts) + end + end + + # :call-seq: + # on_zsuper: () -> ZSuper + def on_zsuper + keyword = find_token(Kw, 'super') + + ZSuper.new(value: keyword.value, location: keyword.location) + end +end diff --git a/spec/syntax_suggest/fixtures/this_project_extra_def.rb.txt b/spec/syntax_suggest/fixtures/this_project_extra_def.rb.txt new file mode 100644 index 0000000000..e62fd3fa66 --- /dev/null +++ b/spec/syntax_suggest/fixtures/this_project_extra_def.rb.txt @@ -0,0 +1,64 @@ +module SyntaxErrorSearch + # Used for formatting invalid blocks + class DisplayInvalidBlocks + attr_reader :filename + + def initialize(block_array, io: $stderr, filename: nil) + @filename = filename + @io = io + @blocks = block_array + @lines = @blocks.map(&:lines).flatten + @digit_count = @lines.last.line_number.to_s.length + @code_lines = @blocks.first.code_lines + + @invalid_line_hash = @lines.each_with_object({}) {|line, h| h[line] = true} + end + + def call + @io.puts <<~EOM + + SyntaxSuggest: A syntax error was detected + + This code has an unmatched `end` this is caused by either + missing a syntax keyword (`def`, `do`, etc.) or inclusion + of an extra `end` line: + EOM + + @io.puts(<<~EOM) if filename + file: #{filename} + EOM + + @io.puts <<~EOM + #{code_with_filename} + EOM + end + + def filename + + def code_with_filename + string = String.new("") + string << "```\n" + string << "#".rjust(@digit_count) + " filename: #{filename}\n\n" if filename + string << code_with_lines + string << "```\n" + string + end + + def code_with_lines + @code_lines.map do |line| + next if line.hidden? + number = line.line_number.to_s.rjust(@digit_count) + if line.empty? + "#{number.to_s}#{line}" + else + string = String.new + string << "\e[1;3m" if @invalid_line_hash[line] # Bold, italics + string << "#{number.to_s} " + string << line.to_s + string << "\e[0m" + string + end + end.join + end + end +end diff --git a/spec/syntax_suggest/fixtures/webmock.rb.txt b/spec/syntax_suggest/fixtures/webmock.rb.txt new file mode 100644 index 0000000000..16da0d2ac0 --- /dev/null +++ b/spec/syntax_suggest/fixtures/webmock.rb.txt @@ -0,0 +1,35 @@ +describe "webmock tests" do + before(:each) do + WebMock.enable! + end + + after(:each) do + WebMock.disable! + end + + it "port" do + port = rand(1000...9999) + stub_request(:any, "localhost:#{port}") + + query = Cutlass::FunctionQuery.new( + port: port + ).call + + expect(WebMock).to have_requested(:post, "localhost:#{port}"). + with(body: "{}") + end + + it "body" do + body = { lol: "hi" } + port = 8080 + stub_request(:any, "localhost:#{port}") + + query = Cutlass::FunctionQuery.new( + port: port + body: body + ).call + + expect(WebMock).to have_requested(:post, "localhost:#{port}"). + with(body: body.to_json) + end +end diff --git a/spec/syntax_suggest/integration/exe_cli_spec.rb b/spec/syntax_suggest/integration/exe_cli_spec.rb new file mode 100644 index 0000000000..79e659a27a --- /dev/null +++ b/spec/syntax_suggest/integration/exe_cli_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module SyntaxSuggest + RSpec.describe "exe" do + def exe_path + root_dir.join("exe").join("syntax_suggest") + end + + def exe(cmd) + out = run!("#{exe_path} #{cmd}", raise_on_nonzero_exit: false) + puts out if ENV["SYNTAX_SUGGEST_DEBUG"] + out + end + + it "prints the version" do + out = exe("-v") + expect(out.strip).to include(SyntaxSuggest::VERSION) + end + end +end diff --git a/spec/syntax_suggest/integration/ruby_command_line_spec.rb b/spec/syntax_suggest/integration/ruby_command_line_spec.rb new file mode 100644 index 0000000000..7a1c5c654e --- /dev/null +++ b/spec/syntax_suggest/integration/ruby_command_line_spec.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module SyntaxSuggest + RSpec.describe "Requires with ruby cli" do + it "namespaces all monkeypatched methods" do + Dir.mktmpdir do |dir| + tmpdir = Pathname(dir) + script = tmpdir.join("script.rb") + script.write <<~'EOM' + puts Kernel.private_methods + EOM + + syntax_suggest_methods_file = tmpdir.join("syntax_suggest_methods.txt") + api_only_methods_file = tmpdir.join("api_only_methods.txt") + kernel_methods_file = tmpdir.join("kernel_methods.txt") + + d_pid = Process.spawn("ruby -I#{lib_dir} -rsyntax_suggest #{script} 2>&1 > #{syntax_suggest_methods_file}") + k_pid = Process.spawn("ruby #{script} 2>&1 >> #{kernel_methods_file}") + r_pid = Process.spawn("ruby -I#{lib_dir} -rsyntax_suggest/api #{script} 2>&1 > #{api_only_methods_file}") + + Process.wait(k_pid) + Process.wait(d_pid) + Process.wait(r_pid) + + kernel_methods_array = kernel_methods_file.read.strip.lines.map(&:strip) + syntax_suggest_methods_array = syntax_suggest_methods_file.read.strip.lines.map(&:strip) + api_only_methods_array = api_only_methods_file.read.strip.lines.map(&:strip) + + # In ruby 3.1.0-preview1 the `timeout` file is already required + # we can remove it if it exists to normalize the output for + # all ruby versions + [syntax_suggest_methods_array, kernel_methods_array, api_only_methods_array].each do |array| + array.delete("timeout") + end + + methods = (syntax_suggest_methods_array - kernel_methods_array).sort + if methods.any? + expect(methods).to eq(["syntax_suggest_original_load", "syntax_suggest_original_require", "syntax_suggest_original_require_relative"]) + end + + methods = (api_only_methods_array - kernel_methods_array).sort + expect(methods).to eq([]) + end + end + + it "detects require error and adds a message with auto mode" do + Dir.mktmpdir do |dir| + tmpdir = Pathname(dir) + script = tmpdir.join("script.rb") + script.write <<~EOM + describe "things" do + it "blerg" do + end + + it "flerg" + end + + it "zlerg" do + end + end + EOM + + require_rb = tmpdir.join("require.rb") + require_rb.write <<~EOM + load "#{script.expand_path}" + EOM + + out = `ruby -I#{lib_dir} -rsyntax_suggest #{require_rb} 2>&1` + + expect($?.success?).to be_falsey + expect(out).to include('❯ 5 it "flerg"').once + end + end + + it "annotates a syntax error in Ruby 3.2+ when require is not used" do + pending("Support for SyntaxError#detailed_message monkeypatch needed https://gist.github.com/schneems/09f45cc23b9a8c46e9af6acbb6e6840d?permalink_comment_id=4172585#gistcomment-4172585") + + skip if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.2") + + Dir.mktmpdir do |dir| + tmpdir = Pathname(dir) + script = tmpdir.join("script.rb") + script.write <<~EOM + describe "things" do + it "blerg" do + end + + it "flerg" + end + + it "zlerg" do + end + end + EOM + + out = `ruby -I#{lib_dir} -rsyntax_suggest #{script} 2>&1` + + expect($?.success?).to be_falsey + expect(out).to include('❯ 5 it "flerg"').once + end + end + + it "does not load internals into memory if no syntax error" do + Dir.mktmpdir do |dir| + tmpdir = Pathname(dir) + script = tmpdir.join("script.rb") + script.write <<~EOM + class Dog + end + + if defined?(SyntaxSuggest::DEFAULT_VALUE) + puts "SyntaxSuggest is loaded" + else + puts "SyntaxSuggest is NOT loaded" + end + EOM + + require_rb = tmpdir.join("require.rb") + require_rb.write <<~EOM + load "#{script.expand_path}" + EOM + + out = `ruby -I#{lib_dir} -rsyntax_suggest #{require_rb} 2>&1` + + expect($?.success?).to be_truthy + expect(out).to include("SyntaxSuggest is NOT loaded").once + end + end + + it "ignores eval" do + Dir.mktmpdir do |dir| + tmpdir = Pathname(dir) + script = tmpdir.join("script.rb") + script.write <<~'EOM' + $stderr = STDOUT + eval("def lol") + EOM + + out = `ruby -I#{lib_dir} -rsyntax_suggest #{script} 2>&1` + + expect($?.success?).to be_falsey + expect(out).to include("(eval):1") + + expect(out).to_not include("SyntaxSuggest") + expect(out).to_not include("Could not find filename") + end + end + end +end diff --git a/spec/syntax_suggest/integration/syntax_suggest_spec.rb b/spec/syntax_suggest/integration/syntax_suggest_spec.rb new file mode 100644 index 0000000000..a7287ff64e --- /dev/null +++ b/spec/syntax_suggest/integration/syntax_suggest_spec.rb @@ -0,0 +1,211 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module SyntaxSuggest + RSpec.describe "Integration tests that don't spawn a process (like using the cli)" do + it "does not timeout on massive files" do + next unless ENV["SYNTAX_SUGGEST_TIMEOUT"] + + file = fixtures_dir.join("syntax_tree.rb.txt") + lines = file.read.lines + lines.delete_at(768 - 1) + + io = StringIO.new + + benchmark = Benchmark.measure do + debug_perf do + SyntaxSuggest.call( + io: io, + source: lines.join, + filename: file + ) + end + debug_display(io.string) + debug_display(benchmark) + end + + expect(io.string).to include(<<~'EOM') + 6 class SyntaxTree < Ripper + 170 def self.parse(source) + 174 end + ❯ 754 def on_args_add(arguments, argument) + ❯ 776 class ArgsAddBlock + ❯ 810 end + 9233 end + EOM + end + + it "re-checks all block code, not just what's visible issues/95" do + file = fixtures_dir.join("ruby_buildpack.rb.txt") + io = StringIO.new + + debug_perf do + benchmark = Benchmark.measure do + SyntaxSuggest.call( + io: io, + source: file.read, + filename: file + ) + end + debug_display(io.string) + debug_display(benchmark) + end + + expect(io.string).to_not include("def ruby_install_binstub_path") + expect(io.string).to include(<<~'EOM') + ❯ 1067 def add_yarn_binary + ❯ 1068 return [] if yarn_preinstalled? + ❯ 1069 | + ❯ 1075 end + EOM + end + + it "returns good results on routes.rb" do + source = fixtures_dir.join("routes.rb.txt").read + + io = StringIO.new + SyntaxSuggest.call( + io: io, + source: source + ) + debug_display(io.string) + + expect(io.string).to include(<<~'EOM') + 1 Rails.application.routes.draw do + ❯ 113 namespace :admin do + ❯ 116 match "/foobar(*path)", via: :all, to: redirect { |_params, req| + ❯ 120 } + 121 end + EOM + end + + it "handles multi-line-methods issues/64" do + source = fixtures_dir.join("webmock.rb.txt").read + + io = StringIO.new + SyntaxSuggest.call( + io: io, + source: source + ) + debug_display(io.string) + + expect(io.string).to include(<<~'EOM') + 1 describe "webmock tests" do + 22 it "body" do + 27 query = Cutlass::FunctionQuery.new( + ❯ 28 port: port + ❯ 29 body: body + 30 ).call + 34 end + 35 end + EOM + end + + it "handles derailed output issues/50" do + source = fixtures_dir.join("derailed_require_tree.rb.txt").read + + io = StringIO.new + SyntaxSuggest.call( + io: io, + source: source + ) + debug_display(io.string) + + expect(io.string).to include(<<~'EOM') + 5 module DerailedBenchmarks + 6 class RequireTree + 7 REQUIRED_BY = {} + 9 attr_reader :name + 10 attr_writer :cost + ❯ 13 def initialize(name) + ❯ 18 def self.reset! + ❯ 25 end + 73 end + 74 end + EOM + end + + it "handles heredocs" do + lines = fixtures_dir.join("rexe.rb.txt").read.lines + lines.delete_at(85 - 1) + io = StringIO.new + SyntaxSuggest.call( + io: io, + source: lines.join + ) + + out = io.string + debug_display(out) + + expect(out).to include(<<~EOM) + 16 class Rexe + ❯ 77 class Lookups + ❯ 78 def input_modes + ❯ 148 end + 551 end + EOM + end + + it "rexe" do + lines = fixtures_dir.join("rexe.rb.txt").read.lines + lines.delete_at(148 - 1) + source = lines.join + + io = StringIO.new + SyntaxSuggest.call( + io: io, + source: source + ) + out = io.string + expect(out).to include(<<~EOM) + 16 class Rexe + 18 VERSION = '1.5.1' + ❯ 77 class Lookups + ❯ 140 def format_requires + ❯ 148 end + 551 end + EOM + end + + it "ambiguous end" do + source = <<~'EOM' + def call # 0 + print "lol" # 1 + end # one # 2 + end # two # 3 + EOM + io = StringIO.new + SyntaxSuggest.call( + io: io, + source: source + ) + out = io.string + expect(out).to include(<<~EOM) + ❯ 1 def call # 0 + ❯ 3 end # one # 2 + ❯ 4 end # two # 3 + EOM + end + + it "simple regression" do + source = <<~'EOM' + class Dog + def bark + puts "woof" + end + EOM + io = StringIO.new + SyntaxSuggest.call( + io: io, + source: source + ) + out = io.string + expect(out).to include(<<~EOM) + ❯ 1 class Dog + ❯ 2 def bark + ❯ 4 end + EOM + end + end +end diff --git a/spec/syntax_suggest/spec_helper.rb b/spec/syntax_suggest/spec_helper.rb new file mode 100644 index 0000000000..33f3ef3708 --- /dev/null +++ b/spec/syntax_suggest/spec_helper.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "bundler/setup" +require "syntax_suggest/api" + +require "benchmark" +require "tempfile" + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = ".rspec_status" + + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + c.syntax = :expect + end +end + +# Used for debugging modifications to +# display output +def debug_display(output) + return unless ENV["DEBUG_DISPLAY"] + puts + puts output + puts +end + +def spec_dir + Pathname(__dir__) +end + +def lib_dir + root_dir.join("lib") +end + +def root_dir + spec_dir.join("..") +end + +def fixtures_dir + spec_dir.join("fixtures") +end + +def code_line_array(source) + SyntaxSuggest::CleanDocument.new(source: source).call.lines +end + +autoload :RubyProf, "ruby-prof" + +def debug_perf + raise "No block given" unless block_given? + + if ENV["DEBUG_PERF"] + out = nil + result = RubyProf.profile do + out = yield + end + + dir = SyntaxSuggest.record_dir("tmp") + printer = RubyProf::MultiPrinter.new(result, [:flat, :graph, :graph_html, :tree, :call_tree, :stack, :dot]) + printer.print(path: dir, profile: "profile") + + out + else + yield + end +end + +def run!(cmd, raise_on_nonzero_exit: true) + out = `#{cmd} 2>&1` + raise "Command: #{cmd} failed: #{out}" if !$?.success? && raise_on_nonzero_exit + out +end + +# Allows us to write cleaner tests since <<~EOM block quotes +# strip off all leading indentation and we need it to be preserved +# sometimes. +class String + def indent(number) + lines.map do |line| + if line.chomp.empty? + line + else + " " * number + line + end + end.join + end +end diff --git a/spec/syntax_suggest/unit/api_spec.rb b/spec/syntax_suggest/unit/api_spec.rb new file mode 100644 index 0000000000..284a4cdeec --- /dev/null +++ b/spec/syntax_suggest/unit/api_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "ruby-prof" + +module SyntaxSuggest + RSpec.describe "Top level SyntaxSuggest api" do + it "has a `handle_error` interface" do + fake_error = Object.new + def fake_error.message + "#{__FILE__}:216: unterminated string meets end of file " + end + + def fake_error.is_a?(v) + true + end + + io = StringIO.new + SyntaxSuggest.handle_error( + fake_error, + re_raise: false, + io: io + ) + + expect(io.string.strip).to eq("Syntax OK") + end + + it "raises original error with warning if a non-syntax error is passed" do + error = NameError.new("blerg") + io = StringIO.new + expect { + SyntaxSuggest.handle_error( + error, + re_raise: false, + io: io + ) + }.to raise_error { |e| + expect(io.string).to include("Must pass a SyntaxError") + expect(e).to eq(error) + } + end + + it "raises original error with warning if file is not found" do + fake_error = SyntaxError.new + def fake_error.message + "#does/not/exist/lol/doesnotexist:216: unterminated string meets end of file " + end + + io = StringIO.new + expect { + SyntaxSuggest.handle_error( + fake_error, + re_raise: false, + io: io + ) + }.to raise_error { |e| + expect(io.string).to include("Could not find filename") + expect(e).to eq(fake_error) + } + end + + it "respects highlight API" do + skip if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.2") + + error = SyntaxError.new("#{fixtures_dir.join("this_project_extra_def.rb.txt")}:1 ") + + require "syntax_suggest/core_ext" + + expect(error.detailed_message(highlight: true)).to include(SyntaxSuggest::DisplayCodeWithLineNumbers::TERMINAL_HIGHLIGHT) + expect(error.detailed_message(highlight: false)).to_not include(SyntaxSuggest::DisplayCodeWithLineNumbers::TERMINAL_HIGHLIGHT) + end + + it "can be disabled via falsey kwarg" do + skip if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.2") + + error = SyntaxError.new("#{fixtures_dir.join("this_project_extra_def.rb.txt")}:1 ") + + require "syntax_suggest/core_ext" + + expect(error.detailed_message(syntax_suggest: true)).to_not eq(error.detailed_message(syntax_suggest: false)) + end + end +end diff --git a/spec/syntax_suggest/unit/around_block_scan_spec.rb b/spec/syntax_suggest/unit/around_block_scan_spec.rb new file mode 100644 index 0000000000..6053c3947e --- /dev/null +++ b/spec/syntax_suggest/unit/around_block_scan_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module SyntaxSuggest + RSpec.describe AroundBlockScan do + it "continues scan from last location even if scan is false" do + source = <<~'EOM' + print 'omg' + print 'lol' + print 'haha' + EOM + code_lines = CodeLine.from_source(source) + block = CodeBlock.new(lines: code_lines[1]) + expand = AroundBlockScan.new(code_lines: code_lines, block: block) + .scan_neighbors + + expect(expand.code_block.to_s).to eq(source) + expand.scan_while { |line| false } + + expect(expand.code_block.to_s).to eq(source) + end + + it "scan_adjacent_indent works on first or last line" do + source_string = <<~EOM + def foo + if [options.output_format_tty, options.output_format_block].include?(nil) + raise("Bad output mode '\#{v}'; each must be one of \#{lookups.output_formats.keys}.") + end + end + EOM + + code_lines = code_line_array(source_string) + block = CodeBlock.new(lines: code_lines[4]) + expand = AroundBlockScan.new(code_lines: code_lines, block: block) + .scan_adjacent_indent + + expect(expand.code_block.to_s).to eq(<<~EOM) + def foo + if [options.output_format_tty, options.output_format_block].include?(nil) + raise("Bad output mode '\#{v}'; each must be one of \#{lookups.output_formats.keys}.") + end + end + EOM + end + + it "expands indentation" do + source_string = <<~EOM + def foo + if [options.output_format_tty, options.output_format_block].include?(nil) + raise("Bad output mode '\#{v}'; each must be one of \#{lookups.output_formats.keys}.") + end + end + EOM + + code_lines = code_line_array(source_string) + block = CodeBlock.new(lines: code_lines[2]) + expand = AroundBlockScan.new(code_lines: code_lines, block: block) + .stop_after_kw + .scan_adjacent_indent + + expect(expand.code_block.to_s).to eq(<<~EOM.indent(2)) + if [options.output_format_tty, options.output_format_block].include?(nil) + raise("Bad output mode '\#{v}'; each must be one of \#{lookups.output_formats.keys}.") + end + EOM + end + + it "can stop before hitting another end" do + source_string = <<~EOM + def lol + end + def foo + puts "lol" + end + EOM + + code_lines = code_line_array(source_string) + block = CodeBlock.new(lines: code_lines[3]) + expand = AroundBlockScan.new(code_lines: code_lines, block: block) + expand.stop_after_kw + expand.scan_while { true } + + expect(expand.code_block.to_s).to eq(<<~EOM) + def foo + puts "lol" + end + EOM + end + + it "captures multiple empty and hidden lines" do + source_string = <<~EOM + def foo + Foo.call + + puts "lol" + + end + end + EOM + + code_lines = code_line_array(source_string) + block = CodeBlock.new(lines: code_lines[3]) + expand = AroundBlockScan.new(code_lines: code_lines, block: block) + expand.scan_while { true } + + expect(expand.before_index).to eq(0) + expect(expand.after_index).to eq(6) + expect(expand.code_block.to_s).to eq(source_string) + end + + it "only takes what you ask" do + source_string = <<~EOM + def foo + Foo.call + + puts "lol" + + end + end + EOM + + code_lines = code_line_array(source_string) + block = CodeBlock.new(lines: code_lines[3]) + expand = AroundBlockScan.new(code_lines: code_lines, block: block) + expand.scan_while { |line| line.not_empty? } + + expect(expand.code_block.to_s).to eq(<<~EOM.indent(4)) + puts "lol" + EOM + end + + it "skips what you want" do + source_string = <<~EOM + def foo + Foo.call + + puts "haha" + # hide me + + puts "lol" + + end + end + EOM + + code_lines = code_line_array(source_string) + code_lines[4].mark_invisible + + block = CodeBlock.new(lines: code_lines[3]) + expand = AroundBlockScan.new(code_lines: code_lines, block: block) + expand.skip(:empty?) + expand.skip(:hidden?) + expand.scan_neighbors + + expect(expand.code_block.to_s).to eq(<<~EOM.indent(4)) + + puts "haha" + + puts "lol" + + EOM + end + end +end diff --git a/spec/syntax_suggest/unit/block_expand_spec.rb b/spec/syntax_suggest/unit/block_expand_spec.rb new file mode 100644 index 0000000000..ba0b0457a1 --- /dev/null +++ b/spec/syntax_suggest/unit/block_expand_spec.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module SyntaxSuggest + RSpec.describe BlockExpand do + it "captures multiple empty and hidden lines" do + source_string = <<~EOM + def foo + Foo.call + + + puts "lol" + + # hidden + end + end + EOM + + code_lines = code_line_array(source_string) + + code_lines[6].mark_invisible + + block = CodeBlock.new(lines: [code_lines[3]]) + expansion = BlockExpand.new(code_lines: code_lines) + block = expansion.call(block) + + expect(block.to_s).to eq(<<~EOM.indent(4)) + + + puts "lol" + + EOM + end + + it "captures multiple empty lines" do + source_string = <<~EOM + def foo + Foo.call + + + puts "lol" + + end + end + EOM + + code_lines = code_line_array(source_string) + block = CodeBlock.new(lines: [code_lines[3]]) + expansion = BlockExpand.new(code_lines: code_lines) + block = expansion.call(block) + + expect(block.to_s).to eq(<<~EOM.indent(4)) + + + puts "lol" + + EOM + end + + it "expands neighbors then indentation" do + source_string = <<~EOM + def foo + Foo.call + puts "hey" + puts "lol" + puts "sup" + end + end + EOM + + code_lines = code_line_array(source_string) + block = CodeBlock.new(lines: [code_lines[3]]) + expansion = BlockExpand.new(code_lines: code_lines) + block = expansion.call(block) + + expect(block.to_s).to eq(<<~EOM.indent(4)) + puts "hey" + puts "lol" + puts "sup" + EOM + + block = expansion.call(block) + + expect(block.to_s).to eq(<<~EOM.indent(2)) + Foo.call + puts "hey" + puts "lol" + puts "sup" + end + EOM + end + + it "handles else code" do + source_string = <<~EOM + Foo.call + if blerg + puts "lol" + else + puts "haha" + end + end + EOM + + code_lines = code_line_array(source_string) + block = CodeBlock.new(lines: [code_lines[2]]) + expansion = BlockExpand.new(code_lines: code_lines) + block = expansion.call(block) + + expect(block.to_s).to eq(<<~EOM.indent(2)) + if blerg + puts "lol" + else + puts "haha" + end + EOM + end + + it "expand until next boundry (indentation)" do + source_string = <<~EOM + describe "what" do + Foo.call + end + + describe "hi" + Bar.call do + Foo.call + end + end + + it "blerg" do + end + EOM + + code_lines = code_line_array(source_string) + + block = CodeBlock.new( + lines: code_lines[6] + ) + + expansion = BlockExpand.new(code_lines: code_lines) + block = expansion.call(block) + + expect(block.to_s).to eq(<<~EOM.indent(2)) + Bar.call do + Foo.call + end + EOM + + block = expansion.call(block) + + expect(block.to_s).to eq(<<~EOM) + describe "hi" + Bar.call do + Foo.call + end + end + EOM + end + + it "expand until next boundry (empty lines)" do + source_string = <<~EOM + describe "what" do + end + + describe "hi" + end + + it "blerg" do + end + EOM + + code_lines = code_line_array(source_string) + expansion = BlockExpand.new(code_lines: code_lines) + + block = CodeBlock.new(lines: code_lines[3]) + block = expansion.call(block) + + expect(block.to_s).to eq(<<~EOM) + + describe "hi" + end + + EOM + + block = expansion.call(block) + + expect(block.to_s).to eq(<<~EOM) + describe "what" do + end + + describe "hi" + end + + it "blerg" do + end + EOM + end + end +end diff --git a/spec/syntax_suggest/unit/capture_code_context_spec.rb b/spec/syntax_suggest/unit/capture_code_context_spec.rb new file mode 100644 index 0000000000..e1bc281c13 --- /dev/null +++ b/spec/syntax_suggest/unit/capture_code_context_spec.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module SyntaxSuggest + RSpec.describe CaptureCodeContext do + it "capture_before_after_kws" do + source = <<~'EOM' + def sit + end + + def bark + + def eat + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + block = CodeBlock.new(lines: code_lines[0]) + + display = CaptureCodeContext.new( + blocks: [block], + code_lines: code_lines + ) + lines = display.call + expect(lines.join).to eq(<<~'EOM') + def sit + end + def bark + def eat + end + EOM + end + + it "handles ambiguous end" do + source = <<~'EOM' + def call # 0 + print "lol" # 1 + end # one # 2 + end # two # 3 + EOM + + code_lines = CleanDocument.new(source: source).call.lines + code_lines[0..2].each(&:mark_invisible) + block = CodeBlock.new(lines: code_lines) + + display = CaptureCodeContext.new( + blocks: [block], + code_lines: code_lines + ) + lines = display.call + + lines = lines.sort.map(&:original) + + expect(lines.join).to eq(<<~'EOM') + def call # 0 + end # one # 2 + end # two # 3 + EOM + end + + it "shows ends of captured block" do + lines = fixtures_dir.join("rexe.rb.txt").read.lines + lines.delete_at(148 - 1) + source = lines.join + + code_lines = CleanDocument.new(source: source).call.lines + + code_lines[0..75].each(&:mark_invisible) + code_lines[77..-1].each(&:mark_invisible) + expect(code_lines.join.strip).to eq("class Lookups") + + block = CodeBlock.new(lines: code_lines[76..149]) + + display = CaptureCodeContext.new( + blocks: [block], + code_lines: code_lines + ) + lines = display.call + + lines = lines.sort.map(&:original) + expect(lines.join).to include(<<~'EOM'.indent(2)) + class Lookups + def format_requires + end + EOM + end + + it "shows ends of captured block" do + source = <<~'EOM' + class Dog + def bark + puts "woof" + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + block = CodeBlock.new(lines: code_lines) + code_lines[1..-1].each(&:mark_invisible) + + expect(block.to_s.strip).to eq("class Dog") + + display = CaptureCodeContext.new( + blocks: [block], + code_lines: code_lines + ) + lines = display.call.sort.map(&:original) + expect(lines.join).to eq(<<~'EOM') + class Dog + def bark + end + EOM + end + + it "captures surrounding context on falling indent" do + source = <<~'EOM' + class Blerg + end + + class OH + + def hello + it "foo" do + end + end + + class Zerg + end + EOM + code_lines = CleanDocument.new(source: source).call.lines + block = CodeBlock.new(lines: code_lines[6]) + + expect(block.to_s.strip).to eq('it "foo" do') + + display = CaptureCodeContext.new( + blocks: [block], + code_lines: code_lines + ) + lines = display.call.sort.map(&:original) + expect(lines.join).to eq(<<~'EOM') + class OH + def hello + it "foo" do + end + end + EOM + end + + it "captures surrounding context on same indent" do + source = <<~'EOM' + class Blerg + end + class OH + + def nope + end + + def lol + end + + end # here + + def haha + end + + def nope + end + end + + class Zerg + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + block = CodeBlock.new(lines: code_lines[7..10]) + expect(block.to_s).to eq(<<~'EOM'.indent(2)) + def lol + end + + end # here + EOM + + code_context = CaptureCodeContext.new( + blocks: [block], + code_lines: code_lines + ) + + lines = code_context.call + out = DisplayCodeWithLineNumbers.new( + lines: lines + ).call + + expect(out).to eq(<<~'EOM'.indent(2)) + 3 class OH + 8 def lol + 9 end + 11 end # here + 18 end + EOM + end + end +end diff --git a/spec/syntax_suggest/unit/clean_document_spec.rb b/spec/syntax_suggest/unit/clean_document_spec.rb new file mode 100644 index 0000000000..fa049ad8df --- /dev/null +++ b/spec/syntax_suggest/unit/clean_document_spec.rb @@ -0,0 +1,259 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module SyntaxSuggest + RSpec.describe CleanDocument do + it "heredocs" do + source = fixtures_dir.join("this_project_extra_def.rb.txt").read + code_lines = CleanDocument.new(source: source).call.lines + + expect(code_lines[18 - 1].to_s).to eq(<<-'EOL') + @io.puts <<~EOM + + SyntaxSuggest: A syntax error was detected + + This code has an unmatched `end` this is caused by either + missing a syntax keyword (`def`, `do`, etc.) or inclusion + of an extra `end` line: + EOM + EOL + expect(code_lines[18].to_s).to eq("") + + expect(code_lines[27 - 1].to_s).to eq(<<-'EOL') + @io.puts(<<~EOM) if filename + file: #{filename} + EOM + EOL + expect(code_lines[27].to_s).to eq("") + + expect(code_lines[31 - 1].to_s).to eq(<<-'EOL') + @io.puts <<~EOM + #{code_with_filename} + EOM + EOL + expect(code_lines[31].to_s).to eq("") + end + + it "joins: multi line methods" do + source = <<~EOM + User + .where(name: 'schneems') + .first + EOM + + doc = CleanDocument.new(source: source).join_consecutive! + + expect(doc.lines[0].to_s).to eq(source) + expect(doc.lines[1].to_s).to eq("") + expect(doc.lines[2].to_s).to eq("") + expect(doc.lines[3]).to eq(nil) + + lines = doc.lines + expect( + DisplayCodeWithLineNumbers.new( + lines: lines + ).call + ).to eq(<<~'EOM'.indent(2)) + 1 User + 2 .where(name: 'schneems') + 3 .first + EOM + + expect( + DisplayCodeWithLineNumbers.new( + lines: lines, + highlight_lines: lines[0] + ).call + ).to eq(<<~'EOM') + ❯ 1 User + ❯ 2 .where(name: 'schneems') + ❯ 3 .first + EOM + end + + it "helper method: take_while_including" do + source = <<~EOM + User + .where(name: 'schneems') + .first + EOM + + doc = CleanDocument.new(source: source) + + lines = doc.take_while_including { |line| !line.to_s.include?("where") } + expect(lines.count).to eq(2) + end + + it "comments: removes comments" do + source = <<~EOM + # lol + puts "what" + # yolo + EOM + + out = CleanDocument.new(source: source).lines.join + expect(out.to_s).to eq(<<~EOM) + + puts "what" + + EOM + end + + it "whitespace: removes whitespace" do + source = " \n" + <<~EOM + puts "what" + EOM + + out = CleanDocument.new(source: source).lines.join + expect(out.to_s).to eq(<<~EOM) + + puts "what" + EOM + + expect(source.lines.first.to_s).to_not eq("\n") + expect(out.lines.first.to_s).to eq("\n") + end + + it "trailing slash: does not join trailing do" do + # Some keywords and syntaxes trigger the "ignored line" + # lex output, we ignore them by filtering by BEG + # + # The `do` keyword is one of these: + # https://gist.github.com/schneems/6a7d7f988d3329fb3bd4b5be3e2efc0c + source = <<~EOM + foo do + puts "lol" + end + EOM + + doc = CleanDocument.new(source: source).join_consecutive! + + expect(doc.lines[0].to_s).to eq(source.lines[0]) + expect(doc.lines[1].to_s).to eq(source.lines[1]) + expect(doc.lines[2].to_s).to eq(source.lines[2]) + end + + it "trailing slash: formats output" do + source = <<~'EOM' + context "timezones workaround" do + it "should receive a time in UTC format and return the time with the"\ + "office's UTC offset substracted from it" do + travel_to DateTime.new(2020, 10, 1, 10, 0, 0) do + office = build(:office) + end + end + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + expect( + DisplayCodeWithLineNumbers.new( + lines: code_lines.select(&:visible?) + ).call + ).to eq(<<~'EOM'.indent(2)) + 1 context "timezones workaround" do + 2 it "should receive a time in UTC format and return the time with the"\ + 3 "office's UTC offset substracted from it" do + 4 travel_to DateTime.new(2020, 10, 1, 10, 0, 0) do + 5 office = build(:office) + 6 end + 7 end + 8 end + EOM + + expect( + DisplayCodeWithLineNumbers.new( + lines: code_lines.select(&:visible?), + highlight_lines: code_lines[1] + ).call + ).to eq(<<~'EOM') + 1 context "timezones workaround" do + ❯ 2 it "should receive a time in UTC format and return the time with the"\ + ❯ 3 "office's UTC offset substracted from it" do + 4 travel_to DateTime.new(2020, 10, 1, 10, 0, 0) do + 5 office = build(:office) + 6 end + 7 end + 8 end + EOM + end + + it "trailing slash: basic detection" do + source = <<~'EOM' + it "trailing s" \ + "lash" do + EOM + + code_lines = CleanDocument.new(source: source).call.lines + + expect(code_lines[0]).to_not be_hidden + expect(code_lines[1]).to be_hidden + + expect( + code_lines.join + ).to eq(code_lines.map(&:original).join) + end + + it "trailing slash: joins multiple lines" do + source = <<~'EOM' + it "should " \ + "keep " \ + "going " do + end + EOM + + doc = CleanDocument.new(source: source).join_trailing_slash! + expect(doc.lines[0].to_s).to eq(source.lines[0..2].join) + expect(doc.lines[1].to_s).to eq("") + expect(doc.lines[2].to_s).to eq("") + expect(doc.lines[3].to_s).to eq(source.lines[3]) + + lines = doc.lines + expect( + DisplayCodeWithLineNumbers.new( + lines: lines + ).call + ).to eq(<<~'EOM'.indent(2)) + 1 it "should " \ + 2 "keep " \ + 3 "going " do + 4 end + EOM + + expect( + DisplayCodeWithLineNumbers.new( + lines: lines, + highlight_lines: lines[0] + ).call + ).to eq(<<~'EOM') + ❯ 1 it "should " \ + ❯ 2 "keep " \ + ❯ 3 "going " do + 4 end + EOM + end + + it "trailing slash: no false positives" do + source = <<~'EOM' + def formatters + @formatters ||= { + amazing_print: ->(obj) { obj.ai + "\n" }, + inspect: ->(obj) { obj.inspect + "\n" }, + json: ->(obj) { obj.to_json }, + marshal: ->(obj) { Marshal.dump(obj) }, + none: ->(_obj) { nil }, + pretty_json: ->(obj) { JSON.pretty_generate(obj) }, + pretty_print: ->(obj) { obj.pretty_inspect }, + puts: ->(obj) { require 'stringio'; sio = StringIO.new; sio.puts(obj); sio.string }, + to_s: ->(obj) { obj.to_s + "\n" }, + yaml: ->(obj) { obj.to_yaml }, + } + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + expect(code_lines.join).to eq(code_lines.join) + end + end +end diff --git a/spec/syntax_suggest/unit/cli_spec.rb b/spec/syntax_suggest/unit/cli_spec.rb new file mode 100644 index 0000000000..fecf3e304c --- /dev/null +++ b/spec/syntax_suggest/unit/cli_spec.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module SyntaxSuggest + class FakeExit + def initialize + @called = false + @value = nil + end + + def exit(value = nil) + @called = true + @value = value + end + + def called? + @called + end + + attr_reader :value + end + + RSpec.describe Cli do + it "parses valid code" do + Dir.mktmpdir do |dir| + dir = Pathname(dir) + file = dir.join("script.rb") + file.write("puts 'lol'") + + io = StringIO.new + exit_obj = FakeExit.new + Cli.new( + io: io, + argv: [file.to_s], + exit_obj: exit_obj + ).call + + expect(exit_obj.called?).to be_truthy + expect(exit_obj.value).to eq(0) + expect(io.string.strip).to eq("Syntax OK") + end + end + + it "parses invalid code" do + file = fixtures_dir.join("this_project_extra_def.rb.txt") + + io = StringIO.new + exit_obj = FakeExit.new + Cli.new( + io: io, + argv: [file.to_s], + exit_obj: exit_obj + ).call + + out = io.string + debug_display(out) + + expect(exit_obj.called?).to be_truthy + expect(exit_obj.value).to eq(1) + expect(out.strip).to include("❯ 36 def filename") + end + + it "parses valid code with flags" do + Dir.mktmpdir do |dir| + dir = Pathname(dir) + file = dir.join("script.rb") + file.write("puts 'lol'") + + io = StringIO.new + exit_obj = FakeExit.new + cli = Cli.new( + io: io, + argv: ["--terminal", file.to_s], + exit_obj: exit_obj + ) + cli.call + + expect(exit_obj.called?).to be_truthy + expect(exit_obj.value).to eq(0) + expect(cli.options[:terminal]).to be_truthy + expect(io.string.strip).to eq("Syntax OK") + end + end + + it "errors when no file given" do + io = StringIO.new + exit_obj = FakeExit.new + cli = Cli.new( + io: io, + argv: ["--terminal"], + exit_obj: exit_obj + ) + cli.call + + expect(exit_obj.called?).to be_truthy + expect(exit_obj.value).to eq(1) + expect(io.string.strip).to eq("No file given") + end + + it "errors when file does not exist" do + io = StringIO.new + exit_obj = FakeExit.new + cli = Cli.new( + io: io, + argv: ["lol-i-d-o-not-ex-ist-yololo.txtblerglol"], + exit_obj: exit_obj + ) + cli.call + + expect(exit_obj.called?).to be_truthy + expect(exit_obj.value).to eq(1) + expect(io.string.strip).to include("file not found:") + end + + # We cannot execute the parser here + # because it calls `exit` and it will exit + # our tests, however we can assert that the + # parser has the right value for version + it "-v version" do + io = StringIO.new + exit_obj = FakeExit.new + parser = Cli.new( + io: io, + argv: ["-v"], + exit_obj: exit_obj + ).parser + + expect(parser.version).to include(SyntaxSuggest::VERSION.to_s) + end + + it "SYNTAX_SUGGEST_RECORD_DIR" do + io = StringIO.new + exit_obj = FakeExit.new + cli = Cli.new( + io: io, + argv: [], + env: {"SYNTAX_SUGGEST_RECORD_DIR" => "hahaha"}, + exit_obj: exit_obj + ).parse + + expect(exit_obj.called?).to be_falsey + expect(cli.options[:record_dir]).to eq("hahaha") + end + + it "--record-dir=" do + io = StringIO.new + exit_obj = FakeExit.new + cli = Cli.new( + io: io, + argv: ["--record=lol"], + exit_obj: exit_obj + ).parse + + expect(exit_obj.called?).to be_falsey + expect(cli.options[:record_dir]).to eq("lol") + end + + it "terminal default to respecting TTY" do + io = StringIO.new + exit_obj = FakeExit.new + cli = Cli.new( + io: io, + argv: [], + exit_obj: exit_obj + ).parse + + expect(exit_obj.called?).to be_falsey + expect(cli.options[:terminal]).to eq(SyntaxSuggest::DEFAULT_VALUE) + end + + it "--terminal" do + io = StringIO.new + exit_obj = FakeExit.new + cli = Cli.new( + io: io, + argv: ["--terminal"], + exit_obj: exit_obj + ).parse + + expect(exit_obj.called?).to be_falsey + expect(cli.options[:terminal]).to be_truthy + end + + it "--no-terminal" do + io = StringIO.new + exit_obj = FakeExit.new + cli = Cli.new( + io: io, + argv: ["--no-terminal"], + exit_obj: exit_obj + ).parse + + expect(exit_obj.called?).to be_falsey + expect(cli.options[:terminal]).to be_falsey + end + + it "--help outputs help" do + io = StringIO.new + exit_obj = FakeExit.new + Cli.new( + io: io, + argv: ["--help"], + exit_obj: exit_obj + ).call + + expect(exit_obj.called?).to be_truthy + expect(io.string).to include("Usage: syntax_suggest [options]") + end + + it " outputs help" do + io = StringIO.new + exit_obj = FakeExit.new + Cli.new( + io: io, + argv: [], + exit_obj: exit_obj + ).call + + expect(exit_obj.called?).to be_truthy + expect(io.string).to include("Usage: syntax_suggest [options]") + end + end +end diff --git a/spec/syntax_suggest/unit/code_block_spec.rb b/spec/syntax_suggest/unit/code_block_spec.rb new file mode 100644 index 0000000000..3ab2751b27 --- /dev/null +++ b/spec/syntax_suggest/unit/code_block_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module SyntaxSuggest + RSpec.describe CodeBlock do + it "can detect if it's valid or not" do + code_lines = code_line_array(<<~EOM) + def foo + puts 'lol' + end + EOM + + block = CodeBlock.new(lines: code_lines[1]) + expect(block.valid?).to be_truthy + end + + it "can be sorted in indentation order" do + code_lines = code_line_array(<<~EOM) + def foo + puts 'lol' + end + EOM + + block_0 = CodeBlock.new(lines: code_lines[0]) + block_1 = CodeBlock.new(lines: code_lines[1]) + block_2 = CodeBlock.new(lines: code_lines[2]) + + expect(block_0 <=> block_0.dup).to eq(0) + expect(block_1 <=> block_0).to eq(1) + expect(block_1 <=> block_2).to eq(-1) + + array = [block_2, block_1, block_0].sort + expect(array.last).to eq(block_2) + + block = CodeBlock.new(lines: CodeLine.new(line: " " * 8 + "foo", index: 4, lex: [])) + array.prepend(block) + expect(array.max).to eq(block) + end + + it "knows it's current indentation level" do + code_lines = code_line_array(<<~EOM) + def foo + puts 'lol' + end + EOM + + block = CodeBlock.new(lines: code_lines[1]) + expect(block.current_indent).to eq(2) + + block = CodeBlock.new(lines: code_lines[0]) + expect(block.current_indent).to eq(0) + end + + it "knows it's current indentation level when mismatched indents" do + code_lines = code_line_array(<<~EOM) + def foo + puts 'lol' + end + EOM + + block = CodeBlock.new(lines: [code_lines[1], code_lines[2]]) + expect(block.current_indent).to eq(1) + end + + it "before lines and after lines" do + code_lines = code_line_array(<<~EOM) + def foo + bar; end + end + EOM + + block = CodeBlock.new(lines: code_lines[1]) + expect(block.valid?).to be_falsey + end + end +end diff --git a/spec/syntax_suggest/unit/code_frontier_spec.rb b/spec/syntax_suggest/unit/code_frontier_spec.rb new file mode 100644 index 0000000000..c9aba7c8d8 --- /dev/null +++ b/spec/syntax_suggest/unit/code_frontier_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module SyntaxSuggest + RSpec.describe CodeFrontier do + it "detect_bad_blocks" do + code_lines = code_line_array(<<~EOM) + describe "lol" do + end + end + + it "lol" do + end + end + EOM + + frontier = CodeFrontier.new(code_lines: code_lines) + blocks = [] + blocks << CodeBlock.new(lines: code_lines[1]) + blocks << CodeBlock.new(lines: code_lines[5]) + blocks.each do |b| + frontier << b + end + + expect(frontier.detect_invalid_blocks.sort).to eq(blocks.sort) + end + + it "self.combination" do + expect( + CodeFrontier.combination([:a, :b, :c, :d]) + ).to eq( + [ + [:a], [:b], [:c], [:d], + [:a, :b], + [:a, :c], + [:a, :d], + [:b, :c], + [:b, :d], + [:c, :d], + [:a, :b, :c], + [:a, :b, :d], + [:a, :c, :d], + [:b, :c, :d], + [:a, :b, :c, :d] + ] + ) + end + + it "doesn't duplicate blocks" do + code_lines = code_line_array(<<~EOM) + def foo + puts "lol" + puts "lol" + puts "lol" + end + EOM + + frontier = CodeFrontier.new(code_lines: code_lines) + frontier << CodeBlock.new(lines: [code_lines[2]]) + expect(frontier.count).to eq(1) + + frontier << CodeBlock.new(lines: [code_lines[1], code_lines[2], code_lines[3]]) + # expect(frontier.count).to eq(1) + expect(frontier.pop.to_s).to eq(<<~EOM.indent(2)) + puts "lol" + puts "lol" + puts "lol" + EOM + + expect(frontier.pop).to be_nil + + code_lines = code_line_array(<<~EOM) + def foo + puts "lol" + puts "lol" + puts "lol" + end + EOM + + frontier = CodeFrontier.new(code_lines: code_lines) + frontier << CodeBlock.new(lines: [code_lines[2]]) + expect(frontier.count).to eq(1) + + frontier << CodeBlock.new(lines: [code_lines[3]]) + expect(frontier.count).to eq(2) + expect(frontier.pop.to_s).to eq(<<~EOM.indent(2)) + puts "lol" + EOM + end + + it "detects if multiple syntax errors are found" do + code_lines = code_line_array(<<~EOM) + def foo + end + end + EOM + + frontier = CodeFrontier.new(code_lines: code_lines) + + frontier << CodeBlock.new(lines: code_lines[1]) + block = frontier.pop + expect(block.to_s).to eq(<<~EOM.indent(2)) + end + EOM + frontier << block + + expect(frontier.holds_all_syntax_errors?).to be_truthy + end + + it "detects if it has not captured all syntax errors" do + code_lines = code_line_array(<<~EOM) + def foo + puts "lol" + end + + describe "lol" + end + + it "lol" + end + EOM + + frontier = CodeFrontier.new(code_lines: code_lines) + frontier << CodeBlock.new(lines: [code_lines[1]]) + block = frontier.pop + expect(block.to_s).to eq(<<~EOM.indent(2)) + puts "lol" + EOM + frontier << block + + expect(frontier.holds_all_syntax_errors?).to be_falsey + end + end +end diff --git a/spec/syntax_suggest/unit/code_line_spec.rb b/spec/syntax_suggest/unit/code_line_spec.rb new file mode 100644 index 0000000000..cc4fa48bc9 --- /dev/null +++ b/spec/syntax_suggest/unit/code_line_spec.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module SyntaxSuggest + RSpec.describe CodeLine do + it "bug in keyword detection" do + lines = CodeLine.from_source(<<~'EOM') + def to_json(*opts) + { + type: :module, + }.to_json(*opts) + end + EOM + expect(lines.count(&:is_kw?)).to eq(1) + expect(lines.count(&:is_end?)).to eq(1) + end + + it "supports endless method definitions" do + skip("Unsupported ruby version") unless Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3") + + line = CodeLine.from_source(<<~'EOM').first + def square(x) = x * x + EOM + + expect(line.is_kw?).to be_falsey + expect(line.is_end?).to be_falsey + end + + it "retains original line value, after being marked invisible" do + line = CodeLine.from_source(<<~'EOM').first + puts "lol" + EOM + expect(line.line).to match('puts "lol"') + line.mark_invisible + expect(line.line).to eq("") + expect(line.original).to match('puts "lol"') + end + + it "knows which lines can be joined" do + code_lines = CodeLine.from_source(<<~'EOM') + user = User. + where(name: 'schneems'). + first + puts user.name + EOM + + # Indicates line 1 can join 2, 2 can join 3, but 3 won't join it's next line + expect(code_lines.map(&:ignore_newline_not_beg?)).to eq([true, true, false, false]) + end + it "trailing if" do + code_lines = CodeLine.from_source(<<~'EOM') + puts "lol" if foo + if foo + end + EOM + + expect(code_lines.map(&:is_kw?)).to eq([false, true, false]) + end + + it "trailing unless" do + code_lines = CodeLine.from_source(<<~'EOM') + puts "lol" unless foo + unless foo + end + EOM + + expect(code_lines.map(&:is_kw?)).to eq([false, true, false]) + end + + it "trailing slash" do + code_lines = CodeLine.from_source(<<~'EOM') + it "trailing s" \ + "lash" do + EOM + + expect(code_lines.map(&:trailing_slash?)).to eq([true, false]) + + code_lines = CodeLine.from_source(<<~'EOM') + amazing_print: ->(obj) { obj.ai + "\n" }, + EOM + expect(code_lines.map(&:trailing_slash?)).to eq([false]) + end + + it "knows it's got an end" do + line = CodeLine.from_source(" end").first + + expect(line.is_end?).to be_truthy + expect(line.is_kw?).to be_falsey + end + + it "knows it's got a keyword" do + line = CodeLine.from_source(" if").first + + expect(line.is_end?).to be_falsey + expect(line.is_kw?).to be_truthy + end + + it "ignores marked lines" do + code_lines = CodeLine.from_source(<<~EOM) + def foo + Array(value) |x| + end + end + EOM + + expect(SyntaxSuggest.valid?(code_lines)).to be_falsey + expect(code_lines.join).to eq(<<~EOM) + def foo + Array(value) |x| + end + end + EOM + + expect(code_lines[0].visible?).to be_truthy + expect(code_lines[3].visible?).to be_truthy + + code_lines[0].mark_invisible + code_lines[3].mark_invisible + + expect(code_lines[0].visible?).to be_falsey + expect(code_lines[3].visible?).to be_falsey + + expect(code_lines.join).to eq(<<~EOM.indent(2)) + Array(value) |x| + end + EOM + expect(SyntaxSuggest.valid?(code_lines)).to be_falsey + end + + it "knows empty lines" do + code_lines = CodeLine.from_source(<<~EOM) + # Not empty + + # Not empty + EOM + + expect(code_lines.map(&:empty?)).to eq([false, true, false]) + expect(code_lines.map(&:not_empty?)).to eq([true, false, true]) + expect(code_lines.map { |l| SyntaxSuggest.valid?(l) }).to eq([true, true, true]) + end + + it "counts indentations" do + code_lines = CodeLine.from_source(<<~EOM) + def foo + Array(value) |x| + puts 'lol' + end + end + EOM + + expect(code_lines.map(&:indent)).to eq([0, 2, 4, 2, 0]) + end + + it "doesn't count empty lines as having an indentation" do + code_lines = CodeLine.from_source(<<~EOM) + + + EOM + + expect(code_lines.map(&:indent)).to eq([0, 0]) + end + end +end diff --git a/spec/syntax_suggest/unit/code_search_spec.rb b/spec/syntax_suggest/unit/code_search_spec.rb new file mode 100644 index 0000000000..b62b2c0a3c --- /dev/null +++ b/spec/syntax_suggest/unit/code_search_spec.rb @@ -0,0 +1,505 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module SyntaxSuggest + RSpec.describe CodeSearch do + it "rexe regression" do + lines = fixtures_dir.join("rexe.rb.txt").read.lines + lines.delete_at(148 - 1) + source = lines.join + + search = CodeSearch.new(source) + search.call + + expect(search.invalid_blocks.join.strip).to eq(<<~'EOM'.strip) + class Lookups + EOM + end + + it "squished do regression" do + source = <<~'EOM' + def call + trydo + + @options = CommandLineParser.new.parse + + options.requires.each { |r| require!(r) } + load_global_config_if_exists + options.loads.each { |file| load(file) } + + @user_source_code = ARGV.join(' ') + @user_source_code = 'self' if @user_source_code == '' + + @callable = create_callable + + init_rexe_context + init_parser_and_formatters + + # This is where the user's source code will be executed; the action will in turn call `execute`. + lookup_action(options.input_mode).call unless options.noop + + output_log_entry + end # one + end # two + EOM + + search = CodeSearch.new(source) + search.call + + expect(search.invalid_blocks.join).to eq(<<~'EOM'.indent(2)) + trydo + end # one + EOM + end + + it "regression test ambiguous end" do + source = <<~'EOM' + def call # 0 + print "lol" # 1 + end # one # 2 + end # two # 3 + EOM + + search = CodeSearch.new(source) + search.call + + expect(search.invalid_blocks.join).to eq(<<~'EOM') + end # two # 3 + EOM + end + + it "regression dog test" do + source = <<~'EOM' + class Dog + def bark + puts "woof" + end + EOM + search = CodeSearch.new(source) + search.call + + expect(search.invalid_blocks.join).to eq(<<~'EOM') + class Dog + EOM + expect(search.invalid_blocks.first.lines.length).to eq(4) + end + + it "handles mismatched |" do + source = <<~EOM + class Blerg + Foo.call do |a + end # one + + puts lol + class Foo + end # two + end # three + EOM + search = CodeSearch.new(source) + search.call + + expect(search.invalid_blocks.join).to eq(<<~'EOM'.indent(2)) + Foo.call do |a + end # one + EOM + end + + it "handles mismatched }" do + source = <<~EOM + class Blerg + Foo.call do { + + puts lol + class Foo + end # two + end # three + EOM + search = CodeSearch.new(source) + search.call + + expect(search.invalid_blocks.join).to eq(<<~'EOM'.indent(2)) + Foo.call do { + EOM + end + + it "handles no spaces between blocks and trailing slash" do + source = <<~'EOM' + require "rails_helper" + RSpec.describe Foo, type: :model do + describe "#bar" do + context "context" do + it "foos the bar with a foo and then bazes the foo with a bar to"\ + "fooify the barred bar" do + travel_to DateTime.new(2020, 10, 1, 10, 0, 0) do + foo = build(:foo) + end + end + end + end + describe "#baz?" do + context "baz has barred the foo" do + it "returns true" do # <== HERE + end + end + end + EOM + + search = CodeSearch.new(source) + search.call + + expect(search.invalid_blocks.join.strip).to eq('it "returns true" do # <== HERE') + end + + it "handles no spaces between blocks" do + source = <<~'EOM' + context "foo bar" do + it "bars the foo" do + travel_to DateTime.new(2020, 10, 1, 10, 0, 0) do + end + end + end + context "test" do + it "should" do + end + EOM + search = CodeSearch.new(source) + search.call + + expect(search.invalid_blocks.join.strip).to eq('it "should" do') + end + + it "records debugging steps to a directory" do + Dir.mktmpdir do |dir| + dir = Pathname(dir) + search = CodeSearch.new(<<~'EOM', record_dir: dir) + class OH + def hello + def hai + end + end + EOM + search.call + + expect(search.record_dir.entries.map(&:to_s)).to include("1-add-1-(3__4).txt") + expect(search.record_dir.join("1-add-1-(3__4).txt").read).to include(<<~EOM) + 1 class OH + 2 def hello + ❯ 3 def hai + ❯ 4 end + 5 end + EOM + end + end + + it "def with missing end" do + search = CodeSearch.new(<<~'EOM') + class OH + def hello + + def hai + puts "lol" + end + end + EOM + search.call + + expect(search.invalid_blocks.join.strip).to eq("def hello") + + search = CodeSearch.new(<<~'EOM') + class OH + def hello + + def hai + end + end + EOM + search.call + + expect(search.invalid_blocks.join.strip).to eq("def hello") + + search = CodeSearch.new(<<~'EOM') + class OH + def hello + def hai + end + end + EOM + search.call + + expect(search.invalid_blocks.join).to eq(<<~'EOM'.indent(2)) + def hello + EOM + end + + describe "real world cases" do + it "finds hanging def in this project" do + source_string = fixtures_dir.join("this_project_extra_def.rb.txt").read + search = CodeSearch.new(source_string) + search.call + + document = DisplayCodeWithLineNumbers.new( + lines: search.code_lines.select(&:visible?), + terminal: false, + highlight_lines: search.invalid_blocks.flat_map(&:lines) + ).call + + expect(document).to include(<<~'EOM') + ❯ 36 def filename + EOM + end + + it "Format Code blocks real world example" do + search = CodeSearch.new(<<~'EOM') + require 'rails_helper' + + RSpec.describe AclassNameHere, type: :worker do + describe "thing" do + context "when" do + let(:thing) { stuff } + let(:another_thing) { moarstuff } + subject { foo.new.perform(foo.id, true) } + + it "stuff" do + subject + + expect(foo.foo.foo).to eq(true) + end + end + end # line 16 accidental end, but valid block + + context "stuff" do + let(:thing) { create(:foo, foo: stuff) } + let(:another_thing) { create(:stuff) } + + subject { described_class.new.perform(foo.id, false) } + + it "more stuff" do + subject + + expect(foo.foo.foo).to eq(false) + end + end + end # mismatched due to 16 + end + EOM + search.call + + document = DisplayCodeWithLineNumbers.new( + lines: search.code_lines.select(&:visible?), + terminal: false, + highlight_lines: search.invalid_blocks.flat_map(&:lines) + ).call + + expect(document).to include(<<~'EOM') + 1 require 'rails_helper' + 2 + 3 RSpec.describe AclassNameHere, type: :worker do + ❯ 4 describe "thing" do + ❯ 16 end # line 16 accidental end, but valid block + ❯ 30 end # mismatched due to 16 + 31 end + EOM + end + end + + # For code that's not perfectly formatted, we ideally want to do our best + # These examples represent the results that exist today, but I would like to improve upon them + describe "needs improvement" do + describe "mis-matched-indentation" do + it "extra space before end" do + search = CodeSearch.new(<<~'EOM') + Foo.call + def foo + puts "lol" + puts "lol" + end # one + end # two + EOM + search.call + + expect(search.invalid_blocks.join).to eq(<<~'EOM') + Foo.call + end # two + EOM + end + + it "stacked ends 2" do + search = CodeSearch.new(<<~'EOM') + def cat + blerg + end + + Foo.call do + end # one + end # two + + def dog + end + EOM + search.call + + expect(search.invalid_blocks.join).to eq(<<~'EOM') + Foo.call do + end # one + end # two + + EOM + end + + it "stacked ends " do + search = CodeSearch.new(<<~'EOM') + Foo.call + def foo + puts "lol" + puts "lol" + end + end + EOM + search.call + + expect(search.invalid_blocks.join).to eq(<<~'EOM') + Foo.call + end + EOM + end + + it "missing space before end" do + search = CodeSearch.new(<<~'EOM') + Foo.call + + def foo + puts "lol" + puts "lol" + end + end + EOM + search.call + + # expand-1 and expand-2 seem to be broken? + expect(search.invalid_blocks.join).to eq(<<~'EOM') + Foo.call + end + EOM + end + end + end + + it "returns syntax error in outer block without inner block" do + search = CodeSearch.new(<<~'EOM') + Foo.call + def foo + puts "lol" + puts "lol" + end # one + end # two + EOM + search.call + + expect(search.invalid_blocks.join).to eq(<<~'EOM') + Foo.call + end # two + EOM + end + + it "doesn't just return an empty `end`" do + search = CodeSearch.new(<<~'EOM') + Foo.call + end + EOM + search.call + + expect(search.invalid_blocks.join).to eq(<<~'EOM') + Foo.call + end + EOM + end + + it "finds multiple syntax errors" do + search = CodeSearch.new(<<~'EOM') + describe "hi" do + Foo.call + end + end + + it "blerg" do + Bar.call + end + end + EOM + search.call + + expect(search.invalid_blocks.join).to eq(<<~'EOM'.indent(2)) + Foo.call + end + Bar.call + end + EOM + end + + it "finds a typo def" do + search = CodeSearch.new(<<~'EOM') + defzfoo + puts "lol" + end + EOM + search.call + + expect(search.invalid_blocks.join).to eq(<<~'EOM') + defzfoo + end + EOM + end + + it "finds a mis-matched def" do + search = CodeSearch.new(<<~'EOM') + def foo + def blerg + end + EOM + search.call + + expect(search.invalid_blocks.join).to eq(<<~'EOM'.indent(2)) + def blerg + EOM + end + + it "finds a naked end" do + search = CodeSearch.new(<<~'EOM') + def foo + end # one + end # two + EOM + search.call + + expect(search.invalid_blocks.join).to eq(<<~'EOM'.indent(2)) + end # one + EOM + end + + it "returns when no invalid blocks are found" do + search = CodeSearch.new(<<~'EOM') + def foo + puts 'lol' + end + EOM + search.call + + expect(search.invalid_blocks).to eq([]) + end + + it "expands frontier by eliminating valid lines" do + search = CodeSearch.new(<<~'EOM') + def foo + puts 'lol' + end + EOM + search.create_blocks_from_untracked_lines + + expect(search.code_lines.join).to eq(<<~'EOM') + def foo + end + EOM + end + end +end diff --git a/spec/syntax_suggest/unit/display_invalid_blocks_spec.rb b/spec/syntax_suggest/unit/display_invalid_blocks_spec.rb new file mode 100644 index 0000000000..c696132782 --- /dev/null +++ b/spec/syntax_suggest/unit/display_invalid_blocks_spec.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module SyntaxSuggest + RSpec.describe DisplayInvalidBlocks do + it "works with valid code" do + syntax_string = <<~EOM + class OH + def hello + end + def hai + end + end + EOM + + search = CodeSearch.new(syntax_string) + search.call + + io = StringIO.new + display = DisplayInvalidBlocks.new( + io: io, + blocks: search.invalid_blocks, + terminal: false, + code_lines: search.code_lines + ) + display.call + expect(io.string).to include("Syntax OK") + end + + it "selectively prints to terminal if input is a tty by default" do + source = <<~EOM + class OH + def hello + def hai + end + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + + io = StringIO.new + def io.isatty + true + end + + block = CodeBlock.new(lines: code_lines[1]) + display = DisplayInvalidBlocks.new( + io: io, + blocks: block, + code_lines: code_lines + ) + display.call + expect(io.string).to include([ + "❯ 2 ", + DisplayCodeWithLineNumbers::TERMINAL_HIGHLIGHT, + " def hello" + ].join) + + io = StringIO.new + def io.isatty + false + end + + block = CodeBlock.new(lines: code_lines[1]) + display = DisplayInvalidBlocks.new( + io: io, + blocks: block, + code_lines: code_lines + ) + display.call + expect(io.string).to include("❯ 2 def hello") + end + + it "outputs to io when using `call`" do + source = <<~EOM + class OH + def hello + def hai + end + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + + io = StringIO.new + block = CodeBlock.new(lines: code_lines[1]) + display = DisplayInvalidBlocks.new( + io: io, + blocks: block, + terminal: false, + code_lines: code_lines + ) + display.call + expect(io.string).to include("❯ 2 def hello") + end + + it " wraps code with github style codeblocks" do + source = <<~EOM + class OH + def hello + + def hai + end + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + block = CodeBlock.new(lines: code_lines[1]) + io = StringIO.new + DisplayInvalidBlocks.new( + io: io, + blocks: block, + terminal: false, + code_lines: code_lines + ).call + expect(io.string).to include(<<~EOM) + 1 class OH + ❯ 2 def hello + 4 def hai + 5 end + 6 end + EOM + end + + it "shows terminal characters" do + code_lines = code_line_array(<<~EOM) + class OH + def hello + def hai + end + end + EOM + + io = StringIO.new + block = CodeBlock.new(lines: code_lines[1]) + DisplayInvalidBlocks.new( + io: io, + blocks: block, + terminal: false, + code_lines: code_lines + ).call + + expect(io.string).to include([ + " 1 class OH", + "❯ 2 def hello", + " 4 end", + " 5 end", + "" + ].join($/)) + + block = CodeBlock.new(lines: code_lines[1]) + io = StringIO.new + DisplayInvalidBlocks.new( + io: io, + blocks: block, + terminal: true, + code_lines: code_lines + ).call + + expect(io.string).to include( + [ + " 1 class OH", + ["❯ 2 ", DisplayCodeWithLineNumbers::TERMINAL_HIGHLIGHT, " def hello"].join, + " 4 end", + " 5 end", + "" + ].join($/ + DisplayCodeWithLineNumbers::TERMINAL_END) + ) + end + end +end diff --git a/spec/syntax_suggest/unit/explain_syntax_spec.rb b/spec/syntax_suggest/unit/explain_syntax_spec.rb new file mode 100644 index 0000000000..394981dcf6 --- /dev/null +++ b/spec/syntax_suggest/unit/explain_syntax_spec.rb @@ -0,0 +1,255 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module SyntaxSuggest + RSpec.describe "ExplainSyntax" do + it "handles shorthand syntaxes with non-bracket characters" do + source = <<~EOM + %Q* lol + EOM + + explain = ExplainSyntax.new( + code_lines: CodeLine.from_source(source) + ).call + + expect(explain.missing).to eq([]) + expect(explain.errors.join).to include("unterminated string") + end + + it "handles %w[]" do + source = <<~EOM + node.is_a?(Op) && %w[| ||].include?(node.value) && + EOM + + explain = ExplainSyntax.new( + code_lines: CodeLine.from_source(source) + ).call + + expect(explain.missing).to eq([]) + end + + it "doesn't falsely identify strings or symbols as critical chars" do + source = <<~EOM + a = ['(', '{', '[', '|'] + EOM + + explain = ExplainSyntax.new( + code_lines: CodeLine.from_source(source) + ).call + + expect(explain.missing).to eq([]) + + source = <<~EOM + a = [:'(', :'{', :'[', :'|'] + EOM + + explain = ExplainSyntax.new( + code_lines: CodeLine.from_source(source) + ).call + + expect(explain.missing).to eq([]) + end + + it "finds missing |" do + source = <<~EOM + Foo.call do | + end + EOM + + explain = ExplainSyntax.new( + code_lines: CodeLine.from_source(source) + ).call + + expect(explain.missing).to eq(["|"]) + expect(explain.errors).to eq([explain.why("|")]) + end + + it "finds missing {" do + source = <<~EOM + class Cat + lol = { + end + EOM + + explain = ExplainSyntax.new( + code_lines: CodeLine.from_source(source) + ).call + + expect(explain.missing).to eq(["}"]) + expect(explain.errors).to eq([explain.why("}")]) + end + + it "finds missing }" do + source = <<~EOM + def foo + lol = "foo" => :bar } + end + EOM + + explain = ExplainSyntax.new( + code_lines: CodeLine.from_source(source) + ).call + + expect(explain.missing).to eq(["{"]) + expect(explain.errors).to eq([explain.why("{")]) + end + + it "finds missing [" do + source = <<~EOM + class Cat + lol = [ + end + EOM + + explain = ExplainSyntax.new( + code_lines: CodeLine.from_source(source) + ).call + + expect(explain.missing).to eq(["]"]) + expect(explain.errors).to eq([explain.why("]")]) + end + + it "finds missing ]" do + source = <<~EOM + def foo + lol = ] + end + EOM + + explain = ExplainSyntax.new( + code_lines: CodeLine.from_source(source) + ).call + + expect(explain.missing).to eq(["["]) + expect(explain.errors).to eq([explain.why("[")]) + end + + it "finds missing (" do + source = "def initialize; ); end" + + explain = ExplainSyntax.new( + code_lines: CodeLine.from_source(source) + ).call + + expect(explain.missing).to eq(["("]) + expect(explain.errors).to eq([explain.why("(")]) + end + + it "finds missing )" do + source = "def initialize; (; end" + + explain = ExplainSyntax.new( + code_lines: CodeLine.from_source(source) + ).call + + expect(explain.missing).to eq([")"]) + expect(explain.errors).to eq([explain.why(")")]) + end + + it "finds missing keyword" do + source = <<~EOM + class Cat + end + end + EOM + + explain = ExplainSyntax.new( + code_lines: CodeLine.from_source(source) + ).call + + expect(explain.missing).to eq(["keyword"]) + expect(explain.errors).to eq([explain.why("keyword")]) + end + + it "finds missing end" do + source = <<~EOM + class Cat + def meow + end + EOM + + explain = ExplainSyntax.new( + code_lines: CodeLine.from_source(source) + ).call + + expect(explain.missing).to eq(["end"]) + expect(explain.errors).to eq([explain.why("end")]) + end + + it "falls back to ripper on unknown errors" do + source = <<~EOM + class Cat + def meow + 1 * + end + end + EOM + + explain = ExplainSyntax.new( + code_lines: CodeLine.from_source(source) + ).call + + expect(explain.missing).to eq([]) + expect(explain.errors).to eq(RipperErrors.new(source).call.errors) + end + + it "handles an unexpected rescue" do + source = <<~EOM + def foo + if bar + "baz" + else + "foo" + rescue FooBar + nil + end + EOM + + explain = ExplainSyntax.new( + code_lines: CodeLine.from_source(source) + ).call + + expect(explain.missing).to eq(["end"]) + end + + # String embeds are `"#{foo} <-- here` + # + # We need to count a `#{` as a `{` + # otherwise it will report that we are + # missing a curly when we are using valid + # string embed syntax + it "is not confused by valid string embed" do + source = <<~'EOM' + foo = "#{hello}" + EOM + + explain = ExplainSyntax.new( + code_lines: CodeLine.from_source(source) + ).call + expect(explain.missing).to eq([]) + end + + # Missing string embed beginnings are not a + # syntax error. i.e. `"foo}"` or `"{foo}` or "#foo}" + # would just be strings with extra characters. + # + # However missing the end curly will trigger + # an error: i.e. `"#{foo` + # + # String embed beginning is a `#{` rather than + # a `{`, make sure we handle that case and + # report the correct missing `}` diagnosis + it "finds missing string embed end" do + source = <<~'EOM' + "#{foo + EOM + + explain = ExplainSyntax.new( + code_lines: CodeLine.from_source(source) + ).call + + expect(explain.missing).to eq(["}"]) + end + end +end diff --git a/spec/syntax_suggest/unit/lex_all_spec.rb b/spec/syntax_suggest/unit/lex_all_spec.rb new file mode 100644 index 0000000000..0c0df7cfaa --- /dev/null +++ b/spec/syntax_suggest/unit/lex_all_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module SyntaxSuggest + RSpec.describe "EndBlockParse" do + it "finds blocks based on `end` keyword" do + source = <<~EOM + describe "cat" # 1 + Cat.call do # 2 + end # 3 + end # 4 + # 5 + it "dog" do # 6 + Dog.call do # 7 + end # 8 + end # 9 + EOM + + # raw_lex = Ripper.lex(source) + # expect(raw_lex.to_s).to_not include("dog") + + lex = LexAll.new(source: source) + expect(lex.map(&:token).to_s).to include("dog") + expect(lex.first.line).to eq(1) + expect(lex.last.line).to eq(9) + end + end +end diff --git a/spec/syntax_suggest/unit/pathname_from_message_spec.rb b/spec/syntax_suggest/unit/pathname_from_message_spec.rb new file mode 100644 index 0000000000..76756efda9 --- /dev/null +++ b/spec/syntax_suggest/unit/pathname_from_message_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module SyntaxSuggest + RSpec.describe "PathnameFromMessage" do + it "handles filenames with colons in them" do + Dir.mktmpdir do |dir| + dir = Pathname(dir) + + file = dir.join("scr:atch.rb").tap { |p| FileUtils.touch(p) } + + message = "#{file}:2:in `require_relative': /private/tmp/bad.rb:1: syntax error, unexpected `end' (SyntaxError)" + file = PathnameFromMessage.new(message).call.name + + expect(file).to be_truthy + end + end + + it "checks if the file exists" do + Dir.mktmpdir do |dir| + dir = Pathname(dir) + + file = dir.join("scratch.rb") + # No touch, file does not exist + expect(file.exist?).to be_falsey + + message = "#{file}:2:in `require_relative': /private/tmp/bad.rb:1: syntax error, unexpected `end' (SyntaxError)" + io = StringIO.new + file = PathnameFromMessage.new(message, io: io).call.name + + expect(io.string).to include(file.to_s) + expect(file).to be_falsey + end + end + + it "does not output error message on syntax error inside of an (eval)" do + message = "(eval):1: invalid multibyte char (UTF-8) (SyntaxError)\n" + io = StringIO.new + file = PathnameFromMessage.new(message, io: io).call.name + + expect(io.string).to eq("") + expect(file).to be_falsey + end + + it "does not output error message on syntax error inside of streamed code" do + # An example of streamed code is: $ echo "def foo" | ruby + message = "-:1: syntax error, unexpected end-of-input\n" + io = StringIO.new + file = PathnameFromMessage.new(message, io: io).call.name + + expect(io.string).to eq("") + expect(file).to be_falsey + end + end +end diff --git a/spec/syntax_suggest/unit/priority_queue_spec.rb b/spec/syntax_suggest/unit/priority_queue_spec.rb new file mode 100644 index 0000000000..17361833e5 --- /dev/null +++ b/spec/syntax_suggest/unit/priority_queue_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module SyntaxSuggest + class CurrentIndex + attr_reader :current_indent + + def initialize(value) + @current_indent = value + end + + def <=>(other) + @current_indent <=> other.current_indent + end + + def inspect + @current_indent + end + end + + RSpec.describe CodeFrontier do + it "works" do + q = PriorityQueue.new + q << 1 + q << 2 + expect(q.elements).to eq([2, 1]) + + q << 3 + expect(q.elements).to eq([3, 1, 2]) + + expect(q.pop).to eq(3) + expect(q.pop).to eq(2) + expect(q.pop).to eq(1) + expect(q.pop).to eq(nil) + + array = [] + q = PriorityQueue.new + array.reverse_each do |v| + q << v + end + expect(q.elements).to eq(array) + + array = [100, 36, 17, 19, 25, 0, 3, 1, 7, 2] + array.reverse_each do |v| + q << v + end + + expect(q.pop).to eq(100) + expect(q.elements).to eq([36, 25, 19, 17, 0, 1, 7, 2, 3]) + + # expected [36, 25, 19, 17, 0, 1, 7, 2, 3] + expect(q.pop).to eq(36) + expect(q.pop).to eq(25) + expect(q.pop).to eq(19) + expect(q.pop).to eq(17) + expect(q.pop).to eq(7) + expect(q.pop).to eq(3) + expect(q.pop).to eq(2) + expect(q.pop).to eq(1) + expect(q.pop).to eq(0) + expect(q.pop).to eq(nil) + end + + it "priority queue" do + frontier = PriorityQueue.new + frontier << CurrentIndex.new(0) + frontier << CurrentIndex.new(1) + + expect(frontier.sorted.map(&:current_indent)).to eq([0, 1]) + + frontier << CurrentIndex.new(1) + expect(frontier.sorted.map(&:current_indent)).to eq([0, 1, 1]) + + frontier << CurrentIndex.new(0) + expect(frontier.sorted.map(&:current_indent)).to eq([0, 0, 1, 1]) + + frontier << CurrentIndex.new(10) + expect(frontier.sorted.map(&:current_indent)).to eq([0, 0, 1, 1, 10]) + + frontier << CurrentIndex.new(2) + expect(frontier.sorted.map(&:current_indent)).to eq([0, 0, 1, 1, 2, 10]) + + frontier = PriorityQueue.new + values = [18, 18, 0, 18, 0, 18, 18, 18, 18, 16, 18, 8, 18, 8, 8, 8, 16, 6, 0, 0, 16, 16, 4, 14, 14, 12, 12, 12, 10, 12, 12, 12, 12, 8, 10, 10, 8, 8, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 8, 10, 6, 6, 6, 6, 6, 6, 8, 10, 8, 8, 10, 8, 10, 8, 10, 8, 6, 8, 8, 6, 8, 6, 6, 8, 0, 8, 0, 0, 8, 8, 0, 8, 0, 8, 8, 0, 8, 8, 8, 0, 8, 0, 8, 8, 8, 8, 8, 8, 8, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, 8, 8, 8, 6, 8, 6, 6, 6, 6, 8, 6, 8, 6, 6, 4, 4, 6, 6, 4, 6, 4, 6, 6, 4, 6, 4, 4, 6, 6, 6, 6, 4, 4, 4, 2, 4, 4, 4, 4, 4, 4, 6, 6, 0, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 0, 0, 6, 6, 2] + + values.each do |v| + value = CurrentIndex.new(v) + frontier << value # CurrentIndex.new(v) + end + + expect(frontier.sorted.map(&:current_indent)).to eq(values.sort) + end + end +end -- cgit v1.2.1