From 8566d42fc3d99761cc17cba70e109f1d82835bc4 Mon Sep 17 00:00:00 2001 From: Kevin Newton Date: Mon, 27 Oct 2025 09:04:51 -0400 Subject: [PATCH] Switch parser over to prism --- .gitattributes | 1 - .github/workflows/main.yml | 17 +- .rubocop.yml | 16 +- Gemfile | 6 +- Gemfile.lock | 19 +- README.md | 78 +- Rakefile | 20 +- bin/bench | 42 - bin/profile | 4 +- exe/stree | 1 - lib/syntax_tree.rb | 209 +- lib/syntax_tree/cli.rb | 414 +- lib/syntax_tree/format.rb | 4750 ++++++ lib/syntax_tree/formatter.rb | 228 - lib/syntax_tree/language_server.rb | 163 - lib/syntax_tree/lsp.rb | 104 + lib/syntax_tree/node.rb | 12380 ---------------- lib/syntax_tree/parser.rb | 4648 ------ .../plugin/disable_auto_ternary.rb | 7 - lib/syntax_tree/plugin/single_quotes.rb | 7 - lib/syntax_tree/plugin/trailing_comma.rb | 7 - lib/syntax_tree/rake.rb | 128 + lib/syntax_tree/rake/check_task.rb | 29 - lib/syntax_tree/rake/task.rb | 85 - lib/syntax_tree/rake/write_task.rb | 29 - lib/syntax_tree/rake_tasks.rb | 4 - syntax_tree.gemspec | 47 +- test/cli_test.rb | 75 +- test/encoded.rb | 2 - test/fixtures/alias.rb | 6 +- test/fixtures/altpattern.rb | 4 + test/fixtures/array_literal.rb | 2 + test/fixtures/assoc.rb | 2 - test/fixtures/bare_assoc_hash.rb | 2 +- test/fixtures/def.rb | 6 + test/fixtures/def_endless.rb | 4 +- test/fixtures/dyna_symbol.rb | 4 - test/fixtures/hash.rb | 2 +- test/fixtures/hshptn.rb | 22 +- test/fixtures/if.rb | 2 - test/fixtures/label.rb | 4 + test/fixtures/lambda.rb | 8 - test/fixtures/mlhs_paren.rb | 4 - test/fixtures/not.rb | 4 +- test/fixtures/string_concat.rb | 2 + test/fixtures/string_embexpr.rb | 8 + test/fixtures/undef.rb | 2 + test/fixtures_test.rb | 64 + test/formatting_test.rb | 64 - test/idempotency_test.rb | 4 +- test/language_server_test.rb | 261 - test/location_test.rb | 28 - test/lsp_test.rb | 214 + test/node_test.rb | 1450 -- test/parser_test.rb | 126 - test/plugin/disable_auto_ternary_test.rb | 32 - test/quotes_test.rb | 15 - test/ractor_test.rb | 48 +- test/rake_test.rb | 67 +- test/{plugin => }/single_quotes_test.rb | 19 +- test/syntax_tree_test.rb | 55 +- test/test_helper.rb | 108 - test/{plugin => }/trailing_comma_test.rb | 10 +- 63 files changed, 5828 insertions(+), 20345 deletions(-) delete mode 100644 .gitattributes delete mode 100755 bin/bench create mode 100644 lib/syntax_tree/format.rb delete mode 100644 lib/syntax_tree/formatter.rb delete mode 100644 lib/syntax_tree/language_server.rb create mode 100644 lib/syntax_tree/lsp.rb delete mode 100644 lib/syntax_tree/node.rb delete mode 100644 lib/syntax_tree/parser.rb delete mode 100644 lib/syntax_tree/plugin/disable_auto_ternary.rb delete mode 100644 lib/syntax_tree/plugin/single_quotes.rb delete mode 100644 lib/syntax_tree/plugin/trailing_comma.rb create mode 100644 lib/syntax_tree/rake.rb delete mode 100644 lib/syntax_tree/rake/check_task.rb delete mode 100644 lib/syntax_tree/rake/task.rb delete mode 100644 lib/syntax_tree/rake/write_task.rb delete mode 100644 lib/syntax_tree/rake_tasks.rb delete mode 100644 test/encoded.rb create mode 100644 test/fixtures/altpattern.rb create mode 100644 test/fixtures_test.rb delete mode 100644 test/formatting_test.rb delete mode 100644 test/language_server_test.rb delete mode 100644 test/location_test.rb create mode 100644 test/lsp_test.rb delete mode 100644 test/node_test.rb delete mode 100644 test/parser_test.rb delete mode 100644 test/plugin/disable_auto_ternary_test.rb delete mode 100644 test/quotes_test.rb rename test/{plugin => }/single_quotes_test.rb (60%) rename test/{plugin => }/trailing_comma_test.rb (84%) diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index b24bb2da..00000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -bin/* linguist-language=Ruby diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 468591bd..77b7f6b2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,19 +9,20 @@ jobs: strategy: fail-fast: false matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest ruby: - - '2.7.0' - - '3.0' - - '3.1' - '3.2' - '3.3' - '3.4' - truffleruby-head + exclude: + - os: windows-latest + ruby: truffleruby-head name: CI - runs-on: ubuntu-latest - env: - CI: true - # TESTOPTS: --verbose + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@master - uses: ruby/setup-ruby@v1 @@ -34,8 +35,6 @@ jobs: check: name: Check runs-on: ubuntu-latest - env: - CI: true steps: - uses: actions/checkout@master - uses: ruby/setup-ruby@v1 diff --git a/.rubocop.yml b/.rubocop.yml index 1b81a535..51159813 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -5,7 +5,7 @@ AllCops: DisplayStyleGuide: true NewCops: enable SuggestExtensions: false - TargetRubyVersion: 2.7 + TargetRubyVersion: 3.2 Exclude: - '{.git,.github,.ruby-lsp,bin,coverage,doc,pkg,sorbet,spec,test/fixtures,vendor,tmp}/**/*' - test.rb @@ -14,7 +14,7 @@ Gemspec/DevelopmentDependencies: Enabled: false Layout/LineLength: - Max: 80 + Max: 100 Lint/AmbiguousBlockAssociation: Enabled: false @@ -70,6 +70,9 @@ Naming/MethodName: Naming/MethodParameterName: Enabled: false +Naming/PredicateMethod: + Enabled: false + Naming/RescuedExceptionsVariableName: PreferredName: error @@ -79,12 +82,18 @@ Naming/VariableNumber: Security/Eval: Enabled: false +Style/AccessModifierDeclarations: + Enabled: false + Style/AccessorGrouping: Enabled: false Style/Alias: Enabled: false +Style/BlockDelimiters: + Enabled: false + Style/CaseEquality: Enabled: false @@ -151,6 +160,9 @@ Style/Next: Style/NumericPredicate: Enabled: false +Style/OptionalBooleanParameter: + Enabled: false + Style/ParallelAssignment: Enabled: false diff --git a/Gemfile b/Gemfile index b4252fb5..8a08c3e6 100644 --- a/Gemfile +++ b/Gemfile @@ -4,4 +4,8 @@ source "https://rubygems.org" gemspec -gem "fiddle" +gem "bundler" +gem "minitest" +gem "rake" +gem "rubocop" +gem "simplecov" diff --git a/Gemfile.lock b/Gemfile.lock index 7be345b8..17b50c13 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,23 +2,21 @@ PATH remote: . specs: syntax_tree (6.3.0) - prettier_print (>= 1.2.0) + prism GEM remote: https://rubygems.org/ specs: ast (2.4.3) docile (1.4.1) - fiddle (1.1.8) - json (2.15.1) + json (2.15.2) language_server-protocol (3.17.0.5) lint_roller (1.1.0) minitest (5.26.0) parallel (1.27.0) - parser (3.3.9.0) + parser (3.3.10.0) ast (~> 2.4.1) racc - prettier_print (1.2.1) prism (1.6.0) racc (1.8.1) rainbow (3.1.1) @@ -43,22 +41,19 @@ GEM docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) - simplecov-html (0.13.1) + simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.1.0) PLATFORMS - arm64-darwin-21 - ruby - x86_64-darwin-19 - x86_64-darwin-21 + arm64-darwin-23 + x64-mingw-ucrt x86_64-linux DEPENDENCIES bundler - fiddle minitest rake rubocop @@ -66,4 +61,4 @@ DEPENDENCIES syntax_tree! BUNDLED WITH - 2.3.6 + 2.4.19 diff --git a/README.md b/README.md index b5984a63..f6434051 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,12 @@ Syntax Tree -# SyntaxTree +# Syntax Tree [![Build Status](https://github.com/ruby-syntax-tree/syntax_tree/actions/workflows/main.yml/badge.svg)](https://github.com/ruby-syntax-tree/syntax_tree/actions/workflows/main.yml) [![Gem Version](https://img.shields.io/gem/v/syntax_tree.svg)](https://rubygems.org/gems/syntax_tree) -Syntax Tree is a suite of tools built on top of the internal CRuby parser. It provides the ability to generate a syntax tree from source, as well as the tools necessary to inspect and manipulate that syntax tree. It can be used to build formatters, linters, language servers, and more. - -It is built with only standard library dependencies. It additionally ships with a plugin system so that you can build your own syntax trees from other languages and incorporate these tools. +Syntax Tree is fast Ruby parser built on top of the [prism](https://github.com/ruby/prism) Ruby parser. It is built with only standard library dependencies. - [Installation](#installation) - [CLI](#cli) @@ -19,11 +17,9 @@ It is built with only standard library dependencies. It additionally ships with - [Configuration](#configuration) - [Globbing](#globbing) - [Language server](#language-server) - - [textDocument/formatting](#textdocumentformatting) - [Customization](#customization) - [Ignoring code](#ignoring-code) - [Plugins](#plugins) - - [Languages](#languages) - [Integration](#integration) - [Rake](#rake) - [RuboCop](#rubocop) @@ -144,16 +140,28 @@ stree write --ignore-files='db/**/*.rb' '**/*.rb' ### Configuration -Any of the above CLI commands can also read configuration options from a `.streerc` file in the directory where the commands are executed. +All of the above commands accept additional configuration options. Those are: + +- `--print-width=?` - The print width is the suggested line length that should be used when formatting the source. Note that this is not a hard limit like a linter. Instead, it is used as a guideline for how long lines _should_ be. For example, if you have the following code: + +```ruby +foo do + bar +end +``` + +In this case, the formatter will see that the block fits into the print width and will rewrite it using the `{}` syntax. This will actually make the line longer than originally written. This is why it is helpful to think of it as a suggestion, rather than a limit. +- `--preferred-quote=?` - The quote to use for string and character literals. This can be either `"` or `'`. It is "preferred" because in the case that the formatter encounters a string that contains interpolation or certain escape sequences, it will not attempt to change the quote style to avoid accidentally changing the semantic meaning of the code. +- `--[no-]trailing-comma` - Whether or not to add trailing commas to multiline array literals, hash literals, and method calls that can support trailing commas. -This should be a text file with each argument on a separate line. +Any of the above CLI commands can also read configuration options from a `.streerc` file in the directory where the commands are executed. This should be a text file with each argument on a separate line. ```txt --print-width=100 ---plugins=plugin/trailing_comma +--trailing-comma ``` -If this file is present, it will _always_ be used for CLI commands. You can also pass options from the command line as in the examples above. The options in the `.streerc` file are passed to the CLI first, then the arguments from the command line. In the case of exclusive options (e.g. `--print-width`), this means that the command line options override what's in the config file. In the case of options that can take multiple inputs (e.g. `--plugins`), the effect is additive. That is, the plugins passed from the command line will be loaded _in addition to_ the plugins in the config file. +If this file is present, it will _always_ be used for CLI commands. The options in the `.streerc` file are passed to the CLI first, then the arguments from the command line. In the case of exclusive options (e.g. `--print-width`), this means that the command line options override what's in the config file. In the case of options that can take multiple inputs (e.g. `--plugins`), the effect is additive. That is, the plugins passed from the command line will be loaded _in addition to_ the plugins in the config file. ### Globbing @@ -220,32 +228,8 @@ To register plugins, define a file somewhere in your load path named `syntax_tre * `plugin/single_quotes` - This will change all of your string literals to use single quotes instead of the default double quotes. * `plugin/trailing_comma` - This will put trailing commas into multiline array literals, hash literals, and method calls that can support trailing commas. -* `plugin/disable_auto_ternary` - This will prevent the automatic conversion of `if ... else` to ternary expressions. - -If you're using Syntax Tree as a library, you can require those files directly or manually pass those options to the formatter initializer through the `SyntaxTree::Formatter::Options` class. - -### Languages - -To register a new language, call: - -```ruby -SyntaxTree.register_handler(".mylang", MyLanguage) -``` - -In this case, whenever the CLI encounters a filepath that ends with the given extension, it will invoke methods on `MyLanguage` instead of `SyntaxTree` itself. To make sure your object conforms to each of the necessary APIs, it should implement: - -* `MyLanguage.read(filepath)` - usually this is just an alias to `File.read(filepath)`, but if you need anything else that hook is here. -* `MyLanguage.parse(source)` - this should return the syntax tree corresponding to the given source. Those objects should implement the `pretty_print` interface. -* `MyLanguage.format(source)` - this should return the formatted version of the given source. -Below are listed all of the "official" language plugins hosted under the same GitHub organization, which can be used as references for how to implement other plugins. - -* [bf](https://github.com/ruby-syntax-tree/syntax_tree-bf) for the [brainf*** language](https://esolangs.org/wiki/Brainfuck). -* [css](https://github.com/ruby-syntax-tree/syntax_tree-css) for the [CSS stylesheet language](https://www.w3.org/Style/CSS/). -* [haml](https://github.com/ruby-syntax-tree/syntax_tree-haml) for the [Haml template language](https://haml.info/). -* [json](https://github.com/ruby-syntax-tree/syntax_tree-json) for the [JSON notation language](https://www.json.org/). -* [rbs](https://github.com/ruby-syntax-tree/syntax_tree-rbs) for the [RBS type language](https://github.com/ruby/rbs). -* [xml](https://github.com/ruby-syntax-tree/syntax_tree-xml) for the [XML markup language](https://www.w3.org/XML/). +If you're using Syntax Tree as a library, you can require those files directly or manually pass those options to the formatter initializer through the `SyntaxTree::Options` class. ## Integration @@ -256,12 +240,12 @@ Syntax Tree's goal is to seamlessly integrate into your workflow. To this end, i Syntax Tree ships with the ability to define [rake](https://github.com/ruby/rake) tasks that will trigger runs of the CLI. To define them in your application, add the following configuration to your `Rakefile`: ```ruby -require "syntax_tree/rake_tasks" +require "syntax_tree/rake" SyntaxTree::Rake::CheckTask.new SyntaxTree::Rake::WriteTask.new ``` -These calls will define `rake stree:check` and `rake stree:write` (equivalent to calling `stree check` and `stree write` with the CLI respectively). You can configure them by either passing arguments to the `new` method or by using a block. +These calls will define `rake stree:check` and `rake stree:write` (equivalent to calling `stree check` and `stree write` with the CLI respectively). You can configure them by either passing arguments to the `new` method or by using a block. In addition to the regular configuration options used for the formatter, there are a few additional options specific to the rake tasks. #### `name` @@ -292,26 +276,6 @@ SyntaxTree::Rake::WriteTask.new do |t| end ``` -#### `print_width` - -If you want to use a different print width from the default (80), you can pass that to the `print_width` field, as in: - -```ruby -SyntaxTree::Rake::WriteTask.new do |t| - t.print_width = 100 -end -``` - -#### `plugins` - -If you're running Syntax Tree with plugins (either your own or the pre-built ones), you can pass that to the `plugins` field, as in: - -```ruby -SyntaxTree::Rake::WriteTask.new do |t| - t.plugins = ["plugin/single_quotes"] -end -``` - ### RuboCop RuboCop and Syntax Tree serve different purposes, but there is overlap with some of RuboCop's functionality. Syntax Tree provides a RuboCop configuration file to disable rules that are redundant with Syntax Tree. To use this configuration file, add the following snippet to the top of your project's `.rubocop.yml`: diff --git a/Rakefile b/Rakefile index 69bb0f1c..f12d6446 100644 --- a/Rakefile +++ b/Rakefile @@ -2,7 +2,7 @@ require "bundler/gem_tasks" require "rake/testtask" -require "syntax_tree/rake_tasks" +require "syntax_tree/rake" Rake::TestTask.new(:test) do |t| t.libs << "test" @@ -14,23 +14,7 @@ task default: :test configure = ->(task) do task.source_files = - FileList[ - %w[ - Gemfile - Rakefile - syntax_tree.gemspec - lib/**/*.rb - tasks/*.rake - test/*.rb - ] - ] - - # Since Syntax Tree supports back to Ruby 2.7.0, we need to make sure that we - # format our code such that it's compatible with that version. This actually - # has very little effect on the output, the only change at the moment is that - # Ruby < 2.7.3 didn't allow a newline before the closing brace of a hash - # pattern. - task.target_ruby_version = Gem::Version.new("2.7.0") + FileList[%w[Gemfile Rakefile syntax_tree.gemspec lib/**/*.rb tasks/*.rake test/*.rb]] end SyntaxTree::Rake::CheckTask.new(&configure) diff --git a/bin/bench b/bin/bench deleted file mode 100755 index 46f47184..00000000 --- a/bin/bench +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -require "bundler/inline" - -gemfile do - source "https://rubygems.org" - gem "benchmark-ips" - gem "parser", require: "parser/current" - gem "ruby_parser" -end - -$:.unshift(File.expand_path("../lib", __dir__)) -require "syntax_tree" - -def compare(filepath) - prefix = "#{File.expand_path("..", __dir__)}/" - puts "=== #{filepath.delete_prefix(prefix)} ===" - - source = File.read(filepath) - - Benchmark.ips do |x| - x.report("syntax_tree") { SyntaxTree.parse(source) } - x.report("parser") { Parser::CurrentRuby.parse(source) } - x.report("ruby_parser") { RubyParser.new.parse(source) } - x.compare! - end -end - -filepaths = ARGV - -# If the user didn't supply any files to parse to benchmark, then we're going to -# default to parsing this file and the main syntax_tree file (a small and large -# file). -if filepaths.empty? - filepaths = [ - File.expand_path("bench", __dir__), - File.expand_path("../lib/syntax_tree/node.rb", __dir__) - ] -end - -filepaths.each { |filepath| compare(filepath) } diff --git a/bin/profile b/bin/profile index 15bd28ae..34cba40f 100755 --- a/bin/profile +++ b/bin/profile @@ -6,7 +6,7 @@ require "bundler/inline" gemfile do source "https://rubygems.org" gem "stackprof" - gem "prettier_print" + gem "prism" end $:.unshift(File.expand_path("../lib", __dir__)) @@ -14,7 +14,7 @@ require "syntax_tree" StackProf.run(mode: :cpu, out: "tmp/profile.dump", raw: true) do Dir[File.join(RbConfig::CONFIG["libdir"], "**/*.rb")].each do |filepath| - SyntaxTree.format(SyntaxTree.read(filepath)) + SyntaxTree.format_file(filepath) end end diff --git a/exe/stree b/exe/stree index 3bae88e9..71fbe6ac 100755 --- a/exe/stree +++ b/exe/stree @@ -4,6 +4,5 @@ $:.unshift(File.expand_path("../lib", __dir__)) require "syntax_tree" -require "syntax_tree/cli" exit(SyntaxTree::CLI.run(ARGV)) diff --git a/lib/syntax_tree.rb b/lib/syntax_tree.rb index c5458c90..e5b3e3b1 100644 --- a/lib/syntax_tree.rb +++ b/lib/syntax_tree.rb @@ -1,104 +1,141 @@ # frozen_string_literal: true -require "prettier_print" -require "ripper" - -require_relative "syntax_tree/formatter" -require_relative "syntax_tree/node" -require_relative "syntax_tree/parser" -require_relative "syntax_tree/version" - -# Syntax Tree is a suite of tools built on top of the internal CRuby parser. It -# provides the ability to generate a syntax tree from source, as well as the -# tools necessary to inspect and manipulate that syntax tree. It can be used to -# build formatters, linters, language servers, and more. +require "thread" +require "syntax_tree/format" + +# Syntax Tree is a formatter built on top of the internal CRuby parser. module SyntaxTree - autoload :LanguageServer, "syntax_tree/language_server" - - # This holds references to objects that respond to both #parse and #format - # so that we can use them in the CLI. - HANDLERS = {} - HANDLERS.default = SyntaxTree - - # This is the default print width when formatting. It can be overridden in the - # CLI by passing the --print-width option or here in the API by passing the - # optional second argument to ::format. - DEFAULT_PRINT_WIDTH = 80 - - # This is the default ruby version that we're going to target for formatting. - # It shouldn't really be changed except in very niche circumstances. - DEFAULT_RUBY_VERSION = Formatter::SemanticVersion.new(RUBY_VERSION).freeze - - # The default indentation level for formatting. We allow changing this so - # that Syntax Tree can format arbitrary parts of a document. - DEFAULT_INDENTATION = 0 - - # Parses the given source and returns the formatted source. - def self.format( - source, - maxwidth = DEFAULT_PRINT_WIDTH, - base_indentation = DEFAULT_INDENTATION, - options: Formatter::Options.new - ) - format_node( - source, - parse(source), - maxwidth, - base_indentation, - options: options - ) + autoload :CLI, "syntax_tree/cli" + autoload :LSP, "syntax_tree/lsp" + autoload :Rake, "syntax_tree/rake" + autoload :Version, "syntax_tree/version" + + # Raised when an error is encountered while parsing the source to be + # formatted through the #format or #format_file methods. + class ParseError < StandardError end - # Parses the given file and returns the formatted source. - def self.format_file( - filepath, - maxwidth = DEFAULT_PRINT_WIDTH, - base_indentation = DEFAULT_INDENTATION, - options: Formatter::Options.new - ) - format(read(filepath), maxwidth, base_indentation, options: options) + # We want to minimize as much as possible the number of options that are + # available in the formatter. For the most part, if users want non-default + # formatting, they should override the visit methods below. However, because + # of some history with prettier and the fact that folks have become entrenched + # in their ways, we decided to provide a small amount of configurability. + class Options + # The print width is the suggested line length that should be used when + # formatting the source. Note that this is not a hard limit like a linter. + # Instead, it is used as a guideline for how long lines _should_ be. For + # example, if you have the following code: + # + # foo do + # bar + # end + # + # In this case, the formatter will see that the block fits into the print + # width and will rewrite it using the `{}` syntax. This will actually make + # the line longer than originally written. This is why it is helpful to + # think of it as a suggestion, rather than a limit. + attr_accessor :print_width + + # The quote style to use when formatting string literals. This can be + # either a single quote (`'`) or a double quote (`"`). This is a + # preference, but not a hard rule. If a string contains interpolation, + # the formatter will leave it as it is in the source to avoid changing the + # meaning of the code. + attr_accessor :preferred_quote + + # Trailing commas can be used in multi-line collection literals and when + # specifying arguments to a method call, in most cases (there are a few + # rare exceptions). This option controls whether or not they should be + # used. + attr_accessor :trailing_comma + + def initialize(print_width: 100, preferred_quote: '"', trailing_comma: false) + @print_width = print_width + @preferred_quote = preferred_quote + @trailing_comma = trailing_comma + end end - # Accepts a node in the tree and returns the formatted source. - def self.format_node( - source, - node, - maxwidth = DEFAULT_PRINT_WIDTH, - base_indentation = DEFAULT_INDENTATION, - options: Formatter::Options.new - ) - formatter = Formatter.new(source, [], maxwidth, options: options) - node.format(formatter) - - formatter.flush(base_indentation) - formatter.output.join + # Mutex to synchronize modifications to the module configuration. + @lock = Mutex.new + + # The default formatting options used by the formatter when an options object + # is not explicitly provided. + @options = Options.new.freeze + + # Configure the default formatting options that will be used when options are + # not explicitly provided. + def self.configure + @lock.synchronize do + options = @options.dup + yield options + @options = options.freeze + end end - # Parses the given source and returns the syntax tree. - def self.parse(source) - parser = Parser.new(source) - response = parser.parse - response unless parser.error? + # Create a new set of options that falls back to the default options for any + # unspecified values. + def self.options(print_width: :default, preferred_quote: :default, trailing_comma: :default) + options = @lock.synchronize { @options.dup } + options.print_width = print_width unless print_width == :default + options.preferred_quote = preferred_quote unless preferred_quote == :default + options.trailing_comma = trailing_comma unless trailing_comma == :default + options.freeze end - # Returns the source from the given filepath taking into account any potential - # magic encoding comments. - def self.read(filepath) - encoding = - File.open(filepath, "r") do |file| - break Encoding.default_external if file.eof? - - header = file.readline - header += file.readline if !file.eof? && header.start_with?("#!") - Ripper.new(header).tap(&:parse).encoding - end + # Options should not be directly used by consumers. Instead they should create + # new options object through the SyntaxTree.options method. + private_constant :Options - File.read(filepath, encoding: encoding) - end + # It is possible to extend the formatter to support other languages by + # registering extension. An extension is any object that responds to both + # the #format and #format_file methods. + @handlers = Hash.new(SyntaxTree) # This is a hook provided so that plugins can register themselves as the # handler for a particular file type. def self.register_handler(extension, handler) - HANDLERS[extension] = handler + @lock.synchronize { @handlers[extension] = handler } + end + + # Unregisters the handler for the given file extension. + def self.unregister_handler(extension) + @lock.synchronize { @handlers.delete(extension) } + end + + # Retrieves the handler registered for the given file extension. + def self.handler_for(extension) + @lock.synchronize { @handlers[extension] } + end + + class << self + # Parses the given source and returns the formatted source. + def format(source, options = @options) + process(Prism.parse(source), options) + end + + # Parses the given file and returns the formatted source. + def format_file(filepath, options = @options) + process(Prism.parse_file(filepath), options) + end + + private + + # Processes the result of parsing the source and returns the formatted + # source. + def process(result, options) + raise ParseError, result.errors_format if result.failure? + result.attach_comments! + + formatter = Prism::Format.new(result.source.source, options) + result.value.accept(formatter) + + if (data_loc = result.data_loc) + formatted = formatter.format + formatted.empty? ? data_loc.slice : "#{formatted}\n\n#{data_loc.slice}" + else + "#{formatter.format}\n" + end + end end end diff --git a/lib/syntax_tree/cli.rb b/lib/syntax_tree/cli.rb index 6437fd3b..a7f2b3e0 100644 --- a/lib/syntax_tree/cli.rb +++ b/lib/syntax_tree/cli.rb @@ -28,10 +28,6 @@ def self.gray(value) new(value, "38;5;102") end - def self.red(value) - new(value, "1;31") - end - def self.yellow(value) new(value, "33") end @@ -39,18 +35,18 @@ def self.yellow(value) # An item of work that corresponds to a file to be processed. class FileItem - attr_reader :filepath - def initialize(filepath) @filepath = filepath end def handler - HANDLERS[File.extname(filepath)] + SyntaxTree.handler_for(File.extname(filepath)) end + attr_reader :filepath + def source - handler.read(filepath) + File.read(filepath) end def writable? @@ -61,21 +57,21 @@ def writable? # An item of work that corresponds to a script content passed via the # command line. class ScriptItem - attr_reader :source - def initialize(source, extension) @source = source @extension = extension end def handler - HANDLERS[@extension] + SyntaxTree.handler_for(@extension) end def filepath :script end + attr_reader :source + def writable? false end @@ -88,7 +84,7 @@ def initialize(extension) end def handler - HANDLERS[@extension] + SyntaxTree.handler_for(@extension) end def filepath @@ -122,13 +118,6 @@ def failure end end - # An action of the CLI that prints out the AST for the given source. - class AST < Action - def run(item) - pp item.handler.parse(item.source) - end - end - # An action of the CLI that ensures that the filepath is formatted as # expected. class Check < Action @@ -137,13 +126,7 @@ class UnformattedError < StandardError def run(item) source = item.source - formatted = - item.handler.format( - source, - options.print_width, - options: options.formatter_options - ) - + formatted = item.handler.format(source, options) raise UnformattedError if source != formatted rescue StandardError warn("[#{Color.yellow("warn")}] #{item.filepath}") @@ -167,25 +150,11 @@ class NonIdempotentFormatError < StandardError def run(item) handler = item.handler - warning = "[#{Color.yellow("warn")}] #{item.filepath}" - - formatted = - handler.format( - item.source, - options.print_width, - options: options.formatter_options - ) - - double_formatted = - handler.format( - formatted, - options.print_width, - options: options.formatter_options - ) - + formatted = handler.format(item.source, options) + double_formatted = handler.format(formatted, options) raise NonIdempotentFormatError if formatted != double_formatted rescue StandardError - warn(warning) + warn("[#{Color.yellow("warn")}] #{item.filepath}") raise end @@ -198,30 +167,10 @@ def failure end end - # An action of the CLI that prints out the doc tree IR for the given source. - class Doc < Action - def run(item) - source = item.source - - formatter_options = options.formatter_options - formatter = Formatter.new(source, [], options: formatter_options) - - item.handler.parse(source).format(formatter) - pp formatter.groups.first - end - end - # An action of the CLI that formats the input source and prints it out. class Format < Action def run(item) - formatted = - item.handler.format( - item.source, - options.print_width, - options: options.formatter_options - ) - - puts formatted + puts item.handler.format(item.source, options) end end @@ -233,12 +182,7 @@ def run(item) start = Time.now source = item.source - formatted = - item.handler.format( - source, - options.print_width, - options: options.formatter_options - ) + formatted = item.handler.format(source, options) changed = source != formatted File.write(filepath, formatted) if item.writable? && changed @@ -256,33 +200,32 @@ def run(item) # The help message displayed if the input arguments are not correctly # ordered or formatted. HELP = <<~HELP - #{Color.bold("stree ast [--plugins=...] [--print-width=NUMBER] [-e SCRIPT] FILE")} - Print out the AST corresponding to the given files - - #{Color.bold("stree check [--plugins=...] [--print-width=NUMBER] [-e SCRIPT] FILE")} + #{Color.bold("stree check OPTIONS? SOURCE")} Check that the given files are formatted as syntax tree would format them - #{Color.bold("stree debug [--plugins=...] [--print-width=NUMBER] [-e SCRIPT] FILE")} + #{Color.bold("stree debug OPTIONS? SOURCE")} Check that the given files can be formatted idempotently - #{Color.bold("stree doc [--plugins=...] [-e SCRIPT] FILE")} - Print out the doc tree that would be used to format the given files - - #{Color.bold("stree format [--plugins=...] [--print-width=NUMBER] [-e SCRIPT] FILE")} + #{Color.bold("stree format OPTIONS? SOURCE")} Print out the formatted version of the given files #{Color.bold("stree help")} Display this help message - #{Color.bold("stree lsp [--plugins=...] [--print-width=NUMBER]")} + #{Color.bold("stree lsp OPTIONS?")} Run syntax tree in language server mode #{Color.bold("stree version")} Output the current version of syntax tree - #{Color.bold("stree write [--plugins=...] [--print-width=NUMBER] [-e SCRIPT] FILE")} + #{Color.bold("stree write OPTIONS? SOURCE")} Read, format, and write back the source of the given files + OPTIONS: + + --config=... + Path to a configuration file. Defaults to ./.streerc. + --ignore-files=... A glob pattern to ignore files when processing. This can be specified multiple times to ignore multiple patterns. @@ -290,50 +233,58 @@ def run(item) --plugins=... A comma-separated list of plugins to load. + --extension=... + A file extension matching the content passed in via STDIN or -e. + Defaults to '.rb'. + --print-width=... - The maximum line width to use when formatting. + The print width to use when formatting. + + --preferred-quote=... + The preferred quote style to use when formatting. Valid styles are + single, double, ', and ". + + --[no-]trailing-comma + Whether or not to add trailing commas to multi-line collections and + method calls. + + SOURCE: -e ... Parse an inline string. - --extension=... - A file extension matching the content passed in via STDIN or -e. - Defaults to '.rb'. - - --config=... - Path to a configuration file. Defaults to .streerc in the current - working directory. + path + One or more file paths or glob patterns to process. HELP # This represents all of the options that can be passed to the CLI. It is # responsible for parsing the list and then returning the file paths at the # end. class Options - attr_reader :ignore_files, - :plugins, - :print_width, - :scripts, - :extension, - :target_ruby_version - - def initialize + attr_reader :ignore_files, :plugins, :scripts, :extension + attr_reader :print_width, :preferred_quote, :trailing_comma + + def initialize(arguments) @ignore_files = [] @plugins = [] - @print_width = DEFAULT_PRINT_WIDTH @scripts = [] @extension = ".rb" - @target_ruby_version = DEFAULT_RUBY_VERSION - end - def formatter_options - @formatter_options ||= - Formatter::Options.new(target_ruby_version: target_ruby_version) - end + @print_width = :default + @preferred_quote = :default + @trailing_comma = :default - def parse(arguments) parser.parse!(arguments) end + def options + SyntaxTree.options( + print_width: print_width, + preferred_quote: preferred_quote, + trailing_comma: trailing_comma + ) + end + private def parser @@ -360,12 +311,6 @@ def parser @plugins.each { |plugin| require "syntax_tree/#{plugin}" } end - # If there is a print width specified on the command line, then - # parse that out here and use it when formatting. - opts.on("--print-width=NUMBER", Integer) do |print_width| - @print_width = print_width - end - # If there is a script specified on the command line, then parse # it and add it to the list of scripts to run. opts.on("-e SCRIPT") { |script| @scripts << script } @@ -377,11 +322,27 @@ def parser @extension = ".#{extension.delete_prefix(".")}" end - # If there is a target ruby version specified on the command line, - # parse that out and use it when formatting. - opts.on("--target-ruby-version=VERSION") do |version| - @target_ruby_version = Formatter::SemanticVersion.new(version) + # If there is a print width specified on the command line, then + # parse that out here and use it when formatting. + opts.on("--print-width=NUMBER", Integer) { |print_width| @print_width = print_width } + + # If there is a preferred quote style specified on the command line, + # then parse that out here and use it when formatting. + opts.on("--preferred-quote=STYLE") do |preferred_quote| + @preferred_quote = + case preferred_quote + when "single", "'" + "'" + when "double", '"' + '"' + else + raise ArgumentError, "Invalid preferred quote style: #{preferred_quote}" + end end + + # If there is a trailing comma style specified on the command line, + # then parse that out here and use it when formatting. + opts.on("--[no-]trailing-comma") { |trailing_comma| @trailing_comma = trailing_comma } end end end @@ -390,8 +351,8 @@ def parser # arguments to the CLI. Each line of the config file should be a new # argument, as in: # - # --plugins=plugin/single_quote # --print-width=100 + # --trailing-comma # # When invoking the CLI, we will read this config file and then parse it if # it exists in the current working directory. @@ -446,42 +407,33 @@ def run(argv) config_file = ConfigFile.new(config_filepath) arguments = config_file.arguments.concat(arguments) - options = Options.new - options.parse(arguments) - + options = Options.new(arguments) action = case name - when "a", "ast" - AST.new(options) when "c", "check" - Check.new(options) + Check.new(options.options) when "debug" - Debug.new(options) - when "doc" - Doc.new(options) + Debug.new(options.options) when "f", "format" - Format.new(options) + Format.new(options.options) when "help" puts HELP return 0 when "lsp" - LanguageServer.new( - print_width: options.print_width, - ignore_files: options.ignore_files - ).run + LSP.new(options: options.options, ignore_files: options.ignore_files).run return 0 when "version" - puts SyntaxTree::VERSION + puts VERSION return 0 when "w", "write" - Write.new(options) + Write.new(options.options) else warn(HELP) return 1 end # We're going to build up a queue of items to process. - queue = Queue.new + queue = [] # If there are any arguments or scripts, then we'll add those to the # queue. Otherwise we'll read the content off STDIN. @@ -501,16 +453,14 @@ def run(argv) end end - options.scripts.each do |script| - queue << ScriptItem.new(script, options.extension) - end + options.scripts.each { |script| queue << ScriptItem.new(script, options.extension) } else queue << STDINItem.new(options.extension) end - # At the end, we're going to return whether or not this worker ever + # At the end, we're going to return whether or not this CLI ever # encountered an error. - if process_queue(queue, action) + if process_queue(action, queue) action.failure 1 else @@ -521,93 +471,123 @@ def run(argv) private - # Processes each item in the queue with the given action. Returns whether - # or not any errors were encountered. - def process_queue(queue, action) - workers = - [Etc.nprocessors, queue.size].min.times.map do - Thread.new do - # Propagate errors in the worker threads up to the parent thread. - Thread.current.abort_on_exception = true - - # Track whether or not there are any errors from any of the files - # that we take action on so that we can properly clean up and - # exit. - errored = false - - # While there is still work left to do, shift off the queue and - # process the item. - until queue.empty? - item = queue.shift - errored |= - begin - action.run(item) - false - rescue Parser::ParseError => error - warn("Error: #{error.message}") - highlight_error(error, item.source) - true - rescue Check::UnformattedError, - Debug::NonIdempotentFormatError - true - rescue StandardError => error - warn(error.message) - warn(error.backtrace) - true - end + def process_item(item, action) + action.run(item) + false + rescue ParseError => error + warn("syntax error:\n#{error.message}") + true + rescue Check::UnformattedError, Debug::NonIdempotentFormatError + true + rescue StandardError => error + warn(error.message) + warn(error.backtrace) + true + end + + if Process.respond_to?(:fork) + def process_queue(action, queue) + queue.freeze + nworkers = [Etc.nprocessors - 1, queue.size].min + + requests = Array.new(nworkers) { IO.pipe } + responses = Array.new(nworkers) { IO.pipe } + + pids = + nworkers.times.map do |nworker| + # In each child process, we will continuously write to the request + # pipe when we are available for work. The parent process will + # then write back on the response pipe either an index to work on + # or a -1 to indicate that there is no more work to be done. At + # the end of the loop, we will write the status of our work back + # to the parent process to indicate if there were any errors. + fork do + requests.each_with_index do |(reader, writer), npipe| + reader.close + writer.close if npipe != nworker + end + + responses.each_with_index do |(reader, writer), npipe| + writer.close + reader.close if npipe != nworker + end + + request_writer = requests[nworker][1] + response_reader = responses[nworker][0] + errored = false + + loop do + request_writer.puts(0) + break if (response = Integer(response_reader.gets)) == -1 + errored |= process_item(queue[response], action) + end + + request_writer.puts(errored ? -1 : 1) + request_writer.close + response_reader.close end - - # At the end, we're going to return whether or not this worker - # ever encountered an error. - errored end - end - - workers.map(&:value).inject(:|) - end - - # Highlights a snippet from a source and parse error. - def highlight_error(error, source) - lines = source.lines - maximum = [error.lineno + 3, lines.length].min - digits = Math.log10(maximum).ceil + requests.each { |(_, writer)| writer.close } + responses.each { |(reader, _)| reader.close } + + indices = queue.each_index + errored = false + + request_readers = requests.map(&:first) + response_writers = + requests.zip(responses).to_h { |(reader, _), (_, writer)| [reader, writer] } + + # The parent process will continuously listen for requests from the + # child processes and respond accordingly. When the child processes + # write that they are ready for work, we will send them the next index + # to work on. When they write that they are done, we will remove them + # from the list of active workers. + until request_readers.empty? + IO + .select(request_readers)[0] + .each do |request_reader| + case Integer(request_reader.gets) + when 0 + response_writer = response_writers[request_reader] - ([error.lineno - 3, 0].max...maximum).each do |line_index| - line_number = line_index + 1 - - if line_number == error.lineno - part1 = Color.red(">") - part2 = Color.gray("%#{digits}d |" % line_number) - warn("#{part1} #{part2} #{colorize_line(lines[line_index])}") - - part3 = Color.gray(" %#{digits}s |" % " ") - warn("#{part3} #{" " * error.column}#{Color.red("^")}") - else - prefix = Color.gray(" %#{digits}d |" % line_number) - warn("#{prefix} #{colorize_line(lines[line_index])}") + begin + response_writer.puts(indices.next) + rescue StopIteration + response_writer.puts(-1) + end + when -1 + errored = true + request_readers.delete(request_reader) + when 1 + request_readers.delete(request_reader) + end + end end - end - end - - # Take a line of Ruby source and colorize the output. - def colorize_line(line) - require "irb" - IRB::Color.colorize_code(line, **colorize_options) - end - # These are the options we're going to pass into IRB::Color.colorize_code. - # Since we support multiple versions of IRB, we're going to need to do - # some reflection to make sure we always pass valid options. - def colorize_options - options = { complete: false } - - parameters = IRB::Color.method(:colorize_code).parameters - if parameters.any? { |(_type, name)| name == :ignore_error } - options[:ignore_error] = true + pids.each { |pid| Process.waitpid(pid) } + errored end + else + def process_queue(action, queue) + queue = Queue.new(queue).tap(&:close) + workers = [Etc.nprocessors, queue.size].min + .times + .map do + Thread.new do + Thread.current.abort_on_exception = true + errored = false + + while (item = queue.shift) + errored |= process_item(item, action) + end + + errored + end + end - options + workers.map(&:value).any? + end end end end diff --git a/lib/syntax_tree/format.rb b/lib/syntax_tree/format.rb new file mode 100644 index 00000000..6a527946 --- /dev/null +++ b/lib/syntax_tree/format.rb @@ -0,0 +1,4750 @@ +# frozen_string_literal: true + +require "prism" + +module Prism + class Node + def heredoc_end_line + @heredoc_end_line ||= [end_line, *compact_child_nodes.map(&:heredoc_end_line)].max + end + end + + class StringNode < Node + def heredoc_end_line + @heredoc_end_line ||= (heredoc? ? closing_loc.start_line : end_line) + end + end + + class XStringNode < Node + def heredoc_end_line + @heredoc_end_line ||= (heredoc? ? closing_loc.start_line : end_line) + end + end + + class InterpolatedStringNode < Node + def heredoc_end_line + @heredoc_end_line ||= [ + heredoc? ? closing_loc.start_line : end_line, + *compact_child_nodes.map(&:heredoc_end_line) + ].max + end + end + + class InterpolatedXStringNode < Node + def heredoc_end_line + @heredoc_end_line ||= [ + heredoc? ? closing_loc.start_line : end_line, + *compact_child_nodes.map(&:heredoc_end_line) + ].max + end + end + + # Philip Wadler, A prettier printer, March 1998 + # https://homepages.inf.ed.ac.uk/wadler/papers/prettier/prettier.pdf + class Format + # A node in the print tree that represents aligning nested nodes to a + # certain prefix width or string. + class Align + attr_reader :indent, :contents + + def initialize(indent, contents) + @indent = indent + @contents = contents + end + + def type + :align + end + end + + # A node in the print tree that represents a place in the buffer that the + # content can be broken onto multiple lines. + class Breakable + attr_reader :separator, :width, :force, :indent + + def initialize(separator, width, force, indent) + @separator = separator + @width = width + @force = force + @indent = indent + end + + def type + :breakable + end + end + + # Below here are the most common combination of options that are created + # when creating new breakables. They are here to cut down on some + # allocations. + BREAKABLE_SPACE = Breakable.new(" ", 1, false, true).freeze + BREAKABLE_EMPTY = Breakable.new("", 0, false, true).freeze + BREAKABLE_FORCE = Breakable.new(" ", 1, true, true).freeze + BREAKABLE_RETURN = Breakable.new(" ", 1, true, false).freeze + + # A node in the print tree that forces the surrounding group to print out in + # the "break" mode as opposed to the "flat" mode. Useful for when you need + # to force a newline into a group. + class BreakParent + def type + :break_parent + end + end + + # Since there's really no difference in these instances, just using the same + # one saves on some allocations. + BREAK_PARENT = BreakParent.new.freeze + + # A node in the print tree that represents a group of items which the printer + # should try to fit onto one line. This is the basic command to tell the + # printer when to break. Groups are usually nested, and the printer will try + # to fit everything on one line, but if it doesn't fit it will break the + # outermost group first and try again. It will continue breaking groups until + # everything fits (or there are no more groups to break). + class Group + attr_reader :contents, :break + + def initialize(contents) + @contents = contents + @break = false + end + + def unbreak! + @break = false + end + + def break! + @break = true + end + + def type + :group + end + end + + # A node in the print tree that represents printing one thing if the + # surrounding group node is broken and another thing if the surrounding group + # node is flat. + class IfBreak + attr_reader :break_contents, :flat_contents + + def initialize(break_contents, flat_contents) + @break_contents = break_contents + @flat_contents = flat_contents + end + + def type + :if_break + end + end + + # A node in the print tree that is a variant of the Align node that indents + # its contents by one level. + class Indent + attr_reader :contents + + def initialize(contents) + @contents = contents + end + + def type + :indent + end + end + + # A node in the print tree that has its own special buffer for implementing + # content that should flush before any newline. + # + # Useful for implementating trailing content, as it's not always practical to + # constantly check where the line ends to avoid accidentally printing some + # content after a line suffix node. + class LineSuffix + attr_reader :priority, :contents + + def initialize(priority, contents) + @priority = priority + @contents = contents + end + + def type + :line_suffix + end + end + + # A node in the print tree that represents trimming all of the indentation of + # the current line, in the rare case that you need to ignore the indentation + # that you've already created. This node should be placed after a Breakable. + class Trim + def type + :trim + end + end + + # Since all of the instances here are the same, we can reuse the same one to + # cut down on allocations. + TRIM = Trim.new.freeze + + # Refine string so that we can consistently call #type in case statements and + # treat all of the nodes in the tree as homogenous. + using Module.new { + refine String do + def type + :string + end + end + } + + # There are two modes in printing, break and flat. When we're in break mode, + # any lines will use their newline, any if-breaks will use their break + # contents, etc. + MODE_BREAK = 1 + + # This is another print mode much like MODE_BREAK. When we're in flat mode, we + # attempt to print everything on one line until we either hit a broken group, + # a forced line, or the maximum width. + MODE_FLAT = 2 + + # The default indentation for printing is zero, assuming that the code starts + # at the top level. That can be changed if desired to start from a different + # indentation level. + DEFAULT_INDENTATION = 0 + + COMMENT_PRIORITY = 1 + HEREDOC_PRIORITY = 2 + + attr_reader :source, :stack + attr_reader :print_width, :preferred_quote, :trailing_comma + + # This is an output buffer that contains the various parts of the printed + # source code. + attr_reader :buffer + + # The stack of groups that are being printed. + attr_reader :groups + + # The current array of contents that calls to methods that generate print + # tree nodes will append to. + attr_reader :target + + def initialize(source, options) + @source = source + @stack = [] + @buffer = [] + + @print_width = options.print_width + @preferred_quote = options.preferred_quote + @trailing_comma = options.trailing_comma + + contents = [] + @groups = [Group.new(contents)] + @target = contents + end + + # The main API for this visitor. + def format + flush + buffer.join + end + + # A convenience method used by a lot of the print tree node builders that + # temporarily changes the target that the builders will append to. + def with_target(target) + previous_target, @target = @target, target + yield + @target = previous_target + end + + # -------------------------------------------------------------------------- + # Visit methods + # -------------------------------------------------------------------------- + + # alias $foo $bar + # ^^^^^^^^^^^^^^^ + def visit_alias_global_variable_node(node) + group do + text("alias ") + visit(node.new_name) + align(6) do + breakable_space + visit(node.old_name) + end + end + end + + # alias foo bar + # ^^^^^^^^^^^^^ + def visit_alias_method_node(node) + new_name = node.new_name + old_name = node.old_name + + group do + text("alias ") + + if new_name.is_a?(SymbolNode) + text(new_name.value) + new_name.location.comments.each { |comment| visit_comment(comment) } + else + visit(new_name) + end + + align(6) do + breakable_space + if old_name.is_a?(SymbolNode) + text(old_name.value) + else + visit(old_name) + end + end + end + end + + # foo => bar | baz + # ^^^^^^^^^ + def visit_alternation_pattern_node(node) + group do + visit(node.left) + text(" ") + visit_location(node.operator_loc) + breakable_space + visit(node.right) + end + end + + # a and b + # ^^^^^^^ + def visit_and_node(node) + visit_binary(node.left, node.operator_loc, node.right) + end + + # [] + # ^^ + def visit_array_node(node) + opening_loc = node.opening_loc + elements = node.elements + + # If we have no opening location, then this is an implicit array by virtue + # of an assignment operator. In this case we will print out the elements + # of the array separated by commas and newlines. + if opening_loc.nil? + group { seplist(elements) { |element| visit(element) } } + return + end + + # If this is a specially formatted array, we will leave it be and format + # it according to how the source has it formatted. + opening = opening_loc.slice + if opening.start_with?("%") + group do + text(opening) + indent do + breakable_empty + seplist(elements, -> { breakable_space }) { |element| visit(element) } + end + breakable_empty + text(node.closing) + end + return + end + + # If this array has no comments on the start of the end location and it + # has more than 2 elements, we'll check if we can automatically convert it + # into a %w or %i array. + closing_loc = node.closing_loc + if opening_loc.comments.empty? && closing_loc.comments.empty? && elements.length >= 2 + if elements.all? { |element| + element.is_a?(StringNode) && element.location.comments.empty? && + !(content = element.content).empty? && + !content.match?(/[\s\[\]\\]/) + } + group do + text("%w[") + indent do + breakable_empty + seplist(elements, -> { breakable_space }) { |element| text(element.content) } + end + breakable_empty + text("]") + end + return + elsif elements.all? { |element| + element.is_a?(SymbolNode) && element.location.comments.empty? + } + group do + text("%i[") + indent do + breakable_empty + seplist(elements, -> { breakable_space }) { |element| text(element.value) } + end + breakable_empty + text("]") + end + return + end + end + + # Otherwise we'll format the array normally. + group do + visit_location(opening_loc) + visit_elements(elements, closing_loc.comments) + breakable_empty + text("]") + end + end + + # foo => [bar] + # ^^^^^ + def visit_array_pattern_node(node) + constant = node.constant + opening_loc = node.opening_loc + + targets = [*node.requireds, *node.rest, *node.posts] + implicit_rest = targets.pop if targets.last.is_a?(ImplicitRestNode) + + group do + visit(constant) unless constant.nil? + text("[") + opening_loc.comments.each { |comment| visit_comment(comment) } unless opening_loc.nil? + visit_elements(targets, node.closing_loc&.comments || []) + visit(implicit_rest) if implicit_rest + breakable_empty + text("]") + end + end + + # foo(bar) + # ^^^ + def visit_arguments_node(node) + seplist(node.arguments) { |argument| visit(argument) } + end + + # { a: 1 } + # ^^^^ + def visit_assoc_node(node) + if node.value.is_a?(HashNode) + visit_assoc_node_inner(node) + else + group { visit_assoc_node_inner(node) } + end + end + + # Visit an assoc node and format the key and value. + private def visit_assoc_node_inner(node) + operator_loc = node.operator_loc + value = node.value + + if operator_loc.nil? + visit(node.key) + visit_assoc_node_value(value) if !value.nil? && !value.is_a?(ImplicitNode) + else + visit(node.key) + text(" ") + visit_location(operator_loc) + visit_assoc_node_value(value) + end + end + + # Visit the value of an association node. + private def visit_assoc_node_value(node) + if indent_write?(node) + indent do + breakable_space + visit(node) + end + else + text(" ") + visit(node) + end + end + + # Visit an assoc node and format the key as a label. + private def visit_assoc_node_label(node) + if node.operator_loc.nil? + visit(node) + else + case (key = node.key).type + when :interpolated_symbol_node + opening = key.opening + + if opening.start_with?("%") + group do + text(preferred_quote) + key.parts.each { |part| visit(part) } + text("#{preferred_quote}:") + end + else + group do + text(key.opening[1..]) + key.parts.each { |part| visit(part) } + text(key.closing) + text(":") + end + end + when :symbol_node + value = key.value + if value.match?(/^[_A-Za-z]+$/) + text(value) + else + text("#{preferred_quote}#{value}#{preferred_quote}") + end + + text(":") + else + raise "Unexpected key: #{key.inspect}" + end + + visit_assoc_node_value(node.value) + end + end + + # Visit an assoc node and format the key as a rocket. + private def visit_assoc_node_rocket(node) + case (key = node.key).type + when :interpolated_symbol_node + opening = key.opening + + if opening.start_with?("%") + visit(key) + else + group do + text(":") + text(opening) + key.parts.each { |part| visit(part) } + text(key.closing.chomp(":")) + end + end + when :symbol_node + if key.closing&.end_with?(":") + text(":") + text(key.value) + else + visit(key) + end + else + visit(key) + end + + text(" ") + operator_loc = node.operator_loc + operator_loc ? visit_location(operator_loc) : text("=>") + visit_assoc_node_value(node.value) + end + + # def foo(**); bar(**); end + # ^^ + # + # { **foo } + # ^^^^^ + def visit_assoc_splat_node(node) + visit_prefix(node.operator_loc, node.value) + end + + # $+ + # ^^ + def visit_back_reference_read_node(node) + text(node.slice) + end + + # begin end + # ^^^^^^^^^ + def visit_begin_node(node) + begin_keyword_loc = node.begin_keyword_loc + statements = node.statements + + rescue_clause = node.rescue_clause + else_clause = node.else_clause + ensure_clause = node.ensure_clause + + if begin_keyword_loc.nil? + group do + visit(statements) unless statements.nil? + + unless rescue_clause.nil? + align(-2) do + breakable_force + visit(rescue_clause) + end + end + + unless else_clause.nil? + align(-2) do + breakable_force + visit(else_clause) + end + end + + unless ensure_clause.nil? + align(-2) do + breakable_force + visit(ensure_clause) + end + end + end + else + group do + visit_location(begin_keyword_loc) + + unless statements.nil? + indent do + breakable_force + visit(statements) + end + end + + unless rescue_clause.nil? + breakable_force + visit(rescue_clause) + end + + unless else_clause.nil? + breakable_force + visit(else_clause) + end + + unless ensure_clause.nil? + breakable_force + visit(ensure_clause) + end + + breakable_force + text("end") + end + end + end + + # foo(&bar) + # ^^^^ + def visit_block_argument_node(node) + visit_prefix(node.operator_loc, node.expression) + end + + # foo { |; bar| } + # ^^^ + def visit_block_local_variable_node(node) + text(node.name.name) + end + + private def inside_command?(end_index) + stack[0...end_index].reverse_each.any? do |parent| + case parent.type + when :statements_node + return false + when :call_node + return true if parent.arguments && parent.opening_loc.nil? + return false unless parent.opening_loc.nil? + end + false + end + end + + # A block on a keyword or method call. + def visit_block_node(node) + parameters = node.parameters + body = node.body + opening = node.opening + + # If this is nested anywhere inside of a Command or CommandCall node, then + # we can't change which operators we're using for the bounds of the block. + previous = nil + break_opening, break_closing, flat_opening, flat_closing = + if inside_command?(-2) + block_close = opening == "do" ? "end" : "}" + [opening, block_close, opening, block_close] + elsif %i[forwarding_super_node super_node].include?(stack[-2].type) + %w[do end do end] + elsif stack[0...-1].reverse_each.any? { |parent| + case parent.type + when :parentheses_node, :statements_node + break false + when :if_node, :unless_node, :while_node, :until_node + break true if parent.predicate == previous + end + + previous = parent + false + } + %w[{ } { }] + else + %w[do end { }] + end + + parent = stack[-2] + + # If the receiver of this block a call without parentheses, so we need to + # break the block. + if parent.is_a?(CallNode) && parent.arguments && parent.opening_loc.nil? + break_parent + visit_block_node_break(node, break_opening, break_closing) + else + group do + if_break { visit_block_node_break(node, break_opening, break_closing) }.if_flat do + text(flat_opening) + + if parameters.is_a?(BlockParametersNode) + text(" ") + visit(parameters) + end + + breakable_space if body || node.closing_loc.comments.any? + visit_body(body, node.closing_loc.comments, false) + breakable_space if parameters || body + + text(flat_closing) + end + end + end + end + + # Visit a block node in the break form. + private def visit_block_node_break(node, break_opening, break_closing) + parameters = node.parameters + + text(break_opening) + node.opening_loc.comments.each { |comment| visit_comment(comment) } + + if parameters.is_a?(BlockParametersNode) + text(" ") + visit(parameters) + end + + visit_body(node.body, node.closing_loc.comments, false) + breakable_space + text(break_closing) + end + + # def foo(&bar); end + # ^^^^ + def visit_block_parameter_node(node) + name = node.name + + group do + visit_location(node.operator_loc) + + if name + align(1) do + breakable_empty + text(name.name) + end + end + end + end + + # A block's parameters. + def visit_block_parameters_node(node) + parameters = node.parameters + locals = node.locals + opening_loc = node.opening_loc + + group do + if parameters || locals.any? + if opening_loc + visit_location(opening_loc) + else + text("(") + end + end + + remove_breaks(visit(parameters)) if parameters + + if locals.any? + text("; ") + seplist(locals) { |local| visit(local) } + end + + text(node.closing || ")") if parameters || locals.any? + end + end + + # break + # ^^^^^ + # + # break foo + # ^^^^^^^^^ + def visit_break_node(node) + visit_jump("break", node.arguments) + end + + ATTACH_DIRECTLY = %i[ + array_node + hash_node + string_node + interpolated_string_node + x_string_node + interpolated_x_string_node + if_node + unless_node + ].freeze + + # foo + # ^^^ + # + # foo.bar + # ^^^^^^^ + # + # foo.bar() {} + # ^^^^^^^^^^^^ + def visit_call_node(node) + receiver = node.receiver + message = node.message + name = node.name + + opening_loc = node.opening_loc + closing_loc = node.closing_loc + + arguments = [*node.arguments&.arguments] + block = node.block + + if block.is_a?(BlockArgumentNode) + arguments << block + block = nil + end + + unless node.safe_navigation? + case name + when :! + if message == "not" + if receiver + group do + text("not") + + if opening_loc + visit_location(opening_loc) + else + if_break { text("(") }.if_flat { text(" ") } + end + + indent do + breakable_empty + visit(receiver) + end + + if closing_loc + breakable_empty + visit_location(closing_loc) + else + if_break do + breakable_empty + text(")") + end + end + end + else + text("not()") + end + + return + end + + if arguments.empty? && block.nil? + visit_prefix(node.message_loc, receiver) + return + end + when :+@, :-@, :~ + if arguments.empty? && block.nil? + visit_prefix(node.message_loc, receiver) + return + end + when :+, :-, :*, :/, :%, :==, :===, :!=, :!~, :=~, :>, :<, :>=, :<=, :<=>, :<<, :>>, :&, :|, + :^ + if arguments.length == 1 && block.nil? + visit_binary(receiver, node.message_loc, arguments.first) + return + end + when :** + if arguments.length == 1 && block.nil? + group do + visit(receiver) + text("**") + indent do + breakable_empty + seplist(arguments) { |argument| visit(argument) } + end + end + + return + end + when :[] + group do + visit(receiver) + text("[") + + if arguments.any? + indent do + breakable_empty + seplist(arguments) { |argument| visit(argument) } + end + + breakable_empty + end + + text("]") + + if block + text(" ") + visit(block) + end + end + + return + when :[]= + if arguments.any? + group do + *before, after = arguments + + group do + visit(receiver) + text("[") + + if before.any? + indent do + breakable_empty + seplist(before) { |argument| visit(argument) } + end + breakable_empty + end + + text("]") + end + + text(" ") + group do + text("=") + indent do + breakable_space + visit(after) + end + end + + if block + text(" ") + visit(block) + end + end + else + group do + visit(receiver) + text("[]") + + if block + text(" ") + visit(block) + end + end + end + + return + when :to, :to_not, :not_to + # Very special handling here for RSpec. Methods on expectation objects + # are almost always used without parentheses. This can result in + # pretty ugly formatting, because the DSL gets super confusing. + if opening_loc.nil? + group do + visit(receiver) if receiver + visit_call_node_call_operator(node.call_operator_loc) if node.call_operator_loc + visit_location(node.message_loc) if node.message_loc + visit_call_node_rhs(node, 0) + end + + return + end + end + end + + # Now that we've passed through all of the special handling for specific + # method names, we can handle the general case of a method call. In this + # case we'll first build up a call chain for all of the calls in a row. + # This could potentially be just a single method call. + chain = [node] + current = node + + while (receiver = current.receiver).is_a?(CallNode) + chain.unshift(receiver) + current = receiver + end + + chain.unshift(receiver) if receiver + + if chain.length > 1 + if !ATTACH_DIRECTLY.include?(receiver&.type) && + chain[0...-1].all? { |node| + !node.is_a?(CallNode) || + ( + ((node.opening_loc.nil? && !node.arguments) || node.name == :[]) && + !node.block && + node.location.comments.none? && + !node.call_operator_loc&.comments&.any? && + !node.message_loc&.comments&.any? + ) + } && + !chain[-1].call_operator_loc&.comments&.any? && + !chain[-1].message_loc&.comments&.any? + # Special handling here for the case that we have a call chain that is + # just method names and operators, ending with a call that has + # anything else. In this case we'll put everything on the same line + # and break the chain at the end. This can look like: + # + # foo.bar.baz { |qux| qux } + # + # In this case if it gets broken, we don't want multiple lines of + # method calls, instead we want to only break the block at the end, + # like: + # + # foo.bar.baz do |qux| + # qux + # end + # + group do + *rest, last = chain + doc = + align(0) do + visit(rest.shift) unless rest.first.is_a?(CallNode) + + rest.each do |node| + visit_call_node_call_operator(node.call_operator_loc) + + if node.name == :[] + visit_call_node_rhs(node, 0) + else + visit_location(node.message_loc) + end + end + + visit_call_node_call_operator(last.call_operator_loc) + end + + group do + visit_location(last.message_loc) if last.message_loc && last.name != :[] + visit_call_node_rhs(last, last_position(doc) + (last.message&.length || 0) + 1) + end + end + else + # Otherwise we'll break the chain at each node, indenting all of the + # calls beyond the first one by one level of indentation. + group do + first, *rest = chain + + # If a call operator has a trailing comment on it, then we need to + # put it on the previous line. In this case we need to communicate + # to the next iteration in the loop that we have already printed the + # call operator. + call_operator_printed = false + + case first.type + when :call_node + # If the first node in the chain is a call node, we only need to + # print the message because we will not have a receiver and we + # will handle the arguments and block in the loop below. + visit_location(first.message_loc) + visit_call_node_rhs(first, first.message.length + 1) + + # If the first call in the chain has a trailing comment on its + # call operator, then we need to print it within this group. + if (subseq = rest.first) && + ( + (call_operator_loc = subseq.call_operator_loc)&.trailing_comments&.any? || + subseq.message_loc&.leading_comments&.any? + ) + call_operator_printed = true + visit_call_node_call_operator(call_operator_loc) + end + + if first.block.is_a?(BlockNode) + node = rest.shift + + group do + if rest.any? + node.location.leading_comments.each { |comment| visit_comment(comment) } + end + + visit_call_node_call_operator(node.call_operator_loc) unless call_operator_printed + visit_location(node.message_loc) if node.message_loc && node.name != :[] + visit_call_node_rhs(node, (message&.length || 0) + 2) + + if rest.any? + node.location.trailing_comments.each { |comment| visit_comment(comment) } + + # If the first call in the chain has a trailing comment on its + # call operator, then we need to print it within this group. + if (subseq = rest.first) && + ( + ( + call_operator_loc = subseq.call_operator_loc + )&.trailing_comments&.any? || + subseq.message_loc&.leading_comments&.any? + ) + call_operator_printed = true + visit_call_node_call_operator(call_operator_loc) + else + call_operator_printed = false + end + end + end + end + when *ATTACH_DIRECTLY + # Certain nodes we want to attach our message directly to them, + # because it looks strange to have a message on a separate line. + group do + visit(first) + node = rest.shift + + group do + if rest.any? + node.location.leading_comments.each { |comment| visit_comment(comment) } + end + + visit_call_node_call_operator(node.call_operator_loc) + visit_location(node.message_loc) if node.message_loc && node.name != :[] + visit_call_node_rhs(node, (message&.length || 0) + 2) + + if rest.any? + node.location.trailing_comments.each { |comment| visit_comment(comment) } + + # If the first call in the chain has a trailing comment on its + # call operator, then we need to print it within this group. + if (subseq = rest.first) && + ( + ( + call_operator_loc = subseq.call_operator_loc + )&.trailing_comments&.any? || + subseq.message_loc&.leading_comments&.any? + ) + call_operator_printed = true + visit_call_node_call_operator(call_operator_loc) + end + end + end + end + else + # Otherwise, we'll format the receiver of the first member of the + # call chain and then indent all of the calls by one level. + visit(first) + + # If the first call in the chain has a trailing comment on its + # call operator, then we need to print it within this group. + if (subseq = rest.first) && + ( + (call_operator_loc = subseq.call_operator_loc)&.trailing_comments&.any? || + subseq.message_loc&.leading_comments&.any? + ) + call_operator_printed = true + visit_call_node_call_operator(call_operator_loc) + end + end + + inside_command = inside_command?(-1) + indent do + rest.each_with_index do |node, index| + if inside_command && !call_operator_printed + # Do not break the chain if we're inside a command, because + # that would lead to this method call being placed on the + # command as opposed to this chain. + elsif node.name == :[] + # If this is a call to `[]`, then we don't want to break the + # chain here, because we want to effectively treat it as a + # postfix operator. + elsif node.name == :not && (receiver = node.receiver).is_a?(CallNode) && + receiver.name == :where && + !receiver.arguments && + !receiver.block + # Generally we will always break the chain at each node. + # However, there is some nice behavior here if we have a call + # chain with `where.not` in it (common in Rails). In that case + # it's nice to keep the `not` on the same line as the `where`. + elsif call_operator_printed && + node.message_loc&.leading_comments&.any? { |comment| + comment.is_a?(EmbDocComment) + } + # If we have already printed the call operator and the message + # location has a leading embdoc comment, then we already have + # a newline printed in this chain. + else + breakable_empty + end + + group do + if index != rest.length - 1 + node.location.leading_comments.each { |comment| visit_comment(comment) } + end + + visit_call_node_call_operator(node.call_operator_loc) unless call_operator_printed + visit_location(node.message_loc) if node.message_loc && node.name != :[] + visit_call_node_rhs(node, (node.message&.length || 0) + 2) + + if index != rest.length - 1 + node.location.trailing_comments.each { |comment| visit_comment(comment) } + + # If the call operator has a trailing comment, then we need + # to print it within this group. + if (subseq = rest[index + 1]) && + ( + ( + call_operator_loc = subseq.call_operator_loc + )&.trailing_comments&.any? || + subseq.message_loc&.leading_comments&.any? + ) + call_operator_printed = true + visit_call_node_call_operator(call_operator_loc) + else + call_operator_printed = false + end + end + end + end + end + end + end + else + # If there is no call chain, then it's not possible that there's a + # receiver. In this case we'll visit the message and then the arguments + # and block. + group do + visit_location(node.message_loc) + visit_call_node_rhs(node, node.message.length + 1) + end + end + end + + private def visit_call_node_call_operator(location) + visit_location(location, location.slice == "&." ? "&." : ".") if location + end + + private def visit_call_node_rhs(node, position) + arguments = [*node.arguments&.arguments] + block = node.block + + if block.is_a?(BlockArgumentNode) + arguments << block + block = nil + end + + if arguments.length == 1 && node.name.end_with?("=") && + !%i[== === != >= <=].include?(node.name) && + block.nil? + argument = arguments.first + text(" =") + + if indent_write?(argument) + indent do + breakable_space + visit(argument) + end + else + text(" ") + visit(argument) + end + elsif !node.opening_loc.nil? && arguments.any? && !node.closing_loc.nil? + group do + visit_location(node.message_loc) if node.name == :[] && node.call_operator_loc + visit_location(node.opening_loc) + indent do + breakable_empty + seplist(arguments) { |argument| visit(argument) } + + if trailing_comma && !arguments.last.is_a?(BlockArgumentNode) && + !( + arguments.length == 1 && (argument = arguments.first).is_a?(CallNode) && + argument.arguments && + argument.opening_loc.nil? + ) + if_break { text(",") } + end + end + breakable_empty + visit_location(node.closing_loc) + end + elsif arguments.any? + text(" ") + group { visit_call_node_command_arguments(node, arguments, position) } + elsif node.opening_loc && node.closing_loc + visit_location(node.opening_loc) + visit_location(node.closing_loc) + end + + if block + text(" ") + visit(block) + end + end + + # Align the contents of the given node with the last position. This is used + # to align method calls without parentheses. + private def visit_call_node_command_arguments(node, arguments, position) + if node.arguments && node.arguments.arguments.length == 1 + argument = node.arguments.arguments.first + + case argument.type + when :def_node + seplist(arguments) { |argument| visit(argument) } + return + when :call_node + if argument.opening_loc.nil? + visit_call_node_command_arguments(argument, arguments, position) + return + elsif argument.block.is_a?(BlockNode) + seplist(arguments) { |argument| visit(argument) } + return + end + end + end + + align(position > (print_width / 2) ? 0 : position) do + seplist(arguments) { |argument| visit(argument) } + end + end + + # foo.bar += baz + # ^^^^^^^^^^^^^^^ + def visit_call_operator_write_node(node) + receiver = node.receiver + call_operator_loc = node.call_operator_loc + + visit_write(node.binary_operator_loc, node.value) do + group do + if receiver + visit(receiver) + visit_call_node_call_operator(call_operator_loc) + end + + text(node.message) + end + end + end + + # foo.bar &&= baz + # ^^^^^^^^^^^^^^^ + def visit_call_and_write_node(node) + receiver = node.receiver + call_operator_loc = node.call_operator_loc + + visit_write(node.operator_loc, node.value) do + group do + if receiver + visit(receiver) + visit_call_node_call_operator(call_operator_loc) + end + + text(node.message) + end + end + end + + # foo.bar ||= baz + # ^^^^^^^^^^^^^^^ + def visit_call_or_write_node(node) + receiver = node.receiver + call_operator_loc = node.call_operator_loc + + visit_write(node.operator_loc, node.value) do + group do + if receiver + visit(receiver) + visit_call_node_call_operator(call_operator_loc) + end + + text(node.message) + end + end + end + + # foo.bar, = 1 + # ^^^^^^^ + def visit_call_target_node(node) + group do + visit(node.receiver) + visit_location(node.call_operator_loc) + text(node.message) + end + end + + # foo => bar => baz + # ^^^^^^^^^^ + def visit_capture_pattern_node(node) + visit_binary(node.value, node.operator_loc, node.target) + end + + # case foo; when bar; end + # ^^^^^^^^^^^^^^^^^^^^^^^ + def visit_case_node(node) + visit_case( + node.case_keyword_loc, + node.predicate, + node.conditions, + node.else_clause, + node.end_keyword_loc + ) + end + + # case foo; in bar; end + # ^^^^^^^^^^^^^^^^^^^^^ + def visit_case_match_node(node) + visit_case( + node.case_keyword_loc, + node.predicate, + node.conditions, + node.else_clause, + node.end_keyword_loc + ) + end + + private def visit_case(case_keyword_loc, predicate, conditions, else_clause, end_keyword_loc) + group do + group do + visit_location(case_keyword_loc) + + if predicate + text(" ") + align(5) { visit(predicate) } + end + end + + breakable_force + seplist(conditions, -> { breakable_force }) { |condition| visit(condition) } + + if else_clause + breakable_force + visit(else_clause) + end + + indent do + end_keyword_loc.comments.each do |comment| + breakable_force + text(comment.location.slice) + end + end + + breakable_force + text("end") + end + end + + # class Foo; end + # ^^^^^^^^^^^^^^ + def visit_class_node(node) + class_keyword_loc = node.class_keyword_loc + inheritance_operator_loc = node.inheritance_operator_loc + superclass = node.superclass + + group do + group do + visit_location(class_keyword_loc) + + if class_keyword_loc.comments.any? + indent do + breakable_space + visit(node.constant_path) + end + else + text(" ") + visit(node.constant_path) + end + + if superclass + text(" ") + visit_location(inheritance_operator_loc) + + if inheritance_operator_loc.comments.any? + indent do + breakable_space + visit(superclass) + end + else + text(" ") + visit(superclass) + end + end + end + + visit_body(node.body, node.end_keyword_loc.comments) + breakable_force + text("end") + end + end + + # @@foo + # ^^^^^ + def visit_class_variable_read_node(node) + text(node.name.name) + end + + # @@foo = 1 + # ^^^^^^^^^ + # + # @@foo, @@bar = 1 + # ^^^^^ ^^^^^ + def visit_class_variable_write_node(node) + visit_write(node.operator_loc, node.value) { text(node.name.name) } + end + + # @@foo += bar + # ^^^^^^^^^^^^ + def visit_class_variable_operator_write_node(node) + visit_write(node.binary_operator_loc, node.value) { text(node.name.name) } + end + + # @@foo &&= bar + # ^^^^^^^^^^^^^ + def visit_class_variable_and_write_node(node) + visit_write(node.operator_loc, node.value) { text(node.name.name) } + end + + # @@foo ||= bar + # ^^^^^^^^^^^^^ + def visit_class_variable_or_write_node(node) + visit_write(node.operator_loc, node.value) { text(node.name.name) } + end + + # @@foo, = bar + # ^^^^^ + def visit_class_variable_target_node(node) + text(node.name.name) + end + + # Foo + # ^^^ + def visit_constant_read_node(node) + text(node.name.name) + end + + # Foo = 1 + # ^^^^^^^ + # + # Foo, Bar = 1 + # ^^^ ^^^ + def visit_constant_write_node(node) + visit_write(node.operator_loc, node.value) { text(node.name.name) } + end + + # Foo += bar + # ^^^^^^^^^^^ + def visit_constant_operator_write_node(node) + visit_write(node.binary_operator_loc, node.value) { text(node.name.name) } + end + + # Foo &&= bar + # ^^^^^^^^^^^^ + def visit_constant_and_write_node(node) + visit_write(node.operator_loc, node.value) { text(node.name.name) } + end + + # Foo ||= bar + # ^^^^^^^^^^^^ + def visit_constant_or_write_node(node) + visit_write(node.operator_loc, node.value) { text(node.name.name) } + end + + # Foo, = bar + # ^^^ + def visit_constant_target_node(node) + text(node.name.name) + end + + # Foo::Bar + # ^^^^^^^^ + def visit_constant_path_node(node) + parent = node.parent + + group do + visit(parent) if parent + visit_location(node.delimiter_loc) + indent do + breakable_empty + visit_location(node.name_loc) + end + end + end + + # Foo::Bar = 1 + # ^^^^^^^^^^^^ + # + # Foo::Foo, Bar::Bar = 1 + # ^^^^^^^^ ^^^^^^^^ + def visit_constant_path_write_node(node) + visit_write(node.operator_loc, node.value) { visit(node.target) } + end + + # Foo::Bar += baz + # ^^^^^^^^^^^^^^^ + def visit_constant_path_operator_write_node(node) + visit_write(node.binary_operator_loc, node.value) { visit(node.target) } + end + + # Foo::Bar &&= baz + # ^^^^^^^^^^^^^^^^ + def visit_constant_path_and_write_node(node) + visit_write(node.operator_loc, node.value) { visit(node.target) } + end + + # Foo::Bar ||= baz + # ^^^^^^^^^^^^^^^^ + def visit_constant_path_or_write_node(node) + visit_write(node.operator_loc, node.value) { visit(node.target) } + end + + # Foo::Bar, = baz + # ^^^^^^^^ + def visit_constant_path_target_node(node) + parent = node.parent + + group do + visit(parent) if parent + visit_location(node.delimiter_loc) + indent do + breakable_empty + visit_location(node.name_loc) + end + end + end + + # def foo; end + # ^^^^^^^^^^^^ + # + # def self.foo; end + # ^^^^^^^^^^^^^^^^^ + def visit_def_node(node) + receiver = node.receiver + name_loc = node.name_loc + parameters = node.parameters + lparen_loc = node.lparen_loc + rparen_loc = node.rparen_loc + + group do + group do + group do + text("def") + text(" ") if !receiver.nil? || name_loc.leading_comments.none? + + group do + if receiver + visit(receiver) + text(".") + end + + visit_location(name_loc) + end + end + + if parameters + lparen_loc ? visit_location(lparen_loc) : text("(") + + if parameters + indent do + breakable_empty + visit(parameters) + end + end + + breakable_empty + text(")") + + # Very specialized behavior here where inline comments do not force + # a break parent. This should probably be an option on + # visit_location. + rparen_loc&.comments&.each do |comment| + if comment.is_a?(InlineComment) + line_suffix(COMMENT_PRIORITY) do + comment.trailing? ? text(" ") : breakable + text(comment.location.slice) + end + else + breakable_force + trim + text(comment.location.slice.rstrip) + end + end + else + visit_location(lparen_loc) if lparen_loc + breakable_empty if lparen_loc&.comments&.any? + visit_location(rparen_loc) if rparen_loc + end + end + + if node.equal_loc + text(" ") + visit_location(node.equal_loc) + text(" ") + visit(node.body) + else + visit_body(node.body, node.end_keyword_loc.comments) + breakable_force + text("end") + end + end + end + + # defined? a + # ^^^^^^^^^^ + # + # defined?(a) + # ^^^^^^^^^^^ + def visit_defined_node(node) + group do + visit_location(node.keyword_loc) + if (lparen_loc = node.lparen_loc) + visit_location(lparen_loc) + else + text("(") + end + + visit_body(node.value, node.rparen_loc&.comments || [], false) + breakable_empty + text(")") + end + end + + # if foo then bar else baz end + # ^^^^^^^^^^^^ + def visit_else_node(node) + group do + visit_location(node.else_keyword_loc) + visit_body(node.statements, node.end_keyword_loc.comments) + end + end + + # "foo #{bar}" + # ^^^^^^ + def visit_embedded_statements_node(node) + group do + visit_location(node.opening_loc) + + if (statements = node.statements) + indent do + breakable_empty + visit(statements) + end + breakable_empty + end + + text("}") + end + end + + # "foo #@bar" + # ^^^^^ + def visit_embedded_variable_node(node) + group do + text("\#{") + indent do + breakable_empty + visit(node.variable) + end + breakable_empty + text("}") + end + end + + # begin; foo; ensure; bar; end + # ^^^^^^^^^^^^ + def visit_ensure_node(node) + group do + visit_location(node.ensure_keyword_loc) + visit_body(node.statements, node.end_keyword_loc.comments) + end + end + + # false + # ^^^^^ + def visit_false_node(_node) + text("false") + end + + # foo => [*, bar, *] + # ^^^^^^^^^^^ + def visit_find_pattern_node(node) + constant = node.constant + + group do + visit(constant) if constant + text("[") + + indent do + breakable_empty + seplist([node.left, *node.requireds, node.right]) { |element| visit(element) } + end + + breakable_empty + text("]") + end + end + + # if foo .. bar; end + # ^^^^^^^^^^ + def visit_flip_flop_node(node) + left = node.left + right = node.right + + group do + visit(left) if left + text(" ") + visit_location(node.operator_loc) + + if right + indent do + breakable_space + visit(right) + end + end + end + end + + # 1.0 + # ^^^ + def visit_float_node(node) + text(node.slice) + end + + # for foo in bar do end + # ^^^^^^^^^^^^^^^^^^^^^ + def visit_for_node(node) + group do + text("for ") + group { visit(node.index) } + text(" in ") + group { visit(node.collection) } + visit_body(node.statements, node.end_keyword_loc.comments) + breakable_force + text("end") + end + end + + # def foo(...); bar(...); end + # ^^^ + def visit_forwarding_arguments_node(_node) + text("...") + end + + # def foo(...); end + # ^^^ + def visit_forwarding_parameter_node(_node) + text("...") + end + + # super + # ^^^^^ + # + # super {} + # ^^^^^^^^ + def visit_forwarding_super_node(node) + block = node.block + + if block + group do + text("super ") + visit(block) + end + else + text("super") + end + end + + # $foo + # ^^^^ + def visit_global_variable_read_node(node) + text(node.name.name) + end + + # $foo = 1 + # ^^^^^^^^ + # + # $foo, $bar = 1 + # ^^^^ ^^^^ + def visit_global_variable_write_node(node) + visit_write(node.operator_loc, node.value) { text(node.name.name) } + end + + # $foo += bar + # ^^^^^^^^^^^ + def visit_global_variable_operator_write_node(node) + visit_write(node.binary_operator_loc, node.value) { text(node.name.name) } + end + + # $foo &&= bar + # ^^^^^^^^^^^^ + def visit_global_variable_and_write_node(node) + visit_write(node.operator_loc, node.value) { text(node.name.name) } + end + + # $foo ||= bar + # ^^^^^^^^^^^^ + def visit_global_variable_or_write_node(node) + visit_write(node.operator_loc, node.value) { text(node.name.name) } + end + + # $foo, = bar + # ^^^^ + def visit_global_variable_target_node(node) + text(node.name.name) + end + + # {} + # ^^ + def visit_hash_node(node) + elements = node.elements + + group_if(!stack[-2].is_a?(AssocNode)) do + if elements.any? { |element| element.value.is_a?(ImplicitNode) } + visit_hash_node_layout(node) { |element| visit(element) } + elsif elements.all? { |element| + !element.is_a?(AssocNode) || element.operator_loc.nil? || + element.key.is_a?(InterpolatedSymbolNode) || + ( + element.key.is_a?(SymbolNode) && (value = element.key.value) && + value.match?(/^[_A-Za-z]/) && + !value.end_with?("=") + ) + } + visit_hash_node_layout(node) { |element| visit_assoc_node_label(element) } + else + visit_hash_node_layout(node) { |element| visit_assoc_node_rocket(element) } + end + end + end + + # Visit a hash node and yield out each plain association element for + # formatting by the caller. + private def visit_hash_node_layout(node) + elements = node.elements + + group do + visit_location(node.opening_loc) + indent do + if elements.any? + breakable_space + seplist(elements) do |element| + if element.is_a?(AssocNode) + if element.value.is_a?(HashNode) + yield element + else + group { yield element } + end + else + visit(element) + end + end + if_break { text(",") } if trailing_comma + end + + node.closing_loc.comments.each do |comment| + breakable_force + text(comment.location.slice) + end + end + + elements.any? ? breakable_space : breakable_empty + text("}") + end + end + + # foo => {} + # ^^ + def visit_hash_pattern_node(node) + constant = node.constant + opening_loc = node.opening_loc + closing_loc = node.closing_loc + + elements = [*node.elements, *node.rest] + + if constant + group do + visit(constant) + text("[") + opening_loc.comments.each { |comment| visit_comment(comment) } if opening_loc + visit_elements(elements, closing_loc&.comments || []) + breakable_empty + text("]") + end + else + group do + text("{") + opening_loc.comments.each { |comment| visit_comment(comment) } if opening_loc + visit_elements_spaced(elements, closing_loc&.comments || []) + elements.any? ? breakable_space : breakable_empty + text("}") + end + end + end + + # if foo then bar end + # ^^^^^^^^^^^^^^^^^^^ + # + # bar if foo + # ^^^^^^^^^^ + # + # foo ? bar : baz + # ^^^^^^^^^^^^^^^ + def visit_if_node(node) + if_keyword_loc = node.if_keyword_loc + if_keyword = node.if_keyword + + statements = node.statements + subsequent = node.subsequent + + if if_keyword == "elsif" + # If we get here, then this is an if node that was expressed as an elsif + # clause in a larger chain. In this case we can simplify formatting + # because there are many things we don't need to check. + group do + visit_location(if_keyword_loc) + text(" ") + align(6) { visit(node.predicate) } + + if subsequent + visit_body(statements, [], true) + breakable_force + visit(subsequent) + else + visit_body(statements, node.end_keyword_loc.comments, true) + end + end + elsif !if_keyword_loc + # If there is no keyword location, then this if node was expressed as a + # ternary. In this case we know quite a bit about the structure of the + # node and will format it quite differently. + truthy = statements.body.first + falsy = subsequent.statements.body.first + + if stack[-2].is_a?(ParenthesesNode) || forced_ternary?(truthy) || forced_ternary?(falsy) + group { visit_ternary_node_flat(node, truthy, falsy) } + else + group do + if_break { visit_ternary_node_break(node, truthy, falsy) }.if_flat do + visit_ternary_node_flat(node, truthy, falsy) + end + end + end + elsif !statements || subsequent || contains_conditional?(statements.body.first) + # If there are no statements, no subsequent clause, or the body of the + # node has a conditional, then we will format the node in a break form, + # which is to say the keyword first. + group do + visit_if_node_break(node) + break_parent + end + elsif contains_write?(node.predicate) || contains_write?(statements) + # If the predicate or the body of the node contains a write, then + # changing the form of the conditional could impact the meaning of the + # expression. In this case we will respect the form of the source. + if node.end_keyword_loc.nil? + group { visit_if_node_flat(node) } + else + group do + visit_if_node_break(node) + break_parent + end + end + else + # Otherwise, we will attempt to format the node in the flat form if it + # fits, and otherwise we will break it into multiple lines. + group do + if_break { visit_if_node_break(node) }.if_flat do + ensure_parentheses { visit_if_node_flat(node) } + end + end + end + end + + private def forced_ternary?(node) + case node.type + when :alias_node, :alias_global_variable_node, :break_node, :if_node, :unless_node, + :lambda_node, :multi_write_node, :next_node, :rescue_modifier_node, :super_node, + :forwarding_super_node, :undef_node, :yield_node, :return_node, :call_and_write_node, + :call_or_write_node, :call_operator_write_node, :class_variable_write_node, + :class_variable_and_write_node, :class_variable_or_write_node, + :class_variable_operator_write_node, :constant_write_node, :constant_and_write_node, + :constant_or_write_node, :constant_operator_write_node, :constant_path_write_node, + :constant_path_and_write_node, :constant_path_or_write_node, + :constant_path_operator_write_node, :global_variable_write_node, + :global_variable_and_write_node, :global_variable_or_write_node, + :global_variable_operator_write_node, :instance_variable_write_node, + :instance_variable_and_write_node, :instance_variable_or_write_node, + :instance_variable_operator_write_node, :local_variable_write_node, + :local_variable_and_write_node, :local_variable_or_write_node, + :local_variable_operator_write_node + true + when :call_node + node.receiver && node.opening_loc.nil? + when :string_node, :interpolated_string_node, :x_string_node, :interpolated_x_string_node + node.heredoc? + else + false + end + end + + private def visit_ternary_node_flat(node, truthy, falsy) + visit(node.predicate) + text(" ?") + indent do + breakable_space + visit(truthy) + text(" :") + breakable_space + visit(falsy) + end + end + + private def visit_ternary_node_break(node, truthy, falsy) + group do + text("if ") + align(3) { visit(node.predicate) } + end + + indent do + breakable_space + visit(truthy) + end + + breakable_space + text("else") + + indent do + breakable_space + visit(falsy) + end + + breakable_space + text("end") + end + + # Visit an if node in the break form. + private def visit_if_node_break(node) + statements = node.statements + subsequent = node.subsequent + + group do + visit_location(node.if_keyword_loc) + text(" ") + align(3) { visit(node.predicate) } + end + + if subsequent + visit_body(statements, [], false) + breakable_space + visit(subsequent) + else + visit_body(statements, node.end_keyword_loc&.comments || [], false) + end + + breakable_space + text("end") + end + + # Visit an if node in the flat form. + private def visit_if_node_flat(node) + visit(node.statements) + text(" if ") + visit(node.predicate) + end + + # 1i + def visit_imaginary_node(node) + text(node.slice) + end + + # { foo: } + # ^^^^ + def visit_implicit_node(node) + # Nothing, because it represents implicit syntax. + end + + # foo { |bar,| } + # ^ + def visit_implicit_rest_node(_node) + text(",") + end + + # case foo; in bar; end + # ^^^^^^^^^^^^^^^^^^^^^ + def visit_in_node(node) + statements = node.statements + + group do + text("in ") + align(3) { visit(node.pattern) } + + if statements + indent do + breakable_force + visit(statements) + end + end + end + end + + # foo[bar] += baz + # ^^^^^^^^^^^^^^^ + def visit_index_operator_write_node(node) + arguments = [*node.arguments, *node.block] + + visit_write(node.binary_operator_loc, node.value) do + group do + visit(node.receiver) + visit_location(node.opening_loc) + + if arguments.any? + indent do + breakable_empty + seplist(arguments) { |argument| visit(argument) } + end + end + + breakable_empty + text("]") + end + end + end + + # foo[bar] &&= baz + # ^^^^^^^^^^^^^^^^ + def visit_index_and_write_node(node) + arguments = [*node.arguments, *node.block] + + visit_write(node.operator_loc, node.value) do + group do + visit(node.receiver) + visit_location(node.opening_loc) + + if arguments.any? + indent do + breakable_empty + seplist(arguments) { |argument| visit(argument) } + end + end + + breakable_empty + text("]") + end + end + end + + # foo[bar] ||= baz + # ^^^^^^^^^^^^^^^^ + def visit_index_or_write_node(node) + arguments = [*node.arguments, *node.block] + + visit_write(node.operator_loc, node.value) do + group do + visit(node.receiver) + visit_location(node.opening_loc) + + if arguments.any? + indent do + breakable_empty + seplist(arguments) { |argument| visit(argument) } + end + end + + breakable_empty + text("]") + end + end + end + + # foo[bar], = 1 + # ^^^^^^^^ + def visit_index_target_node(node) + group do + visit(node.receiver) + visit_location(node.opening_loc) + + if (arguments = (node.arguments&.arguments || [])).any? + indent do + breakable_empty + seplist(arguments) { |argument| visit(argument) } + end + end + + breakable_empty + text("]") + end + end + + # @foo + # ^^^^ + def visit_instance_variable_read_node(node) + text(node.name.name) + end + + # @foo = 1 + # ^^^^^^^^ + # + # @foo, @bar = 1 + # ^^^^ ^^^^ + def visit_instance_variable_write_node(node) + visit_write(node.operator_loc, node.value) { text(node.name.name) } + end + + # @foo += bar + # ^^^^^^^^^^^ + def visit_instance_variable_operator_write_node(node) + visit_write(node.binary_operator_loc, node.value) { text(node.name.name) } + end + + # @foo &&= bar + # ^^^^^^^^^^^^ + def visit_instance_variable_and_write_node(node) + visit_write(node.operator_loc, node.value) { text(node.name.name) } + end + + # @foo ||= bar + # ^^^^^^^^^^^^ + def visit_instance_variable_or_write_node(node) + visit_write(node.operator_loc, node.value) { text(node.name.name) } + end + + # @foo, = bar + # ^^^^ + def visit_instance_variable_target_node(node) + text(node.name.name) + end + + # 1 + # ^ + def visit_integer_node(node) + slice = node.slice + + if slice.match?(/^[1-9]\d{4,}$/) + # If it's a plain integer and it doesn't have any underscores separating + # the values, then we're going to insert them every 3 characters + # starting from the right. + index = (slice.length + 2) % 3 + text(" #{slice}"[index..].scan(/.../).join("_").strip) + else + text(slice) + end + end + + # if /foo #{bar}/ then end + # ^^^^^^^^^^^^ + def visit_interpolated_match_last_line_node(node) + visit_regular_expression_node_parts(node, node.parts) + end + + # /foo #{bar}/ + # ^^^^^^^^^^^^ + def visit_interpolated_regular_expression_node(node) + visit_regular_expression_node_parts(node, node.parts) + end + + # "foo #{bar}" + # ^^^^^^^^^^^^ + def visit_interpolated_string_node(node) + parts = node.parts + + if node.heredoc? + # First, if this interpolated string was expressed as a heredoc, then + # we'll maintain that formatting and print it out again as a heredoc. + visit_heredoc(node, parts) + elsif parts.length > 1 && independent_string?(parts[0]) && independent_string?(parts[1]) + # Next, we'll check if this string is composed of multiple parts that + # have their own opening. If it is, then this actually represents string + # concatenation and not a single string literal. In this case we'll + # format each on their own with appropriate spacing. + group do + visit(parts[0]) + if_break { text(" \\") } + indent do + breakable_space + seplist( + parts[1..], + -> do + if_break { text(" \\") } + breakable_space + end + ) { |part| visit(part) } + end + end + else + # Finally, if it's a regular interpolated string, we'll forward this on + # to our generic string node formatter. + visit_string_node_parts(node, node.parts) + end + end + + private def independent_string?(node) + case node.type + when :string_node + !node.opening_loc.nil? + when :interpolated_string_node + !node.opening_loc.nil? + else + false + end + end + + # :"foo #{bar}" + # ^^^^^^^^^^^^^ + def visit_interpolated_symbol_node(node) + opening = node.opening + parts = node.parts + + # First, we'll check if we don't have an opening. If we don't, then this + # is inside of a %I literal and we should just print the parts as-is. + return group { parts.each { |part| visit(part) } } if opening.nil? + + # If we're inside of an assoc node as the key, then it will handle + # printing the : on its own since it could change sides. + parent = stack[-2] + hash_key = parent.is_a?(AssocNode) && parent.key == node + + # Here we determine the quotes to use for an interpolated symbol. It's + # bound by a lot of rules because it could be in many different contexts + # with many different kinds of escaping. + opening_quote, closing_quote = + if opening.start_with?("%s") + # Here we're going to check if there is a closing character, a new + # line, or a quote in the content of the dyna symbol. If there is, + # then quoting could get weird, so just bail out and stick to the + # original quotes in the source. + matching = quotes_matching(opening[2]) + pattern = /[\n#{Regexp.escape(matching)}'"]/ + + # This check is to ensure we don't find a matching quote inside of the + # symbol that would be confusing. + matched = parts.any? { |part| part.is_a?(StringNode) && part.content.match?(pattern) } + + if matched + [opening, matching] + elsif quotes_locked?(parts) + ["#{":" unless hash_key}'", "'"] + else + ["#{":" unless hash_key}#{preferred_quote}", preferred_quote] + end + elsif quotes_locked?(parts) + if hash_key + if opening.start_with?(":") + [opening[1..], "#{opening[1..]}:"] + else + [opening, node.closing] + end + else + [opening, node.closing] + end + else + [hash_key ? preferred_quote : ":#{preferred_quote}", preferred_quote] + end + + group do + text(opening_quote) + parts.each do |part| + if part.is_a?(StringNode) + value = quotes_normalize(part.content, closing_quote) + first = true + + value.each_line(chomp: true) do |line| + if first + first = false + else + breakable_return + end + + text(line) + end + + breakable_return if value.end_with?("\n") + else + visit(part) + end + end + text(closing_quote) + end + end + + # `foo #{bar}` + # ^^^^^^^^^^^^ + def visit_interpolated_x_string_node(node) + if node.heredoc? + visit_heredoc(node, node.parts) + else + group do + text(node.opening) + node.parts.each { |part| visit(part) } + text(node.closing) + end + end + end + + # it + # ^^ + def visit_it_local_variable_read_node(_node) + text("it") + end + + # foo(bar: baz) + # ^^^^^^^^ + def visit_keyword_hash_node(node) + elements = node.elements + + case stack[-2]&.type + when :break_node, :next_node, :return_node + visit_keyword_hash_node_layout(node) { |element| visit(element) } + else + if elements.any? { |element| element.value.is_a?(ImplicitNode) } + visit_keyword_hash_node_layout(node) { |element| visit(element) } + elsif elements.all? { |element| + !element.is_a?(AssocNode) || element.operator_loc.nil? || + element.key.is_a?(InterpolatedSymbolNode) || + ( + element.key.is_a?(SymbolNode) && (value = element.key.value) && + value.match?(/^[_A-Za-z]/) && + !value.end_with?("=") + ) + } + visit_keyword_hash_node_layout(node) do |element| + group { visit_assoc_node_label(element) } + end + else + visit_keyword_hash_node_layout(node) do |element| + group { visit_assoc_node_rocket(element) } + end + end + end + end + + # -> { it } + # ^^^^^^^^^ + def visit_it_parameters_node(_node) + raise "Visiting ItParametersNode is not supported." + end + + # Visit a keyword hash node and yield out each plain association element for + # formatting by the caller. + private def visit_keyword_hash_node_layout(node) + seplist(node.elements) do |element| + if element.is_a?(AssocNode) + yield element + else + visit(element) + end + end + end + + # def foo(**bar); end + # ^^^^^ + # + # def foo(**); end + # ^^ + def visit_keyword_rest_parameter_node(node) + name = node.name + + text("**") + text(name.name) if name + end + + # -> {} + def visit_lambda_node(node) + parameters = node.parameters + body = node.body + closing_comments = node.closing_loc.comments + + group do + text("->") + visit(parameters) if parameters.is_a?(BlockParametersNode) + + if body || closing_comments.any? + text(" ") + if_break do + text("do") + node.opening_loc.comments.each { |comment| visit_comment(comment) } + + indent do + if body + breakable_space + visit(body) + end + + closing_comments.each do |comment| + breakable_force + + if comment.is_a?(InlineComment) + text(comment.location.slice) + else + trim + text(comment.location.slice.rstrip) + end + end + end + + breakable_space + text("end") + end.if_flat do + if body + text("{ ") + visit(body) + text(" }") + else + text(" {}") + end + end + else + text(" {}") + end + end + end + + # foo + # ^^^ + def visit_local_variable_read_node(node) + text(node.name.name) + end + + # foo = 1 + # ^^^^^^^ + # + # foo, bar = 1 + # ^^^ ^^^ + def visit_local_variable_write_node(node) + visit_write(node.operator_loc, node.value) { text(node.name.name) } + end + + # foo += bar + # ^^^^^^^^^^ + def visit_local_variable_operator_write_node(node) + visit_write(node.binary_operator_loc, node.value) { text(node.name.name) } + end + + # foo &&= bar + # ^^^^^^^^^^^ + def visit_local_variable_and_write_node(node) + visit_write(node.operator_loc, node.value) { text(node.name.name) } + end + + # foo ||= bar + # ^^^^^^^^^^^ + def visit_local_variable_or_write_node(node) + visit_write(node.operator_loc, node.value) { text(node.name.name) } + end + + # foo, = bar + # ^^^ + def visit_local_variable_target_node(node) + text(node.name.name) + end + + # if /foo/ then end + # ^^^^^ + def visit_match_last_line_node(node) + visit_regular_expression_node_parts( + node, + [bare_string(node.send(:source), node.location.copy, node.content_loc)] + ) + end + + # foo in bar + # ^^^^^^^^^^ + def visit_match_predicate_node(node) + pattern = node.pattern + + group do + visit(node.value) + text(" in") + + case pattern.type + when :array_pattern_node, :hash_pattern_node, :find_pattern_node + text(" ") + visit(pattern) + else + indent do + breakable_space + visit(pattern) + end + end + end + end + + # foo => bar + # ^^^^^^^^^^ + def visit_match_required_node(node) + pattern = node.pattern + + group do + visit(node.value) + text(" =>") + + case pattern.type + when :array_pattern_node, :hash_pattern_node, :find_pattern_node + text(" ") + visit(pattern) + else + indent do + breakable_space + visit(pattern) + end + end + end + end + + # /(?foo)/ =~ bar + # ^^^^^^^^^^^^^^^^^^^^ + def visit_match_write_node(node) + visit(node.call) + end + + # A node that is missing from the syntax tree. This is only used in the + # case of a syntax error. We'll format it as empty. + def visit_missing_node(node) + end + + # module Foo; end + # ^^^^^^^^^^^^^^^ + def visit_module_node(node) + module_keyword_loc = node.module_keyword_loc + + group do + group do + visit_location(module_keyword_loc) + + if module_keyword_loc.comments.any? + indent do + breakable_space + visit(node.constant_path) + end + else + text(" ") + visit(node.constant_path) + end + end + + visit_body(node.body, node.end_keyword_loc.comments) + breakable_force + text("end") + end + end + + # foo, bar = baz + # ^^^^^^^^ + def visit_multi_target_node(node) + targets = [*node.lefts, *node.rest, *node.rights] + implicit_rest = targets.pop if targets.last.is_a?(ImplicitRestNode) + lparen_loc = node.lparen_loc + + group do + if lparen_loc && node.rparen_loc + visit_location(lparen_loc) + indent do + breakable_empty + seplist(targets) { |target| visit(target) } + visit(implicit_rest) if implicit_rest + end + breakable_empty + text(")") + else + seplist(targets) { |target| visit(target) } + visit(implicit_rest) if implicit_rest + end + end + end + + # foo, bar = baz + # ^^^^^^^^^^^^^^ + def visit_multi_write_node(node) + targets = [*node.lefts, *node.rest, *node.rights] + implicit_rest = targets.pop if targets.last.is_a?(ImplicitRestNode) + lparen_loc = node.lparen_loc + + visit_write(node.operator_loc, node.value) do + group do + if lparen_loc && node.rparen_loc + visit_location(lparen_loc) + indent do + breakable_empty + seplist(targets) { |target| visit(target) } + visit(implicit_rest) if implicit_rest + end + breakable_empty + text(")") + else + seplist(targets) { |target| visit(target) } + visit(implicit_rest) if implicit_rest + end + end + end + end + + # next + # ^^^^ + # + # next foo + # ^^^^^^^^ + def visit_next_node(node) + visit_jump("next", node.arguments) + end + + # nil + # ^^^ + def visit_nil_node(_node) + text("nil") + end + + # def foo(**nil); end + # ^^^^^ + def visit_no_keywords_parameter_node(node) + group do + visit_location(node.operator_loc) + align(2) do + breakable_empty + text("nil") + end + end + end + + # -> { _1 + _2 } + # ^^^^^^^^^^^^^^ + def visit_numbered_parameters_node(_node) + raise "Visiting NumberedParametersNode is not supported." + end + + # $1 + # ^^ + def visit_numbered_reference_read_node(node) + text(node.slice) + end + + # def foo(bar: baz); end + # ^^^^^^^^ + def visit_optional_keyword_parameter_node(node) + group do + text("#{node.name.name}:") + indent do + breakable_space + visit(node.value) + end + end + end + + # def foo(bar = 1); end + # ^^^^^^^ + def visit_optional_parameter_node(node) + group do + text("#{node.name.name} ") + visit_location(node.operator_loc) + indent do + breakable_space + visit(node.value) + end + end + end + + # a or b + # ^^^^^^ + def visit_or_node(node) + visit_binary(node.left, node.operator_loc, node.right) + end + + # def foo(bar, *baz); end + # ^^^^^^^^^ + def visit_parameters_node(node) + parameters = node.compact_child_nodes + implicit_rest = parameters.pop if parameters.last.is_a?(ImplicitRestNode) + + align(0) do + seplist(parameters) { |parameter| visit(parameter) } + visit(implicit_rest) if implicit_rest + end + end + + # () + # ^^ + # + # (1) + # ^^^ + def visit_parentheses_node(node) + group do + visit_location(node.opening_loc) + visit_body(node.body, node.closing_loc.comments, false) + breakable_empty + text(")") + end + end + + # foo => ^(bar) + # ^^^^^^ + def visit_pinned_expression_node(node) + group do + text("^") + visit_location(node.lparen_loc) + visit_body(node.expression, node.rparen_loc.comments, false) + breakable_empty + text(")") + end + end + + # foo = 1 and bar => ^foo + # ^^^^ + def visit_pinned_variable_node(node) + visit_prefix(node.operator_loc, node.variable) + end + + # END {} + def visit_post_execution_node(node) + statements = node.statements + closing_comments = node.closing_loc.comments + + group do + text("END ") + visit_location(node.opening_loc) + + if statements || closing_comments.any? + indent do + if statements + breakable_space + visit(statements) + end + + closing_comments.each do |comment| + breakable_empty + text(comment.location.slice) + end + end + + breakable_space + end + + text("}") + end + end + + # BEGIN {} + def visit_pre_execution_node(node) + statements = node.statements + closing_comments = node.closing_loc.comments + + group do + text("BEGIN ") + visit_location(node.opening_loc) + + if statements || closing_comments.any? + indent do + if statements + breakable_space + visit(statements) + end + + closing_comments.each do |comment| + breakable_empty + text(comment.location.slice) + end + end + + breakable_space + end + + text("}") + end + end + + # The top-level program node. + def visit_program_node(node) + visit(node.statements) + seplist(node.location.comments, -> { breakable_force }) do |comment| + text(comment.location.slice.rstrip) + end + break_parent + end + + # 0..5 + # ^^^^ + def visit_range_node(node) + left = node.left + right = node.right + + group do + visit(left) if left + visit_location(node.operator_loc) + visit(right) if right + end + end + + # 1r + # ^^ + def visit_rational_node(node) + text(node.slice) + end + + # redo + # ^^^^ + def visit_redo_node(_node) + text("redo") + end + + # /foo/ + # ^^^^^ + def visit_regular_expression_node(node) + visit_regular_expression_node_parts( + node, + [bare_string(node.send(:source), node.location.copy, node.content_loc)] + ) + end + + # def foo(bar:); end + # ^^^^ + def visit_required_keyword_parameter_node(node) + text("#{node.name.name}:") + end + + # def foo(bar); end + # ^^^ + def visit_required_parameter_node(node) + text(node.name.name) + end + + # foo rescue bar + # ^^^^^^^^^^^^^^ + def visit_rescue_modifier_node(node) + group do + text("begin") + indent do + breakable_force + visit(node.expression) + end + breakable_force + visit_location(node.keyword_loc) + text(" StandardError") + indent do + breakable_force + visit(node.rescue_expression) + end + breakable_force + text("end") + end + end + + # begin; rescue; end + # ^^^^^^^ + def visit_rescue_node(node) + exceptions = node.exceptions + operator_loc = node.operator_loc + reference = node.reference + + statements = node.statements + subsequent = node.subsequent + + group do + group do + visit_location(node.keyword_loc) + + if exceptions.any? + text(" ") + align(7) { seplist(exceptions) { |exception| visit(exception) } } + elsif reference.nil? + text(" StandardError") + end + + if reference + text(" ") + visit_location(operator_loc) + + if operator_loc.comments.any? + indent do + breakable_space + visit(reference) + end + else + text(" ") + visit(reference) + end + end + end + + if statements + indent do + breakable_force + visit(statements) + end + end + + if subsequent + breakable_force + visit(subsequent) + end + end + end + + # def foo(*bar); end + # ^^^^ + # + # def foo(*); end + # ^ + def visit_rest_parameter_node(node) + name = node.name + + if name + group do + visit_location(node.operator_loc) + align(1) do + breakable_empty + text(node.name.name) + end + end + else + visit_location(node.operator_loc) + end + end + + # retry + # ^^^^^ + def visit_retry_node(_node) + text("retry") + end + + # return + # ^^^^^^ + # + # return 1 + # ^^^^^^^^ + def visit_return_node(node) + visit_jump("return", node.arguments) + end + + # self + # ^^^^ + def visit_self_node(_node) + text("self") + end + + # A shareable constant. + def visit_shareable_constant_node(node) + visit(node.write) + end + + # class << self; end + # ^^^^^^^^^^^^^^^^^^ + def visit_singleton_class_node(node) + operator_loc = node.operator_loc + + group do + group do + text("class ") + visit_location(operator_loc) + + if operator_loc.comments.any? + indent do + breakable_space + visit(node.expression) + end + else + text(" ") + visit(node.expression) + end + end + + visit_body(node.body, node.end_keyword_loc.comments) + breakable_force + text("end") + end + end + + # __ENCODING__ + # ^^^^^^^^^^^^ + def visit_source_encoding_node(_node) + text("__ENCODING__") + end + + # __FILE__ + # ^^^^^^^^ + def visit_source_file_node(_node) + text("__FILE__") + end + + # __LINE__ + # ^^^^^^^^ + def visit_source_line_node(_node) + text("__LINE__") + end + + # foo(*bar) + # ^^^^ + # + # def foo((bar, *baz)); end + # ^^^^ + # + # def foo(*); bar(*); end + # ^ + def visit_splat_node(node) + expression = node.expression + operator_loc = node.operator_loc + + if expression + group do + text("*") + operator_loc.comments.each { |comment| visit_comment(comment) } + + align(1) { visit(expression) } + end + else + text("*") + operator_loc.comments.each { |comment| visit_comment(comment) } + end + end + + # A list of statements. + def visit_statements_node(node) + parent = stack[-2] + + previous_line = nil + previous_access_control = false + + node.body.each do |statement| + if previous_line.nil? + visit(statement) + elsif ((statement.location.start_line - previous_line) > 1) || previous_access_control || + access_control?(statement) + breakable_force + breakable_force + visit(statement) + elsif (statement.location.start_line != previous_line) || + !parent.is_a?(EmbeddedStatementsNode) + breakable_force + visit(statement) + else + text("; ") + visit(statement) + end + + previous_line = statement.heredoc_end_line + previous_access_control = access_control?(statement) + end + end + + # "foo" + # ^^^^^ + def visit_string_node(node) + if node.heredoc? + visit_heredoc(node, [node]) + else + opening = node.opening + content = node.content + + if !opening + text(content) + elsif opening == "?" + if content.length == 1 + text(preferred_quote) + text(content == preferred_quote ? "\\#{preferred_quote}" : content) + text(preferred_quote) + else + text("?#{content}") + end + else + visit_string_node_parts(node, [node]) + end + end + end + + # super(foo) + # ^^^^^^^^^^ + def visit_super_node(node) + arguments = [*node.arguments] + block = node.block + + if block.is_a?(BlockArgumentNode) + arguments << block + block = nil + end + + group do + text("super") + + if node.lparen_loc && node.rparen_loc + text("(") + + if arguments.any? + indent do + breakable_empty + seplist(arguments) { |argument| visit(argument) } + end + breakable_empty + end + + text(")") + elsif arguments.any? + text(" ") + align(6) { seplist(arguments) { |argument| visit(argument) } } + end + + if block + text(" ") + visit(block) + end + end + end + + # :foo + # ^^^^ + def visit_symbol_node(node) + text(node.slice) + end + + # true + # ^^^^ + def visit_true_node(_node) + text("true") + end + + # undef foo + # ^^^^^^^^^ + def visit_undef_node(node) + group do + text("undef ") + align(6) do + seplist(node.names) do |name| + if name.is_a?(SymbolNode) + text(name.value) + + if (comment = name.location.comments.first) + visit_comment(comment) + end + else + visit(name) + end + end + end + end + end + + # unless foo; bar end + # ^^^^^^^^^^^^^^^^^^^ + # + # bar unless foo + # ^^^^^^^^^^^^^^ + def visit_unless_node(node) + statements = node.statements + else_clause = node.else_clause + + if !statements || else_clause || contains_conditional?(statements.body.first) + group do + visit_unless_node_break(node) + break_parent + end + elsif contains_write?(node.predicate) || contains_write?(statements) + if node.end_keyword_loc + group do + visit_unless_node_break(node) + break_parent + end + else + group { visit_unless_node_flat(node) } + end + else + group do + if_break { visit_unless_node_break(node) }.if_flat do + ensure_parentheses { visit_unless_node_flat(node) } + end + end + end + end + + # Visit an unless node in the break form. + private def visit_unless_node_break(node) + statements = node.statements + else_clause = node.else_clause + + group do + visit_location(node.keyword_loc) + text(" ") + align(3) { visit(node.predicate) } + end + + if else_clause + visit_body(statements, [], false) + breakable_space + visit(else_clause) + else + visit_body(statements, node.end_keyword_loc&.comments || [], false) + end + + breakable_space + text("end") + end + + # Visit an unless node in the flat form. + private def visit_unless_node_flat(node) + visit(node.statements) + text(" unless ") + visit(node.predicate) + end + + # until foo; bar end + # ^^^^^^^^^^^^^^^^^ + # + # bar until foo + # ^^^^^^^^^^^^^ + def visit_until_node(node) + statements = node.statements + closing_loc = node.closing_loc + + if node.begin_modifier? + group { visit_until_node_flat(node) } + elsif statements.nil? || node.keyword_loc.comments.any? || closing_loc&.comments&.any? + group do + visit_until_node_break(node) + break_parent + end + elsif contains_write?(node.predicate) || contains_write?(statements) + if closing_loc + group do + visit_until_node_break(node) + break_parent + end + else + group { visit_until_node_flat(node) } + end + else + group { if_break { visit_until_node_break(node) }.if_flat { visit_until_node_flat(node) } } + end + end + + # Visit an until node in the break form. + private def visit_until_node_break(node) + visit_location(node.keyword_loc) + text(" ") + align(6) { visit(node.predicate) } + visit_body(node.statements, node.closing_loc&.comments || [], false) + breakable_space + text("end") + end + + # Visit an until node in the flat form. + private def visit_until_node_flat(node) + ensure_parentheses do + visit(node.statements) + text(" until ") + visit(node.predicate) + end + end + + # case foo; when bar; end + # ^^^^^^^^^^^^^ + def visit_when_node(node) + conditions = node.conditions + statements = node.statements + + group do + group do + text("when ") + align(5) do + seplist(conditions, -> { group { comma_breakable } }) { |condition| visit(condition) } + + # Very special case here. If you're inside of a when clause and the + # last condition is an endless range, then you are forced to use the + # "then" keyword to make it parse properly. + last = conditions.last + text(" then") if last.is_a?(RangeNode) && last.right.nil? + end + end + + if statements + indent do + breakable_force + visit(statements) + end + end + end + end + + # while foo; bar end + # ^^^^^^^^^^^^^^^^^^ + # + # bar while foo + # ^^^^^^^^^^^^^ + def visit_while_node(node) + statements = node.statements + closing_loc = node.closing_loc + + if node.begin_modifier? + group { visit_while_node_flat(node) } + elsif statements.nil? || node.keyword_loc.comments.any? || closing_loc&.comments&.any? + group do + visit_while_node_break(node) + break_parent + end + elsif contains_write?(node.predicate) || contains_write?(statements) + if closing_loc + group do + visit_while_node_break(node) + break_parent + end + else + group { visit_while_node_flat(node) } + end + else + group { if_break { visit_while_node_break(node) }.if_flat { visit_while_node_flat(node) } } + end + end + + # Visit a while node in the flat form. + private def visit_while_node_flat(node) + ensure_parentheses do + visit(node.statements) + text(" while ") + visit(node.predicate) + end + end + + # Visit a while node in the break form. + private def visit_while_node_break(node) + visit_location(node.keyword_loc) + text(" ") + align(6) { visit(node.predicate) } + visit_body(node.statements, node.closing_loc&.comments || [], false) + breakable_space + text("end") + end + + # `foo` + # ^^^^^ + def visit_x_string_node(node) + if node.heredoc? + visit_heredoc(node, [node]) + else + text("`#{node.content}`") + end + end + + # yield + # ^^^^^ + # + # yield 1 + # ^^^^^^^ + def visit_yield_node(node) + arguments = node.arguments + + if arguments.nil? + text("yield") + else + lparen_loc = node.lparen_loc + rparen_loc = node.rparen_loc + + group do + text("yield") + + if lparen_loc + visit_location(lparen_loc) + else + if_break { text("(") }.if_flat { text(" ") } + end + + indent do + breakable_empty + visit(arguments) + end + breakable_empty + + if rparen_loc + visit_location(rparen_loc) + else + if_break { text(")") } + end + end + end + end + + private + + # -------------------------------------------------------------------------- + # Helper methods + # -------------------------------------------------------------------------- + + # Returns whether or not the given statement is an access control statement. + # Truthfully, we can't actually tell this for sure without performing method + # lookup, but we assume none of these methods are overridden. + def access_control?(statement) + statement.is_a?(CallNode) && statement.variable_call? && + %i[private protected public].include?(statement.name) + end + + # There are times when it is useful to create string nodes so that + # non-interpolated nodes can be formatted as if they were their interpolated + # counterparts with a single part. + def bare_string(source, location, content_loc) + StringNode.new(source, 0, location, 0, nil, content_loc, 0, "") + end + + # (source, node_id, location, flags, opening_loc, content_loc, closing_loc, unescaped) + + # True if the given node contains a conditional expression. + def contains_conditional?(node) + case node.type + when :if_node, :unless_node + true + else + false + end + end + + # True if the given node contains a write expression. + def contains_write?(node) + case node.type + when :call_and_write_node, :call_or_write_node, :call_operator_write_node, + :class_variable_write_node, :class_variable_and_write_node, + :class_variable_or_write_node, :class_variable_operator_write_node, :constant_write_node, + :constant_and_write_node, :constant_or_write_node, :constant_operator_write_node, + :constant_path_write_node, :constant_path_and_write_node, :constant_path_or_write_node, + :constant_path_operator_write_node, :global_variable_write_node, + :global_variable_and_write_node, :global_variable_or_write_node, + :global_variable_operator_write_node, :instance_variable_write_node, + :instance_variable_and_write_node, :instance_variable_or_write_node, + :instance_variable_operator_write_node, :local_variable_write_node, + :local_variable_and_write_node, :local_variable_or_write_node, + :local_variable_operator_write_node, :multi_write_node + true + when :class_node, :module_node, :singleton_class_node + false + else + node.compact_child_nodes.any? { |child| contains_write?(child) } + end + end + + # If you have a modifier statement (for instance a modifier if statement or + # a modifier while loop) there are times when you need to wrap the entire + # statement in parentheses. This occurs when you have something like: + # + # foo[:foo] = + # if bar? + # baz + # end + # + # Normally we would shorten this to an inline version, which would result in: + # + # foo[:foo] = baz if bar? + # + # but this actually has different semantic meaning. The first example will + # result in a nil being inserted into the hash for the :foo key, whereas the + # second example will result in an empty hash because the if statement + # applies to the entire assignment. + # + # We can fix this in a couple of ways. We can use the then keyword, as in: + # + # foo[:foo] = if bar? then baz end + # + # But this isn't used very often. We can also just leave it as is with the + # multi-line version, but for a short predicate and short value it looks + # verbose. The last option and the one used here is to add parentheses on + # both sides of the expression, as in: + # + # foo[:foo] = (baz if bar?) + # + # This approach maintains the nice conciseness of the inline version, while + # keeping the correct semantic meaning. + def ensure_parentheses + case stack[-2]&.type + when :arguments_node, :assoc_node, :call_node, :call_and_write_node, :call_or_write_node, + :call_operator_write_node, :class_variable_write_node, :class_variable_and_write_node, + :class_variable_or_write_node, :class_variable_operator_write_node, :constant_write_node, + :constant_and_write_node, :constant_or_write_node, :constant_operator_write_node, + :constant_path_write_node, :constant_path_and_write_node, :constant_path_or_write_node, + :constant_path_operator_write_node, :global_variable_write_node, + :global_variable_and_write_node, :global_variable_or_write_node, + :global_variable_operator_write_node, :instance_variable_write_node, + :instance_variable_and_write_node, :instance_variable_or_write_node, + :instance_variable_operator_write_node, :local_variable_write_node, + :local_variable_and_write_node, :local_variable_or_write_node, + :local_variable_operator_write_node, :multi_write_node + text("(") + yield + text(")") + else + yield + end + end + + # Returns whether or not the given node should be indented when it is + # printed as the value of a write expression. + def indent_write?(node) + case node.type + when :array_node + node.opening_loc.nil? + when :hash_node, :lambda_node + false + when :string_node, :x_string_node, :interpolated_string_node, :interpolated_x_string_node + !node.heredoc? + when :call_node + node.receiver.nil? || indent_write?(node.receiver) + when :interpolated_symbol_node + node.opening_loc.nil? || !node.opening.start_with?("%s") + else + true + end + end + + # If there is some part of the string that matches an escape sequence or + # that contains the interpolation pattern ("#{"), then we are locked into + # whichever quote the user chose. (If they chose single quotes, then double + # quoting would activate the escape sequence, and if they chose double + # quotes, then single quotes would deactivate it.) + def quotes_locked?(parts) + parts.any? do |node| + !node.is_a?(StringNode) || node.content.match?(/\\|#[@${]|#{preferred_quote}/) + end + end + + # Find the matching closing quote for the given opening quote. + def quotes_matching(quote) + case quote + when "(" + ")" + when "[" + "]" + when "{" + "}" + when "<" + ">" + else + quote + end + end + + # Escape and unescape single and double quotes as needed to be able to + # enclose +content+ with +enclosing+. + def quotes_normalize(content, enclosing) + return content if enclosing != "\"" && enclosing != "'" + + content.gsub(/\\([\s\S])|(['"])/) do + _match, escaped, quote = Regexp.last_match.to_a + + if quote == enclosing + "\\#{quote}" + elsif quote + quote + else + "\\#{escaped}" + end + end + end + + # -------------------------------------------------------------------------- + # Visit helpers + # -------------------------------------------------------------------------- + + # Visit a node and format it, including any comments that are found around + # it that are attached to its location. + def visit(node) + stack << node + + ignore = false + previous_line = nil + + node.location.leading_comments.each do |comment| + slice = comment.slice + + if previous_line + if (comment.location.start_line - previous_line) > 1 + breakable_force + breakable_force + else + breakable_force + end + end + + if comment.is_a?(InlineComment) + text(slice) + previous_line = comment.location.end_line + else + trim + text(slice.rstrip) + previous_line = comment.location.end_line - 1 + end + + ignore ||= slice.include?("stree-ignore") + end + + if previous_line + if (node.location.start_line - previous_line) > 1 + breakable_force + breakable_force + else + breakable_force + end + end + + doc = + if ignore + # If the node has a stree-ignore comment right before it, then we're + # going to just print out the node as it was seen in the source. + align(0) do + slice = node.slice + seplist(slice.each_line(chomp: true), -> { breakable_return }) do |line| + text(line) + end + breakable_return if slice.end_with?("\n") + end + else + node.accept(self) + end + + node.location.trailing_comments.each { |comment| visit_comment(comment) } + + stack.pop + doc + end + + # Visit a binary expression, and format it with the given left and right + # nodes, and the given operator location. + def visit_binary(left, operator_loc, right) + group do + visit(left) + text(" ") + visit_location(operator_loc) + indent do + breakable_space + visit(right) + end + end + end + + # Visit the body of a node, and format it with the given comments. + def visit_body(body, comments, force_break = true) + break_parent if force_break && (body || comments.any?) + + indent do + if body + breakable_empty if !body.is_a?(BeginNode) || !body.statements.nil? + visit(body) + end + + comments.each do |comment| + if comment.is_a?(InlineComment) + breakable_force + text(comment.location.slice) + else + breakable_force + trim + text(comment.location.slice.rstrip) + end + end + end + end + + # Visit a comment and print it out. + def visit_comment(comment) + if !comment.is_a?(InlineComment) + breakable_force + trim + text(comment.location.slice.rstrip) + breakable_force + elsif comment.trailing? + line_suffix(COMMENT_PRIORITY) do + text(" ") + text(comment.location.slice) + break_parent + end + else + breakable_space + text(comment.location.slice) + break_parent + end + end + + # Visit a set of elements within a collection, along with the comments that + # may be present within them. + def visit_elements(elements, comments) + indent do + if elements.any? + breakable_empty + seplist(elements) { |element| visit(element) } + if_break { text(",") } if trailing_comma + end + + comments.each do |comment| + breakable_force + text(comment.location.slice) + end + end + end + + # Visit a set of elements within a collection, along with the comments that + # may be present within them. Additionally add a space before the start and + # after the end of the collection. + def visit_elements_spaced(elements, comments) + indent do + if elements.any? + breakable_space + seplist(elements) { |element| visit(element) } + end + + comments.each do |comment| + breakable_force + text(comment.slice) + end + end + end + + # Visit a heredoc node, and format it with the given parts. + def visit_heredoc(node, parts) + # If the heredoc is indented, then we're going to need to reintroduce the + # indentation to the parts of the heredoc. + indent = parts.first.is_a?(StringNode) ? "" : parts.first.location.start_line_slice + opening = node.opening + + if opening[2] == "~" + parts.each do |part| + if part.is_a?(StringNode) && !part.content.start_with?("\n") + indent = part.content[/\A[ \t]*/].delete_prefix(part.unescaped[/\A[ \t]*/]) + break + end + end + end + + group do + text(opening) + line_suffix(HEREDOC_PRIORITY) do + group do + target << BREAKABLE_RETURN + + previous_newline = true + parts.each do |part| + case part.type + when :string_node, :x_string_node + value = part.content + seplist(value.each_line(chomp: true), -> { target << BREAKABLE_RETURN }) do |line| + text(line) + end + + if (previous_newline = value.end_with?("\n")) + target << BREAKABLE_RETURN + end + else + text(indent) if previous_newline + visit(part) + previous_newline = false + end + end + + text(node.closing.chomp) + end + end + end + end + + # Visit a jump expression, which consists of a keyword followed by an + # optional set of arguments. + def visit_jump(keyword, arguments) + if !arguments + text(keyword) + elsif arguments.arguments.length == 1 + argument = arguments.arguments.first + + case argument.type + when :parentheses_node + body = argument.body + + if body.is_a?(StatementsNode) && body.body.length == 1 + case (first = body.body.first).type + when :class_variable_read_node, :constant_read_node, :false_node, :float_node, + :global_variable_read_node, :imaginary_node, :instance_variable_read_node, + :integer_node, :local_variable_read_node, :nil_node, :rational_node, :self_node, + :true_node + text("#{keyword} ") + visit(first) + when :array_node + if first.elements.length > 1 + group do + text(keyword) + if_break { text(" [") }.if_flat { text(" ") } + + indent do + breakable_empty + seplist(first.elements) { |element| visit(element) } + end + + if_break do + breakable_empty + text("]") + end + end + else + text("#{keyword} ") + visit(first) + end + else + group do + text(keyword) + visit(argument) + end + end + else + group do + text(keyword) + visit(argument) + end + end + when :class_variable_read_node, :constant_read_node, :false_node, :float_node, + :global_variable_read_node, :imaginary_node, :instance_variable_read_node, + :integer_node, :local_variable_read_node, :nil_node, :rational_node, :self_node, + :true_node + text("#{keyword} ") + visit(argument) + when :array_node + if argument.elements.length > 1 + group do + text(keyword) + if_break { text(" [") }.if_flat { text(" ") } + + indent do + breakable_empty + seplist(argument.elements) { |element| visit(element) } + end + + if_break do + breakable_empty + text("]") + end + end + else + text("#{keyword} ") + visit(argument) + end + else + group do + text(keyword) + if_break { text("(") }.if_flat { text(" ") } + + indent do + breakable_empty + visit(argument) + end + + if_break do + breakable_empty + text(")") + end + end + end + else + group do + text(keyword) + if_break { text(" [") }.if_flat { text(" ") } + + indent do + breakable_empty + visit(arguments) + end + + if_break do + breakable_empty + text("]") + end + end + end + end + + # Print out a slice of the given location, and handle any attached trailing + # comments that may be present. + def visit_location(location, value = location.slice) + location.leading_comments.each do |comment| + if comment.is_a?(InlineComment) + text(comment.location.slice) + else + breakable_force + trim + text(comment.location.slice.rstrip) + end + breakable_force + end + + text(value) + location.trailing_comments.each { |comment| visit_comment(comment) } + end + + # Visit a prefix expression, which consists of a single operator prefixing + # a nested expression. + def visit_prefix(operator_loc, value) + if value + group do + visit_location(operator_loc) + align(operator_loc.length) { visit(value) } + end + else + visit_location(operator_loc) + end + end + + # Visit the parts of a regular expression-like node. + def visit_regular_expression_node_parts(node, parts) + # If the first part of this regex is plain string content, we have a space + # or an =, and we're contained within a command or command_call node, then + # we want to use braces because otherwise we could end up with an + # ambiguous operator, e.g. foo / bar/ or foo /=bar/ + ambiguous = + ( + (part = parts.first) && part.is_a?(StringNode) && part.content.start_with?(" ", "=") && + stack[0...-1].reverse_each.any? do |parent| + parent.is_a?(CallNode) && parent.arguments && parent.opening_loc.nil? + end + ) + + braces = + (ambiguous || parts.any? { |part| part.is_a?(StringNode) && part.content.include?("/") }) + + if braces && parts.any? { |part| part.is_a?(StringNode) && part.content.match?(/[{}]/) } + group do + text(node.opening) + parts.each { |part| visit(part) } + text(node.closing) + end + elsif braces + group do + text("%r{") + + parts.each do |part| + if part.is_a?(StringNode) + seplist(part.content.each_line(chomp: true), -> { breakable_return }) do |line| + text(line.gsub(%r{(?(line_suffix) do + [-line_suffix.last.priority, -line_suffixes.index(line_suffix)] + end + + # This is a linear stack instead of a mutually recursive call defined on + # the individual doc nodes for efficiency. + while (indent, mode, doc = commands.pop) + case doc.type + when :string + buffer << doc + position += doc.length + when :group + if mode == MODE_FLAT && !should_remeasure + next_mode = doc.break ? MODE_BREAK : MODE_FLAT + commands.concat(doc.contents.reverse.map { |part| [indent, next_mode, part] }) + else + should_remeasure = false + + if doc.break + commands.concat(doc.contents.reverse.map { |part| [indent, MODE_BREAK, part] }) + else + next_commands = doc.contents.reverse.map { |part| [indent, MODE_FLAT, part] } + + if fits?(next_commands, commands, print_width - position) + commands.concat(next_commands) + else + next_commands.each { |command| command[1] = MODE_BREAK } + commands.concat(next_commands) + end + end + end + when :breakable + if mode == MODE_FLAT + if doc.force + # This line was forced into the output even if we were in flat mode, + # so we need to tell the next group that no matter what, it needs to + # remeasure because the previous measurement didn't accurately + # capture the entire expression (this is necessary for nested + # groups). + should_remeasure = true + else + buffer << doc.separator + position += doc.width + next + end + end + + # If there are any commands in the line suffix buffer, then we're going + # to flush them now, as we are about to add a newline. + if line_suffixes.any? + commands << [indent, mode, doc] + + line_suffixes + .sort_by(&line_suffix_sort) + .each do |(indent, mode, doc)| + commands += doc.contents.reverse.map { |part| [indent, mode, part] } + end + + line_suffixes.clear + next + end + + if !doc.indent + buffer << "\n" + position = 0 + else + position -= trim!(buffer) + buffer << "\n" + buffer << " " * indent + position = indent + end + when :indent + next_indent = indent + 2 + commands.concat(doc.contents.reverse.map { |part| [next_indent, mode, part] }) + when :align + next_indent = indent + doc.indent + commands.concat(doc.contents.reverse.map { |part| [next_indent, mode, part] }) + when :trim + position -= trim!(buffer) + when :if_break + if mode == MODE_BREAK && doc.break_contents.any? + commands.concat(doc.break_contents.reverse.map { |part| [indent, mode, part] }) + elsif mode == MODE_FLAT && doc.flat_contents.any? + commands.concat(doc.flat_contents.reverse.map { |part| [indent, mode, part] }) + end + when :line_suffix + line_suffixes << [indent, mode, doc] + when :break_parent + # do nothing + else + # Special case where the user has defined some way to get an extra doc + # node that we don't explicitly support into the list. In this case + # we're going to assume it's 0-width and just append it to the output + # buffer. + # + # This is useful behavior for putting marker nodes into the list so that + # you can know how things are getting mapped before they get printed. + buffer << doc + end + + if commands.empty? && line_suffixes.any? + line_suffixes + .sort_by(&line_suffix_sort) + .each do |(indent, mode, doc)| + commands.concat(doc.contents.reverse.map { |part| [indent, mode, part] }) + end + + line_suffixes.clear + end + end + end + + # This method returns a boolean as to whether or not the remaining commands + # fit onto the remaining space on the current line. If we finish printing + # all of the commands or if we hit a newline, then we return true. Otherwise + # if we continue printing past the remaining space, we return false. + def fits?(next_commands, rest_commands, remaining) + # This is the index in the remaining commands that we've handled so far. + # We reverse through the commands and add them to the stack if we've run + # out of nodes to handle. + rest_index = rest_commands.length + + # This is our stack of commands, very similar to the commands list in the + # print method. + commands = [*next_commands] + + # This is our output buffer, really only necessary to keep track of + # because we could encounter a Trim doc node that would actually add + # remaining space. + fit_buffer = [] + + while remaining >= 0 + if commands.empty? + return true if rest_index == 0 + + rest_index -= 1 + commands << rest_commands[rest_index] + next + end + + indent, mode, doc = commands.pop + + case doc.type + when :string + fit_buffer << doc + remaining -= doc.length + when :group + next_mode = doc.break ? MODE_BREAK : mode + commands += doc.contents.reverse.map { |part| [indent, next_mode, part] } + when :breakable + if mode == MODE_FLAT && !doc.force + fit_buffer << doc.separator + remaining -= doc.width + next + end + + return true + when :indent + next_indent = indent + 2 + commands += doc.contents.reverse.map { |part| [next_indent, mode, part] } + when :align + next_indent = indent + doc.indent + commands += doc.contents.reverse.map { |part| [next_indent, mode, part] } + when :trim + remaining += trim!(fit_buffer) + when :if_break + if mode == MODE_BREAK && doc.break_contents.any? + commands += doc.break_contents.reverse.map { |part| [indent, mode, part] } + elsif mode == MODE_FLAT && doc.flat_contents.any? + commands += doc.flat_contents.reverse.map { |part| [indent, mode, part] } + end + end + end + + false + end + + def trim!(buffer) + return 0 if buffer.empty? + + trimmed = 0 + + while buffer.any? && buffer.last.is_a?(String) && buffer.last.match?(/\A[\t ]*\z/) + trimmed += buffer.pop.length + end + + if buffer.any? && buffer.last.is_a?(String) && !buffer.last.frozen? + length = buffer.last.length + buffer.last.gsub!(/[\t ]*\z/, "") + trimmed += length - buffer.last.length + end + + trimmed + end + + # -------------------------------------------------------------------------- + # Helper node builders + # -------------------------------------------------------------------------- + + # This method calculates the position of the text relative to the current + # indentation level when the doc has been printed. It's useful for + # determining how to align text to doc nodes that are already built into the + # tree. + def last_position(node) + queue = [node] + width = 0 + + while (doc = queue.shift) + case doc.type + when :string + width += doc.length + when :group, :indent, :align + queue = doc.contents + queue + when :breakable + width = 0 + when :if_break + queue = doc.break_contents + queue + end + end + + width + end + + # This method will remove any breakables from the list of contents so that + # no newlines are present in the output. If a newline is being forced into + # the output, the replace value will be used. + def remove_breaks(node, replace = "; ") + queue = [node] + + while (doc = queue.shift) + case doc.type + when :align, :indent + doc.contents.map! { |child| remove_breaks_with(child, replace) } + queue.concat(doc.contents) + when :group + doc.unbreak! + doc.contents.map! { |child| remove_breaks_with(child, replace) } + queue.concat(doc.contents) + when :if_break + doc.flat_contents.map! { |child| remove_breaks_with(child, replace) } + queue.concat(doc.flat_contents) + end + end + end + + # Remove breaks from a subtree with the given replacement string. + def remove_breaks_with(doc, replace) + case doc.type + when :breakable + doc.force ? replace : doc.separator + when :if_break + Align.new(0, doc.flat_contents) + else + doc + end + end + + # Adds a separated list. + # The list is separated by comma with breakable space, by default. + # + # #seplist iterates the +list+ using +each+. + # It yields each object to the block given for #seplist. + # The procedure +separator_proc+ is called between each yields. + # + # If the iteration is zero times, +separator_proc+ is not called at all. + # + # If +separator_proc+ is nil or not given, + # +lambda { comma_breakable }+ is used. + # + # For example, following 3 code fragments has similar effect. + # + # q.seplist([1,2,3]) {|v| xxx v } + # + # q.seplist([1,2,3], lambda { q.comma_breakable }, :each) {|v| xxx v } + # + # xxx 1 + # q.comma_breakable + # xxx 2 + # q.comma_breakable + # xxx 3 + def seplist(list, sep = nil) + first = true + + list.each do |v| + if first + first = false + elsif sep + sep.call + else + comma_breakable + end + + yield(v) + end + end + + # -------------------------------------------------------------------------- + # Markers node builders + # -------------------------------------------------------------------------- + + # This says "you can break a line here if necessary", and a +width+\-column + # text +separator+ is inserted if a line is not broken at the point. + # + # If +separator+ is not specified, ' ' is used. + # + # If +width+ is not specified, +separator.length+ is used. You will have to + # specify this when +separator+ is a multibyte character, for example. + # + # By default, if the surrounding group is broken and a newline is inserted, + # the printer will indent the subsequent line up to the current level of + # indentation. You can disable this behavior with the +indent+ argument if + # that's not desired (rare). + # + # By default, when you insert a Breakable into the print tree, it only + # breaks the surrounding group when the group's contents cannot fit onto the + # remaining space of the current line. You can force it to break the + # surrounding group instead if you always want the newline with the +force+ + # argument. + # + # There are a few circumstances where you'll want to force the newline into + # the output but no insert a break parent (because you don't want to + # necessarily force the groups to break unless they need to). In this case + # you can pass `force: :skip_break_parent` to this method and it will not + # insert a break parent. + + # The vast majority of breakable calls you receive while formatting are a + # space in flat mode and a newline in break mode. Since this is so common, + # we have a method here to skip past unnecessary calculation. + def breakable_space + target << BREAKABLE_SPACE + end + + # Another very common breakable call you receive while formatting is an + # empty string in flat mode and a newline in break mode. Similar to + # breakable_space, this is here for avoid unnecessary calculation. + def breakable_empty + target << BREAKABLE_EMPTY + end + + # The final of the very common breakable calls you receive while formatting + # is the normal breakable space but with the addition of the break_parent. + def breakable_force + target << BREAKABLE_FORCE + break_parent + end + + # This is the same shortcut as breakable_force, except that it doesn't + # indent the next line. This is necessary if you're trying to preserve some + # custom formatting like a multi-line string. + def breakable_return + target << BREAKABLE_RETURN + break_parent + end + + # This inserts a BreakParent node into the print tree which forces the + # surrounding and all parent group nodes to break. + def break_parent + doc = BREAK_PARENT + target << doc + + groups.reverse_each do |group| + break if group.break + group.break! + end + end + + # A convenience method which is same as follows: + # + # text(",") + # breakable + def comma_breakable + text(",") + breakable_space + end + + # This inserts a Trim node into the print tree which, when printed, will + # clear all whitespace at the end of the output buffer. This is useful for + # the rare case where you need to delete printed indentation and force the + # next node to start at the beginning of the line. + def trim + target << TRIM + end + + # -------------------------------------------------------------------------- + # Container node builders + # -------------------------------------------------------------------------- + + # Increases left margin after newline with +indent+ for line breaks added in + # the block. + def align(indent) + contents = [] + doc = Align.new(indent, contents) + target << doc + + with_target(contents) { yield } + doc + end + + # Groups line break hints added in the block. The line break hints are all + # to be used or not. + # + # If +indent+ is specified, the method call is regarded as nested by + # align(indent) { ... }. + # + # If +open_object+ is specified, text(open_object, open_width) is + # called before grouping. If +close_object+ is specified, + # text(close_object, close_width) is called after grouping. + def group + contents = [] + doc = Group.new(contents) + + groups << doc + target << doc + + with_target(contents) { yield } + groups.pop + + doc + end + + # Group if the predicate is true, otherwise just yield the block. + def group_if(predicate) + if predicate + group { yield } + else + yield + end + end + + # A small DSL-like object used for specifying the alternative contents to be + # printed if the surrounding group doesn't break for an IfBreak node. + class IfBreakBuilder + attr_reader :q, :flat_contents + + def initialize(q, flat_contents) + @q = q + @flat_contents = flat_contents + end + + def if_flat + q.with_target(flat_contents) { yield } + end + end + + # When we already know that groups are broken, we don't actually need to + # track the flat versions of the contents. So this builder version is + # effectively a no-op, but we need it to maintain the same API. The only + # thing this can impact is that if there's a forced break in the flat + # contents, then we need to propagate that break up the whole tree. + class IfFlatIgnore + attr_reader :q + + def initialize(q) + @q = q + end + + def if_flat + contents = [] + group = Group.new(contents) + + q.with_target(contents) { yield } + q.break_parent if group.break + end + end + + # Inserts an IfBreak node with the contents of the block being added to its + # list of nodes that should be printed if the surrounding node breaks. If it + # doesn't, then you can specify the contents to be printed with the #if_flat + # method used on the return object from this method. For example, + # + # q.if_break { q.text('do') }.if_flat { q.text('{') } + # + # In the example above, if the surrounding group is broken it will print + # 'do' and if it is not it will print '{'. + def if_break + break_contents = [] + flat_contents = [] + + doc = IfBreak.new(break_contents, flat_contents) + target << doc + + with_target(break_contents) { yield } + + if groups.last.break + IfFlatIgnore.new(self) + else + IfBreakBuilder.new(self, flat_contents) + end + end + + # This is similar to if_break in that it also inserts an IfBreak node into + # the print tree, however it's starting from the flat contents, and cannot + # be used to build the break contents. + def if_flat + if groups.last.break + contents = [] + group = Group.new(contents) + + with_target(contents) { yield } + break_parent if group.break + else + flat_contents = [] + doc = IfBreak.new(break_contents, flat_contents) + target << doc + + with_target(flat_contents) { yield } + doc + end + end + + # Very similar to the #nest method, this indents the nested content by one + # level by inserting an Indent node into the print tree. The contents of the + # node are determined by the block. + def indent + contents = [] + doc = Indent.new(contents) + target << doc + + with_target(contents) { yield } + doc + end + + # Inserts a LineSuffix node into the print tree. The contents of the node + # are determined by the block. + def line_suffix(priority) + contents = [] + doc = LineSuffix.new(priority, contents) + target << doc + + with_target(contents) { yield } + doc + end + + # Push a value onto the output target. + def text(value) + target << value + end + end +end diff --git a/lib/syntax_tree/formatter.rb b/lib/syntax_tree/formatter.rb deleted file mode 100644 index 2b229885..00000000 --- a/lib/syntax_tree/formatter.rb +++ /dev/null @@ -1,228 +0,0 @@ -# frozen_string_literal: true - -module SyntaxTree - # A slightly enhanced PP that knows how to format recursively including - # comments. - class Formatter < PrettierPrint - # Unfortunately, Gem::Version.new is not ractor-safe because it performs - # global caching using a class variable. This works around that by just - # setting the instance variables directly. - class SemanticVersion < ::Gem::Version - def initialize(version) - @version = version - @segments = nil - end - end - - # We want to minimize as much as possible the number of options that are - # available in syntax tree. For the most part, if users want non-default - # formatting, they should override the format methods on the specific nodes - # themselves. However, because of some history with prettier and the fact - # that folks have become entrenched in their ways, we decided to provide a - # small amount of configurability. - class Options - attr_reader :quote, - :trailing_comma, - :disable_auto_ternary, - :target_ruby_version - - def initialize( - quote: :default, - trailing_comma: :default, - disable_auto_ternary: :default, - target_ruby_version: :default - ) - @quote = - if quote == :default - # We ship with a single quotes plugin that will define this - # constant. That constant is responsible for determining the default - # quote style. If it's defined, we default to single quotes, - # otherwise we default to double quotes. - defined?(SINGLE_QUOTES) ? "'" : "\"" - else - quote - end - - @trailing_comma = - if trailing_comma == :default - # We ship with a trailing comma plugin that will define this - # constant. That constant is responsible for determining the default - # trailing comma value. If it's defined, then we default to true. - # Otherwise we default to false. - defined?(TRAILING_COMMA) - else - trailing_comma - end - - @disable_auto_ternary = - if disable_auto_ternary == :default - # We ship with a disable ternary plugin that will define this - # constant. That constant is responsible for determining the default - # disable ternary value. If it's defined, then we default to true. - # Otherwise we default to false. - defined?(DISABLE_AUTO_TERNARY) - else - disable_auto_ternary - end - - @target_ruby_version = - if target_ruby_version == :default - # The default target Ruby version is the current version of Ruby. - # This is really only used for very niche cases, and it shouldn't be - # used by most users. - SemanticVersion.new(RUBY_VERSION) - else - target_ruby_version - end - end - end - - COMMENT_PRIORITY = 1 - HEREDOC_PRIORITY = 2 - - attr_reader :source, :stack - - # These options are overridden in plugins to we need to make sure they are - # available here. - attr_reader :quote, - :trailing_comma, - :disable_auto_ternary, - :target_ruby_version - - alias trailing_comma? trailing_comma - alias disable_auto_ternary? disable_auto_ternary - - def initialize(source, *args, options: Options.new) - super(*args) - - @source = source - @stack = [] - - # Memoizing these values to make access faster. - @quote = options.quote - @trailing_comma = options.trailing_comma - @disable_auto_ternary = options.disable_auto_ternary - @target_ruby_version = options.target_ruby_version - end - - def self.format(source, node, base_indentation = 0) - q = new(source, []) - q.format(node) - q.flush(base_indentation) - q.output.join - end - - def format(node, stackable: true) - stack << node if stackable - doc = nil - - # If there are comments, then we're going to format them around the node - # so that they get printed properly. - if node.comments.any? - trailing = [] - last_leading = nil - - # First, we're going to print all of the comments that were found before - # the node. We'll also gather up any trailing comments that we find. - node.comments.each do |comment| - if comment.leading? - comment.format(self) - breakable(force: true) - last_leading = comment - else - trailing << comment - end - end - - # If the node has a stree-ignore comment right before it, then we're - # going to just print out the node as it was seen in the source. - doc = - if last_leading&.ignore? - range = source[node.start_char...node.end_char] - first = true - - range.each_line(chomp: true) do |line| - if first - first = false - else - breakable_return - end - - text(line) - end - - breakable_return if range.end_with?("\n") - else - node.format(self) - end - - # Print all comments that were found after the node. - trailing.each do |comment| - line_suffix(priority: COMMENT_PRIORITY) do - comment.inline? ? text(" ") : breakable - comment.format(self) - break_parent - end - end - else - doc = node.format(self) - end - - stack.pop if stackable - doc - end - - def format_each(nodes) - nodes.each { |node| format(node) } - end - - def grandparent - stack[-3] - end - - def parent - stack[-2] - end - - def parents - stack[0...-1].reverse_each - end - - # This is a simplified version of prettyprint's group. It doesn't provide - # any of the more advanced options because we don't need them and they take - # up expensive computation time. - def group - contents = [] - doc = Group.new(0, contents: contents) - - groups << doc - target << doc - - with_target(contents) { yield } - groups.pop - doc - end - - # A similar version to the super, except that it calls back into the - # separator proc with the instance of `self`. - def seplist(list, sep = nil, iter_method = :each) - first = true - list.__send__(iter_method) do |*v| - if first - first = false - elsif sep - sep.call(self) - else - comma_breakable - end - yield(*v) - end - end - - # This is a much simplified version of prettyprint's text. It avoids - # calculating width by pushing the string directly onto the target. - def text(string) - target << string - end - end -end diff --git a/lib/syntax_tree/language_server.rb b/lib/syntax_tree/language_server.rb deleted file mode 100644 index 7d838a0c..00000000 --- a/lib/syntax_tree/language_server.rb +++ /dev/null @@ -1,163 +0,0 @@ -# frozen_string_literal: true - -require "cgi" -require "json" -require "pp" -require "uri" - -module SyntaxTree - # Syntax Tree additionally ships with a language server conforming to the - # language server protocol. It can be invoked through the CLI by running: - # - # stree lsp - # - class LanguageServer - # This is a small module that effectively mirrors pattern matching. We're - # using it so that we can support truffleruby without having to ignore the - # language server. - module Request - # Represents a hash pattern. - class Shape - attr_reader :values - - def initialize(values) - @values = values - end - - def ===(other) - values.all? do |key, value| - value == :any ? other.key?(key) : value === other[key] - end - end - end - - # Represents an array pattern. - class Tuple - attr_reader :values - - def initialize(values) - @values = values - end - - def ===(other) - values.each_with_index.all? { |value, index| value === other[index] } - end - end - - def self.[](value) - case value - when Array - Tuple.new(value.map { |child| self[child] }) - when Hash - Shape.new(value.transform_values { |child| self[child] }) - else - value - end - end - end - - attr_reader :input, :output, :print_width - - def initialize( - input: $stdin, - output: $stdout, - print_width: DEFAULT_PRINT_WIDTH, - ignore_files: [] - ) - @input = input.binmode - @output = output.binmode - @print_width = print_width - @ignore_files = ignore_files - end - - # rubocop:disable Layout/LineLength - def run - store = - Hash.new do |hash, uri| - filepath = CGI.unescape(URI.parse(uri).path) - File.exist?(filepath) ? (hash[uri] = File.read(filepath)) : nil - end - - while (headers = input.gets("\r\n\r\n")) - source = input.read(headers[/Content-Length: (\d+)/i, 1].to_i) - request = JSON.parse(source, symbolize_names: true) - - # stree-ignore - case request - when Request[method: "initialize", id: :any] - store.clear - write(id: request[:id], result: { capabilities: capabilities }) - when Request[method: "initialized"] - # ignored - when Request[method: "shutdown"] # tolerate missing ID to be a good citizen - store.clear - write(id: request[:id], result: {}) - return - when Request[method: "textDocument/didChange", params: { textDocument: { uri: :any }, contentChanges: [{ text: :any }] }] - store[request.dig(:params, :textDocument, :uri)] = request.dig(:params, :contentChanges, 0, :text) - when Request[method: "textDocument/didOpen", params: { textDocument: { uri: :any, text: :any } }] - store[request.dig(:params, :textDocument, :uri)] = request.dig(:params, :textDocument, :text) - when Request[method: "textDocument/didClose", params: { textDocument: { uri: :any } }] - store.delete(request.dig(:params, :textDocument, :uri)) - when Request[method: "textDocument/formatting", id: :any, params: { textDocument: { uri: :any } }] - uri = request.dig(:params, :textDocument, :uri) - filepath = uri.split("///").last - ignore = @ignore_files.any? do |glob| - File.fnmatch(glob, filepath) - end - contents = store[uri] - write(id: request[:id], result: contents && !ignore ? format(contents, uri.split(".").last) : nil) - when Request[method: %r{\$/.+}] - # ignored - when Request[method: "textDocument/documentColor", params: { textDocument: { uri: :any } }] - # ignored - else - raise ArgumentError, "Unhandled: #{request}" - end - end - end - # rubocop:enable Layout/LineLength - - private - - def capabilities - { - documentFormattingProvider: true, - textDocumentSync: { - change: 1, - openClose: true - } - } - end - - def format(source, extension) - text = SyntaxTree::HANDLERS[".#{extension}"].format(source, print_width) - - [ - { - range: { - start: { - line: 0, - character: 0 - }, - end: { - line: source.lines.size + 1, - character: 0 - } - }, - newText: text - } - ] - rescue Parser::ParseError - # If there is a parse error, then we're not going to return any formatting - # changes for this source. - nil - end - - def write(value) - response = value.merge(jsonrpc: "2.0").to_json - output.print("Content-Length: #{response.bytesize}\r\n\r\n#{response}") - output.flush - end - end -end diff --git a/lib/syntax_tree/lsp.rb b/lib/syntax_tree/lsp.rb new file mode 100644 index 00000000..e38e8166 --- /dev/null +++ b/lib/syntax_tree/lsp.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require "cgi/escape" +require "json" +require "uri" + +module SyntaxTree + # A language server conforming to the language server protocol. It can be + # invoked through the CLI by running: + # + # stree lsp + # + # rubocop:disable Layout/LineLength + class LSP + def initialize(input = $stdin, output = $stdout, options: SyntaxTree.options, ignore_files: []) + @input = input.binmode + @output = output.binmode.tap { |io| io.sync = true } + + @options = options + @ignore_files = ignore_files + end + + def run + store = + Hash.new do |hash, uri| + filepath = CGI.unescape(URI.parse(uri).path) + hash[uri] = File.read(filepath) if File.exist?(filepath) + end + + # stree-ignore + while (headers = @input.gets("\r\n\r\n")) + body = @input.read(Integer(headers[/Content-Length: (\d+)/i, 1])) + + case (request = JSON.parse(body, symbolize_names: true))[:method].to_sym + when :"textDocument/didChange" + request => { params: { textDocument: { uri: %r{\A.+//(.+\..+?)\z} => uri }, contentChanges: [{ text: }] } } + store[uri] = text unless ignored?($1) + when :"textDocument/didOpen" + request => { params: { textDocument: { uri: %r{\A.+//(.+\..+?)\z} => uri, text: } } } + store[uri] = text unless ignored?($1) + when :"textDocument/didClose" + request => { params: { textDocument: { uri: } } } + store.delete(uri) + when :"textDocument/formatting" + request => { params: { textDocument: { uri: %r{\A.+//(.+(\..+?))\z} => uri } } } + filepath = $1 + extension = $2 + + write( + request[:id], + if (source = store[uri]) && !ignored?(filepath) + begin + [ + { + newText: SyntaxTree.handler_for(extension).format(source, @options), + range: { + start: { line: 0, character: 0 }, + end: { line: source.count("\n") + 1, character: 0 } + } + } + ] + rescue ParseError + end + end + ) + when :initialize + store.clear + + write( + request[:id], + { + capabilities: { + documentFormattingProvider: true, + textDocumentSync: { change: 1, openClose: true } + } + } + ) + when :shutdown + store.clear + + write(request[:id], {}) and break + when :initialized, :"textDocument/documentColor" + # ignored + else + unless request[:method].start_with?("$/") + raise ArgumentError, "Unknown method: #{request[:method]}" + end + end + end + end + + private + + def ignored?(filepath) + @ignore_files.any? { |pattern| File.fnmatch(pattern, filepath) } + end + + def write(id, result) + response = { id: id, result: result, jsonrpc: "2.0" }.to_json + @output.print("Content-Length: #{response.bytesize}\r\n\r\n#{response}") + end + end + # rubocop:enable Layout/LineLength +end diff --git a/lib/syntax_tree/node.rb b/lib/syntax_tree/node.rb deleted file mode 100644 index 495dc7b0..00000000 --- a/lib/syntax_tree/node.rb +++ /dev/null @@ -1,12380 +0,0 @@ -# frozen_string_literal: true - -module SyntaxTree - # Represents the location of a node in the tree from the source code. - class Location - attr_reader :start_line, - :start_char, - :start_column, - :end_line, - :end_char, - :end_column - - def initialize( - start_line:, - start_char:, - start_column:, - end_line:, - end_char:, - end_column: - ) - @start_line = start_line - @start_char = start_char - @start_column = start_column - @end_line = end_line - @end_char = end_char - @end_column = end_column - end - - def lines - start_line..end_line - 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, - start_column: start_column, - end_line: [end_line, other.end_line].max, - end_char: other.end_char, - end_column: other.end_column - ) - end - - def deconstruct - [start_line, start_char, start_column, end_line, end_char, end_column] - end - - def deconstruct_keys(_keys) - { - start_line: start_line, - start_char: start_char, - start_column: start_column, - end_line: end_line, - end_char: end_char, - end_column: end_column - } - end - - def self.token(line:, char:, column:, size:) - new( - start_line: line, - start_char: char, - start_column: column, - end_line: line, - end_char: char + size, - end_column: column + size - ) - end - - def self.fixed(line:, char:, column:) - new( - start_line: line, - start_char: char, - start_column: column, - end_line: line, - end_char: char, - end_column: column - ) - end - - # A convenience method that is typically used when you don't care about the - # location of a node, but need to create a Location instance to pass to a - # constructor. - def self.default - new( - start_line: 1, - start_char: 0, - start_column: 0, - end_line: 1, - end_char: 0, - end_column: 0 - ) - end - end - - # This is the parent node of all of the syntax tree nodes. It's pretty much - # exclusively here to make it easier to operate with the tree in cases where - # you're trying to monkey-patch or strictly type. - class Node - # [Location] the location of this node - attr_reader :location - - def accept(visitor) - raise NotImplementedError - end - - def child_nodes - raise NotImplementedError - end - - def deconstruct - raise NotImplementedError - end - - def deconstruct_keys(keys) - raise NotImplementedError - end - - def format(q) - raise NotImplementedError - end - - def start_char - location.start_char - end - - def end_char - location.end_char - end - end - - # When we're implementing the === operator for a node, we oftentimes need to - # compare two arrays. We want to skip over the === definition of array and use - # our own here, so we do that using this module. - module ArrayMatch - def self.call(left, right) - left.length === right.length && - left - .zip(right) - .all? { |left_value, right_value| left_value === right_value } - end - end - - # 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 < Node - # [LBrace] the left brace that is seen after the keyword - attr_reader :lbrace - - # [Statements] the expressions to be executed - attr_reader :statements - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(lbrace:, statements:, location:) - @lbrace = lbrace - @statements = statements - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_BEGIN(self) - end - - def child_nodes - [lbrace, statements] - end - - def copy(lbrace: nil, statements: nil, location: nil) - node = - BEGINBlock.new( - lbrace: lbrace || self.lbrace, - statements: statements || self.statements, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - lbrace: lbrace, - statements: statements, - location: location, - comments: comments - } - end - - def format(q) - q.group do - q.text("BEGIN ") - q.format(lbrace) - q.indent do - q.breakable_space - q.format(statements) - end - q.breakable_space - q.text("}") - end - end - - def ===(other) - other.is_a?(BEGINBlock) && lbrace === other.lbrace && - statements === other.statements - end - 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 < Node - # [String] the value of the character literal - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_CHAR(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - node = - CHAR.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - if value.length != 2 - q.text(value) - else - q.text(q.quote) - q.text(value[1] == q.quote ? "\\#{q.quote}" : value[1]) - q.text(q.quote) - end - end - - def ===(other) - other.is_a?(CHAR) && value === other.value - end - 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 < Node - # [LBrace] the left brace that is seen after the keyword - attr_reader :lbrace - - # [Statements] the expressions to be executed - attr_reader :statements - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(lbrace:, statements:, location:) - @lbrace = lbrace - @statements = statements - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_END(self) - end - - def child_nodes - [lbrace, statements] - end - - def copy(lbrace: nil, statements: nil, location: nil) - node = - ENDBlock.new( - lbrace: lbrace || self.lbrace, - statements: statements || self.statements, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - lbrace: lbrace, - statements: statements, - location: location, - comments: comments - } - end - - def format(q) - q.group do - q.text("END ") - q.format(lbrace) - q.indent do - q.breakable_space - q.format(statements) - end - q.breakable_space - q.text("}") - end - end - - def ===(other) - other.is_a?(ENDBlock) && lbrace === other.lbrace && - statements === other.statements - end - 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 < Node - # [String] the content after the script - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit___end__(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - node = - EndContent.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text("__END__") - q.breakable_force - - first = true - value.each_line(chomp: true) do |line| - if first - first = false - else - q.breakable_return - end - - q.text(line) - end - - q.breakable_return if value.end_with?("\n") - end - - def ===(other) - other.is_a?(EndContent) && value === other.value - end - 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 AliasNode < Node - # Formats an argument to the alias keyword. For symbol literals it uses the - # value of the symbol directly to look like bare words. - class AliasArgumentFormatter - # [Backref | DynaSymbol | GVar | SymbolLiteral] the argument being passed - # to alias - attr_reader :argument - - def initialize(argument) - @argument = argument - end - - def comments - if argument.is_a?(SymbolLiteral) - argument.comments + argument.value.comments - else - argument.comments - end - end - - def format(q) - if argument.is_a?(SymbolLiteral) - q.format(argument.value) - else - q.format(argument) - end - end - end - - # [DynaSymbol | GVar | SymbolLiteral] the new name of the method - attr_reader :left - - # [Backref | DynaSymbol | GVar | SymbolLiteral] the old name of the method - attr_reader :right - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(left:, right:, location:) - @left = left - @right = right - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_alias(self) - end - - def child_nodes - [left, right] - end - - def copy(left: nil, right: nil, location: nil) - node = - AliasNode.new( - left: left || self.left, - right: right || self.right, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { left: left, right: right, location: location, comments: comments } - end - - def format(q) - keyword = "alias " - left_argument = AliasArgumentFormatter.new(left) - - q.group do - q.text(keyword) - q.format(left_argument, stackable: false) - q.group do - q.nest(keyword.length) do - left_argument.comments.any? ? q.breakable_force : q.breakable_space - q.format(AliasArgumentFormatter.new(right), stackable: false) - end - end - end - end - - def ===(other) - other.is_a?(AliasNode) && left === other.left && right === other.right - end - 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 < Node - # [Node] the value being indexed - attr_reader :collection - - # [nil | Args] the value being passed within the brackets - attr_reader :index - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(collection:, index:, location:) - @collection = collection - @index = index - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_aref(self) - end - - def child_nodes - [collection, index] - end - - def copy(collection: nil, index: nil, location: nil) - node = - ARef.new( - collection: collection || self.collection, - index: index || self.index, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - collection: collection, - index: index, - location: location, - comments: comments - } - end - - def format(q) - q.group do - q.format(collection) - q.text("[") - - if index - q.indent do - q.breakable_empty - q.format(index) - end - q.breakable_empty - end - - q.text("]") - end - end - - def ===(other) - other.is_a?(ARef) && collection === other.collection && - index === other.index - end - 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 < Node - # [Node] the value being indexed - attr_reader :collection - - # [nil | Args] the value being passed within the brackets - attr_reader :index - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(collection:, index:, location:) - @collection = collection - @index = index - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_aref_field(self) - end - - def child_nodes - [collection, index] - end - - def copy(collection: nil, index: nil, location: nil) - node = - ARefField.new( - collection: collection || self.collection, - index: index || self.index, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - collection: collection, - index: index, - location: location, - comments: comments - } - end - - def format(q) - q.group do - q.format(collection) - q.text("[") - - if index - q.indent do - q.breakable_empty - q.format(index) - end - q.breakable_empty - end - - q.text("]") - end - end - - def ===(other) - other.is_a?(ARefField) && collection === other.collection && - index === other.index - end - 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 Args 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 < Node - # [nil | Args | ArgsForward] the arguments inside the - # parentheses - attr_reader :arguments - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(arguments:, location:) - @arguments = arguments - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_arg_paren(self) - end - - def child_nodes - [arguments] - end - - def copy(arguments: nil, location: nil) - node = - ArgParen.new( - arguments: arguments || self.arguments, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { arguments: arguments, location: location, comments: comments } - end - - def format(q) - unless arguments - q.text("()") - return - end - - q.text("(") - q.group do - q.indent do - q.breakable_empty - q.format(arguments) - q.if_break { q.text(",") } if q.trailing_comma? && trailing_comma? - end - q.breakable_empty - end - q.text(")") - end - - def ===(other) - other.is_a?(ArgParen) && arguments === other.arguments - end - - def arity - arguments&.arity || 0 - end - - private - - def trailing_comma? - arguments = self.arguments - return false unless arguments.is_a?(Args) - - parts = arguments.parts - if parts.last.is_a?(ArgBlock) - # If the last argument is a block, then we can't put a trailing comma - # after it without resulting in a syntax error. - false - elsif (parts.length == 1) && (part = parts.first) && - (part.is_a?(Command) || part.is_a?(CommandCall)) - # If the only argument is a command or command call, then a trailing - # comma would be parsed as part of that expression instead of on this - # one, so we don't want to add a trailing comma. - false - else - # Otherwise, we should be okay to add a trailing comma. - true - end - end - end - - # Args represents a list of arguments being passed to a method call or array - # literal. - # - # method(first, second, third) - # - class Args < Node - # [Array[ Node ]] the arguments that this node wraps - attr_reader :parts - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(parts:, location:) - @parts = parts - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_args(self) - end - - def child_nodes - parts - end - - def copy(parts: nil, location: nil) - node = - Args.new( - parts: parts || self.parts, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { parts: parts, location: location, comments: comments } - end - - def format(q) - q.seplist(parts) { |part| q.format(part) } - end - - def ===(other) - other.is_a?(Args) && ArrayMatch.call(parts, other.parts) - end - - def arity - parts.sum do |part| - case part - when ArgStar, ArgsForward - Float::INFINITY - when BareAssocHash - part.assocs.sum do |assoc| - assoc.is_a?(AssocSplat) ? Float::INFINITY : 1 - end - else - 1 - end - end - end - end - - # ArgBlock represents using a block operator on an expression. - # - # method(&expression) - # - class ArgBlock < Node - # [nil | Node] the expression being turned into a block - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_arg_block(self) - end - - def child_nodes - [value] - end - - def copy(value: nil, location: nil) - node = - ArgBlock.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text("&") - q.format(value) if value - end - - def ===(other) - other.is_a?(ArgBlock) && value === other.value - end - end - - # Star represents using a splat operator on an expression. - # - # method(*arguments) - # - class ArgStar < Node - # [nil | Node] the expression being splatted - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_arg_star(self) - end - - def child_nodes - [value] - end - - def copy(value: nil, location: nil) - node = - ArgStar.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text("*") - q.format(value) if value - end - - def ===(other) - other.is_a?(ArgStar) && value === other.value - end - 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 < Node - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(location:) - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_args_forward(self) - end - - def child_nodes - [] - end - - def copy(location: nil) - node = ArgsForward.new(location: location || self.location) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { location: location, comments: comments } - end - - def format(q) - q.text("...") - end - - def ===(other) - other.is_a?(ArgsForward) - end - - def arity - Float::INFINITY - end - end - - # ArrayLiteral represents an array literal, which can optionally contain - # elements. - # - # [] - # [one, two, three] - # - class ArrayLiteral < Node - # It's very common to use seplist with ->(q) { q.breakable_space }. We wrap - # that pattern into an object to cut down on having to create a bunch of - # lambdas all over the place. - class BreakableSpaceSeparator - def call(q) - q.breakable_space - end - end - - BREAKABLE_SPACE_SEPARATOR = BreakableSpaceSeparator.new.freeze - - # Formats an array of multiple simple string literals into the %w syntax. - class QWordsFormatter - # [Args] the contents of the array - attr_reader :contents - - def initialize(contents) - @contents = contents - end - - def format(q) - q.text("%w[") - q.group do - q.indent do - q.breakable_empty - q.seplist(contents.parts, BREAKABLE_SPACE_SEPARATOR) do |part| - if part.is_a?(StringLiteral) - q.format(part.parts.first) - else - q.text(part.value[1..]) - end - end - end - q.breakable_empty - end - q.text("]") - end - end - - # Formats an array of multiple simple symbol literals into the %i syntax. - class QSymbolsFormatter - # [Args] the contents of the array - attr_reader :contents - - def initialize(contents) - @contents = contents - end - - def format(q) - q.text("%i[") - q.group do - q.indent do - q.breakable_empty - q.seplist(contents.parts, BREAKABLE_SPACE_SEPARATOR) do |part| - q.format(part.value) - end - end - q.breakable_empty - end - q.text("]") - end - end - - # This is a special formatter used if the array literal contains no values - # but _does_ contain comments. In this case we do some special formatting to - # make sure the comments gets indented properly. - class EmptyWithCommentsFormatter - # [LBracket] the opening bracket - attr_reader :lbracket - - def initialize(lbracket) - @lbracket = lbracket - end - - def format(q) - q.group do - q.text("[") - q.indent do - lbracket.comments.each do |comment| - q.breakable_force - comment.format(q) - end - end - q.breakable_force - q.text("]") - end - end - end - - # [nil | LBracket | QSymbolsBeg | QWordsBeg | SymbolsBeg | WordsBeg] the - # bracket that opens this array - attr_reader :lbracket - - # [nil | Args] the contents of the array - attr_reader :contents - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(lbracket:, contents:, location:) - @lbracket = lbracket - @contents = contents - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_array(self) - end - - def child_nodes - [lbracket, contents] - end - - def copy(lbracket: nil, contents: nil, location: nil) - node = - ArrayLiteral.new( - lbracket: lbracket || self.lbracket, - contents: contents || self.contents, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - lbracket: lbracket, - contents: contents, - location: location, - comments: comments - } - end - - def format(q) - lbracket = self.lbracket - contents = self.contents - - if lbracket.is_a?(LBracket) && lbracket.comments.empty? && contents && - contents.comments.empty? && contents.parts.length > 1 - if qwords? - QWordsFormatter.new(contents).format(q) - return - end - - if qsymbols? - QSymbolsFormatter.new(contents).format(q) - return - end - end - - if empty_with_comments? - EmptyWithCommentsFormatter.new(lbracket).format(q) - return - end - - q.group do - q.format(lbracket) - - if contents - q.indent do - q.breakable_empty - q.format(contents) - q.if_break { q.text(",") } if q.trailing_comma? - end - end - - q.breakable_empty - q.text("]") - end - end - - def ===(other) - other.is_a?(ArrayLiteral) && lbracket === other.lbracket && - contents === other.contents - end - - private - - def qwords? - contents.parts.all? do |part| - case part - when StringLiteral - part.comments.empty? && part.parts.length == 1 && - part.parts.first.is_a?(TStringContent) && - !part.parts.first.value.match?(/[\s\[\]\\]/) - when CHAR - !part.value.match?(/[\[\]\\]/) - else - false - end - end - end - - def qsymbols? - contents.parts.all? do |part| - part.is_a?(SymbolLiteral) && part.comments.empty? - end - end - - # If we have an empty array that contains only comments, then we're going - # to do some special printing to ensure they get indented correctly. - def empty_with_comments? - contents.nil? && lbracket.comments.any? && - lbracket.comments.none?(&:inline?) - 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 < Node - # Formats the optional splat of an array pattern. - class RestFormatter - # [VarField] the identifier that represents the remaining positionals - attr_reader :value - - def initialize(value) - @value = value - end - - def comments - value.comments - end - - def format(q) - q.text("*") - q.format(value) - end - end - - # [nil | VarRef | ConstPathRef] the optional constant wrapper - attr_reader :constant - - # [Array[ Node ]] 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[ Node ]] the list of positional arguments occurring after the - # optional star if there is one - attr_reader :posts - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(constant:, requireds:, rest:, posts:, location:) - @constant = constant - @requireds = requireds - @rest = rest - @posts = posts - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_aryptn(self) - end - - def child_nodes - [constant, *requireds, rest, *posts] - end - - def copy( - constant: nil, - requireds: nil, - rest: nil, - posts: nil, - location: nil - ) - node = - AryPtn.new( - constant: constant || self.constant, - requireds: requireds || self.requireds, - rest: rest || self.rest, - posts: posts || self.posts, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - constant: constant, - requireds: requireds, - rest: rest, - posts: posts, - location: location, - comments: comments - } - end - - def format(q) - q.group do - q.format(constant) if constant - q.text("[") - q.indent do - q.breakable_empty - - parts = [*requireds] - parts << RestFormatter.new(rest) if rest - parts += posts - - q.seplist(parts) { |part| q.format(part) } - end - q.breakable_empty - q.text("]") - end - end - - def ===(other) - other.is_a?(AryPtn) && constant === other.constant && - ArrayMatch.call(requireds, other.requireds) && rest === other.rest && - ArrayMatch.call(posts, other.posts) - end - end - - # Determins if the following value should be indented or not. - module AssignFormatting - def self.skip_indent?(value) - case value - when ArrayLiteral, HashLiteral, Heredoc, Lambda, QSymbols, QWords, - Symbols, Words - true - when CallNode - skip_indent?(value.receiver) - when DynaSymbol - value.quote.start_with?("%s") - else - false - end - end - 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 < Node - # [ARefField | ConstPathField | Field | TopConstField | VarField] the target - # to assign the result of the expression to - attr_reader :target - - # [Node] the expression to be assigned - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(target:, value:, location:) - @target = target - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_assign(self) - end - - def child_nodes - [target, value] - end - - def copy(target: nil, value: nil, location: nil) - node = - Assign.new( - target: target || self.target, - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { target: target, value: value, location: location, comments: comments } - end - - def format(q) - q.group do - q.format(target) - q.text(" =") - - if skip_indent? - q.text(" ") - q.format(value) - else - q.indent do - q.breakable_space - q.format(value) - end - end - end - end - - def ===(other) - other.is_a?(Assign) && target === other.target && value === other.value - end - - private - - def skip_indent? - target.comments.empty? && - (target.is_a?(ARefField) || AssignFormatting.skip_indent?(value)) - end - 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 Assoc nodes. - class Assoc < Node - # [Node] the key of this pair - attr_reader :key - - # [nil | Node] the value of this pair - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(key:, value:, location:) - @key = key - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_assoc(self) - end - - def child_nodes - [key, value] - end - - def copy(key: nil, value: nil, location: nil) - node = - Assoc.new( - key: key || self.key, - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { key: key, value: value, location: location, comments: comments } - end - - def format(q) - if value.is_a?(HashLiteral) - format_contents(q) - else - q.group { format_contents(q) } - end - end - - def ===(other) - other.is_a?(Assoc) && key === other.key && value === other.value - end - - private - - def format_contents(q) - (q.parent || HashKeyFormatter::Identity.new).format_key(q, key) - return unless value - - if key.comments.empty? && AssignFormatting.skip_indent?(value) - q.text(" ") - q.format(value) - else - q.indent do - q.breakable_space - q.format(value) - end - end - end - 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 < Node - # [nil | Node] the expression that is being splatted - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_assoc_splat(self) - end - - def child_nodes - [value] - end - - def copy(value: nil, location: nil) - node = - AssocSplat.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text("**") - q.format(value) if value - end - - def ===(other) - other.is_a?(AssocSplat) && value === other.value - end - 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 < Node - # [String] the name of the global backreference variable - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_backref(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - node = - Backref.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text(value) - end - - def ===(other) - other.is_a?(Backref) && value === other.value - end - 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 < Node - # [String] the backtick in the string - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_backtick(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - node = - Backtick.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text(value) - end - - def ===(other) - other.is_a?(Backtick) && value === other.value - end - end - - # This module is responsible for formatting the assocs contained within a - # hash or bare hash. It first determines if every key in the hash can use - # labels. If it can, it uses labels. Otherwise it uses hash rockets. - module HashKeyFormatter - # Formats the keys of a hash literal using labels. - class Labels - LABEL = /\A[A-Za-z_](\w*[\w!?])?\z/.freeze - - def format_key(q, key) - case key - when Label - q.format(key) - when SymbolLiteral - q.format(key.value) - q.text(":") - when DynaSymbol - parts = key.parts - - if parts.length == 1 && (part = parts.first) && - part.is_a?(TStringContent) && part.value.match?(LABEL) - q.format(part) - q.text(":") - else - q.format(key) - q.text(":") - end - end - end - end - - # Formats the keys of a hash literal using hash rockets. - class Rockets - def format_key(q, key) - case key - when Label - q.text(":#{key.value.chomp(":")}") - when DynaSymbol - q.text(":") - q.format(key) - else - q.format(key) - end - - q.text(" =>") - end - end - - # When formatting a single assoc node without the context of the parent - # hash, this formatter is used. It uses whatever is present in the node, - # because there is nothing to be consistent with. - class Identity - def format_key(q, key) - if key.is_a?(Label) - q.format(key) - else - q.format(key) - q.text(" =>") - end - end - end - - class << self - def for(container) - (assocs = container.assocs).each_with_index do |assoc, index| - if assoc.is_a?(AssocSplat) - # Splat nodes do not impact the formatting choice. - elsif assoc.value.nil? - # If the value is nil, then it has been omitted. In this case we - # have to match the existing formatting because standardizing would - # potentially break the code. For example: - # - # { first:, "second" => "value" } - # - return Identity.new - else - # Otherwise, we need to check the type of the key. If it's a label - # or dynamic symbol, we can use labels. If it's a symbol literal - # then it needs to match a certain pattern to be used as a label. If - # it's anything else, then we need to use hash rockets. - case assoc.key - when Label, DynaSymbol - # Here labels can be used. - when SymbolLiteral - # When attempting to convert a hash rocket into a hash label, - # you need to take care because only certain patterns are - # allowed. Ruby source says that they have to match keyword - # arguments to methods, but don't specify what that is. After - # some experimentation, it looks like it's: - value = assoc.key.value.value - - if !value.match?(/^[_A-Za-z]/) || value.end_with?("=") - if omitted_value?(assocs[(index + 1)..]) - return Identity.new - else - return Rockets.new - end - end - else - if omitted_value?(assocs[(index + 1)..]) - return Identity.new - else - return Rockets.new - end - end - end - end - - Labels.new - end - - private - - def omitted_value?(assocs) - assocs.any? { |assoc| !assoc.is_a?(AssocSplat) && assoc.value.nil? } - end - end - 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 < Node - # [Array[ Assoc | AssocSplat ]] - attr_reader :assocs - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(assocs:, location:) - @assocs = assocs - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_bare_assoc_hash(self) - end - - def child_nodes - assocs - end - - def copy(assocs: nil, location: nil) - node = - BareAssocHash.new( - assocs: assocs || self.assocs, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { assocs: assocs, location: location, comments: comments } - end - - def format(q) - q.seplist(assocs) { |assoc| q.format(assoc) } - end - - def ===(other) - other.is_a?(BareAssocHash) && ArrayMatch.call(assocs, other.assocs) - end - - def format_key(q, key) - @key_formatter ||= - case q.parents.take(3).last - when Break, Next, ReturnNode - HashKeyFormatter::Identity.new - else - HashKeyFormatter.for(self) - end - - @key_formatter.format_key(q, key) - end - end - - # Begin represents a begin..end chain. - # - # begin - # value - # end - # - class Begin < Node - # [BodyStmt] the bodystmt that contains the contents of this begin block - attr_reader :bodystmt - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(bodystmt:, location:) - @bodystmt = bodystmt - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_begin(self) - end - - def child_nodes - [bodystmt] - end - - def copy(bodystmt: nil, location: nil) - node = - Begin.new( - bodystmt: bodystmt || self.bodystmt, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { bodystmt: bodystmt, location: location, comments: comments } - end - - def format(q) - q.text("begin") - - unless bodystmt.empty? - q.indent do - q.breakable_force unless bodystmt.statements.empty? - q.format(bodystmt) - end - end - - q.breakable_force - q.text("end") - end - - def ===(other) - other.is_a?(Begin) && bodystmt === other.bodystmt - end - end - - # PinnedBegin represents a pinning a nested statement within pattern matching. - # - # case value - # in ^(statement) - # end - # - class PinnedBegin < Node - # [Node] the expression being pinned - attr_reader :statement - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(statement:, location:) - @statement = statement - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_pinned_begin(self) - end - - def child_nodes - [statement] - end - - def copy(statement: nil, location: nil) - node = - PinnedBegin.new( - statement: statement || self.statement, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { statement: statement, location: location, comments: comments } - end - - def format(q) - q.group do - q.text("^(") - q.nest(1) do - q.indent do - q.breakable_empty - q.format(statement) - end - q.breakable_empty - q.text(")") - end - end - end - - def ===(other) - other.is_a?(PinnedBegin) && statement === other.statement - end - 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 < Node - # Since Binary's operator is a symbol, it's better to use the `name` method - # than to allocate a new string every time. This is a tiny performance - # optimization, but enough that it shows up in the profiler. Adding this in - # for older Ruby versions. - unless :+.respond_to?(:name) - using Module.new { - refine Symbol do - def name - to_s.freeze - end - end - } - end - - # [Node] the left-hand side of the expression - attr_reader :left - - # [Symbol] the operator used between the two expressions - attr_reader :operator - - # [Node] the right-hand side of the expression - attr_reader :right - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(left:, operator:, right:, location:) - @left = left - @operator = operator - @right = right - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_binary(self) - end - - def child_nodes - [left, right] - end - - def copy(left: nil, operator: nil, right: nil, location: nil) - node = - Binary.new( - left: left || self.left, - operator: operator || self.operator, - right: right || self.right, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - left: left, - operator: operator, - right: right, - location: location, - comments: comments - } - end - - def format(q) - left = self.left - power = operator == :** - - q.group do - q.group { q.format(left) } - q.text(" ") unless power - - if operator != :<< - q.group do - q.text(operator.name) - q.indent do - power ? q.breakable_empty : q.breakable_space - q.format(right) - end - end - elsif left.is_a?(Binary) && left.operator == :<< - q.group do - q.text(operator.name) - q.indent do - power ? q.breakable_empty : q.breakable_space - q.format(right) - end - end - else - q.text("<< ") - q.format(right) - end - end - end - - def ===(other) - other.is_a?(Binary) && left === other.left && - operator === other.operator && right === other.right - end - 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 < Node - # [Params] the parameters being declared with the block - attr_reader :params - - # [Array[ Ident ]] the list of block-local variable declarations - attr_reader :locals - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(params:, locals:, location:) - @params = params - @locals = locals - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_block_var(self) - end - - def child_nodes - [params, *locals] - end - - def copy(params: nil, locals: nil, location: nil) - node = - BlockVar.new( - params: params || self.params, - locals: locals || self.locals, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { params: params, locals: locals, location: location, comments: comments } - end - - # Within the pipes of the block declaration, we don't want any spaces. So - # we'll separate the parameters with a comma and space but no breakables. - class Separator - def call(q) - q.text(", ") - end - end - - # We'll keep a single instance of this separator around for all block vars - # to cut down on allocations. - SEPARATOR = Separator.new.freeze - - def format(q) - q.text("|") - q.group do - q.remove_breaks(q.format(params)) - - if locals.any? - q.text("; ") - q.seplist(locals, SEPARATOR) { |local| q.format(local) } - end - end - q.text("|") - end - - def ===(other) - other.is_a?(BlockVar) && params === other.params && - ArrayMatch.call(locals, other.locals) - end - - # When a single required parameter is declared for a block, it gets - # automatically expanded if the values being yielded into it are an array. - def arg0? - params.requireds.length == 1 && params.optionals.empty? && - params.rest.nil? && params.posts.empty? && params.keywords.empty? && - params.keyword_rest.nil? && params.block.nil? - end - end - - # BlockArg represents declaring a block parameter on a method definition. - # - # def method(&block); end - # - class BlockArg < Node - # [nil | Ident] the name of the block argument - attr_reader :name - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(name:, location:) - @name = name - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_blockarg(self) - end - - def child_nodes - [name] - end - - def copy(name: nil, location: nil) - node = - BlockArg.new( - name: name || self.name, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { name: name, location: location, comments: comments } - end - - def format(q) - q.text("&") - q.format(name) if name - end - - def ===(other) - other.is_a?(BlockArg) && name === other.name - end - 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 < Node - # [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 | Kw] the optional else keyword - attr_reader :else_keyword - - # [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 - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize( - statements:, - rescue_clause:, - else_keyword:, - else_clause:, - ensure_clause:, - location: - ) - @statements = statements - @rescue_clause = rescue_clause - @else_keyword = else_keyword - @else_clause = else_clause - @ensure_clause = ensure_clause - @location = location - @comments = [] - end - - def bind(parser, start_char, start_column, end_char, end_column) - rescue_clause = self.rescue_clause - - @location = - Location.new( - start_line: location.start_line, - start_char: start_char, - start_column: start_column, - end_line: location.end_line, - end_char: end_char, - end_column: end_column - ) - - # Here we're going to determine the bounds for the statements - consequent = rescue_clause || else_clause || ensure_clause - statements.bind( - parser, - start_char, - start_column, - consequent ? consequent.location.start_char : end_char, - consequent ? consequent.location.start_column : end_column - ) - - # Next we're going to determine the rescue clause if there is one - if rescue_clause - consequent = else_clause || ensure_clause - - rescue_clause.bind_end( - consequent ? consequent.location.start_char : end_char, - consequent ? consequent.location.start_column : end_column - ) - end - end - - def empty? - statements.empty? && !rescue_clause && !else_clause && !ensure_clause - end - - def accept(visitor) - visitor.visit_bodystmt(self) - end - - def child_nodes - [statements, rescue_clause, else_keyword, else_clause, ensure_clause] - end - - def copy( - statements: nil, - rescue_clause: nil, - else_keyword: nil, - else_clause: nil, - ensure_clause: nil, - location: nil - ) - node = - BodyStmt.new( - statements: statements || self.statements, - rescue_clause: rescue_clause || self.rescue_clause, - else_keyword: else_keyword || self.else_keyword, - else_clause: else_clause || self.else_clause, - ensure_clause: ensure_clause || self.ensure_clause, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - statements: statements, - rescue_clause: rescue_clause, - else_keyword: else_keyword, - else_clause: else_clause, - ensure_clause: ensure_clause, - location: location, - comments: comments - } - end - - def format(q) - q.group do - q.format(statements) unless statements.empty? - - if rescue_clause - q.nest(-2) do - q.breakable_force - q.format(rescue_clause) - end - end - - if else_clause - q.nest(-2) do - q.breakable_force - q.format(else_keyword) - end - - unless else_clause.empty? - q.breakable_force - q.format(else_clause) - end - end - - if ensure_clause - q.nest(-2) do - q.breakable_force - q.format(ensure_clause) - end - end - end - end - - def ===(other) - other.is_a?(BodyStmt) && statements === other.statements && - rescue_clause === other.rescue_clause && - else_keyword === other.else_keyword && - else_clause === other.else_clause && - ensure_clause === other.ensure_clause - end - end - - # Formats either a Break, Next, or Return node. - class FlowControlFormatter - # [String] the keyword to print - attr_reader :keyword - - # [Break | Next | Return] the node being formatted - attr_reader :node - - def initialize(keyword, node) - @keyword = keyword - @node = node - end - - def format(q) - # If there are no arguments associated with this flow control, then we can - # safely just print the keyword and return. - if node.arguments.nil? - q.text(keyword) - return - end - - q.group do - q.text(keyword) - - parts = node.arguments.parts - length = parts.length - - if length == 0 - # Here there are no arguments at all, so we're not going to print - # anything. This would be like if we had: - # - # break - # - elsif length >= 2 - # If there are multiple arguments, format them all. If the line is - # going to break into multiple, then use brackets to start and end the - # expression. - format_arguments(q, " [", "]") - else - # If we get here, then we're formatting a single argument to the flow - # control keyword. - part = parts.first - - case part - when Paren - statements = part.contents.body - - if statements.length == 1 - statement = statements.first - - if statement.is_a?(ArrayLiteral) - contents = statement.contents - - if contents && contents.parts.length >= 2 - # Here we have a single argument that is a set of parentheses - # wrapping an array literal that has at least 2 elements. - # We're going to print the contents of the array directly. - # This would be like if we had: - # - # break([1, 2, 3]) - # - # which we will print as: - # - # break 1, 2, 3 - # - q.text(" ") - format_array_contents(q, statement) - else - # Here we have a single argument that is a set of parentheses - # wrapping an array literal that has 0 or 1 elements. We're - # going to skip the parentheses but print the array itself. - # This would be like if we had: - # - # break([1]) - # - # which we will print as: - # - # break [1] - # - q.text(" ") - q.format(statement) - end - elsif skip_parens?(statement) - # Here we have a single argument that is a set of parentheses - # that themselves contain a single statement. That statement is - # a simple value that we can skip the parentheses for. This - # would be like if we had: - # - # break(1) - # - # which we will print as: - # - # break 1 - # - q.text(" ") - q.format(statement) - else - # Here we have a single argument that is a set of parentheses. - # We're going to print the parentheses themselves as if they - # were the set of arguments. This would be like if we had: - # - # break(foo.bar) - # - q.format(part) - end - else - q.format(part) - end - when ArrayLiteral - contents = part.contents - - if contents && contents.parts.length >= 2 - # Here there is a single argument that is an array literal with at - # least two elements. We skip directly into the array literal's - # elements in order to print the contents. This would be like if - # we had: - # - # break [1, 2, 3] - # - # which we will print as: - # - # break 1, 2, 3 - # - q.text(" ") - format_array_contents(q, part) - else - # Here there is a single argument that is an array literal with 0 - # or 1 elements. In this case we're going to print the array as it - # is because skipping the brackets would change the remaining. - # This would be like if we had: - # - # break [] - # break [1] - # - q.text(" ") - q.format(part) - end - else - # Here there is a single argument that hasn't matched one of our - # previous cases. We're going to print the argument as it is. This - # would be like if we had: - # - # break foo - # - format_arguments(q, "(", ")") - end - end - end - end - - private - - def format_array_contents(q, array) - q.if_break { q.text("[") } - q.indent do - q.breakable_empty - q.format(array.contents) - end - q.breakable_empty - q.if_break { q.text("]") } - end - - def format_arguments(q, opening, closing) - q.if_break { q.text(opening) } - q.indent do - q.breakable_space - q.format(node.arguments) - end - q.breakable_empty - q.if_break { q.text(closing) } - end - - def skip_parens?(node) - case node - when FloatLiteral, Imaginary, Int, RationalLiteral - true - when VarRef - case node.value - when Const, CVar, GVar, IVar, Kw - true - else - false - end - else - false - end - end - end - - # Break represents using the +break+ keyword. - # - # break - # - # It can also optionally accept arguments, as in: - # - # break 1 - # - class Break < Node - # [Args] the arguments being sent to the keyword - attr_reader :arguments - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(arguments:, location:) - @arguments = arguments - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_break(self) - end - - def child_nodes - [arguments] - end - - def copy(arguments: nil, location: nil) - node = - Break.new( - arguments: arguments || self.arguments, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { arguments: arguments, location: location, comments: comments } - end - - def format(q) - FlowControlFormatter.new("break", self).format(q) - end - - def ===(other) - other.is_a?(Break) && arguments === other.arguments - end - end - - # Wraps a call operator (which can be a string literal :: or an Op node or a - # Period node) and formats it when called. - class CallOperatorFormatter - # [:"::" | Op | Period] the operator being formatted - attr_reader :operator - - def initialize(operator) - @operator = operator - end - - def comments - operator == :"::" ? [] : operator.comments - end - - def format(q) - case operator - when :"::" - q.text(".") - when Op - operator.value == "::" ? q.text(".") : operator.format(q) - else - operator.format(q) - end - end - end - - # This is probably the most complicated formatter in this file. It's - # responsible for formatting chains of method calls, with or without arguments - # or blocks. In general, we want to go from something like - # - # foo.bar.baz - # - # to - # - # foo - # .bar - # .baz - # - # Of course there are a lot of caveats to that, including trailing operators - # when necessary, where comments are places, how blocks are aligned, etc. - class CallChainFormatter - # [CallNode | MethodAddBlock] the top of the call chain - attr_reader :node - - def initialize(node) - @node = node - end - - def format(q) - children = [node] - threshold = 3 - - # First, walk down the chain until we get to the point where we're not - # longer at a chainable node. - loop do - case (child = children.last) - when CallNode - case (receiver = child.receiver) - when CallNode - if receiver.receiver.nil? - break - else - children << receiver - end - when MethodAddBlock - if (call = receiver.call).is_a?(CallNode) && !call.receiver.nil? - children << receiver - else - break - end - else - break - end - when MethodAddBlock - if (call = child.call).is_a?(CallNode) && !call.receiver.nil? - children << call - else - break - end - else - break - end - end - - # Here, we have very specialized behavior where if we're within a sig - # block, then we're going to assume we're creating a Sorbet type - # signature. In that case, we really want the threshold to be lowered so - # that we create method chains off of any two method calls within the - # block. For more details, see - # https://github.com/prettier/plugin-ruby/issues/863. - parents = q.parents.take(4) - if (parent = parents[2]) - # If we're at a block with the `do` keywords, then we want to go one - # more level up. This is because do blocks have BodyStmt nodes instead - # of just Statements nodes. - parent = parents[3] if parent.is_a?(BlockNode) && parent.keywords? - - if parent.is_a?(MethodAddBlock) && - (call = parent.call).is_a?(CallNode) && call.message.value == "sig" - threshold = 2 - end - end - - if children.length >= threshold - q.group do - q - .if_break { format_chain(q, children) } - .if_flat { node.format_contents(q) } - end - else - node.format_contents(q) - end - end - - def format_chain(q, children) - # We're going to have some specialized behavior for if it's an entire - # chain of calls without arguments except for the last one. This is common - # enough in Ruby source code that it's worth the extra complexity here. - empty_except_last = - children - .drop(1) - .all? { |child| child.is_a?(CallNode) && child.arguments.nil? } - - # Here, we're going to add all of the children onto the stack of the - # formatter so it's as if we had descending normally into them. This is - # necessary so they can check their parents as normal. - q.stack.concat(children) - q.format(children.last.receiver) if children.last.receiver - - q.group do - if attach_directly?(children.last) - format_child(q, children.pop) - q.stack.pop - end - - q.indent do - # We track another variable that checks if you need to move the - # operator to the previous line in case there are trailing comments - # and a trailing operator. - skip_operator = false - - while (child = children.pop) - if child.is_a?(CallNode) - if (receiver = child.receiver).is_a?(CallNode) && - (receiver.message != :call) && - (receiver.message.value == "where") && - (child.message != :call && child.message.value == "not") - # This is very specialized behavior wherein we group - # .where.not calls together because it looks better. For more - # information, see - # https://github.com/prettier/plugin-ruby/issues/862. - else - # If we're at a Call node and not a MethodAddBlock node in the - # chain then we're going to add a newline so it indents - # properly. - q.breakable_empty - end - end - - format_child( - q, - child, - skip_comments: children.empty?, - skip_operator: skip_operator, - skip_attached: empty_except_last && children.empty? - ) - - # If the parent call node has a comment on the message then we need - # to print the operator trailing in order to keep it working. - last_child = children.last - if last_child.is_a?(CallNode) && last_child.message != :call && - ( - (last_child.message.comments.any? && last_child.operator) || - (last_child.operator && last_child.operator.comments.any?) - ) - q.format(CallOperatorFormatter.new(last_child.operator)) - skip_operator = true - else - skip_operator = false - end - - # Pop off the formatter's stack so that it aligns with what would - # have happened if we had been formatting normally. - q.stack.pop - end - end - end - - if empty_except_last - case node - when CallNode - node.format_arguments(q) - when MethodAddBlock - q.format(node.block) - end - end - end - - def self.chained?(node) - return false if ENV["STREE_FAST_FORMAT"] - - case node - when CallNode - !node.receiver.nil? - when MethodAddBlock - call = node.call - call.is_a?(CallNode) && !call.receiver.nil? - else - false - end - end - - private - - # For certain nodes, we want to attach directly to the end and don't - # want to indent the first call. So we'll pop off the first children and - # format it separately here. - def attach_directly?(node) - case node.receiver - when ArrayLiteral, HashLiteral, Heredoc, IfNode, UnlessNode, - XStringLiteral - true - else - false - end - end - - def format_child( - q, - child, - skip_comments: false, - skip_operator: false, - skip_attached: false - ) - # First, format the actual contents of the child. - case child - when CallNode - q.group do - if !skip_operator && child.operator - q.format(CallOperatorFormatter.new(child.operator)) - end - q.format(child.message) if child.message != :call - child.format_arguments(q) unless skip_attached - end - when MethodAddBlock - q.format(child.block) unless skip_attached - end - - # If there are any comments on this node then we need to explicitly print - # them out here since we're bypassing the normal comment printing. - if child.comments.any? && !skip_comments - child.comments.each do |comment| - comment.inline? ? q.text(" ") : q.breakable_space - comment.format(q) - end - - q.break_parent - end - end - end - - # CallNode represents a method call. - # - # receiver.message - # - class CallNode < Node - # [nil | Node] the receiver of the method call - attr_reader :receiver - - # [nil | :"::" | 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 - - # [nil | ArgParen | Args] the arguments to the method call - attr_reader :arguments - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(receiver:, operator:, message:, arguments:, location:) - @receiver = receiver - @operator = operator - @message = message - @arguments = arguments - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_call(self) - end - - def child_nodes - [ - receiver, - (operator if operator != :"::"), - (message if message != :call), - arguments - ] - end - - def copy( - receiver: nil, - operator: nil, - message: nil, - arguments: nil, - location: nil - ) - node = - CallNode.new( - receiver: receiver || self.receiver, - operator: operator || self.operator, - message: message || self.message, - arguments: arguments || self.arguments, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - receiver: receiver, - operator: operator, - message: message, - arguments: arguments, - location: location, - comments: comments - } - end - - def format(q) - if receiver - # If we're at the top of a call chain, then we're going to do some - # specialized printing in case we can print it nicely. We _only_ do this - # at the top of the chain to avoid weird recursion issues. - if CallChainFormatter.chained?(receiver) && - !CallChainFormatter.chained?(q.parent) - q.group do - q - .if_break { CallChainFormatter.new(self).format(q) } - .if_flat { format_contents(q) } - end - else - format_contents(q) - end - else - q.format(message) - - # Note that this explicitly leaves parentheses in place even if they are - # empty. There are two reasons we would need to do this. The first is if - # we're calling something that looks like a constant, as in: - # - # Foo() - # - # In this case if we remove the parentheses then this becomes a constant - # reference and not a method call. The second is if we're calling a - # method that is the same name as a local variable that is in scope, as - # in: - # - # foo = foo() - # - # In this case we have to keep the parentheses or else it treats this - # like assigning nil to the local variable. Note that we could attempt - # to be smarter about this by tracking the local variables that are in - # scope, but for now it's simpler and more efficient to just leave the - # parentheses in place. - q.format(arguments) if arguments - end - end - - def ===(other) - other.is_a?(CallNode) && receiver === other.receiver && - operator === other.operator && message === other.message && - arguments === other.arguments - end - - # Print out the arguments to this call. If there are no arguments, then do - # nothing. - def format_arguments(q) - case arguments - when ArgParen - q.format(arguments) - when Args - q.text(" ") - q.format(arguments) - end - end - - def format_contents(q) - call_operator = CallOperatorFormatter.new(operator) - - q.group do - q.format(receiver) - - # If there are trailing comments on the call operator, then we need to - # use the trailing form as opposed to the leading form. - q.format(call_operator) if call_operator.comments.any? - - q.group do - q.indent do - if receiver.comments.any? || call_operator.comments.any? - q.breakable_force - end - - if call_operator.comments.empty? - q.format(call_operator, stackable: false) - end - - q.format(message) if message != :call - end - - format_arguments(q) - end - end - end - - def arity - arguments&.arity || 0 - end - end - - # Case represents the beginning of a case chain. - # - # case value - # when 1 - # "one" - # when 2 - # "two" - # else - # "number" - # end - # - class Case < Node - # [Kw] the keyword that opens this expression - attr_reader :keyword - - # [nil | Node] optional value being switched on - attr_reader :value - - # [In | When] the next clause in the chain - attr_reader :consequent - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(keyword:, value:, consequent:, location:) - @keyword = keyword - @value = value - @consequent = consequent - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_case(self) - end - - def child_nodes - [keyword, value, consequent] - end - - def copy(keyword: nil, value: nil, consequent: nil, location: nil) - node = - Case.new( - keyword: keyword || self.keyword, - value: value || self.value, - consequent: consequent || self.consequent, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - keyword: keyword, - value: value, - consequent: consequent, - location: location, - comments: comments - } - end - - def format(q) - q.group do - q.format(keyword) - - if value - q.text(" ") - q.format(value) - end - - q.breakable_force - q.format(consequent) - q.breakable_force - - q.text("end") - end - end - - def ===(other) - other.is_a?(Case) && keyword === other.keyword && value === other.value && - consequent === other.consequent - end - end - - # RAssign represents a single-line pattern match. - # - # value in pattern - # value => pattern - # - class RAssign < Node - # [Node] 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 - - # [Node] the pattern on the right-hand side of the expression - attr_reader :pattern - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, operator:, pattern:, location:) - @value = value - @operator = operator - @pattern = pattern - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_rassign(self) - end - - def child_nodes - [value, operator, pattern] - end - - def copy(value: nil, operator: nil, pattern: nil, location: nil) - node = - RAssign.new( - value: value || self.value, - operator: operator || self.operator, - pattern: pattern || self.pattern, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - value: value, - operator: operator, - pattern: pattern, - location: location, - comments: comments - } - end - - def format(q) - q.group do - q.format(value) - q.text(" ") - q.format(operator) - - case pattern - when AryPtn, FndPtn, HshPtn - q.text(" ") - q.format(pattern) - else - q.group do - q.indent do - q.breakable_space - q.format(pattern) - end - end - end - end - end - - def ===(other) - other.is_a?(RAssign) && value === other.value && - operator === other.operator && pattern === other.pattern - 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 < Node - # [ConstPathRef | ConstRef | TopConstRef] the name of the class being - # defined - attr_reader :constant - - # [nil | Node] the optional superclass declaration - attr_reader :superclass - - # [BodyStmt] the expressions to execute within the context of the class - attr_reader :bodystmt - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(constant:, superclass:, bodystmt:, location:) - @constant = constant - @superclass = superclass - @bodystmt = bodystmt - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_class(self) - end - - def child_nodes - [constant, superclass, bodystmt] - end - - def copy(constant: nil, superclass: nil, bodystmt: nil, location: nil) - node = - ClassDeclaration.new( - constant: constant || self.constant, - superclass: superclass || self.superclass, - bodystmt: bodystmt || self.bodystmt, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - constant: constant, - superclass: superclass, - bodystmt: bodystmt, - location: location, - comments: comments - } - end - - def format(q) - if bodystmt.empty? - q.group do - format_declaration(q) - q.breakable_force - q.text("end") - end - else - q.group do - format_declaration(q) - - q.indent do - q.breakable_force - q.format(bodystmt) - end - - q.breakable_force - q.text("end") - end - end - end - - def ===(other) - other.is_a?(ClassDeclaration) && constant === other.constant && - superclass === other.superclass && bodystmt === other.bodystmt - end - - private - - def format_declaration(q) - q.group do - q.text("class ") - q.format(constant) - - if superclass - q.text(" < ") - q.format(superclass) - end - end - end - end - - # Comma represents the use of the , operator. - class Comma < Node - # [String] the comma in the string - attr_reader :value - - def initialize(value:, location:) - @value = value - @location = location - end - - def accept(visitor) - visitor.visit_comma(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - Comma.new(value: value || self.value, location: location || self.location) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location } - end - - def ===(other) - other.is_a?(Comma) && value === other.value - end - 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 < Node - # [Const | Ident] the message being sent to the implicit receiver - attr_reader :message - - # [Args] the arguments being sent with the message - attr_reader :arguments - - # [nil | BlockNode] the optional block being passed to the method - attr_reader :block - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(message:, arguments:, block:, location:) - @message = message - @arguments = arguments - @block = block - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_command(self) - end - - def child_nodes - [message, arguments, block] - end - - def copy(message: nil, arguments: nil, block: nil, location: nil) - node = - Command.new( - message: message || self.message, - arguments: arguments || self.arguments, - block: block || self.block, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - message: message, - arguments: arguments, - block: block, - location: location, - comments: comments - } - end - - def format(q) - q.group do - q.format(message) - align(q, self) { q.format(arguments) } - end - - q.format(block) if block - end - - def ===(other) - other.is_a?(Command) && message === other.message && - arguments === other.arguments && block === other.block - end - - def arity - arguments.arity - end - - private - - def align(q, node, &block) - arguments = node.arguments - - if arguments.is_a?(Args) - parts = arguments.parts - - if parts.size == 1 - part = parts.first - - case part - when DefNode - q.text(" ") - yield - when IfOp - q.if_flat { q.text(" ") } - yield - when Command - align(q, part, &block) - else - q.text(" ") - q.nest(message.value.length + 1) { yield } - end - else - q.text(" ") - q.nest(message.value.length + 1) { yield } - end - else - q.text(" ") - q.nest(message.value.length + 1) { yield } - end - end - end - - # CommandCall represents a method call on an object with arguments and no - # parentheses. - # - # object.method argument - # - class CommandCall < Node - # [nil | Node] the receiver of the message - attr_reader :receiver - - # [nil | :"::" | Op | Period] the operator used to send the message - attr_reader :operator - - # [:call | Const | Ident | Op] the message being send - attr_reader :message - - # [nil | Args | ArgParen] the arguments going along with the message - attr_reader :arguments - - # [nil | BlockNode] the block associated with this method call - attr_reader :block - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize( - receiver:, - operator:, - message:, - arguments:, - block:, - location: - ) - @receiver = receiver - @operator = operator - @message = message - @arguments = arguments - @block = block - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_command_call(self) - end - - def child_nodes - [receiver, message, arguments, block] - end - - def copy( - receiver: nil, - operator: nil, - message: nil, - arguments: nil, - block: nil, - location: nil - ) - node = - CommandCall.new( - receiver: receiver || self.receiver, - operator: operator || self.operator, - message: message || self.message, - arguments: arguments || self.arguments, - block: block || self.block, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - receiver: receiver, - operator: operator, - message: message, - arguments: arguments, - block: block, - location: location, - comments: comments - } - end - - def format(q) - message = self.message - arguments = self.arguments - block = self.block - - q.group do - doc = - q.nest(0) do - q.format(receiver) - - # If there are leading comments on the message then we know we have - # a newline in the source that is forcing these things apart. In - # this case we will have to use a trailing operator. - if message != :call && message.comments.any?(&:leading?) - q.format(CallOperatorFormatter.new(operator), stackable: false) - q.indent do - q.breakable_empty - q.format(message) - end - else - q.format(CallOperatorFormatter.new(operator), stackable: false) - q.format(message) - end - end - - # Format the arguments for this command call here. If there are no - # arguments, then print nothing. - if arguments - parts = arguments.parts - - if parts.length == 1 && parts.first.is_a?(IfOp) - q.if_flat { q.text(" ") } - q.format(arguments) - else - q.text(" ") - q.nest(argument_alignment(q, doc)) { q.format(arguments) } - end - end - end - - q.format(block) if block - end - - def ===(other) - other.is_a?(CommandCall) && receiver === other.receiver && - operator === other.operator && message === other.message && - arguments === other.arguments && block === other.block - end - - def arity - arguments&.arity || 0 - end - - private - - def argument_alignment(q, doc) - # Very special handling case for rspec matchers. In general with rspec - # matchers you expect to see something like: - # - # expect(foo).to receive(:bar).with( - # 'one', - # 'two', - # 'three', - # 'four', - # 'five' - # ) - # - # In this case the arguments are aligned to the left side as opposed to - # being aligned with the `receive` call. - if %w[to not_to to_not].include?(message.value) - 0 - else - width = q.last_position(doc) + 1 - width > (q.maxwidth / 2) ? 0 : width - end - end - end - - # Comment represents a comment in the source. - # - # # comment - # - class Comment < Node - # [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 - - def initialize(value:, inline:, location:) - @value = value - @inline = inline - @location = location - - @leading = false - @trailing = false - end - - def leading! - @leading = true - end - - def leading? - @leading - end - - def trailing! - @trailing = true - end - - def trailing? - @trailing - end - - def ignore? - value.match?(/\A#\s*stree-ignore\s*\z/) - end - - def comments - [] - end - - def accept(visitor) - visitor.visit_comment(self) - end - - def child_nodes - [] - end - - def copy(value: nil, inline: nil, location: nil) - Comment.new( - value: value || self.value, - inline: inline || self.inline, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, inline: inline, location: location } - end - - def format(q) - q.text(value) - end - - def ===(other) - other.is_a?(Comment) && value === other.value && inline === other.inline - end - 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 < Node - # [String] the name of the constant - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_const(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - node = - Const.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text(value) - end - - def ===(other) - other.is_a?(Const) && value === other.value - end - 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 < Node - # [Node] the source of the constant - attr_reader :parent - - # [Const] the constant itself - attr_reader :constant - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(parent:, constant:, location:) - @parent = parent - @constant = constant - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_const_path_field(self) - end - - def child_nodes - [parent, constant] - end - - def copy(parent: nil, constant: nil, location: nil) - node = - ConstPathField.new( - parent: parent || self.parent, - constant: constant || self.constant, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - parent: parent, - constant: constant, - location: location, - comments: comments - } - end - - def format(q) - q.format(parent) - q.text("::") - q.format(constant) - end - - def ===(other) - other.is_a?(ConstPathField) && parent === other.parent && - constant === other.constant - end - end - - # ConstPathRef represents referencing a constant by a path. - # - # object::Const - # - class ConstPathRef < Node - # [Node] the source of the constant - attr_reader :parent - - # [Const] the constant itself - attr_reader :constant - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(parent:, constant:, location:) - @parent = parent - @constant = constant - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_const_path_ref(self) - end - - def child_nodes - [parent, constant] - end - - def copy(parent: nil, constant: nil, location: nil) - node = - ConstPathRef.new( - parent: parent || self.parent, - constant: constant || self.constant, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - parent: parent, - constant: constant, - location: location, - comments: comments - } - end - - def format(q) - q.format(parent) - q.text("::") - q.format(constant) - end - - def ===(other) - other.is_a?(ConstPathRef) && parent === other.parent && - constant === other.constant - end - end - - # ConstRef represents the name of the constant being used in a class or module - # declaration. - # - # class Container - # end - # - class ConstRef < Node - # [Const] the constant itself - attr_reader :constant - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(constant:, location:) - @constant = constant - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_const_ref(self) - end - - def child_nodes - [constant] - end - - def copy(constant: nil, location: nil) - node = - ConstRef.new( - constant: constant || self.constant, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { constant: constant, location: location, comments: comments } - end - - def format(q) - q.format(constant) - end - - def ===(other) - other.is_a?(ConstRef) && constant === other.constant - end - end - - # CVar represents the use of a class variable. - # - # @@variable - # - class CVar < Node - # [String] the name of the class variable - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_cvar(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - node = - CVar.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text(value) - end - - def ===(other) - other.is_a?(CVar) && value === other.value - end - end - - # Def represents defining a regular method on the current self object. - # - # def method(param) result end - # def object.method(param) result end - # - class DefNode < Node - # [nil | Node] the target where the method is being defined - attr_reader :target - - # [nil | 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 - - # [nil | Params | Paren] the parameter declaration for the method - attr_reader :params - - # [BodyStmt | Node] the expressions to be executed by the method - attr_reader :bodystmt - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(target:, operator:, name:, params:, bodystmt:, location:) - @target = target - @operator = operator - @name = name - @params = params - @bodystmt = bodystmt - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_def(self) - end - - def child_nodes - [target, operator, name, params, bodystmt] - end - - def copy( - target: nil, - operator: nil, - name: nil, - params: nil, - bodystmt: nil, - location: nil - ) - node = - DefNode.new( - target: target || self.target, - operator: operator || self.operator, - name: name || self.name, - params: params || self.params, - bodystmt: bodystmt || self.bodystmt, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - target: target, - operator: operator, - name: name, - params: params, - bodystmt: bodystmt, - location: location, - comments: comments - } - end - - def format(q) - params = self.params - bodystmt = self.bodystmt - - q.group do - q.group do - q.text("def") - q.text(" ") if target || name.comments.empty? - - if target - q.format(target) - q.format(CallOperatorFormatter.new(operator), stackable: false) - end - - q.format(name) - - case params - when Paren - q.format(params) - when Params - q.format(params) if !params.empty? || params.comments.any? - end - end - - if endless? - q.text(" =") - q.group do - q.indent do - q.breakable_space - q.format(bodystmt) - end - end - else - unless bodystmt.empty? - q.indent do - q.breakable_force - q.format(bodystmt) - end - end - - q.breakable_force - q.text("end") - end - end - end - - def ===(other) - other.is_a?(DefNode) && target === other.target && - operator === other.operator && name === other.name && - params === other.params && bodystmt === other.bodystmt - end - - # Returns true if the method was found in the source in the "endless" form, - # i.e. where the method body is defined using the `=` operator after the - # method name and parameters. - def endless? - !bodystmt.is_a?(BodyStmt) - end - - def arity - params = self.params - - case params - when Params - params.arity - when Paren - params.contents.arity - else - 0..0 - end - end - end - - # Defined represents the use of the +defined?+ operator. It can be used with - # and without parentheses. - # - # defined?(variable) - # - class Defined < Node - # [Node] the value being sent to the keyword - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_defined(self) - end - - def child_nodes - [value] - end - - def copy(value: nil, location: nil) - node = - Defined.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text("defined?(") - q.group do - q.indent do - q.breakable_empty - q.format(value) - end - q.breakable_empty - end - q.text(")") - end - - def ===(other) - other.is_a?(Defined) && value === other.value - end - end - - # Block represents passing a block to a method call using the +do+ and +end+ - # keywords or the +{+ and +}+ operators. - # - # method do |value| - # end - # - # method { |value| } - # - class BlockNode < Node - # Formats the opening brace or keyword of a block. - class BlockOpenFormatter - # [String] the actual output that should be printed - attr_reader :text - - # [LBrace | Keyword] the node that is being represented - attr_reader :node - - def initialize(text, node) - @text = text - @node = node - end - - def comments - node.comments - end - - def format(q) - q.text(text) - end - end - - # [LBrace | Kw] the left brace or the do keyword that opens this block - attr_reader :opening - - # [nil | BlockVar] the optional variable declaration within this block - attr_reader :block_var - - # [BodyStmt | Statements] the expressions to be executed within this block - attr_reader :bodystmt - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(opening:, block_var:, bodystmt:, location:) - @opening = opening - @block_var = block_var - @bodystmt = bodystmt - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_block(self) - end - - def child_nodes - [opening, block_var, bodystmt] - end - - def copy(opening: nil, block_var: nil, bodystmt: nil, location: nil) - node = - BlockNode.new( - opening: opening || self.opening, - block_var: block_var || self.block_var, - bodystmt: bodystmt || self.bodystmt, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - opening: opening, - block_var: block_var, - bodystmt: bodystmt, - location: location, - comments: comments - } - end - - def format(q) - # If this is nested anywhere inside of a Command or CommandCall node, then - # we can't change which operators we're using for the bounds of the block. - break_opening, break_closing, flat_opening, flat_closing = - if unchangeable_bounds?(q) - block_close = keywords? ? "end" : "}" - [opening.value, block_close, opening.value, block_close] - elsif forced_do_end_bounds?(q) - %w[do end do end] - elsif forced_brace_bounds?(q) - %w[{ } { }] - else - %w[do end { }] - end - - # If the receiver of this block a Command or CommandCall node, then there - # are no parentheses around the arguments to that command, so we need to - # break the block. - case q.parent - when nil, Command, CommandCall - q.break_parent - format_break(q, break_opening, break_closing) - return - end - - q.group do - q - .if_break { format_break(q, break_opening, break_closing) } - .if_flat { format_flat(q, flat_opening, flat_closing) } - end - end - - def ===(other) - other.is_a?(BlockNode) && opening === other.opening && - block_var === other.block_var && bodystmt === other.bodystmt - end - - def keywords? - opening.is_a?(Kw) - end - - def arity - case block_var - when BlockVar - block_var.params.arity - else - 0..0 - end - end - - private - - # If this is nested anywhere inside certain nodes, then we can't change - # which operators/keywords we're using for the bounds of the block. - def unchangeable_bounds?(q) - q.parents.any? do |parent| - # If we hit a statements, then we're safe to use whatever since we - # know for certain we're going to get split over multiple lines - # anyway. - case parent - when Statements, ArgParen - break false - when Command, CommandCall - true - else - false - end - end - end - - # If we're a sibling of a control-flow keyword, then we're going to have to - # use the do..end bounds. - def forced_do_end_bounds?(q) - case q.parent&.call - when Break, Next, ReturnNode, Super - true - else - false - end - end - - # If we're the predicate of a loop or conditional, then we're going to have - # to go with the {..} bounds. - def forced_brace_bounds?(q) - previous = nil - q.parents.any? do |parent| - case parent - when Paren, Statements - # If we hit certain breakpoints then we know we're safe. - return false - when IfNode, IfOp, UnlessNode, WhileNode, UntilNode - return true if parent.predicate == previous - end - - previous = parent - false - end - end - - def format_break(q, break_opening, break_closing) - q.text(" ") - q.format(BlockOpenFormatter.new(break_opening, opening), stackable: false) - - if block_var - q.text(" ") - q.format(block_var) - end - - unless bodystmt.empty? - q.indent do - q.breakable_space - q.format(bodystmt) - end - end - - q.breakable_space - q.text(break_closing) - end - - def format_flat(q, flat_opening, flat_closing) - q.text(" ") - q.format(BlockOpenFormatter.new(flat_opening, opening), stackable: false) - - if block_var - q.breakable_space - q.format(block_var) - q.breakable_space - end - - if bodystmt.empty? - q.text(" ") if flat_opening == "do" - else - q.breakable_space unless block_var - q.format(bodystmt) - q.breakable_space - end - - q.text(flat_closing) - end - end - - # RangeNode represents using the .. or 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 RangeNode < Node - # [nil | Node] the left side of the expression - attr_reader :left - - # [Op] the operator used for this range - attr_reader :operator - - # [nil | Node] the right side of the expression - attr_reader :right - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(left:, operator:, right:, location:) - @left = left - @operator = operator - @right = right - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_range(self) - end - - def child_nodes - [left, right] - end - - def copy(left: nil, operator: nil, right: nil, location: nil) - node = - RangeNode.new( - left: left || self.left, - operator: operator || self.operator, - right: right || self.right, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - left: left, - operator: operator, - right: right, - location: location, - comments: comments - } - end - - def format(q) - q.format(left) if left - - case q.parent - when IfNode, UnlessNode - q.text(" #{operator.value} ") - else - q.text(operator.value) - end - - q.format(right) if right - end - - def ===(other) - other.is_a?(RangeNode) && left === other.left && - operator === other.operator && right === other.right - end - end - - # Responsible for providing information about quotes to be used for strings - # and dynamic symbols. - module Quotes - # The matching pairs of quotes that can be used with % literals. - PAIRS = { "(" => ")", "[" => "]", "{" => "}", "<" => ">" }.freeze - - # If there is some part of this string that matches an escape sequence or - # that contains the interpolation pattern ("#{"), then we are locked into - # whichever quote the user chose. (If they chose single quotes, then double - # quoting would activate the escape sequence, and if they chose double - # quotes, then single quotes would deactivate it.) - def self.locked?(node, quote) - node.parts.any? do |part| - !part.is_a?(TStringContent) || part.value.match?(/\\|#[@${]|#{quote}/) - end - end - - # Find the matching closing quote for the given opening quote. - def self.matching(quote) - PAIRS.fetch(quote) { quote } - end - - # Escape and unescape single and double quotes as needed to be able to - # enclose +content+ with +enclosing+. - def self.normalize(content, enclosing) - return content if enclosing != "\"" && enclosing != "'" - - content.gsub(/\\([\s\S])|(['"])/) do - _match, escaped, quote = Regexp.last_match.to_a - - if quote == enclosing - "\\#{quote}" - elsif quote - quote - else - "\\#{escaped}" - end - end - end - 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 < Node - # [Array[ StringDVar | StringEmbExpr | TStringContent ]] the parts of the - # dynamic symbol - attr_reader :parts - - # [nil | String] the quote used to delimit the dynamic symbol - attr_reader :quote - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(parts:, quote:, location:) - @parts = parts - @quote = quote - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_dyna_symbol(self) - end - - def child_nodes - parts - end - - def copy(parts: nil, quote: nil, location: nil) - node = - DynaSymbol.new( - parts: parts || self.parts, - quote: quote || self.quote, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { parts: parts, quote: quote, location: location, comments: comments } - end - - def format(q) - opening_quote, closing_quote = quotes(q) - - q.text(opening_quote) - q.group do - parts.each do |part| - if part.is_a?(TStringContent) - value = Quotes.normalize(part.value, closing_quote) - first = true - - value.each_line(chomp: true) do |line| - if first - first = false - else - q.breakable_return - end - - q.text(line) - end - - q.breakable_return if value.end_with?("\n") - else - q.format(part) - end - end - end - q.text(closing_quote) - end - - def ===(other) - other.is_a?(DynaSymbol) && ArrayMatch.call(parts, other.parts) && - quote === other.quote - end - - private - - # Here we determine the quotes to use for a dynamic symbol. It's bound by a - # lot of rules because it could be in many different contexts with many - # different kinds of escaping. - def quotes(q) - # If we're inside of an assoc node as the key, then it will handle - # printing the : on its own since it could change sides. - parent = q.parent - hash_key = parent.is_a?(Assoc) && parent.key == self - - if quote.start_with?("%s") - # Here we're going to check if there is a closing character, a new line, - # or a quote in the content of the dyna symbol. If there is, then - # quoting could get weird, so just bail out and stick to the original - # quotes in the source. - matching = Quotes.matching(quote[2]) - pattern = /[\n#{Regexp.escape(matching)}'"]/ - - # This check is to ensure we don't find a matching quote inside of the - # symbol that would be confusing. - matched = - parts.any? do |part| - part.is_a?(TStringContent) && part.value.match?(pattern) - end - - if matched - [quote, matching] - elsif Quotes.locked?(self, q.quote) - ["#{":" unless hash_key}'", "'"] - else - ["#{":" unless hash_key}#{q.quote}", q.quote] - end - elsif Quotes.locked?(self, q.quote) - if quote.start_with?(":") - [hash_key ? quote[1..] : quote, quote[1..]] - else - [hash_key ? quote : ":#{quote}", quote] - end - else - [hash_key ? q.quote : ":#{q.quote}", q.quote] - end - end - end - - # Else represents the end of an +if+, +unless+, or +case+ chain. - # - # if variable - # else - # end - # - class Else < Node - # [Kw] the else keyword - attr_reader :keyword - - # [Statements] the expressions to be executed - attr_reader :statements - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(keyword:, statements:, location:) - @keyword = keyword - @statements = statements - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_else(self) - end - - def child_nodes - [keyword, statements] - end - - def copy(keyword: nil, statements: nil, location: nil) - node = - Else.new( - keyword: keyword || self.keyword, - statements: statements || self.statements, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - keyword: keyword, - statements: statements, - location: location, - comments: comments - } - end - - def format(q) - q.group do - q.format(keyword) - - unless statements.empty? - q.indent do - q.breakable_force - q.format(statements) - end - end - end - end - - def ===(other) - other.is_a?(Else) && keyword === other.keyword && - statements === other.statements - end - end - - # Elsif represents another clause in an +if+ or +unless+ chain. - # - # if variable - # elsif other_variable - # end - # - class Elsif < Node - # [Node] 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 - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(predicate:, statements:, consequent:, location:) - @predicate = predicate - @statements = statements - @consequent = consequent - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_elsif(self) - end - - def child_nodes - [predicate, statements, consequent] - end - - def copy(predicate: nil, statements: nil, consequent: nil, location: nil) - node = - Elsif.new( - predicate: predicate || self.predicate, - statements: statements || self.statements, - consequent: consequent || self.consequent, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - predicate: predicate, - statements: statements, - consequent: consequent, - location: location, - comments: comments - } - end - - def format(q) - q.group do - q.group do - q.text("elsif ") - q.nest("elsif".length - 1) { q.format(predicate) } - end - - unless statements.empty? - q.indent do - q.breakable_force - q.format(statements) - end - end - - if consequent - q.group do - q.breakable_force - q.format(consequent) - end - end - end - end - - def ===(other) - other.is_a?(Elsif) && predicate === other.predicate && - statements === other.statements && consequent === other.consequent - end - end - - # EmbDoc represents a multi-line comment. - # - # =begin - # first line - # second line - # =end - # - class EmbDoc < Node - # [String] the contents of the comment - attr_reader :value - - def initialize(value:, location:) - @value = value - @location = location - - @leading = false - @trailing = false - end - - def leading! - @leading = true - end - - def leading? - @leading - end - - def trailing! - @trailing = true - end - - def trailing? - @trailing - end - - def inline? - false - end - - def ignore? - false - end - - def comments - [] - end - - def accept(visitor) - visitor.visit_embdoc(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - EmbDoc.new( - value: value || self.value, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location } - end - - def format(q) - if (q.parent.is_a?(DefNode) && q.parent.endless?) || - q.parent.is_a?(Statements) - q.trim - else - q.breakable_return - end - - q.text(value) - end - - def ===(other) - other.is_a?(EmbDoc) && value === other.value - end - 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 < Node - # [String] the #{ used in the string - attr_reader :value - - def initialize(value:, location:) - @value = value - @location = location - end - - def accept(visitor) - visitor.visit_embexpr_beg(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - EmbExprBeg.new( - value: value || self.value, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location } - end - - def ===(other) - other.is_a?(EmbExprBeg) && value === other.value - end - 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 < Node - # [String] the } used in the string - attr_reader :value - - def initialize(value:, location:) - @value = value - @location = location - end - - def accept(visitor) - visitor.visit_embexpr_end(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - EmbExprEnd.new( - value: value || self.value, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location } - end - - def ===(other) - other.is_a?(EmbExprEnd) && value === other.value - end - 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 < Node - # [String] the # used in the string - attr_reader :value - - def initialize(value:, location:) - @value = value - @location = location - end - - def accept(visitor) - visitor.visit_embvar(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - EmbVar.new( - value: value || self.value, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location } - end - - def ===(other) - other.is_a?(EmbVar) && value === other.value - end - end - - # Ensure represents the use of the +ensure+ keyword and its subsequent - # statements. - # - # begin - # ensure - # end - # - class Ensure < Node - # [Kw] the ensure keyword that began this node - attr_reader :keyword - - # [Statements] the expressions to be executed - attr_reader :statements - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(keyword:, statements:, location:) - @keyword = keyword - @statements = statements - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_ensure(self) - end - - def child_nodes - [keyword, statements] - end - - def copy(keyword: nil, statements: nil, location: nil) - node = - Ensure.new( - keyword: keyword || self.keyword, - statements: statements || self.statements, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - keyword: keyword, - statements: statements, - location: location, - comments: comments - } - end - - def format(q) - q.format(keyword) - - unless statements.empty? - q.indent do - q.breakable_force - q.format(statements) - end - end - end - - def ===(other) - other.is_a?(Ensure) && keyword === other.keyword && - statements === other.statements - end - 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 < Node - # [String] the comma - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_excessed_comma(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - node = - ExcessedComma.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text(value) - end - - def ===(other) - other.is_a?(ExcessedComma) && value === other.value - end - end - - # Field is always the child of an assignment. It represents assigning to a - # “field” on an object. - # - # object.variable = value - # - class Field < Node - # [Node] 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 - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(parent:, operator:, name:, location:) - @parent = parent - @operator = operator - @name = name - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_field(self) - end - - def child_nodes - operator = self.operator - [parent, (operator if operator != :"::"), name] - end - - def copy(parent: nil, operator: nil, name: nil, location: nil) - node = - Field.new( - parent: parent || self.parent, - operator: operator || self.operator, - name: name || self.name, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - parent: parent, - operator: operator, - name: name, - location: location, - comments: comments - } - end - - def format(q) - q.group do - q.format(parent) - q.format(CallOperatorFormatter.new(operator), stackable: false) - q.format(name) - end - end - - def ===(other) - other.is_a?(Field) && parent === other.parent && - operator === other.operator && name === other.name - end - end - - # FloatLiteral represents a floating point number literal. - # - # 1.0 - # - class FloatLiteral < Node - # [String] the value of the floating point number literal - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_float(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - node = - FloatLiteral.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text(value) - end - - def ===(other) - other.is_a?(FloatLiteral) && value === other.value - end - 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 < Node - # [nil | VarRef | ConstPathRef] the optional constant wrapper - attr_reader :constant - - # [VarField] the splat on the left-hand side - attr_reader :left - - # [Array[ Node ]] 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 - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(constant:, left:, values:, right:, location:) - @constant = constant - @left = left - @values = values - @right = right - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_fndptn(self) - end - - def child_nodes - [constant, left, *values, right] - end - - def copy(constant: nil, left: nil, values: nil, right: nil, location: nil) - node = - FndPtn.new( - constant: constant || self.constant, - left: left || self.left, - values: values || self.values, - right: right || self.right, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - constant: constant, - left: left, - values: values, - right: right, - location: location, - comments: comments - } - end - - def format(q) - q.format(constant) if constant - - q.group do - q.text("[") - - q.indent do - q.breakable_empty - - q.text("*") - q.format(left) - q.comma_breakable - - q.seplist(values) { |value| q.format(value) } - q.comma_breakable - - q.text("*") - q.format(right) - end - - q.breakable_empty - q.text("]") - end - end - - def ===(other) - other.is_a?(FndPtn) && constant === other.constant && - left === other.left && ArrayMatch.call(values, other.values) && - right === other.right - end - end - - # For represents using a +for+ loop. - # - # for value in list do - # end - # - class For < Node - # [MLHS | VarField] the variable declaration being used to - # pull values out of the object being enumerated - attr_reader :index - - # [Node] the object being enumerated in the loop - attr_reader :collection - - # [Statements] the statements to be executed - attr_reader :statements - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(index:, collection:, statements:, location:) - @index = index - @collection = collection - @statements = statements - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_for(self) - end - - def child_nodes - [index, collection, statements] - end - - def copy(index: nil, collection: nil, statements: nil, location: nil) - node = - For.new( - index: index || self.index, - collection: collection || self.collection, - statements: statements || self.statements, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - index: index, - collection: collection, - statements: statements, - location: location, - comments: comments - } - end - - def format(q) - q.group do - q.text("for ") - q.group { q.format(index) } - q.text(" in ") - q.format(collection) - - unless statements.empty? - q.indent do - q.breakable_force - q.format(statements) - end - end - - q.breakable_force - q.text("end") - end - end - - def ===(other) - other.is_a?(For) && index === other.index && - collection === other.collection && statements === other.statements - end - end - - # GVar represents a global variable literal. - # - # $variable - # - class GVar < Node - # [String] the name of the global variable - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_gvar(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - node = - GVar.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text(value) - end - - def ===(other) - other.is_a?(GVar) && value === other.value - end - end - - # HashLiteral represents a hash literal. - # - # { key => value } - # - class HashLiteral < Node - # This is a special formatter used if the hash literal contains no values - # but _does_ contain comments. In this case we do some special formatting to - # make sure the comments gets indented properly. - class EmptyWithCommentsFormatter - # [LBrace] the opening brace - attr_reader :lbrace - - def initialize(lbrace) - @lbrace = lbrace - end - - def format(q) - q.group do - q.text("{") - q.indent do - lbrace.comments.each do |comment| - q.breakable_force - comment.format(q) - end - end - q.breakable_force - q.text("}") - end - end - end - - # [LBrace] the left brace that opens this hash - attr_reader :lbrace - - # [Array[ Assoc | AssocSplat ]] the optional contents of the hash - attr_reader :assocs - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(lbrace:, assocs:, location:) - @lbrace = lbrace - @assocs = assocs - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_hash(self) - end - - def child_nodes - [lbrace].concat(assocs) - end - - def copy(lbrace: nil, assocs: nil, location: nil) - node = - HashLiteral.new( - lbrace: lbrace || self.lbrace, - assocs: assocs || self.assocs, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { lbrace: lbrace, assocs: assocs, location: location, comments: comments } - end - - def format(q) - if q.parent.is_a?(Assoc) - format_contents(q) - else - q.group { format_contents(q) } - end - end - - def ===(other) - other.is_a?(HashLiteral) && lbrace === other.lbrace && - ArrayMatch.call(assocs, other.assocs) - end - - def format_key(q, key) - (@key_formatter ||= HashKeyFormatter.for(self)).format_key(q, key) - end - - private - - # If we have an empty hash that contains only comments, then we're going - # to do some special printing to ensure they get indented correctly. - def empty_with_comments? - assocs.empty? && lbrace.comments.any? && lbrace.comments.none?(&:inline?) - end - - def format_contents(q) - if empty_with_comments? - EmptyWithCommentsFormatter.new(lbrace).format(q) - return - end - - q.format(lbrace) - - if assocs.empty? - q.breakable_empty - else - q.indent do - q.breakable_space - q.seplist(assocs) { |assoc| q.format(assoc) } - q.if_break { q.text(",") } if q.trailing_comma? - end - q.breakable_space - end - - q.text("}") - end - end - - # Heredoc represents a heredoc string literal. - # - # <<~DOC - # contents - # DOC - # - class Heredoc < Node - # [HeredocBeg] the opening of the heredoc - attr_reader :beginning - - # [HeredocEnd] the ending of the heredoc - attr_reader :ending - - # [Integer] how far to dedent the heredoc - attr_reader :dedent - - # [Array[ StringEmbExpr | StringDVar | TStringContent ]] the parts of the - # heredoc string literal - attr_reader :parts - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(beginning:, location:, ending: nil, dedent: 0, parts: []) - @beginning = beginning - @ending = ending - @dedent = dedent - @parts = parts - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_heredoc(self) - end - - def child_nodes - [beginning, *parts, ending] - end - - def copy(beginning: nil, location: nil, ending: nil, parts: nil) - node = - Heredoc.new( - beginning: beginning || self.beginning, - location: location || self.location, - ending: ending || self.ending, - parts: parts || self.parts - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - beginning: beginning, - location: location, - ending: ending, - parts: parts, - comments: comments - } - end - - # This is a very specific behavior where you want to force a newline, but - # don't want to force the break parent. - SEPARATOR = - PrettierPrint::Breakable.new(" ", 1, indent: false, force: true).freeze - - def format(q) - q.group do - q.format(beginning) - - q.line_suffix(priority: Formatter::HEREDOC_PRIORITY) do - q.group do - q.target << SEPARATOR - - parts.each do |part| - if part.is_a?(TStringContent) - value = part.value - first = true - - value.each_line(chomp: true) do |line| - if first - first = false - else - q.target << SEPARATOR - end - - q.text(line) - end - - q.target << SEPARATOR if value.end_with?("\n") - else - q.format(part) - end - end - - q.format(ending) - end - end - end - end - - def ===(other) - other.is_a?(Heredoc) && beginning === other.beginning && - ending === other.ending && ArrayMatch.call(parts, other.parts) - end - end - - # HeredocBeg represents the beginning declaration of a heredoc. - # - # <<~DOC - # contents - # DOC - # - # In the example above the HeredocBeg node represents <<~DOC. - class HeredocBeg < Node - # [String] the opening declaration of the heredoc - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_heredoc_beg(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - node = - HeredocBeg.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text(value) - end - - def ===(other) - other.is_a?(HeredocBeg) && value === other.value - end - end - - # HeredocEnd represents the closing declaration of a heredoc. - # - # <<~DOC - # contents - # DOC - # - # In the example above the HeredocEnd node represents the closing DOC. - class HeredocEnd < Node - # [String] the closing declaration of the heredoc - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_heredoc_end(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - node = - HeredocEnd.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text(value) - end - - def ===(other) - other.is_a?(HeredocEnd) && value === other.value - end - end - - # HshPtn represents matching against a hash pattern using the Ruby 2.7+ - # pattern matching syntax. - # - # case value - # in { key: } - # end - # - class HshPtn < Node - # Formats a key-value pair in a hash pattern. The value is optional. - class KeywordFormatter - # [Label] the keyword being used - attr_reader :key - - # [Node] the optional value for the keyword - attr_reader :value - - def initialize(key, value) - @key = key - @value = value - end - - def comments - [] - end - - def format(q) - HashKeyFormatter::Labels.new.format_key(q, key) - - if value - q.text(" ") - q.format(value) - end - end - end - - # Formats the optional double-splat from the pattern. - class KeywordRestFormatter - # [VarField] the parameter that matches the remaining keywords - attr_reader :keyword_rest - - def initialize(keyword_rest) - @keyword_rest = keyword_rest - end - - def comments - [] - end - - def format(q) - q.text("**") - q.format(keyword_rest) - end - end - - # [nil | VarRef | ConstPathRef] the optional constant wrapper - attr_reader :constant - - # [Array[ [DynaSymbol | Label, nil | Node] ]] 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 - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(constant:, keywords:, keyword_rest:, location:) - @constant = constant - @keywords = keywords - @keyword_rest = keyword_rest - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_hshptn(self) - end - - def child_nodes - [constant, *keywords.flatten(1), keyword_rest] - end - - def copy(constant: nil, keywords: nil, keyword_rest: nil, location: nil) - node = - HshPtn.new( - constant: constant || self.constant, - keywords: keywords || self.keywords, - keyword_rest: keyword_rest || self.keyword_rest, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - constant: constant, - keywords: keywords, - keyword_rest: keyword_rest, - location: location, - comments: comments - } - end - - def format(q) - parts = keywords.map { |(key, value)| KeywordFormatter.new(key, value) } - parts << KeywordRestFormatter.new(keyword_rest) if keyword_rest - nested = PATTERNS.include?(q.parent.class) - - # If there is a constant, we're going to format to have the constant name - # first and then use brackets. - if constant - q.group do - q.format(constant) - q.text("[") - q.indent do - q.breakable_empty - format_contents(q, parts, nested) - end - q.breakable_empty - q.text("]") - end - return - end - - # If there's nothing at all, then we're going to use empty braces. - if parts.empty? - q.text("{}") - return - end - - # If there's only one pair, then we'll just print the contents provided - # we're not inside another pattern. - if !nested && parts.size == 1 - format_contents(q, parts, nested) - return - end - - # Otherwise, we're going to always use braces to make it clear it's a hash - # pattern. - q.group do - q.text("{") - q.indent do - q.breakable_space - format_contents(q, parts, nested) - end - - if q.target_ruby_version < Formatter::SemanticVersion.new("2.7.3") - q.text(" }") - else - q.breakable_space - q.text("}") - end - end - end - - def ===(other) - other.is_a?(HshPtn) && constant === other.constant && - keywords.length == other.keywords.length && - keywords - .zip(other.keywords) - .all? { |left, right| ArrayMatch.call(left, right) } && - keyword_rest === other.keyword_rest - end - - private - - def format_contents(q, parts, nested) - keyword_rest = self.keyword_rest - - q.group { q.seplist(parts) { |part| q.format(part, stackable: false) } } - - # If there isn't a constant, and there's a blank keyword_rest, then we - # have an plain ** that needs to have a `then` after it in order to - # parse correctly on the next parse. - if !constant && keyword_rest && keyword_rest.value.nil? && !nested - q.text(" then") - end - end - end - - # The list of nodes that represent patterns inside of pattern matching so that - # when a pattern is being printed it knows if it's nested. - PATTERNS = [AryPtn, Binary, FndPtn, HshPtn, RAssign].freeze - - # 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 < Node - # [String] the value of the identifier - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_ident(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - node = - Ident.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text(value) - end - - def ===(other) - other.is_a?(Ident) && value === other.value - end - end - - # If the predicate of a conditional or loop contains an assignment (in which - # case we can't know for certain that that assignment doesn't impact the - # statements inside the conditional) then we can't use the modifier form - # and we must use the block form. - module ContainsAssignment - def self.call(parent) - queue = [parent] - - while (node = queue.shift) - case node - when Assign, MAssign, OpAssign - return true - else - node.child_nodes.each { |child| queue << child if child } - end - end - - false - end - end - - # In order for an `if` or `unless` expression to be shortened to a ternary, - # there has to be one and only one consequent clause which is an Else. Both - # the body of the main node and the body of the Else node must have only one - # statement, and that statement must not be on the denied list of potential - # statements. - module Ternaryable - class << self - def call(q, node) - return false if ENV["STREE_FAST_FORMAT"] || q.disable_auto_ternary? - - # If this is a conditional inside of a parentheses as the only content, - # then we don't want to transform it into a ternary. Presumably the user - # wanted it to be an explicit conditional because there are parentheses - # around it. So we'll just leave it in place. - grandparent = q.grandparent - if grandparent.is_a?(Paren) && (body = grandparent.contents.body) && - body.length == 1 && body.first == node - return false - end - - # Otherwise, we'll check the type of predicate. For certain nodes we - # want to force it to not be a ternary, like if the predicate is an - # assignment because it's hard to read. - case node.predicate - when Assign, Binary, Command, CommandCall, MAssign, OpAssign - return false - when Not - return false unless node.predicate.parentheses? - end - - # If there's no Else, then this can't be represented as a ternary. - return false unless node.consequent.is_a?(Else) - - truthy_body = node.statements.body - falsy_body = node.consequent.statements.body - - (truthy_body.length == 1) && ternaryable?(truthy_body.first) && - (falsy_body.length == 1) && ternaryable?(falsy_body.first) - end - - private - - # Certain expressions cannot be reduced to a ternary without adding - # parentheses around them. In this case we say they cannot be ternaried - # and default instead to breaking them into multiple lines. - def ternaryable?(statement) - case statement - when AliasNode, Assign, Break, Command, CommandCall, Defined, Heredoc, - IfNode, IfOp, Lambda, MAssign, Next, OpAssign, RescueMod, - ReturnNode, Super, Undef, UnlessNode, UntilNode, VoidStmt, - WhileNode, YieldNode, ZSuper - # This is a list of nodes that should not be allowed to be a part of a - # ternary clause. - false - when Binary - # If the user is using one of the lower precedence "and" or "or" - # operators, then we can't use a ternary expression as it would break - # the flow control. - operator = statement.operator - operator != :and && operator != :or - else - true - end - end - end - end - - # Formats an If or Unless node. - class ConditionalFormatter - # [String] the keyword associated with this conditional - attr_reader :keyword - - # [If | Unless] the node that is being formatted - attr_reader :node - - def initialize(keyword, node) - @keyword = keyword - @node = node - end - - def format(q) - if node.modifier? - statement = node.statements.body[0] - - if ContainsAssignment.call(statement) || q.parent.is_a?(In) - q.group { format_flat(q) } - else - q.group do - q - .if_break { format_break(q, force: false) } - .if_flat { format_flat(q) } - end - end - else - # If we can transform this node into a ternary, then we're going to - # print a special version that uses the ternary operator if it fits on - # one line. - if Ternaryable.call(q, node) - format_ternary(q) - return - end - - # If the predicate of the conditional contains an assignment (in which - # case we can't know for certain that that assignment doesn't impact the - # statements inside the conditional) then we can't use the modifier form - # and we must use the block form. - if ContainsAssignment.call(node.predicate) - format_break(q, force: true) - return - end - - if node.consequent || node.statements.empty? || contains_conditional? - q.group { format_break(q, force: true) } - else - q.group do - q - .if_break { format_break(q, force: false) } - .if_flat do - Parentheses.flat(q) do - q.format(node.statements) - q.text(" #{keyword} ") - q.format(node.predicate) - end - end - end - end - end - end - - private - - def format_flat(q) - Parentheses.flat(q) do - q.format(node.statements.body[0]) - q.text(" #{keyword} ") - q.format(node.predicate) - end - end - - def format_break(q, force:) - q.text("#{keyword} ") - q.nest(keyword.length + 1) { q.format(node.predicate) } - - unless node.statements.empty? - q.indent do - force ? q.breakable_force : q.breakable_space - q.format(node.statements) - end - end - - if node.consequent - force ? q.breakable_force : q.breakable_space - q.format(node.consequent) - end - - force ? q.breakable_force : q.breakable_space - q.text("end") - end - - def format_ternary(q) - q.group do - q - .if_break do - q.text("#{keyword} ") - q.nest(keyword.length + 1) { q.format(node.predicate) } - - q.indent do - q.breakable_space - q.format(node.statements) - end - - q.breakable_space - q.group do - q.format(node.consequent.keyword) - q.indent do - # This is a very special case of breakable where we want to - # force it into the output but we _don't_ want to explicitly - # break the parent. If a break-parent shows up in the tree, then - # it's going to force it all the way up to the tree, which is - # going to negate the ternary. - q.breakable(force: :skip_break_parent) - q.format(node.consequent.statements) - end - end - - q.breakable_space - q.text("end") - end - .if_flat do - Parentheses.flat(q) do - q.format(node.predicate) - q.text(" ? ") - - statements = [node.statements, node.consequent.statements] - statements.reverse! if keyword == "unless" - - q.format(statements[0]) - q.text(" : ") - q.format(statements[1]) - end - end - end - end - - def contains_conditional? - statements = node.statements.body - return false if statements.length != 1 - - case statements.first - when IfNode, IfOp, UnlessNode - true - else - false - end - end - end - - # If represents the first clause in an +if+ chain. - # - # if predicate - # end - # - class IfNode < Node - # [Node] 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 - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(predicate:, statements:, consequent:, location:) - @predicate = predicate - @statements = statements - @consequent = consequent - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_if(self) - end - - def child_nodes - [predicate, statements, consequent] - end - - def copy(predicate: nil, statements: nil, consequent: nil, location: nil) - node = - IfNode.new( - predicate: predicate || self.predicate, - statements: statements || self.statements, - consequent: consequent || self.consequent, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - predicate: predicate, - statements: statements, - consequent: consequent, - location: location, - comments: comments - } - end - - def format(q) - ConditionalFormatter.new("if", self).format(q) - end - - def ===(other) - other.is_a?(IfNode) && predicate === other.predicate && - statements === other.statements && consequent === other.consequent - end - - # Checks if the node was originally found in the modifier form. - def modifier? - predicate.location.start_char > statements.location.start_char - end - end - - # IfOp represents a ternary clause. - # - # predicate ? truthy : falsy - # - class IfOp < Node - # [Node] the expression to be checked - attr_reader :predicate - - # [Node] the expression to be executed if the predicate is truthy - attr_reader :truthy - - # [Node] the expression to be executed if the predicate is falsy - attr_reader :falsy - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(predicate:, truthy:, falsy:, location:) - @predicate = predicate - @truthy = truthy - @falsy = falsy - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_if_op(self) - end - - def child_nodes - [predicate, truthy, falsy] - end - - def copy(predicate: nil, truthy: nil, falsy: nil, location: nil) - node = - IfOp.new( - predicate: predicate || self.predicate, - truthy: truthy || self.truthy, - falsy: falsy || self.falsy, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - predicate: predicate, - truthy: truthy, - falsy: falsy, - location: location, - comments: comments - } - end - - def format(q) - force_flat = [ - AliasNode, - Assign, - Break, - Command, - CommandCall, - Heredoc, - IfNode, - IfOp, - Lambda, - MAssign, - Next, - OpAssign, - RescueMod, - ReturnNode, - Super, - Undef, - UnlessNode, - VoidStmt, - YieldNode, - ZSuper - ] - - if q.parent.is_a?(Paren) || force_flat.include?(truthy.class) || - force_flat.include?(falsy.class) - q.group { format_flat(q) } - return - end - - q.group { q.if_break { format_break(q) }.if_flat { format_flat(q) } } - end - - def ===(other) - other.is_a?(IfOp) && predicate === other.predicate && - truthy === other.truthy && falsy === other.falsy - end - - private - - def format_break(q) - Parentheses.break(q) do - q.text("if ") - q.nest("if ".length) { q.format(predicate) } - - q.indent do - q.breakable_space - q.format(truthy) - end - - q.breakable_space - q.text("else") - - q.indent do - q.breakable_space - q.format(falsy) - end - - q.breakable_space - q.text("end") - end - end - - def format_flat(q) - q.format(predicate) - q.text(" ?") - - q.indent do - q.breakable_space - q.format(truthy) - q.text(" :") - - q.breakable_space - q.format(falsy) - end - end - end - - # Imaginary represents an imaginary number literal. - # - # 1i - # - class Imaginary < Node - # [String] the value of the imaginary number literal - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_imaginary(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - node = - Imaginary.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text(value) - end - - def ===(other) - other.is_a?(Imaginary) && value === other.value - end - end - - # In represents using the +in+ keyword within the Ruby 2.7+ pattern matching - # syntax. - # - # case value - # in pattern - # end - # - class In < Node - # [Node] 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 - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(pattern:, statements:, consequent:, location:) - @pattern = pattern - @statements = statements - @consequent = consequent - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_in(self) - end - - def child_nodes - [pattern, statements, consequent] - end - - def copy(pattern: nil, statements: nil, consequent: nil, location: nil) - node = - In.new( - pattern: pattern || self.pattern, - statements: statements || self.statements, - consequent: consequent || self.consequent, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - pattern: pattern, - statements: statements, - consequent: consequent, - location: location, - comments: comments - } - end - - def format(q) - keyword = "in " - pattern = self.pattern - consequent = self.consequent - - q.group do - q.text(keyword) - q.nest(keyword.length) { q.format(pattern) } - q.text(" then") if pattern.is_a?(RangeNode) && pattern.right.nil? - - unless statements.empty? - q.indent do - q.breakable_force - q.format(statements) - end - end - - if consequent - q.breakable_force - q.format(consequent) - end - end - end - - def ===(other) - other.is_a?(In) && pattern === other.pattern && - statements === other.statements && consequent === other.consequent - end - end - - # Int represents an integer number literal. - # - # 1 - # - class Int < Node - # [String] the value of the integer - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_int(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - node = - Int.new(value: value || self.value, location: location || self.location) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - if !value.start_with?(/\+?0/) && value.length >= 5 && !value.include?("_") - # If it's a plain integer and it doesn't have any underscores separating - # the values, then we're going to insert them every 3 characters - # starting from the right. - index = (value.length + 2) % 3 - q.text(" #{value}"[index..].scan(/.../).join("_").strip) - else - q.text(value) - end - end - - def ===(other) - other.is_a?(Int) && value === other.value - end - end - - # IVar represents an instance variable literal. - # - # @variable - # - class IVar < Node - # [String] the name of the instance variable - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_ivar(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - node = - IVar.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text(value) - end - - def ===(other) - other.is_a?(IVar) && value === other.value - end - 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 < Node - # [String] the value of the keyword - attr_reader :value - - # [Symbol] the symbol version of the value - attr_reader :name - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @name = value.to_sym - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_kw(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - node = - Kw.new(value: value || self.value, location: location || self.location) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text(value) - end - - def ===(other) - other.is_a?(Kw) && value === other.value - end - end - - # KwRestParam represents defining a parameter in a method definition that - # accepts all remaining keyword parameters. - # - # def method(**kwargs) end - # - class KwRestParam < Node - # [nil | Ident] the name of the parameter - attr_reader :name - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(name:, location:) - @name = name - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_kwrest_param(self) - end - - def child_nodes - [name] - end - - def copy(name: nil, location: nil) - node = - KwRestParam.new( - name: name || self.name, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { name: name, location: location, comments: comments } - end - - def format(q) - q.text("**") - q.format(name) if name - end - - def ===(other) - other.is_a?(KwRestParam) && name === other.name - end - 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 < Node - # [String] the value of the label - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_label(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - node = - Label.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text(value) - end - - def ===(other) - other.is_a?(Label) && value === other.value - end - 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 < Node - # [String] the end of the label - attr_reader :value - - def initialize(value:, location:) - @value = value - @location = location - end - - def accept(visitor) - visitor.visit_label_end(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - LabelEnd.new( - value: value || self.value, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location } - end - - def ===(other) - other.is_a?(LabelEnd) && value === other.value - end - end - - # Lambda represents using a lambda literal (not the lambda method call). - # - # ->(value) { value * 2 } - # - class Lambda < Node - # [LambdaVar | Paren] the parameter declaration for this lambda - attr_reader :params - - # [BodyStmt | Statements] the expressions to be executed in this lambda - attr_reader :statements - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(params:, statements:, location:) - @params = params - @statements = statements - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_lambda(self) - end - - def child_nodes - [params, statements] - end - - def copy(params: nil, statements: nil, location: nil) - node = - Lambda.new( - params: params || self.params, - statements: statements || self.statements, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - params: params, - statements: statements, - location: location, - comments: comments - } - end - - def format(q) - params = self.params - - q.text("->") - q.group do - if params.is_a?(Paren) - q.format(params) unless params.contents.empty? - elsif params.empty? && params.comments.any? - q.format(params) - elsif !params.empty? - q.group do - q.text("(") - q.format(params) - q.text(")") - end - end - - q.text(" ") - q - .if_break do - q.text("do") - - unless statements.empty? - q.indent do - q.breakable_space - q.format(statements) - end - end - - q.breakable_space - q.text("end") - end - .if_flat do - q.text("{") - - unless statements.empty? - q.text(" ") - q.format(statements) - q.text(" ") - end - - q.text("}") - end - end - end - - def ===(other) - other.is_a?(Lambda) && params === other.params && - statements === other.statements - end - end - - # LambdaVar represents the parameters being declared for a lambda. Effectively - # this node is everything contained within the parentheses. This includes all - # of the various parameter types, as well as block-local variable - # declarations. - # - # -> (positional, optional = value, keyword:, █ local) do - # end - # - class LambdaVar < Node - # [Params] the parameters being declared with the block - attr_reader :params - - # [Array[ Ident ]] the list of block-local variable declarations - attr_reader :locals - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(params:, locals:, location:) - @params = params - @locals = locals - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_lambda_var(self) - end - - def child_nodes - [params, *locals] - end - - def copy(params: nil, locals: nil, location: nil) - node = - LambdaVar.new( - params: params || self.params, - locals: locals || self.locals, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { params: params, locals: locals, location: location, comments: comments } - end - - def empty? - params.empty? && locals.empty? - end - - def format(q) - q.format(params) - - if locals.any? - q.text("; ") - q.seplist(locals, BlockVar::SEPARATOR) { |local| q.format(local) } - end - end - - def ===(other) - other.is_a?(LambdaVar) && params === other.params && - ArrayMatch.call(locals, other.locals) - end - end - - # LBrace represents the use of a left brace, i.e., {. - class LBrace < Node - # [String] the left brace - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_lbrace(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - node = - LBrace.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text(value) - end - - def ===(other) - other.is_a?(LBrace) && value === other.value - end - - # Because some nodes keep around a { token so that comments can be attached - # to it if they occur in the source, oftentimes an LBrace is a child of - # another node. This means it's required at initialization time. To make it - # easier to create LBrace nodes without any specific value, this method - # provides a default node. - def self.default - new(value: "{", location: Location.default) - end - end - - # LBracket represents the use of a left bracket, i.e., [. - class LBracket < Node - # [String] the left bracket - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_lbracket(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - node = - LBracket.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text(value) - end - - def ===(other) - other.is_a?(LBracket) && value === other.value - end - - # Because some nodes keep around a [ token so that comments can be attached - # to it if they occur in the source, oftentimes an LBracket is a child of - # another node. This means it's required at initialization time. To make it - # easier to create LBracket nodes without any specific value, this method - # provides a default node. - def self.default - new(value: "[", location: Location.default) - end - end - - # LParen represents the use of a left parenthesis, i.e., (. - class LParen < Node - # [String] the left parenthesis - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_lparen(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - node = - LParen.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text(value) - end - - def ===(other) - other.is_a?(LParen) && value === other.value - end - - # Because some nodes keep around a ( token so that comments can be attached - # to it if they occur in the source, oftentimes an LParen is a child of - # another node. This means it's required at initialization time. To make it - # easier to create LParen nodes without any specific value, this method - # provides a default node. - def self.default - new(value: "(", location: Location.default) - end - 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 < Node - # [MLHS | MLHSParen] the target of the multiple assignment - attr_reader :target - - # [Node] the value being assigned - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(target:, value:, location:) - @target = target - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_massign(self) - end - - def child_nodes - [target, value] - end - - def copy(target: nil, value: nil, location: nil) - node = - MAssign.new( - target: target || self.target, - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { target: target, value: value, location: location, comments: comments } - end - - def format(q) - q.group do - q.group { q.format(target) } - q.text(" =") - q.indent do - q.breakable_space - q.format(value) - end - end - end - - def ===(other) - other.is_a?(MAssign) && target === other.target && value === other.value - end - end - - # MethodAddBlock represents a method call with a block argument. - # - # method {} - # - class MethodAddBlock < Node - # [ARef | CallNode | Command | CommandCall | Super | ZSuper] the method call - attr_reader :call - - # [BlockNode] the block being sent with the method call - attr_reader :block - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(call:, block:, location:) - @call = call - @block = block - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_method_add_block(self) - end - - def child_nodes - [call, block] - end - - def copy(call: nil, block: nil, location: nil) - node = - MethodAddBlock.new( - call: call || self.call, - block: block || self.block, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { call: call, block: block, location: location, comments: comments } - end - - def format(q) - # If we're at the top of a call chain, then we're going to do some - # specialized printing in case we can print it nicely. We _only_ do this - # at the top of the chain to avoid weird recursion issues. - if CallChainFormatter.chained?(call) && - !CallChainFormatter.chained?(q.parent) - q.group do - q - .if_break { CallChainFormatter.new(self).format(q) } - .if_flat { format_contents(q) } - end - else - format_contents(q) - end - end - - def ===(other) - other.is_a?(MethodAddBlock) && call === other.call && - block === other.block - end - - def format_contents(q) - q.format(call) - q.format(block) - end - end - - # MLHS represents a list of values being destructured on the left-hand side - # of a multiple assignment. - # - # first, second, third = value - # - class MLHS < Node - # [ - # Array[ - # ARefField | ArgStar | ConstPathField | Field | Ident | MLHSParen | - # TopConstField | 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 - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(parts:, location:, comma: false) - @parts = parts - @comma = comma - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_mlhs(self) - end - - def child_nodes - parts - end - - def copy(parts: nil, location: nil, comma: nil) - node = - MLHS.new( - parts: parts || self.parts, - location: location || self.location, - comma: comma || self.comma - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { parts: parts, location: location, comma: comma, comments: comments } - end - - def format(q) - q.seplist(parts) { |part| q.format(part) } - q.text(",") if comma - end - - def ===(other) - other.is_a?(MLHS) && ArrayMatch.call(parts, other.parts) && - comma === other.comma - end - end - - # MLHSParen represents parentheses being used to destruct values in a multiple - # assignment on the left hand side. - # - # (left, right) = value - # - class MLHSParen < Node - # [MLHS | MLHSParen] the contents inside of the parentheses - attr_reader :contents - - # [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 - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(contents:, location:, comma: false) - @contents = contents - @comma = comma - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_mlhs_paren(self) - end - - def child_nodes - [contents] - end - - def copy(contents: nil, location: nil) - node = - MLHSParen.new( - contents: contents || self.contents, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { contents: contents, location: location, comments: comments } - end - - def format(q) - parent = q.parent - - if parent.is_a?(MAssign) || parent.is_a?(MLHSParen) - q.format(contents) - q.text(",") if comma - else - q.text("(") - q.group do - q.indent do - q.breakable_empty - q.format(contents) - end - - q.text(",") if comma - q.breakable_empty - end - q.text(")") - end - end - - def ===(other) - other.is_a?(MLHSParen) && contents === other.contents - end - end - - # ModuleDeclaration represents defining a module using the +module+ keyword. - # - # module Namespace - # end - # - class ModuleDeclaration < Node - # [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 - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(constant:, bodystmt:, location:) - @constant = constant - @bodystmt = bodystmt - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_module(self) - end - - def child_nodes - [constant, bodystmt] - end - - def copy(constant: nil, bodystmt: nil, location: nil) - node = - ModuleDeclaration.new( - constant: constant || self.constant, - bodystmt: bodystmt || self.bodystmt, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - constant: constant, - bodystmt: bodystmt, - location: location, - comments: comments - } - end - - def format(q) - if bodystmt.empty? - q.group do - format_declaration(q) - q.breakable_force - q.text("end") - end - else - q.group do - format_declaration(q) - - q.indent do - q.breakable_force - q.format(bodystmt) - end - - q.breakable_force - q.text("end") - end - end - end - - def ===(other) - other.is_a?(ModuleDeclaration) && constant === other.constant && - bodystmt === other.bodystmt - end - - private - - def format_declaration(q) - q.group do - q.text("module ") - q.format(constant) - end - end - end - - # MRHS represents the values that are being assigned on the right-hand side of - # a multiple assignment. - # - # values = first, second, third - # - class MRHS < Node - # [Array[Node]] the parts that are being assigned - attr_reader :parts - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(parts:, location:) - @parts = parts - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_mrhs(self) - end - - def child_nodes - parts - end - - def copy(parts: nil, location: nil) - node = - MRHS.new( - parts: parts || self.parts, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { parts: parts, location: location, comments: comments } - end - - def format(q) - q.seplist(parts) { |part| q.format(part) } - end - - def ===(other) - other.is_a?(MRHS) && ArrayMatch.call(parts, other.parts) - end - 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 < Node - # [Args] the arguments passed to the next keyword - attr_reader :arguments - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(arguments:, location:) - @arguments = arguments - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_next(self) - end - - def child_nodes - [arguments] - end - - def copy(arguments: nil, location: nil) - node = - Next.new( - arguments: arguments || self.arguments, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { arguments: arguments, location: location, comments: comments } - end - - def format(q) - FlowControlFormatter.new("next", self).format(q) - end - - def ===(other) - other.is_a?(Next) && arguments === other.arguments - end - end - - # Op represents an operator literal in the source. - # - # 1 + 2 - # - # In the example above, the Op node represents the + operator. - class Op < Node - # [String] the operator - attr_reader :value - - # [Symbol] the symbol version of the value - attr_reader :name - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @name = value.to_sym - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_op(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - node = - Op.new(value: value || self.value, location: location || self.location) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text(value) - end - - def ===(other) - other.is_a?(Op) && value === other.value - end - end - - # OpAssign represents assigning a value to a variable or constant using an - # operator like += or ||=. - # - # variable += value - # - class OpAssign < Node - # [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 - - # [Node] the expression to be assigned - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(target:, operator:, value:, location:) - @target = target - @operator = operator - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_opassign(self) - end - - def child_nodes - [target, operator, value] - end - - def copy(target: nil, operator: nil, value: nil, location: nil) - node = - OpAssign.new( - target: target || self.target, - operator: operator || self.operator, - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - target: target, - operator: operator, - value: value, - location: location, - comments: comments - } - end - - def format(q) - q.group do - q.format(target) - q.text(" ") - q.format(operator) - - if skip_indent? - q.text(" ") - q.format(value) - else - q.indent do - q.breakable_space - q.format(value) - end - end - end - end - - def ===(other) - other.is_a?(OpAssign) && target === other.target && - operator === other.operator && value === other.value - end - - private - - def skip_indent? - target.comments.empty? && - (target.is_a?(ARefField) || AssignFormatting.skip_indent?(value)) - end - end - - # If you have a modifier statement (for instance a modifier if statement or a - # modifier while loop) there are times when you need to wrap the entire - # statement in parentheses. This occurs when you have something like: - # - # foo[:foo] = - # if bar? - # baz - # end - # - # Normally we would shorten this to an inline version, which would result in: - # - # foo[:foo] = baz if bar? - # - # but this actually has different semantic meaning. The first example will - # result in a nil being inserted into the hash for the :foo key, whereas the - # second example will result in an empty hash because the if statement applies - # to the entire assignment. - # - # We can fix this in a couple of ways. We can use the then keyword, as in: - # - # foo[:foo] = if bar? then baz end - # - # But this isn't used very often. We can also just leave it as is with the - # multi-line version, but for a short predicate and short value it looks - # verbose. The last option and the one used here is to add parentheses on - # both sides of the expression, as in: - # - # foo[:foo] = (baz if bar?) - # - # This approach maintains the nice conciseness of the inline version, while - # keeping the correct semantic meaning. - module Parentheses - NODES = [ - Args, - Assign, - Assoc, - Binary, - CallNode, - Defined, - MAssign, - OpAssign - ].freeze - - def self.flat(q) - return yield unless NODES.include?(q.parent.class) - - q.text("(") - yield - q.text(")") - end - - def self.break(q) - return yield unless NODES.include?(q.parent.class) - - q.text("(") - q.indent do - q.breakable_empty - yield - end - q.breakable_empty - q.text(")") - end - end - - # def on_operator_ambiguous(value) - # value - # end - - # Params represents defining parameters on a method or lambda. - # - # def method(param) end - # - class Params < Node - # Formats the optional position of the parameters. This includes the label, - # as well as the default value. - class OptionalFormatter - # [Ident] the name of the parameter - attr_reader :name - - # [Node] the value of the parameter - attr_reader :value - - def initialize(name, value) - @name = name - @value = value - end - - def comments - [] - end - - def format(q) - q.format(name) - q.text(" = ") - q.format(value) - end - end - - # Formats the keyword position of the parameters. This includes the label, - # as well as an optional default value. - class KeywordFormatter - # [Ident] the name of the parameter - attr_reader :name - - # [nil | Node] the value of the parameter - attr_reader :value - - def initialize(name, value) - @name = name - @value = value - end - - def comments - [] - end - - def format(q) - q.format(name) - - if value - q.text(" ") - q.format(value) - end - end - end - - # Formats the keyword_rest position of the parameters. This can be the **nil - # syntax, the ... syntax, or the ** syntax. - class KeywordRestFormatter - # [:nil | ArgsForward | KwRestParam] the value of the parameter - attr_reader :value - - def initialize(value) - @value = value - end - - def comments - [] - end - - def format(q) - value == :nil ? q.text("**nil") : q.format(value) - end - end - - # [Array[ Ident | MLHSParen ]] any required parameters - attr_reader :requireds - - # [Array[ [ Ident, Node ] ]] any optional parameters and their default - # values - attr_reader :optionals - - # [nil | ArgsForward | ExcessedComma | RestParam] the optional rest - # parameter - attr_reader :rest - - # [Array[ Ident | MLHSParen ]] any positional parameters that exist after a - # rest parameter - attr_reader :posts - - # [Array[ [ Label, nil | Node ] ]] any keyword parameters and their - # optional default values - attr_reader :keywords - - # [nil | :nil | ArgsForward | KwRestParam] the optional keyword rest - # parameter - attr_reader :keyword_rest - - # [nil | BlockArg] the optional block parameter - attr_reader :block - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize( - location:, - requireds: [], - optionals: [], - rest: nil, - posts: [], - keywords: [], - keyword_rest: nil, - block: nil - ) - @requireds = requireds - @optionals = optionals - @rest = rest - @posts = posts - @keywords = keywords - @keyword_rest = keyword_rest - @block = block - @location = location - @comments = [] - 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 accept(visitor) - visitor.visit_params(self) - end - - def child_nodes - keyword_rest = self.keyword_rest - - [ - *requireds, - *optionals.flatten(1), - rest, - *posts, - *keywords.flatten(1), - (keyword_rest if keyword_rest != :nil), - block - ] - end - - def copy( - location: nil, - requireds: nil, - optionals: nil, - rest: nil, - posts: nil, - keywords: nil, - keyword_rest: nil, - block: nil - ) - node = - Params.new( - location: location || self.location, - requireds: requireds || self.requireds, - optionals: optionals || self.optionals, - rest: rest || self.rest, - posts: posts || self.posts, - keywords: keywords || self.keywords, - keyword_rest: keyword_rest || self.keyword_rest, - block: block || self.block - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - location: location, - requireds: requireds, - optionals: optionals, - rest: rest, - posts: posts, - keywords: keywords, - keyword_rest: keyword_rest, - block: block, - comments: comments - } - end - - def format(q) - rest = self.rest - keyword_rest = self.keyword_rest - - parts = [ - *requireds, - *optionals.map { |(name, value)| OptionalFormatter.new(name, value) } - ] - - parts << rest if rest && !rest.is_a?(ExcessedComma) - parts.concat(posts) - parts.concat( - keywords.map { |(name, value)| KeywordFormatter.new(name, value) } - ) - - parts << KeywordRestFormatter.new(keyword_rest) if keyword_rest - parts << block if block - - if parts.empty? - q.nest(0) { format_contents(q, parts) } - return - end - - if q.parent.is_a?(DefNode) - q.nest(0) do - q.text("(") - q.group do - q.indent do - q.breakable_empty - format_contents(q, parts) - end - q.breakable_empty - end - q.text(")") - end - else - q.nest(0) { format_contents(q, parts) } - end - end - - def ===(other) - other.is_a?(Params) && ArrayMatch.call(requireds, other.requireds) && - optionals.length == other.optionals.length && - optionals - .zip(other.optionals) - .all? { |left, right| ArrayMatch.call(left, right) } && - rest === other.rest && ArrayMatch.call(posts, other.posts) && - keywords.length == other.keywords.length && - keywords - .zip(other.keywords) - .all? { |left, right| ArrayMatch.call(left, right) } && - keyword_rest === other.keyword_rest && block === other.block - end - - # Returns a range representing the possible number of arguments accepted - # by this params node not including the block. For example: - # - # def foo(a, b = 1, c:, d: 2, &block) - # ... - # end - # - # has arity 2..4. - # - def arity - optional_keywords = keywords.count { |_label, value| value } - - lower_bound = - requireds.length + posts.length + keywords.length - optional_keywords - - upper_bound = - if keyword_rest.nil? && rest.nil? - lower_bound + optionals.length + optional_keywords - end - - lower_bound..upper_bound - end - - private - - def format_contents(q, parts) - q.seplist(parts) { |part| q.format(part) } - q.format(rest) if rest.is_a?(ExcessedComma) - end - 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 < Node - # [LParen] the left parenthesis that opened this statement - attr_reader :lparen - - # [nil | Node] the expression inside the parentheses - attr_reader :contents - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(lparen:, contents:, location:) - @lparen = lparen - @contents = contents - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_paren(self) - end - - def child_nodes - [lparen, contents] - end - - def copy(lparen: nil, contents: nil, location: nil) - node = - Paren.new( - lparen: lparen || self.lparen, - contents: contents || self.contents, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - lparen: lparen, - contents: contents, - location: location, - comments: comments - } - end - - def format(q) - contents = self.contents - - q.group do - q.format(lparen) - - if contents && (!contents.is_a?(Params) || !contents.empty?) - q.indent do - q.breakable_empty - q.format(contents) - end - end - - q.breakable_empty - q.text(")") - end - end - - def ===(other) - other.is_a?(Paren) && lparen === other.lparen && - contents === other.contents - end - end - - # Period represents the use of the +.+ operator. It is usually found in method - # calls. - class Period < Node - # [String] the period - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_period(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - node = - Period.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text(value) - end - - def ===(other) - other.is_a?(Period) && value === other.value - end - end - - # Program represents the overall syntax tree. - class Program < Node - # [Statements] the top-level expressions of the program - attr_reader :statements - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(statements:, location:) - @statements = statements - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_program(self) - end - - def child_nodes - [statements] - end - - def copy(statements: nil, location: nil) - node = - Program.new( - statements: statements || self.statements, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { statements: statements, location: location, comments: comments } - end - - def format(q) - q.format(statements) - - # We're going to put a newline on the end so that it always has one unless - # it ends with the special __END__ syntax. In that case we want to - # replicate the text exactly so we will just let it be. - q.breakable_force unless statements.body.last.is_a?(EndContent) - end - - def ===(other) - other.is_a?(Program) && statements === other.statements - end - end - - # QSymbols represents a symbol literal array without interpolation. - # - # %i[one two three] - # - class QSymbols < Node - # [QSymbolsBeg] the token that opens this array literal - attr_reader :beginning - - # [Array[ TStringContent ]] the elements of the array - attr_reader :elements - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(beginning:, elements:, location:) - @beginning = beginning - @elements = elements - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_qsymbols(self) - end - - def child_nodes - [] - end - - def copy(beginning: nil, elements: nil, location: nil) - node = - QSymbols.new( - beginning: beginning || self.beginning, - elements: elements || self.elements, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - beginning: beginning, - elements: elements, - location: location, - comments: comments - } - end - - def format(q) - opening, closing = "%i[", "]" - - if elements.any? { |element| element.match?(/[\[\]]/) } - opening = beginning.value - closing = Quotes.matching(opening[2]) - end - - q.text(opening) - q.group do - q.indent do - q.breakable_empty - q.seplist( - elements, - ArrayLiteral::BREAKABLE_SPACE_SEPARATOR - ) { |element| q.format(element) } - end - q.breakable_empty - end - q.text(closing) - end - - def ===(other) - other.is_a?(QSymbols) && beginning === other.beginning && - ArrayMatch.call(elements, other.elements) - end - 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 < Node - # [String] the beginning of the array literal - attr_reader :value - - def initialize(value:, location:) - @value = value - @location = location - end - - def accept(visitor) - visitor.visit_qsymbols_beg(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - QSymbolsBeg.new( - value: value || self.value, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location } - end - - def ===(other) - other.is_a?(QSymbolsBeg) && value === other.value - end - end - - # QWords represents a string literal array without interpolation. - # - # %w[one two three] - # - class QWords < Node - # [QWordsBeg] the token that opens this array literal - attr_reader :beginning - - # [Array[ TStringContent ]] the elements of the array - attr_reader :elements - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(beginning:, elements:, location:) - @beginning = beginning - @elements = elements - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_qwords(self) - end - - def child_nodes - [] - end - - def copy(beginning: nil, elements: nil, location: nil) - QWords.new( - beginning: beginning || self.beginning, - elements: elements || self.elements, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - beginning: beginning, - elements: elements, - location: location, - comments: comments - } - end - - def format(q) - opening, closing = "%w[", "]" - - if elements.any? { |element| element.match?(/[\[\]]/) } - opening = beginning.value - closing = Quotes.matching(opening[2]) - end - - q.text(opening) - q.group do - q.indent do - q.breakable_empty - q.seplist( - elements, - ArrayLiteral::BREAKABLE_SPACE_SEPARATOR - ) { |element| q.format(element) } - end - q.breakable_empty - end - q.text(closing) - end - - def ===(other) - other.is_a?(QWords) && beginning === other.beginning && - ArrayMatch.call(elements, other.elements) - end - 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 < Node - # [String] the beginning of the array literal - attr_reader :value - - def initialize(value:, location:) - @value = value - @location = location - end - - def accept(visitor) - visitor.visit_qwords_beg(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - QWordsBeg.new( - value: value || self.value, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location } - end - - def ===(other) - other.is_a?(QWordsBeg) && value === other.value - end - end - - # RationalLiteral represents the use of a rational number literal. - # - # 1r - # - class RationalLiteral < Node - # [String] the rational number literal - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_rational(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - node = - RationalLiteral.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text(value) - end - - def ===(other) - other.is_a?(RationalLiteral) && value === other.value - end - end - - # RBrace represents the use of a right brace, i.e., +++. - class RBrace < Node - # [String] the right brace - attr_reader :value - - def initialize(value:, location:) - @value = value - @location = location - end - - def accept(visitor) - visitor.visit_rbrace(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - RBrace.new( - value: value || self.value, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location } - end - - def ===(other) - other.is_a?(RBrace) && value === other.value - end - end - - # RBracket represents the use of a right bracket, i.e., +]+. - class RBracket < Node - # [String] the right bracket - attr_reader :value - - def initialize(value:, location:) - @value = value - @location = location - end - - def accept(visitor) - visitor.visit_rbracket(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - RBracket.new( - value: value || self.value, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location } - end - - def ===(other) - other.is_a?(RBracket) && value === other.value - end - end - - # Redo represents the use of the +redo+ keyword. - # - # redo - # - class Redo < Node - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(location:) - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_redo(self) - end - - def child_nodes - [] - end - - def copy(location: nil) - node = Redo.new(location: location || self.location) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { location: location, comments: comments } - end - - def format(q) - q.text("redo") - end - - def ===(other) - other.is_a?(Redo) - end - 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 < Node - # [String] the opening of the regular expression - attr_reader :beginning - - # [Array[ StringDVar | StringEmbExpr | TStringContent ]] the parts of the - # regular expression - attr_reader :parts - - def initialize(beginning:, parts:, location:) - @beginning = beginning - @parts = parts - @location = location - end - - def accept(visitor) - visitor.visit_regexp_content(self) - end - - def child_nodes - parts - end - - def copy(beginning: nil, parts: nil, location: nil) - RegexpContent.new( - beginning: beginning || self.beginning, - parts: parts || self.parts, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { beginning: beginning, parts: parts, location: location } - end - - def ===(other) - other.is_a?(RegexpContent) && beginning === other.beginning && - parts === other.parts - end - 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 < Node - # [String] the beginning of the regular expression - attr_reader :value - - def initialize(value:, location:) - @value = value - @location = location - end - - def accept(visitor) - visitor.visit_regexp_beg(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - RegexpBeg.new( - value: value || self.value, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location } - end - - def ===(other) - other.is_a?(RegexpBeg) && value === other.value - end - 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 < Node - # [String] the end of the regular expression - attr_reader :value - - def initialize(value:, location:) - @value = value - @location = location - end - - def accept(visitor) - visitor.visit_regexp_end(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - RegexpEnd.new( - value: value || self.value, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location } - end - - def ===(other) - other.is_a?(RegexpEnd) && value === other.value - end - end - - # RegexpLiteral represents a regular expression literal. - # - # /.+/ - # - class RegexpLiteral < Node - # [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 - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(beginning:, ending:, parts:, location:) - @beginning = beginning - @ending = ending - @parts = parts - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_regexp_literal(self) - end - - def child_nodes - parts - end - - def copy(beginning: nil, ending: nil, parts: nil, location: nil) - node = - RegexpLiteral.new( - beginning: beginning || self.beginning, - ending: ending || self.ending, - parts: parts || self.parts, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - beginning: beginning, - ending: ending, - options: options, - parts: parts, - location: location, - comments: comments - } - end - - def format(q) - braces = ambiguous?(q) || include?(%r{/}) - - if braces && include?(/[{}]/) - q.group do - q.text(beginning) - q.format_each(parts) - q.text(ending) - end - elsif braces - q.group do - q.text("%r{") - - if beginning == "/" - # If we're changing from a forward slash to a %r{, then we can - # replace any escaped forward slashes with regular forward slashes. - parts.each do |part| - if part.is_a?(TStringContent) - q.text(part.value.gsub("\\/", "/")) - else - q.format(part) - end - end - else - q.format_each(parts) - end - - q.text("}") - q.text(options) - end - else - q.group do - q.text("/") - q.format_each(parts) - q.text("/") - q.text(options) - end - end - end - - def ===(other) - other.is_a?(RegexpLiteral) && beginning === other.beginning && - ending === other.ending && options === other.options && - ArrayMatch.call(parts, other.parts) - end - - def options - ending[1..] - end - - private - - def include?(pattern) - parts.any? do |part| - part.is_a?(TStringContent) && part.value.match?(pattern) - end - end - - # If the first part of this regex is plain string content, we have a space - # or an =, and we're contained within a command or command_call node, then - # we want to use braces because otherwise we could end up with an ambiguous - # operator, e.g. foo / bar/ or foo /=bar/ - def ambiguous?(q) - return false if parts.empty? - part = parts.first - - part.is_a?(TStringContent) && part.value.start_with?(" ", "=") && - q.parents.any? { |node| node.is_a?(Command) || node.is_a?(CommandCall) } - end - end - - # RescueEx represents the list of exceptions being rescued in a rescue clause. - # - # begin - # rescue Exception => exception - # end - # - class RescueEx < Node - # [nil | Node] the list of exceptions being rescued - attr_reader :exceptions - - # [nil | Field | VarField] the expression being used to capture the raised - # exception - attr_reader :variable - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(exceptions:, variable:, location:) - @exceptions = exceptions - @variable = variable - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_rescue_ex(self) - end - - def child_nodes - [*exceptions, variable] - end - - def copy(exceptions: nil, variable: nil, location: nil) - node = - RescueEx.new( - exceptions: exceptions || self.exceptions, - variable: variable || self.variable, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - exceptions: exceptions, - variable: variable, - location: location, - comments: comments - } - end - - def format(q) - q.group do - if exceptions - q.text(" ") - q.format(exceptions) - end - - if variable - q.text(" => ") - q.format(variable) - end - end - end - - def ===(other) - other.is_a?(RescueEx) && exceptions === other.exceptions && - variable === other.variable - end - end - - # Rescue represents the use of the rescue keyword inside of a BodyStmt node. - # - # begin - # rescue - # end - # - class Rescue < Node - # [Kw] the rescue keyword - attr_reader :keyword - - # [nil | 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 - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(keyword:, exception:, statements:, consequent:, location:) - @keyword = keyword - @exception = exception - @statements = statements - @consequent = consequent - @location = location - @comments = [] - end - - def bind_end(end_char, end_column) - @location = - Location.new( - start_line: location.start_line, - start_char: location.start_char, - start_column: location.start_column, - end_line: location.end_line, - end_char: end_char, - end_column: end_column - ) - - if (next_node = consequent) - next_node.bind_end(end_char, end_column) - statements.bind_end( - next_node.location.start_char, - next_node.location.start_column - ) - else - statements.bind_end(end_char, end_column) - end - end - - def accept(visitor) - visitor.visit_rescue(self) - end - - def child_nodes - [keyword, exception, statements, consequent] - end - - def copy( - keyword: nil, - exception: nil, - statements: nil, - consequent: nil, - location: nil - ) - node = - Rescue.new( - keyword: keyword || self.keyword, - exception: exception || self.exception, - statements: statements || self.statements, - consequent: consequent || self.consequent, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - keyword: keyword, - exception: exception, - statements: statements, - consequent: consequent, - location: location, - comments: comments - } - end - - def format(q) - q.group do - q.format(keyword) - - if exception - q.nest(keyword.value.length + 1) { q.format(exception) } - else - q.text(" StandardError") - end - - unless statements.empty? - q.indent do - q.breakable_force - q.format(statements) - end - end - - if consequent - q.breakable_force - q.format(consequent) - end - end - end - - def ===(other) - other.is_a?(Rescue) && keyword === other.keyword && - exception === other.exception && statements === other.statements && - consequent === other.consequent - end - end - - # RescueMod represents the use of the modifier form of a +rescue+ clause. - # - # expression rescue value - # - class RescueMod < Node - # [Node] the expression to execute - attr_reader :statement - - # [Node] the value to use if the executed expression raises an error - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(statement:, value:, location:) - @statement = statement - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_rescue_mod(self) - end - - def child_nodes - [statement, value] - end - - def copy(statement: nil, value: nil, location: nil) - node = - RescueMod.new( - statement: statement || self.statement, - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - statement: statement, - value: value, - location: location, - comments: comments - } - end - - def format(q) - q.text("begin") - q.group do - q.indent do - q.breakable_force - q.format(statement) - end - q.breakable_force - q.text("rescue StandardError") - q.indent do - q.breakable_force - q.format(value) - end - q.breakable_force - end - q.text("end") - end - - def ===(other) - other.is_a?(RescueMod) && statement === other.statement && - value === other.value - end - end - - # RestParam represents defining a parameter in a method definition that - # accepts all remaining positional parameters. - # - # def method(*rest) end - # - class RestParam < Node - # [nil | Ident] the name of the parameter - attr_reader :name - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(name:, location:) - @name = name - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_rest_param(self) - end - - def child_nodes - [name] - end - - def copy(name: nil, location: nil) - node = - RestParam.new( - name: name || self.name, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { name: name, location: location, comments: comments } - end - - def format(q) - q.text("*") - q.format(name) if name - end - - def ===(other) - other.is_a?(RestParam) && name === other.name - end - end - - # Retry represents the use of the +retry+ keyword. - # - # retry - # - class Retry < Node - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(location:) - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_retry(self) - end - - def child_nodes - [] - end - - def copy(location: nil) - node = Retry.new(location: location || self.location) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { location: location, comments: comments } - end - - def format(q) - q.text("retry") - end - - def ===(other) - other.is_a?(Retry) - end - end - - # Return represents using the +return+ keyword with arguments. - # - # return value - # - class ReturnNode < Node - # [nil | Args] the arguments being passed to the keyword - attr_reader :arguments - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(arguments:, location:) - @arguments = arguments - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_return(self) - end - - def child_nodes - [arguments] - end - - def copy(arguments: nil, location: nil) - node = - ReturnNode.new( - arguments: arguments || self.arguments, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { arguments: arguments, location: location, comments: comments } - end - - def format(q) - FlowControlFormatter.new("return", self).format(q) - end - - def ===(other) - other.is_a?(ReturnNode) && arguments === other.arguments - end - end - - # RParen represents the use of a right parenthesis, i.e., +)+. - class RParen < Node - # [String] the parenthesis - attr_reader :value - - def initialize(value:, location:) - @value = value - @location = location - end - - def accept(visitor) - visitor.visit_rparen(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - RParen.new( - value: value || self.value, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location } - end - - def ===(other) - other.is_a?(RParen) && value === other.value - end - 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 < Node - # [Node] the target of the singleton class to enter - attr_reader :target - - # [BodyStmt] the expressions to be executed - attr_reader :bodystmt - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(target:, bodystmt:, location:) - @target = target - @bodystmt = bodystmt - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_sclass(self) - end - - def child_nodes - [target, bodystmt] - end - - def copy(target: nil, bodystmt: nil, location: nil) - node = - SClass.new( - target: target || self.target, - bodystmt: bodystmt || self.bodystmt, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - target: target, - bodystmt: bodystmt, - location: location, - comments: comments - } - end - - def format(q) - q.text("class << ") - q.group do - q.format(target) - q.indent do - q.breakable_force - q.format(bodystmt) - end - q.breakable_force - end - q.text("end") - end - - def ===(other) - other.is_a?(SClass) && target === other.target && - bodystmt === other.bodystmt - end - 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 < Node - # [Array[ Node ]] the list of expressions contained within this node - attr_reader :body - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(body:, location:) - @body = body - @location = location - @comments = [] - end - - def bind(parser, start_char, start_column, end_char, end_column) - @location = - Location.new( - start_line: location.start_line, - start_char: start_char, - start_column: start_column, - end_line: location.end_line, - end_char: end_char, - end_column: end_column - ) - - if (void_stmt = body[0]).is_a?(VoidStmt) - location = void_stmt.location - location = - Location.new( - start_line: location.start_line, - start_char: start_char, - start_column: start_column, - end_line: location.end_line, - end_char: start_char, - end_column: end_column - ) - - body[0] = VoidStmt.new(location: location) - end - - attach_comments(parser, start_char, end_char) - end - - def bind_end(end_char, end_column) - @location = - Location.new( - start_line: location.start_line, - start_char: location.start_char, - start_column: location.start_column, - end_line: location.end_line, - end_char: end_char, - end_column: end_column - ) - end - - def empty? - body.all? do |statement| - statement.is_a?(VoidStmt) && statement.comments.empty? - end - end - - def accept(visitor) - visitor.visit_statements(self) - end - - def child_nodes - body - end - - def copy(body: nil, location: nil) - node = - Statements.new( - body: body || self.body, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { body: body, location: location, comments: comments } - end - - def format(q) - line = nil - - # This handles a special case where you've got a block of statements where - # the only value is a comment. In that case a lot of nodes like - # brace_block will attempt to format as a single line, but since that - # wouldn't work with a comment, we intentionally break the parent group. - if body.length == 2 - void_stmt, comment = body - - if void_stmt.is_a?(VoidStmt) && comment.is_a?(Comment) - q.format(comment) - q.break_parent - return - end - end - - previous = nil - body.each do |statement| - next if statement.is_a?(VoidStmt) - - if line.nil? - q.format(statement) - elsif (statement.location.start_line - line) > 1 - q.breakable_force - q.breakable_force - q.format(statement) - elsif (statement.is_a?(VCall) && statement.access_control?) || - (previous.is_a?(VCall) && previous.access_control?) - q.breakable_force - q.breakable_force - q.format(statement) - elsif statement.location.start_line != line - q.breakable_force - q.format(statement) - elsif !q.parent.is_a?(StringEmbExpr) - q.breakable_force - q.format(statement) - else - q.text("; ") - q.format(statement) - end - - line = statement.location.end_line - previous = statement - end - end - - def ===(other) - other.is_a?(Statements) && ArrayMatch.call(body, other.body) - end - - private - - # As efficiently as possible, gather up all of the comments that have been - # found while this statements list was being parsed and add them into the - # body. - def attach_comments(parser, start_char, end_char) - parser_comments = parser.comments - - comment_index = 0 - body_index = 0 - - while comment_index < parser_comments.size - comment = parser_comments[comment_index] - location = comment.location - - if !comment.inline? && (start_char <= location.start_char) && - (end_char >= location.end_char) && !comment.ignore? - while (node = body[body_index]) && - ( - node.is_a?(VoidStmt) || - node.location.start_char < location.start_char - ) - body_index += 1 - end - - if body_index != 0 && - body[body_index - 1].location.start_char < location.start_char && - body[body_index - 1].location.end_char > location.start_char - # The previous node entirely encapsules the comment, so we don't - # want to attach it here since it will get attached normally. This - # is mostly in the case of hash and array literals. - comment_index += 1 - else - parser_comments.delete_at(comment_index) - body.insert(body_index, comment) - end - else - comment_index += 1 - end - end - end - end - - # StringContent represents the contents of a string-like value. - # - # "string" - # - class StringContent < Node - # [Array[ StringEmbExpr | StringDVar | TStringContent ]] the parts of the - # string - attr_reader :parts - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(parts:, location:) - @parts = parts - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_string_content(self) - end - - def child_nodes - parts - end - - def copy(parts: nil, location: nil) - StringContent.new( - parts: parts || self.parts, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { parts: parts, location: location } - end - - def ===(other) - other.is_a?(StringContent) && ArrayMatch.call(parts, other.parts) - end - - def format(q) - q.text(q.quote) - q.group do - parts.each do |part| - if part.is_a?(TStringContent) - value = Quotes.normalize(part.value, q.quote) - first = true - - value.each_line(chomp: true) do |line| - if first - first = false - else - q.breakable_return - end - - q.text(line) - end - - q.breakable_return if value.end_with?("\n") - else - q.format(part) - end - end - end - q.text(q.quote) - end - end - - # StringConcat represents concatenating two strings together using a backward - # slash. - # - # "first" \ - # "second" - # - class StringConcat < Node - # [Heredoc | StringConcat | StringLiteral] the left side of the - # concatenation - attr_reader :left - - # [StringLiteral] the right side of the concatenation - attr_reader :right - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(left:, right:, location:) - @left = left - @right = right - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_string_concat(self) - end - - def child_nodes - [left, right] - end - - def copy(left: nil, right: nil, location: nil) - node = - StringConcat.new( - left: left || self.left, - right: right || self.right, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { left: left, right: right, location: location, comments: comments } - end - - def format(q) - q.group do - q.format(left) - q.text(" \\") - q.indent do - q.breakable_force - q.format(right) - end - end - end - - def ===(other) - other.is_a?(StringConcat) && left === other.left && right === other.right - end - 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 < Node - # [Backref | VarRef] the variable being interpolated - attr_reader :variable - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(variable:, location:) - @variable = variable - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_string_dvar(self) - end - - def child_nodes - [variable] - end - - def copy(variable: nil, location: nil) - node = - StringDVar.new( - variable: variable || self.variable, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { variable: variable, location: location, comments: comments } - end - - def format(q) - q.text('#{') - q.format(variable) - q.text("}") - end - - def ===(other) - other.is_a?(StringDVar) && variable === other.variable - end - 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 < Node - # [Statements] the expressions to be interpolated - attr_reader :statements - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(statements:, location:) - @statements = statements - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_string_embexpr(self) - end - - def child_nodes - [statements] - end - - def copy(statements: nil, location: nil) - node = - StringEmbExpr.new( - statements: statements || self.statements, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { statements: statements, location: location, comments: comments } - end - - def format(q) - if location.start_line == location.end_line - # If the contents of this embedded expression were originally on the - # same line in the source, then we're going to leave them in place and - # assume that's the way the developer wanted this expression - # represented. - q.remove_breaks( - q.group do - q.text('#{') - q.format(statements) - q.text("}") - end - ) - else - q.group do - q.text('#{') - q.indent do - q.breakable_empty - q.format(statements) - end - q.breakable_empty - q.text("}") - end - end - end - - def ===(other) - other.is_a?(StringEmbExpr) && statements === other.statements - end - end - - # StringLiteral represents a string literal. - # - # "string" - # - class StringLiteral < Node - # [Array[ StringEmbExpr | StringDVar | TStringContent ]] the parts of the - # string literal - attr_reader :parts - - # [nil | String] which quote was used by the string literal - attr_reader :quote - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(parts:, quote:, location:) - @parts = parts - @quote = quote - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_string_literal(self) - end - - def child_nodes - parts - end - - def copy(parts: nil, quote: nil, location: nil) - node = - StringLiteral.new( - parts: parts || self.parts, - quote: quote || self.quote, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { parts: parts, quote: quote, location: location, comments: comments } - end - - def format(q) - if parts.empty? - q.text("#{q.quote}#{q.quote}") - return - end - - opening_quote, closing_quote = - if !Quotes.locked?(self, q.quote) - [q.quote, q.quote] - elsif quote&.start_with?("%") - [quote, Quotes.matching(quote[/%[qQ]?(.)/, 1])] - else - [quote, quote] - end - - q.text(opening_quote) - q.group do - parts.each do |part| - if part.is_a?(TStringContent) - value = Quotes.normalize(part.value, closing_quote) - first = true - - value.each_line(chomp: true) do |line| - if first - first = false - else - q.breakable_return - end - - q.text(line) - end - - q.breakable_return if value.end_with?("\n") - else - q.format(part) - end - end - end - q.text(closing_quote) - end - - def ===(other) - other.is_a?(StringLiteral) && ArrayMatch.call(parts, other.parts) && - quote === other.quote - end - end - - # Super represents using the +super+ keyword with arguments. It can optionally - # use parentheses. - # - # super(value) - # - class Super < Node - # [ArgParen | Args] the arguments to the keyword - attr_reader :arguments - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(arguments:, location:) - @arguments = arguments - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_super(self) - end - - def child_nodes - [arguments] - end - - def copy(arguments: nil, location: nil) - node = - Super.new( - arguments: arguments || self.arguments, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { arguments: arguments, location: location, comments: comments } - end - - def format(q) - q.group do - q.text("super") - - if arguments.is_a?(ArgParen) - q.format(arguments) - else - q.text(" ") - q.nest("super ".length) { q.format(arguments) } - end - end - end - - def ===(other) - other.is_a?(Super) && arguments === other.arguments - end - 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 < Node - # [String] the beginning of the symbol - attr_reader :value - - def initialize(value:, location:) - @value = value - @location = location - end - - def accept(visitor) - visitor.visit_symbeg(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - SymBeg.new( - value: value || self.value, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location } - end - - def ===(other) - other.is_a?(SymBeg) && value === other.value - end - end - - # SymbolContent represents symbol contents and is always the child of a - # SymbolLiteral node. - # - # :symbol - # - class SymbolContent < Node - # [Backtick | Const | CVar | GVar | Ident | IVar | Kw | Op] the value of the - # symbol - attr_reader :value - - def initialize(value:, location:) - @value = value - @location = location - end - - def accept(visitor) - visitor.visit_symbol_content(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - SymbolContent.new( - value: value || self.value, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location } - end - - def ===(other) - other.is_a?(SymbolContent) && value === other.value - end - end - - # SymbolLiteral represents a symbol in the system with no interpolation - # (as opposed to a DynaSymbol which has interpolation). - # - # :symbol - # - class SymbolLiteral < Node - # [Backtick | Const | CVar | GVar | Ident | IVar | Kw | Op | TStringContent] - # the value of the symbol - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_symbol_literal(self) - end - - def child_nodes - [value] - end - - def copy(value: nil, location: nil) - node = - SymbolLiteral.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text(":") - q.text("\\") if value.comments.any? - q.format(value) - end - - def ===(other) - other.is_a?(SymbolLiteral) && value === other.value - end - end - - # Symbols represents a symbol array literal with interpolation. - # - # %I[one two three] - # - class Symbols < Node - # [SymbolsBeg] the token that opens this array literal - attr_reader :beginning - - # [Array[ Word ]] the words in the symbol array literal - attr_reader :elements - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(beginning:, elements:, location:) - @beginning = beginning - @elements = elements - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_symbols(self) - end - - def child_nodes - [] - end - - def copy(beginning: nil, elements: nil, location: nil) - Symbols.new( - beginning: beginning || self.beginning, - elements: elements || self.elements, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - beginning: beginning, - elements: elements, - location: location, - comments: comments - } - end - - def format(q) - opening, closing = "%I[", "]" - - if elements.any? { |element| element.match?(/[\[\]]/) } - opening = beginning.value - closing = Quotes.matching(opening[2]) - end - - q.text(opening) - q.group do - q.indent do - q.breakable_empty - q.seplist( - elements, - ArrayLiteral::BREAKABLE_SPACE_SEPARATOR - ) { |element| q.format(element) } - end - q.breakable_empty - end - q.text(closing) - end - - def ===(other) - other.is_a?(Symbols) && beginning === other.beginning && - ArrayMatch.call(elements, other.elements) - end - 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 < Node - # [String] the beginning of the symbol literal array - attr_reader :value - - def initialize(value:, location:) - @value = value - @location = location - end - - def accept(visitor) - visitor.visit_symbols_beg(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - SymbolsBeg.new( - value: value || self.value, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location } - end - - def ===(other) - other.is_a?(SymbolsBeg) && value === other.value - end - end - - # TLambda represents the beginning of a lambda literal. - # - # -> { value } - # - # In the example above the TLambda represents the +->+ operator. - class TLambda < Node - # [String] the beginning of the lambda literal - attr_reader :value - - def initialize(value:, location:) - @value = value - @location = location - end - - def accept(visitor) - visitor.visit_tlambda(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - TLambda.new( - value: value || self.value, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location } - end - - def ===(other) - other.is_a?(TLambda) && value === other.value - end - 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 < Node - # [String] the beginning of the body of the lambda literal - attr_reader :value - - def initialize(value:, location:) - @value = value - @location = location - end - - def accept(visitor) - visitor.visit_tlambeg(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - TLamBeg.new( - value: value || self.value, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location } - end - - def ===(other) - other.is_a?(TLamBeg) && value === other.value - end - 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 < Node - # [Const] the constant being assigned - attr_reader :constant - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(constant:, location:) - @constant = constant - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_top_const_field(self) - end - - def child_nodes - [constant] - end - - def copy(constant: nil, location: nil) - node = - TopConstField.new( - constant: constant || self.constant, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { constant: constant, location: location, comments: comments } - end - - def format(q) - q.text("::") - q.format(constant) - end - - def ===(other) - other.is_a?(TopConstField) && constant === other.constant - end - end - - # TopConstRef is very similar to TopConstField except that it is not involved - # in an assignment. - # - # ::Constant - # - class TopConstRef < Node - # [Const] the constant being referenced - attr_reader :constant - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(constant:, location:) - @constant = constant - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_top_const_ref(self) - end - - def child_nodes - [constant] - end - - def copy(constant: nil, location: nil) - node = - TopConstRef.new( - constant: constant || self.constant, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { constant: constant, location: location, comments: comments } - end - - def format(q) - q.text("::") - q.format(constant) - end - - def ===(other) - other.is_a?(TopConstRef) && constant === other.constant - end - 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 < Node - # [String] the beginning of the string - attr_reader :value - - def initialize(value:, location:) - @value = value - @location = location - end - - def accept(visitor) - visitor.visit_tstring_beg(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - TStringBeg.new( - value: value || self.value, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location } - end - - def ===(other) - other.is_a?(TStringBeg) && value === other.value - end - 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 < Node - # [String] the content of the string - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def match?(pattern) - value.match?(pattern) - end - - def accept(visitor) - visitor.visit_tstring_content(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - node = - TStringContent.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.text(value) - end - - def ===(other) - other.is_a?(TStringContent) && value === other.value - end - 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 < Node - # [String] the end of the string - attr_reader :value - - def initialize(value:, location:) - @value = value - @location = location - end - - def accept(visitor) - visitor.visit_tstring_end(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - TStringEnd.new( - value: value || self.value, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location } - end - - def ===(other) - other.is_a?(TStringEnd) && value === other.value - end - end - - # Not represents the unary +not+ method being called on an expression. - # - # not value - # - class Not < Node - # [nil | Node] the statement on which to operate - attr_reader :statement - - # [boolean] whether or not parentheses were used - attr_reader :parentheses - alias parentheses? parentheses - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(statement:, parentheses:, location:) - @statement = statement - @parentheses = parentheses - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_not(self) - end - - def child_nodes - [statement] - end - - def copy(statement: nil, parentheses: nil, location: nil) - node = - Not.new( - statement: statement || self.statement, - parentheses: parentheses || self.parentheses, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - statement: statement, - parentheses: parentheses, - location: location, - comments: comments - } - end - - def format(q) - q.text("not") - - if parentheses - q.text("(") - q.format(statement) if statement - q.text(")") - else - grandparent = q.grandparent - ternary = - (grandparent.is_a?(IfNode) || grandparent.is_a?(UnlessNode)) && - Ternaryable.call(q, grandparent) - - if ternary - q.if_break { q.text(" ") }.if_flat { q.text("(") } - q.format(statement) if statement - q.if_flat { q.text(")") } if ternary - else - q.text(" ") - q.format(statement) if statement - end - end - end - - def ===(other) - other.is_a?(Not) && statement === other.statement && - parentheses === other.parentheses - end - end - - # Unary represents a unary method being called on an expression, as in +!+ or - # +~+. - # - # !value - # - class Unary < Node - # [String] the operator being used - attr_reader :operator - - # [Node] the statement on which to operate - attr_reader :statement - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(operator:, statement:, location:) - @operator = operator - @statement = statement - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_unary(self) - end - - def child_nodes - [statement] - end - - def copy(operator: nil, statement: nil, location: nil) - node = - Unary.new( - operator: operator || self.operator, - statement: statement || self.statement, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - operator: operator, - statement: statement, - location: location, - comments: comments - } - end - - def format(q) - q.text(operator) - q.format(statement) - end - - def ===(other) - other.is_a?(Unary) && operator === other.operator && - statement === other.statement - end - end - - # Undef represents the use of the +undef+ keyword. - # - # undef method - # - class Undef < Node - # Undef accepts a variable number of arguments that can be either DynaSymbol - # or SymbolLiteral objects. For SymbolLiteral objects we descend directly - # into the value in order to have it come out as bare words. - class UndefArgumentFormatter - # [DynaSymbol | SymbolLiteral] the symbol to undefine - attr_reader :node - - def initialize(node) - @node = node - end - - def comments - if node.is_a?(SymbolLiteral) - node.comments + node.value.comments - else - node.comments - end - end - - def format(q) - node.is_a?(SymbolLiteral) ? q.format(node.value) : q.format(node) - end - end - - # [Array[ DynaSymbol | SymbolLiteral ]] the symbols to undefine - attr_reader :symbols - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(symbols:, location:) - @symbols = symbols - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_undef(self) - end - - def child_nodes - symbols - end - - def copy(symbols: nil, location: nil) - node = - Undef.new( - symbols: symbols || self.symbols, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { symbols: symbols, location: location, comments: comments } - end - - def format(q) - keyword = "undef " - formatters = symbols.map { |symbol| UndefArgumentFormatter.new(symbol) } - - q.group do - q.text(keyword) - q.nest(keyword.length) do - q.seplist(formatters) { |formatter| q.format(formatter) } - end - end - end - - def ===(other) - other.is_a?(Undef) && ArrayMatch.call(symbols, other.symbols) - end - end - - # Unless represents the first clause in an +unless+ chain. - # - # unless predicate - # end - # - class UnlessNode < Node - # [Node] 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 - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(predicate:, statements:, consequent:, location:) - @predicate = predicate - @statements = statements - @consequent = consequent - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_unless(self) - end - - def child_nodes - [predicate, statements, consequent] - end - - def copy(predicate: nil, statements: nil, consequent: nil, location: nil) - node = - UnlessNode.new( - predicate: predicate || self.predicate, - statements: statements || self.statements, - consequent: consequent || self.consequent, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - predicate: predicate, - statements: statements, - consequent: consequent, - location: location, - comments: comments - } - end - - def format(q) - ConditionalFormatter.new("unless", self).format(q) - end - - def ===(other) - other.is_a?(UnlessNode) && predicate === other.predicate && - statements === other.statements && consequent === other.consequent - end - - # Checks if the node was originally found in the modifier form. - def modifier? - predicate.location.start_char > statements.location.start_char - end - end - - # Formats an Until or While node. - class LoopFormatter - # [String] the name of the keyword used for this loop - attr_reader :keyword - - # [Until | While] the node that is being formatted - attr_reader :node - - def initialize(keyword, node) - @keyword = keyword - @node = node - end - - def format(q) - # If we're in the modifier form and we're modifying a `begin`, then this - # is a special case where we need to explicitly use the modifier form - # because otherwise the semantic meaning changes. This looks like: - # - # begin - # foo - # end while bar - # - # Also, if the statement of the modifier includes an assignment, then we - # can't know for certain that it won't impact the predicate, so we need to - # force it to stay as it is. This looks like: - # - # foo = bar while foo - # - if node.modifier? && (statement = node.statements.body.first) && - (statement.is_a?(Begin) || ContainsAssignment.call(statement)) - q.format(statement) - q.text(" #{keyword} ") - q.format(node.predicate) - elsif node.statements.empty? - q.group do - q.text("#{keyword} ") - q.nest(keyword.length + 1) { q.format(node.predicate) } - q.breakable_force - q.text("end") - end - elsif ContainsAssignment.call(node.predicate) - format_break(q) - q.break_parent - else - q.group do - q - .if_break { format_break(q) } - .if_flat do - Parentheses.flat(q) do - q.format(node.statements) - q.text(" #{keyword} ") - q.format(node.predicate) - end - end - end - end - end - - private - - def format_break(q) - q.text("#{keyword} ") - q.nest(keyword.length + 1) { q.format(node.predicate) } - q.indent do - q.breakable_empty - q.format(node.statements) - end - q.breakable_empty - q.text("end") - end - end - - # Until represents an +until+ loop. - # - # until predicate - # end - # - class UntilNode < Node - # [Node] the expression to be checked - attr_reader :predicate - - # [Statements] the expressions to be executed - attr_reader :statements - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(predicate:, statements:, location:) - @predicate = predicate - @statements = statements - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_until(self) - end - - def child_nodes - [predicate, statements] - end - - def copy(predicate: nil, statements: nil, location: nil) - node = - UntilNode.new( - predicate: predicate || self.predicate, - statements: statements || self.statements, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - predicate: predicate, - statements: statements, - location: location, - comments: comments - } - end - - def format(q) - LoopFormatter.new("until", self).format(q) - end - - def ===(other) - other.is_a?(UntilNode) && predicate === other.predicate && - statements === other.statements - end - - def modifier? - predicate.location.start_char > statements.location.start_char - end - 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 < Node - # [nil | :nil | Const | CVar | GVar | Ident | IVar] the target of this node - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_var_field(self) - end - - def child_nodes - value == :nil ? [] : [value] - end - - def copy(value: nil, location: nil) - node = - VarField.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - if value == :nil - q.text("nil") - elsif value - q.format(value) - end - end - - def ===(other) - other.is_a?(VarField) && value === other.value - end - 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 < Node - # [Const | CVar | GVar | Ident | IVar | Kw] the value of this node - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_var_ref(self) - end - - def child_nodes - [value] - end - - def copy(value: nil, location: nil) - node = - VarRef.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.format(value) - end - - def ===(other) - other.is_a?(VarRef) && value === other.value - end - - # Oh man I hate this so much. Basically, ripper doesn't provide enough - # functionality to actually know where pins are within an expression. So we - # have to walk the tree ourselves and insert more information. In doing so, - # we have to replace this node by a pinned node when necessary. - # - # To be clear, this method should just not exist. It's not good. It's a - # place of shame. But it's necessary for now, so I'm keeping it. - def pin(parent, pin) - replace = - PinnedVarRef.new(value: value, location: pin.location.to(location)) - - parent - .deconstruct_keys([]) - .each do |key, value| - if value == self - parent.instance_variable_set(:"@#{key}", replace) - break - elsif value.is_a?(Array) && (index = value.index(self)) - parent.public_send(key)[index] = replace - break - elsif value.is_a?(Array) && - (index = value.index { |(_k, v)| v == self }) - parent.public_send(key)[index][1] = replace - break - end - end - end - end - - # PinnedVarRef represents a pinned variable reference within a pattern - # matching pattern. - # - # case value - # in ^variable - # end - # - # This can be a plain local variable like the example above. It can also be a - # a class variable, a global variable, or an instance variable. - class PinnedVarRef < Node - # [Const | CVar | GVar | Ident | IVar] the value of this node - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_pinned_var_ref(self) - end - - def child_nodes - [value] - end - - def copy(value: nil, location: nil) - node = - PinnedVarRef.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.group do - q.text("^") - q.format(value) - end - end - - def ===(other) - other.is_a?(PinnedVarRef) && value === other.value - end - end - - # VCall represent any plain named object with Ruby that could be either a - # local variable or a method call. - # - # variable - # - class VCall < Node - # [Ident] the value of this expression - attr_reader :value - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(value:, location:) - @value = value - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_vcall(self) - end - - def child_nodes - [value] - end - - def copy(value: nil, location: nil) - node = - VCall.new( - value: value || self.value, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location, comments: comments } - end - - def format(q) - q.format(value) - end - - def ===(other) - other.is_a?(VCall) && value === other.value - end - - def access_control? - @access_control ||= %w[private protected public].include?(value.value) - end - - def arity - 0 - end - end - - # VoidStmt represents an empty lexical block of code. - # - # ;; - # - class VoidStmt < Node - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(location:) - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_void_stmt(self) - end - - def child_nodes - [] - end - - def copy(location: nil) - node = VoidStmt.new(location: location || self.location) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { location: location, comments: comments } - end - - def format(q) - end - - def ===(other) - other.is_a?(VoidStmt) - end - end - - # When represents a +when+ clause in a +case+ chain. - # - # case value - # when predicate - # end - # - class When < Node - # [Args] 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 - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(arguments:, statements:, consequent:, location:) - @arguments = arguments - @statements = statements - @consequent = consequent - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_when(self) - end - - def child_nodes - [arguments, statements, consequent] - end - - def copy(arguments: nil, statements: nil, consequent: nil, location: nil) - node = - When.new( - arguments: arguments || self.arguments, - statements: statements || self.statements, - consequent: consequent || self.consequent, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - arguments: arguments, - statements: statements, - consequent: consequent, - location: location, - comments: comments - } - end - - # We have a special separator here for when clauses which causes them to - # fill as much of the line as possible as opposed to everything breaking - # into its own line as soon as you hit the print limit. - class Separator - def call(q) - q.group do - q.text(",") - q.breakable_space - end - end - end - - # We're going to keep a single instance of this separator around so we don't - # have to allocate a new one every time we format a when clause. - SEPARATOR = Separator.new.freeze - - def format(q) - keyword = "when " - - q.group do - q.group do - q.text(keyword) - q.nest(keyword.length) do - if arguments.comments.any? - q.format(arguments) - else - q.seplist(arguments.parts, SEPARATOR) { |part| q.format(part) } - end - - # Very special case here. If you're inside of a when clause and the - # last argument to the predicate is and endless range, then you are - # forced to use the "then" keyword to make it parse properly. - last = arguments.parts.last - q.text(" then") if last.is_a?(RangeNode) && !last.right - end - end - - unless statements.empty? - q.indent do - q.breakable_force - q.format(statements) - end - end - - if consequent - q.breakable_force - q.format(consequent) - end - end - end - - def ===(other) - other.is_a?(When) && arguments === other.arguments && - statements === other.statements && consequent === other.consequent - end - end - - # While represents a +while+ loop. - # - # while predicate - # end - # - class WhileNode < Node - # [Node] the expression to be checked - attr_reader :predicate - - # [Statements] the expressions to be executed - attr_reader :statements - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(predicate:, statements:, location:) - @predicate = predicate - @statements = statements - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_while(self) - end - - def child_nodes - [predicate, statements] - end - - def copy(predicate: nil, statements: nil, location: nil) - node = - WhileNode.new( - predicate: predicate || self.predicate, - statements: statements || self.statements, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - predicate: predicate, - statements: statements, - location: location, - comments: comments - } - end - - def format(q) - LoopFormatter.new("while", self).format(q) - end - - def ===(other) - other.is_a?(WhileNode) && predicate === other.predicate && - statements === other.statements - end - - def modifier? - predicate.location.start_char > statements.location.start_char - end - 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 < Node - # [Array[ StringEmbExpr | StringDVar | TStringContent ]] the parts of the - # word - attr_reader :parts - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(parts:, location:) - @parts = parts - @location = location - @comments = [] - end - - def match?(pattern) - parts.any? { |part| part.is_a?(TStringContent) && part.match?(pattern) } - end - - def accept(visitor) - visitor.visit_word(self) - end - - def child_nodes - parts - end - - def copy(parts: nil, location: nil) - node = - Word.new( - parts: parts || self.parts, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { parts: parts, location: location, comments: comments } - end - - def format(q) - q.format_each(parts) - end - - def ===(other) - other.is_a?(Word) && ArrayMatch.call(parts, other.parts) - end - end - - # Words represents a string literal array with interpolation. - # - # %W[one two three] - # - class Words < Node - # [WordsBeg] the token that opens this array literal - attr_reader :beginning - - # [Array[ Word ]] the elements of this array - attr_reader :elements - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(beginning:, elements:, location:) - @beginning = beginning - @elements = elements - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_words(self) - end - - def child_nodes - [] - end - - def copy(beginning: nil, elements: nil, location: nil) - Words.new( - beginning: beginning || self.beginning, - elements: elements || self.elements, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { - beginning: beginning, - elements: elements, - location: location, - comments: comments - } - end - - def format(q) - opening, closing = "%W[", "]" - - if elements.any? { |element| element.match?(/[\[\]]/) } - opening = beginning.value - closing = Quotes.matching(opening[2]) - end - - q.text(opening) - q.group do - q.indent do - q.breakable_empty - q.seplist( - elements, - ArrayLiteral::BREAKABLE_SPACE_SEPARATOR - ) { |element| q.format(element) } - end - q.breakable_empty - end - q.text(closing) - end - - def ===(other) - other.is_a?(Words) && beginning === other.beginning && - ArrayMatch.call(elements, other.elements) - end - 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 < Node - # [String] the start of the word literal array - attr_reader :value - - def initialize(value:, location:) - @value = value - @location = location - end - - def accept(visitor) - visitor.visit_words_beg(self) - end - - def child_nodes - [] - end - - def copy(value: nil, location: nil) - WordsBeg.new( - value: value || self.value, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { value: value, location: location } - end - - def ===(other) - other.is_a?(WordsBeg) && value === other.value - end - end - - # XString represents the contents of an XStringLiteral. - # - # `ls` - # - class XString < Node - # [Array[ StringEmbExpr | StringDVar | TStringContent ]] the parts of the - # xstring - attr_reader :parts - - def initialize(parts:, location:) - @parts = parts - @location = location - end - - def accept(visitor) - visitor.visit_xstring(self) - end - - def child_nodes - parts - end - - def copy(parts: nil, location: nil) - XString.new( - parts: parts || self.parts, - location: location || self.location - ) - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { parts: parts, location: location } - end - - def ===(other) - other.is_a?(XString) && ArrayMatch.call(parts, other.parts) - end - end - - # XStringLiteral represents a string that gets executed. - # - # `ls` - # - class XStringLiteral < Node - # [Array[ StringEmbExpr | StringDVar | TStringContent ]] the parts of the - # xstring - attr_reader :parts - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(parts:, location:) - @parts = parts - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_xstring_literal(self) - end - - def child_nodes - parts - end - - def copy(parts: nil, location: nil) - node = - XStringLiteral.new( - parts: parts || self.parts, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { parts: parts, location: location, comments: comments } - end - - def format(q) - q.text("`") - q.format_each(parts) - q.text("`") - end - - def ===(other) - other.is_a?(XStringLiteral) && ArrayMatch.call(parts, other.parts) - end - end - - # Yield represents using the +yield+ keyword with arguments. - # - # yield value - # - class YieldNode < Node - # [nil | Args | Paren] the arguments passed to the yield - attr_reader :arguments - - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(arguments:, location:) - @arguments = arguments - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_yield(self) - end - - def child_nodes - [arguments] - end - - def copy(arguments: nil, location: nil) - node = - YieldNode.new( - arguments: arguments || self.arguments, - location: location || self.location - ) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { arguments: arguments, location: location, comments: comments } - end - - def format(q) - if arguments.nil? - q.text("yield") - return - end - - q.group do - q.text("yield") - - if arguments.is_a?(Paren) - q.format(arguments) - else - q.if_break { q.text("(") }.if_flat { q.text(" ") } - q.indent do - q.breakable_empty - q.format(arguments) - end - q.breakable_empty - q.if_break { q.text(")") } - end - end - end - - def ===(other) - other.is_a?(YieldNode) && arguments === other.arguments - end - end - - # ZSuper represents the bare +super+ keyword with no arguments. - # - # super - # - class ZSuper < Node - # [Array[ Comment | EmbDoc ]] the comments attached to this node - attr_reader :comments - - def initialize(location:) - @location = location - @comments = [] - end - - def accept(visitor) - visitor.visit_zsuper(self) - end - - def child_nodes - [] - end - - def copy(location: nil) - node = ZSuper.new(location: location || self.location) - - node.comments.concat(comments.map(&:copy)) - node - end - - alias deconstruct child_nodes - - def deconstruct_keys(_keys) - { location: location, comments: comments } - end - - def format(q) - q.text("super") - end - - def ===(other) - other.is_a?(ZSuper) - end - end -end diff --git a/lib/syntax_tree/parser.rb b/lib/syntax_tree/parser.rb deleted file mode 100644 index 3869dd9d..00000000 --- a/lib/syntax_tree/parser.rb +++ /dev/null @@ -1,4648 +0,0 @@ -# frozen_string_literal: true - -module SyntaxTree - # Parser is a subclass of the Ripper library that subscribes to the stream of - # tokens and nodes coming from the parser and builds up a syntax tree. - class Parser < Ripper - # 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 - - # 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 - attr_reader :start - - 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 - attr_reader :start, :indices - - def initialize(start, line) - @start = start - @indices = [] - - line - .each_char - .with_index(start) do |char, index| - char.bytesize.times { @indices << index } - end - end - - # Technically it's possible for the column index to be a negative value if - # there's a BOM at the beginning of the file, which is the reason we need - # to compare it to 0 here. - def [](byteindex) - indices[[byteindex, 0].max] - end - end - - # This represents all of the tokens coming back from the lexer. It is - # replacing a simple array because it keeps track of the last deleted token - # from the list for better error messages. - class TokenList - attr_reader :tokens, :last_deleted - - def initialize - @tokens = [] - @last_deleted = nil - end - - def <<(token) - tokens << token - end - - def [](index) - tokens[index] - end - - def any?(&block) - tokens.any?(&block) - end - - def reverse_each(&block) - tokens.reverse_each(&block) - end - - def rindex(&block) - tokens.rindex(&block) - end - - def delete(value) - @last_deleted = tokens.delete(value) || @last_deleted - end - - def delete_at(index) - @last_deleted = tokens.delete_at(index) - end - end - - # [String] the source being parsed - attr_reader :source - - # [Array[ SingleByteString | MultiByteString ]] the list of objects that - # represent the start of each line in character offsets - attr_reader :line_counts - - # [Array[ untyped ]] a running list of tokens that have been found in the - # source. This list changes a lot as certain nodes will "consume" these - # tokens to determine their bounds. - attr_reader :tokens - - # [Array[ Comment | EmbDoc ]] the list of comments that have been found - # while parsing the source. - attr_reader :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 - - # 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 = TokenList.new - - # 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.each_line do |line| - @line_counts << if line.size == line.bytesize - SingleByteString.new(last_index) - else - MultiByteString.new(last_index, line) - end - - last_index += line.size - end - - # Make sure line counts is filled out with the first and last line at - # minimum so that it has something to compare against if the parser is in - # a lineno=2 state for an empty file. - @line_counts << SingleByteString.new(0) if @line_counts.empty? - @line_counts << SingleByteString.new(last_index) - 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 - - # This represents the current column we're in relative to the beginning of - # the current line. - def current_column - line = line_counts[lineno - 1] - line[column].to_i - line.start - end - - # Returns the current location that is being looked at for the parser for - # the purpose of locating the error. - def find_token_error(location) - if location - # If we explicitly passed a location into this find_token_error method, - # that means that's the source of the error, so we'll use that - # information for our error object. - lineno = location.start_line - [lineno, location.start_char - line_counts[lineno - 1].start] - elsif lineno && column - # If there is a line number associated with the current ripper state, - # then we'll use that information to generate the error. - [lineno, column] - elsif (location = tokens.last_deleted&.location) - # If we've already deleted a token from the list of tokens that we are - # consuming, then we'll fall back to that token's location. - lineno = location.start_line - [lineno, location.start_char - line_counts[lineno - 1].start] - else - # Finally, it's possible that when we hit this error the parsing thread - # for ripper has died. In that case, lineno and column both return nil. - # So we're just going to set it to line 1, column 0 in the hopes that - # that makes any sense. - [1, 0] - end - 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. - # - # 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. - - def find_token(type) - index = tokens.rindex { |token| token.is_a?(type) } - tokens[index] if index - end - - def find_token_between(type, left, right) - bounds = left.location.end_char...right.location.start_char - index = - tokens.rindex do |token| - char = token.location.start_char - break if char < bounds.begin - - token.is_a?(type) && bounds.cover?(char) - end - - tokens[index] if index - end - - def find_keyword(name) - index = tokens.rindex { |token| token.is_a?(Kw) && (token.name == name) } - tokens[index] if index - end - - def find_keyword_between(name, left, right) - bounds = left.end_char...right.start_char - index = - tokens.rindex do |token| - char = token.location.start_char - break if char < bounds.begin - - token.is_a?(Kw) && (token.name == name) && bounds.cover?(char) - end - - tokens[index] if index - end - - def find_operator(name) - index = tokens.rindex { |token| token.is_a?(Op) && (token.name == name) } - tokens[index] if index - end - - def consume_error(name, location) - message = "Cannot find expected #{name}" - raise ParseError.new(message, *find_token_error(location)) - end - - def consume_token(type) - index = tokens.rindex { |token| token.is_a?(type) } - consume_error(type.name.split("::", 2).last, nil) unless index - tokens.delete_at(index) - end - - def consume_tstring_end(location) - index = tokens.rindex { |token| token.is_a?(TStringEnd) } - consume_error("string ending", location) unless index - tokens.delete_at(index) - end - - def consume_keyword(name) - index = tokens.rindex { |token| token.is_a?(Kw) && (token.name == name) } - consume_error(name, nil) unless index - tokens.delete_at(index) - end - - def consume_operator(name) - index = tokens.rindex { |token| token.is_a?(Op) && (token.name == name) } - consume_error(name, nil) unless index - tokens.delete_at(index) - 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) - maximum = source.length - - position.upto(maximum) do |pound_index| - case source[pound_index] - when "#" - return source.index("\n", pound_index + 1) || maximum - when " " - # continue - else - return position - end - end - end - - # -------------------------------------------------------------------------- - # :section: Ripper event handlers - # The following methods all handle a dispatched ripper event. - # -------------------------------------------------------------------------- - - # :call-seq: - # on_BEGIN: (Statements statements) -> BEGINBlock - def on_BEGIN(statements) - lbrace = consume_token(LBrace) - rbrace = consume_token(RBrace) - - start_char = find_next_statement_start(lbrace.location.end_char) - statements.bind( - self, - start_char, - start_char - line_counts[lbrace.location.start_line - 1].start, - rbrace.location.start_char, - rbrace.location.start_column - ) - - keyword = consume_keyword(:BEGIN) - - BEGINBlock.new( - lbrace: lbrace, - statements: statements, - location: keyword.location.to(rbrace.location) - ) - end - - # :call-seq: - # on_CHAR: (String value) -> CHAR - def on_CHAR(value) - CHAR.new( - value: value, - location: - Location.token( - line: lineno, - char: char_pos, - column: current_column, - size: value.size - ) - ) - end - - # :call-seq: - # on_END: (Statements statements) -> ENDBlock - def on_END(statements) - lbrace = consume_token(LBrace) - rbrace = consume_token(RBrace) - - start_char = find_next_statement_start(lbrace.location.end_char) - statements.bind( - self, - start_char, - start_char - line_counts[lbrace.location.start_line - 1].start, - rbrace.location.start_char, - rbrace.location.start_column - ) - - keyword = consume_keyword(:END) - - ENDBlock.new( - lbrace: lbrace, - statements: statements, - location: keyword.location.to(rbrace.location) - ) - end - - # :call-seq: - # on___end__: (String value) -> EndContent - def on___end__(value) - @__end__ = - EndContent.new( - value: source[(char_pos + value.length)..], - location: - Location.token( - line: lineno, - char: char_pos, - column: current_column, - size: value.size - ) - ) - end - - # :call-seq: - # on_alias: ( - # (DynaSymbol | SymbolLiteral) left, - # (DynaSymbol | SymbolLiteral) right - # ) -> AliasNode - def on_alias(left, right) - keyword = consume_keyword(:alias) - - AliasNode.new( - left: left, - right: right, - location: keyword.location.to(right.location) - ) - end - - # :call-seq: - # on_aref: (untyped collection, (nil | Args) index) -> ARef - def on_aref(collection, index) - consume_token(LBracket) - rbracket = consume_token(RBracket) - - ARef.new( - collection: collection, - index: index, - location: collection.location.to(rbracket.location) - ) - end - - # :call-seq: - # on_aref_field: ( - # untyped collection, - # (nil | Args) index - # ) -> ARefField - def on_aref_field(collection, index) - consume_token(LBracket) - rbracket = consume_token(RBracket) - - ARefField.new( - collection: collection, - index: index, - location: collection.location.to(rbracket.location) - ) - end - - # def on_arg_ambiguous(value) - # value - # end - - # :call-seq: - # on_arg_paren: ( - # (nil | Args | ArgsForward) arguments - # ) -> ArgParen - def on_arg_paren(arguments) - lparen = consume_token(LParen) - rparen = consume_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 - - # :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 - - # :call-seq: - # on_args_add_block: ( - # Args arguments, - # (false | untyped) block - # ) -> Args - def on_args_add_block(arguments, block) - end_char = arguments.parts.any? && arguments.location.end_char - - # First, see if there is an & operator that could potentially be - # associated with the block part of this args_add_block. If there is not, - # then just return the arguments. - index = - tokens.rindex do |token| - # If there are any arguments and the operator we found from the list - # is not after them, then we're going to return the arguments as-is - # because we're looking at an & that occurs before the arguments are - # done. - return arguments if end_char && token.location.start_char < end_char - token.is_a?(Op) && (token.name == :&) - end - - return arguments unless index - - # Now we know we have an & operator, so we're going to delete it from the - # list of tokens to make sure it doesn't get confused with anything else. - operator = tokens.delete_at(index) - - # Construct the location that represents the block argument. - location = operator.location - location = operator.location.to(block.location) if block - - # Otherwise, we're looking at an actual block argument (with or without a - # block, which could be missing because it could be a bare & since 3.1.0). - arg_block = ArgBlock.new(value: block, location: location) - - Args.new( - parts: arguments.parts << arg_block, - location: arguments.location.to(location) - ) - end - - # :call-seq: - # on_args_add_star: (Args arguments, untyped star) -> Args - def on_args_add_star(arguments, argument) - beginning = consume_operator(:*) - 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 - - # :call-seq: - # on_args_forward: () -> ArgsForward - def on_args_forward - op = consume_operator(:"...") - - ArgsForward.new(location: op.location) - end - - # :call-seq: - # on_args_new: () -> Args - def on_args_new - Args.new( - parts: [], - location: - Location.fixed(line: lineno, column: current_column, char: char_pos) - ) - end - - # :call-seq: - # on_array: ((nil | Args) contents) -> - # ArrayLiteral | QSymbols | QWords | Symbols | Words - def on_array(contents) - if !contents || contents.is_a?(Args) - lbracket = consume_token(LBracket) - rbracket = consume_token(RBracket) - - ArrayLiteral.new( - lbracket: lbracket, - contents: contents, - location: lbracket.location.to(rbracket.location) - ) - else - tstring_end = consume_tstring_end(contents.beginning.location) - - contents.class.new( - beginning: contents.beginning, - elements: contents.elements, - location: contents.location.to(tstring_end.location) - ) - end - end - - # Visitor is a parent class that provides the ability to walk down the tree - # and handle a subset of nodes. By defining your own subclass, you can - # explicitly handle a node type by defining a visit_* method. - class Visitor - def visit(node) - node&.accept(self) - end - - def visit_all(nodes) - nodes.map { |node| visit(node) } - end - - def visit_child_nodes(node) - visit_all(node.child_nodes) - end - - # Visit an ARef node. - alias visit_aref visit_child_nodes - - # Visit an ARefField node. - alias visit_aref_field visit_child_nodes - - # Visit an AliasNode node. - alias visit_alias visit_child_nodes - - # Visit an ArgBlock node. - alias visit_arg_block visit_child_nodes - - # Visit an ArgParen node. - alias visit_arg_paren visit_child_nodes - - # Visit an ArgStar node. - alias visit_arg_star visit_child_nodes - - # Visit an Args node. - alias visit_args visit_child_nodes - - # Visit an ArgsForward node. - alias visit_args_forward visit_child_nodes - - # Visit an ArrayLiteral node. - alias visit_array visit_child_nodes - - # Visit an AryPtn node. - alias visit_aryptn visit_child_nodes - - # Visit an Assign node. - alias visit_assign visit_child_nodes - - # Visit an Assoc node. - alias visit_assoc visit_child_nodes - - # Visit an AssocSplat node. - alias visit_assoc_splat visit_child_nodes - - # Visit a Backref node. - alias visit_backref visit_child_nodes - - # Visit a Backtick node. - alias visit_backtick visit_child_nodes - - # Visit a BareAssocHash node. - alias visit_bare_assoc_hash visit_child_nodes - - # Visit a BEGINBlock node. - alias visit_BEGIN visit_child_nodes - - # Visit a Begin node. - alias visit_begin visit_child_nodes - - # Visit a Binary node. - alias visit_binary visit_child_nodes - - # Visit a Block node. - alias visit_block visit_child_nodes - - # Visit a BlockArg node. - alias visit_blockarg visit_child_nodes - - # Visit a BlockVar node. - alias visit_block_var visit_child_nodes - - # Visit a BodyStmt node. - alias visit_bodystmt visit_child_nodes - - # Visit a Break node. - alias visit_break visit_child_nodes - - # Visit a Call node. - alias visit_call visit_child_nodes - - # Visit a Case node. - alias visit_case visit_child_nodes - - # Visit a CHAR node. - alias visit_CHAR visit_child_nodes - - # Visit a ClassDeclaration node. - alias visit_class visit_child_nodes - - # Visit a Comma node. - alias visit_comma visit_child_nodes - - # Visit a Command node. - alias visit_command visit_child_nodes - - # Visit a CommandCall node. - alias visit_command_call visit_child_nodes - - # Visit a Comment node. - alias visit_comment visit_child_nodes - - # Visit a Const node. - alias visit_const visit_child_nodes - - # Visit a ConstPathField node. - alias visit_const_path_field visit_child_nodes - - # Visit a ConstPathRef node. - alias visit_const_path_ref visit_child_nodes - - # Visit a ConstRef node. - alias visit_const_ref visit_child_nodes - - # Visit a CVar node. - alias visit_cvar visit_child_nodes - - # Visit a Def node. - alias visit_def visit_child_nodes - - # Visit a Defined node. - alias visit_defined visit_child_nodes - - # Visit a DynaSymbol node. - alias visit_dyna_symbol visit_child_nodes - - # Visit an ENDBlock node. - alias visit_END visit_child_nodes - - # Visit an Else node. - alias visit_else visit_child_nodes - - # Visit an Elsif node. - alias visit_elsif visit_child_nodes - - # Visit an EmbDoc node. - alias visit_embdoc visit_child_nodes - - # Visit an EmbExprBeg node. - alias visit_embexpr_beg visit_child_nodes - - # Visit an EmbExprEnd node. - alias visit_embexpr_end visit_child_nodes - - # Visit an EmbVar node. - alias visit_embvar visit_child_nodes - - # Visit an Ensure node. - alias visit_ensure visit_child_nodes - - # Visit an ExcessedComma node. - alias visit_excessed_comma visit_child_nodes - - # Visit a Field node. - alias visit_field visit_child_nodes - - # Visit a FloatLiteral node. - alias visit_float visit_child_nodes - - # Visit a FndPtn node. - alias visit_fndptn visit_child_nodes - - # Visit a For node. - alias visit_for visit_child_nodes - - # Visit a GVar node. - alias visit_gvar visit_child_nodes - - # Visit a HashLiteral node. - alias visit_hash visit_child_nodes - - # Visit a Heredoc node. - alias visit_heredoc visit_child_nodes - - # Visit a HeredocBeg node. - alias visit_heredoc_beg visit_child_nodes - - # Visit a HeredocEnd node. - alias visit_heredoc_end visit_child_nodes - - # Visit a HshPtn node. - alias visit_hshptn visit_child_nodes - - # Visit an Ident node. - alias visit_ident visit_child_nodes - - # Visit an IfNode node. - alias visit_if visit_child_nodes - - # Visit an IfOp node. - alias visit_if_op visit_child_nodes - - # Visit an Imaginary node. - alias visit_imaginary visit_child_nodes - - # Visit an In node. - alias visit_in visit_child_nodes - - # Visit an Int node. - alias visit_int visit_child_nodes - - # Visit an IVar node. - alias visit_ivar visit_child_nodes - - # Visit a Kw node. - alias visit_kw visit_child_nodes - - # Visit a KwRestParam node. - alias visit_kwrest_param visit_child_nodes - - # Visit a Label node. - alias visit_label visit_child_nodes - - # Visit a LabelEnd node. - alias visit_label_end visit_child_nodes - - # Visit a Lambda node. - alias visit_lambda visit_child_nodes - - # Visit a LambdaVar node. - alias visit_lambda_var visit_child_nodes - - # Visit a LBrace node. - alias visit_lbrace visit_child_nodes - - # Visit a LBracket node. - alias visit_lbracket visit_child_nodes - - # Visit a LParen node. - alias visit_lparen visit_child_nodes - - # Visit a MAssign node. - alias visit_massign visit_child_nodes - - # Visit a MethodAddBlock node. - alias visit_method_add_block visit_child_nodes - - # Visit a MLHS node. - alias visit_mlhs visit_child_nodes - - # Visit a MLHSParen node. - alias visit_mlhs_paren visit_child_nodes - - # Visit a ModuleDeclaration node. - alias visit_module visit_child_nodes - - # Visit a MRHS node. - alias visit_mrhs visit_child_nodes - - # Visit a Next node. - alias visit_next visit_child_nodes - - # Visit a Not node. - alias visit_not visit_child_nodes - - # Visit an Op node. - alias visit_op visit_child_nodes - - # Visit an OpAssign node. - alias visit_opassign visit_child_nodes - - # Visit a Params node. - alias visit_params visit_child_nodes - - # Visit a Paren node. - alias visit_paren visit_child_nodes - - # Visit a Period node. - alias visit_period visit_child_nodes - - # Visit a PinnedBegin node. - alias visit_pinned_begin visit_child_nodes - - # Visit a PinnedVarRef node. - alias visit_pinned_var_ref visit_child_nodes - - # Visit a Program node. - alias visit_program visit_child_nodes - - # Visit a QSymbols node. - alias visit_qsymbols visit_child_nodes - - # Visit a QSymbolsBeg node. - alias visit_qsymbols_beg visit_child_nodes - - # Visit a QWords node. - alias visit_qwords visit_child_nodes - - # Visit a QWordsBeg node. - alias visit_qwords_beg visit_child_nodes - - # Visit a RangeNode node - alias visit_range visit_child_nodes - - # Visit a RAssign node. - alias visit_rassign visit_child_nodes - - # Visit a RationalLiteral node. - alias visit_rational visit_child_nodes - - # Visit a RBrace node. - alias visit_rbrace visit_child_nodes - - # Visit a RBracket node. - alias visit_rbracket visit_child_nodes - - # Visit a Redo node. - alias visit_redo visit_child_nodes - - # Visit a RegexpBeg node. - alias visit_regexp_beg visit_child_nodes - - # Visit a RegexpContent node. - alias visit_regexp_content visit_child_nodes - - # Visit a RegexpEnd node. - alias visit_regexp_end visit_child_nodes - - # Visit a RegexpLiteral node. - alias visit_regexp_literal visit_child_nodes - - # Visit a Rescue node. - alias visit_rescue visit_child_nodes - - # Visit a RescueEx node. - alias visit_rescue_ex visit_child_nodes - - # Visit a RescueMod node. - alias visit_rescue_mod visit_child_nodes - - # Visit a RestParam node. - alias visit_rest_param visit_child_nodes - - # Visit a Retry node. - alias visit_retry visit_child_nodes - - # Visit a Return node. - alias visit_return visit_child_nodes - - # Visit a RParen node. - alias visit_rparen visit_child_nodes - - # Visit a SClass node. - alias visit_sclass visit_child_nodes - - # Visit a Statements node. - alias visit_statements visit_child_nodes - - # Visit a StringConcat node. - alias visit_string_concat visit_child_nodes - - # Visit a StringContent node. - alias visit_string_content visit_child_nodes - - # Visit a StringDVar node. - alias visit_string_dvar visit_child_nodes - - # Visit a StringEmbExpr node. - alias visit_string_embexpr visit_child_nodes - - # Visit a StringLiteral node. - alias visit_string_literal visit_child_nodes - - # Visit a Super node. - alias visit_super visit_child_nodes - - # Visit a SymBeg node. - alias visit_symbeg visit_child_nodes - - # Visit a SymbolContent node. - alias visit_symbol_content visit_child_nodes - - # Visit a SymbolLiteral node. - alias visit_symbol_literal visit_child_nodes - - # Visit a Symbols node. - alias visit_symbols visit_child_nodes - - # Visit a SymbolsBeg node. - alias visit_symbols_beg visit_child_nodes - - # Visit a TLambda node. - alias visit_tlambda visit_child_nodes - - # Visit a TLamBeg node. - alias visit_tlambeg visit_child_nodes - - # Visit a TopConstField node. - alias visit_top_const_field visit_child_nodes - - # Visit a TopConstRef node. - alias visit_top_const_ref visit_child_nodes - - # Visit a TStringBeg node. - alias visit_tstring_beg visit_child_nodes - - # Visit a TStringContent node. - alias visit_tstring_content visit_child_nodes - - # Visit a TStringEnd node. - alias visit_tstring_end visit_child_nodes - - # Visit an Unary node. - alias visit_unary visit_child_nodes - - # Visit an Undef node. - alias visit_undef visit_child_nodes - - # Visit an UnlessNode node. - alias visit_unless visit_child_nodes - - # Visit an UntilNode node. - alias visit_until visit_child_nodes - - # Visit a VarField node. - alias visit_var_field visit_child_nodes - - # Visit a VarRef node. - alias visit_var_ref visit_child_nodes - - # Visit a VCall node. - alias visit_vcall visit_child_nodes - - # Visit a VoidStmt node. - alias visit_void_stmt visit_child_nodes - - # Visit a When node. - alias visit_when visit_child_nodes - - # Visit a WhileNode node. - alias visit_while visit_child_nodes - - # Visit a Word node. - alias visit_word visit_child_nodes - - # Visit a Words node. - alias visit_words visit_child_nodes - - # Visit a WordsBeg node. - alias visit_words_beg visit_child_nodes - - # Visit a XString node. - alias visit_xstring visit_child_nodes - - # Visit a XStringLiteral node. - alias visit_xstring_literal visit_child_nodes - - # Visit a YieldNode node. - alias visit_yield visit_child_nodes - - # Visit a ZSuper node. - alias visit_zsuper visit_child_nodes - - # Visit an EndContent node. - alias visit___end__ visit_child_nodes - end - - # Ugh... I really do not like this class. Basically, ripper doesn't provide - # enough information about where pins are located in the tree. It only gives - # events for ^ ops and var_ref nodes. You have to piece it together - # yourself. - # - # Note that there are edge cases here that we straight up do not address, - # because I honestly think it's going to be faster to write a new parser - # than to address them. For example, this will not work properly: - # - # foo in ^((bar = 0; bar; baz)) - # - # If someone actually does something like that, we'll have to find another - # way to make this work. - class PinVisitor < Visitor - attr_reader :pins, :stack - - def initialize(pins) - @pins = pins - @stack = [] - end - - def visit(node) - return if pins.empty? - stack << node - super - stack.pop - end - - def visit_var_ref(node) - if node.start_char > pins.first.start_char - node.pin(stack[-2], pins.shift) - else - super - end - end - - def self.visit(node, tokens) - start_char = node.start_char - allocated = [] - - tokens.reverse_each do |token| - char = token.location.start_char - break if char <= start_char - - if token.is_a?(Op) && token.value == "^" - allocated.unshift(tokens.delete(token)) - end - end - - new(allocated).visit(node) if allocated.any? - 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) - lbracket = find_token(LBracket) - lbracket ||= find_token(LParen) if constant - - rbracket = find_token(RBracket) - rbracket ||= find_token(RParen) if constant - - parts = [constant, lbracket, *requireds, rest, *posts, rbracket].compact - - # The location is going to be determined by the first part to the last - # part. This includes potential brackets. - location = parts[0].location.to(parts[-1].location) - - # Now that we have the location calculated, we can remove the brackets - # from the list of tokens. - tokens.delete(lbracket) if lbracket - tokens.delete(rbracket) if rbracket - - # If there is a plain *, then we're going to fix up the location of it - # here because it currently doesn't have anything to use for its precise - # location. If we hit a comma, then we've gone too far. - if rest.is_a?(VarField) && rest.value.nil? - tokens.rindex do |rtoken| - case rtoken - when Comma - break - when Op - if rtoken.value == "*" - rest = VarField.new(value: nil, location: rtoken.location) - break - end - end - end - end - - AryPtn.new( - constant: constant, - requireds: requireds || [], - rest: rest, - posts: posts || [], - location: location - ) - 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 - - # :call-seq: - # on_assoc_new: (untyped key, untyped value) -> Assoc - def on_assoc_new(key, value) - location = key.location - location = location.to(value.location) if value - - Assoc.new(key: key, value: value, location: location) - end - - # :call-seq: - # on_assoc_splat: (untyped value) -> AssocSplat - def on_assoc_splat(value) - operator = consume_operator(:**) - - AssocSplat.new( - value: value, - location: operator.location.to((value || operator).location) - ) - end - - # def on_assoclist_from_args(assocs) - # assocs - # end - - # :call-seq: - # on_backref: (String value) -> Backref - def on_backref(value) - Backref.new( - value: value, - location: - Location.token( - line: lineno, - char: char_pos, - column: current_column, - size: value.size - ) - ) - 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, - column: current_column, - size: value.size - ) - ) - - tokens << node - node - 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 - - # :call-seq: - # on_begin: (untyped bodystmt) -> Begin | PinnedBegin - def on_begin(bodystmt) - pin = find_operator(:^) - - if pin && pin.location.start_char < bodystmt.location.start_char - tokens.delete(pin) - consume_token(LParen) - - rparen = consume_token(RParen) - location = pin.location.to(rparen.location) - - PinnedBegin.new(statement: bodystmt, location: location) - else - keyword = consume_keyword(:begin) - end_location = - if bodystmt.else_clause - bodystmt.location - else - consume_keyword(:end).location - end - - bodystmt.bind( - self, - find_next_statement_start(keyword.location.end_char), - keyword.location.end_column, - end_location.end_char, - end_location.end_column - ) - - location = keyword.location.to(end_location) - Begin.new(bodystmt: bodystmt, location: location) - end - end - - # :call-seq: - # on_binary: ( - # untyped left, - # (Op | Symbol) operator, - # untyped right - # ) -> Binary - def on_binary(left, operator, right) - if operator.is_a?(Symbol) - # Here, we're going to search backward for the token that's between the - # two operands that matches the operator so we can delete it from the - # list. - range = (left.location.end_char + 1)...right.location.start_char - index = - tokens.rindex do |token| - token.is_a?(Op) && token.name == operator && - range.cover?(token.location.start_char) - end - - tokens.delete_at(index) if index - else - # 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 - end - - Binary.new( - left: left, - operator: operator, - right: right, - location: left.location.to(right.location) - ) - end - - # :call-seq: - # on_block_var: (Params params, (nil | Array[Ident]) locals) -> BlockVar - def on_block_var(params, locals) - index = - tokens.rindex { |node| node.is_a?(Op) && %w[| ||].include?(node.value) } - - ending = tokens.delete_at(index) - beginning = ending.value == "||" ? ending : consume_operator(:|) - - # If there are no parameters, then we didn't have anything to base the - # location information of off. Now that we have an opening of the - # block, we can correct this. - if params.empty? - start_line = params.location.start_line - start_char = - ( - if beginning.value == "||" - beginning.location.start_char - else - find_next_statement_start(beginning.location.end_char) - end - ) - - location = - Location.fixed( - line: start_line, - char: start_char, - column: start_char - line_counts[start_line - 1].start - ) - - params = params.copy(location: location) - end - - BlockVar.new( - params: params, - locals: locals || [], - location: beginning.location.to(ending.location) - ) - end - - # :call-seq: - # on_blockarg: (Ident name) -> BlockArg - def on_blockarg(name) - operator = consume_operator(:&) - - location = operator.location - location = location.to(name.location) if name - - BlockArg.new(name: name, location: location) - 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) - # In certain versions of Ruby, the `statements` argument can be any node - # in the case that we're inside of an endless method definition. In this - # case we'll wrap it in a Statements node to be consistent. - unless statements.is_a?(Statements) - statements = - Statements.new(body: [statements], location: statements.location) - end - - parts = [statements, rescue_clause, else_clause, ensure_clause].compact - - BodyStmt.new( - statements: statements, - rescue_clause: rescue_clause, - else_keyword: else_clause && consume_keyword(:else), - else_clause: else_clause, - ensure_clause: ensure_clause, - location: parts.first.location.to(parts.last.location) - ) - end - - # :call-seq: - # on_brace_block: ( - # (nil | BlockVar) block_var, - # Statements statements - # ) -> BlockNode - def on_brace_block(block_var, statements) - lbrace = consume_token(LBrace) - rbrace = consume_token(RBrace) - location = (block_var || lbrace).location - - start_char = find_next_statement_start(location.end_char) - statements.bind( - self, - start_char, - start_char - line_counts[location.start_line - 1].start, - rbrace.location.start_char, - rbrace.location.start_column - ) - - location = - Location.new( - start_line: lbrace.location.start_line, - start_char: lbrace.location.start_char, - start_column: lbrace.location.start_column, - end_line: [ - rbrace.location.end_line, - statements.location.end_line - ].max, - end_char: rbrace.location.end_char, - end_column: rbrace.location.end_column - ) - - BlockNode.new( - opening: lbrace, - block_var: block_var, - bodystmt: statements, - location: location - ) - end - - # :call-seq: - # on_break: (Args arguments) -> Break - def on_break(arguments) - keyword = consume_keyword(:break) - - location = keyword.location - location = location.to(arguments.location) if arguments.parts.any? - - Break.new(arguments: arguments, location: location) - end - - # :call-seq: - # on_call: ( - # untyped receiver, - # (:"::" | Op | Period) operator, - # (:call | Backtick | Const | Ident | Op) message - # ) -> CallNode - def on_call(receiver, operator, message) - ending = - if message != :call - message - elsif operator != :"::" - operator - else - receiver - end - - CallNode.new( - receiver: receiver, - operator: operator, - message: message, - arguments: nil, - location: receiver.location.to(ending.location) - ) - end - - # :call-seq: - # on_case: (untyped value, untyped consequent) -> Case | RAssign - def on_case(value, consequent) - if value && (operator = find_keyword(:in) || find_operator(:"=>")) && - (value.location.end_char...consequent.location.start_char).cover?( - operator.location.start_char - ) - tokens.delete(operator) - - node = - RAssign.new( - value: value, - operator: operator, - pattern: consequent, - location: value.location.to(consequent.location) - ) - - PinVisitor.visit(node, tokens) - node - else - keyword = consume_keyword(:case) - - Case.new( - keyword: keyword, - value: value, - consequent: consequent, - location: keyword.location.to(consequent.location) - ) - end - end - - # :call-seq: - # on_class: ( - # (ConstPathRef | ConstRef | TopConstRef) constant, - # untyped superclass, - # BodyStmt bodystmt - # ) -> ClassDeclaration - def on_class(constant, superclass, bodystmt) - beginning = consume_keyword(:class) - ending = consume_keyword(:end) - location = (superclass || constant).location - start_char = find_next_statement_start(location.end_char) - - bodystmt.bind( - self, - start_char, - start_char - line_counts[location.start_line - 1].start, - ending.location.start_char, - ending.location.start_column - ) - - ClassDeclaration.new( - constant: constant, - superclass: superclass, - bodystmt: bodystmt, - location: beginning.location.to(ending.location) - ) - 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, - column: current_column, - size: value.size - ) - ) - - tokens << node - node - end - - # :call-seq: - # on_command: ((Const | Ident) message, Args arguments) -> Command - def on_command(message, arguments) - Command.new( - message: message, - arguments: arguments, - block: nil, - location: message.location.to(arguments.location) - ) - end - - # :call-seq: - # on_command_call: ( - # untyped receiver, - # (:"::" | Op | Period) operator, - # (Const | Ident | Op) message, - # (nil | Args) arguments - # ) -> CommandCall - def on_command_call(receiver, operator, message, arguments) - ending = arguments || message - - CommandCall.new( - receiver: receiver, - operator: operator, - message: message, - arguments: arguments, - block: nil, - location: receiver.location.to(ending.location) - ) - end - - # :call-seq: - # on_comment: (String value) -> Comment - def on_comment(value) - # char is the index of the # character in the source. - char = char_pos - location = - Location.token( - line: lineno, - char: char, - column: current_column, - size: value.size - 1 - ) - - # Loop backward in the source string, starting from the beginning of the - # comment, and find the first character that is not a space or a tab. If - # index is -1, this indicates that we've checked all of the characters - # back to the start of the source, so this comment must be at the - # beginning of the file. - # - # We are purposefully not using rindex or regular expressions here because - # they check if there are invalid characters, which is actually possible - # with the use of __END__ syntax. - index = char - 1 - while index > -1 && (source[index] == "\t" || source[index] == " ") - index -= 1 - end - - # If we found a character that was not a space or a tab before the comment - # and it's a newline, then this comment is inline. Otherwise, it stands on - # its own and can be attached as its own node in the tree. - inline = index != -1 && source[index] != "\n" - comment = - Comment.new(value: value.chomp, inline: inline, location: location) - - @comments << comment - comment - end - - # :call-seq: - # on_const: (String value) -> Const - def on_const(value) - Const.new( - value: value, - location: - Location.token( - line: lineno, - char: char_pos, - column: current_column, - size: value.size - ) - ) - end - - # :call-seq: - # on_const_path_field: (untyped parent, Const constant) -> - # ConstPathField | Field - def on_const_path_field(parent, constant) - if constant.is_a?(Const) - ConstPathField.new( - parent: parent, - constant: constant, - location: parent.location.to(constant.location) - ) - else - Field.new( - parent: parent, - operator: consume_operator(:"::"), - name: constant, - location: parent.location.to(constant.location) - ) - 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 - - # :call-seq: - # on_const_ref: (Const constant) -> ConstRef - def on_const_ref(constant) - ConstRef.new(constant: constant, location: constant.location) - end - - # :call-seq: - # on_cvar: (String value) -> CVar - def on_cvar(value) - CVar.new( - value: value, - location: - Location.token( - line: lineno, - char: char_pos, - column: current_column, - size: value.size - ) - ) - end - - # :call-seq: - # on_def: ( - # (Backtick | Const | Ident | Kw | Op) name, - # (nil | Params | Paren) params, - # untyped bodystmt - # ) -> DefNode - 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 = consume_keyword(:def) - - # 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 - end_column = name.location.end_column - location = - Location.new( - start_line: params.location.start_line, - start_char: end_char, - start_column: end_column, - end_line: params.location.end_line, - end_char: end_char, - end_column: end_column - ) - - params = Params.new(location: location) - end - - ending = find_keyword(:end) - - if ending - tokens.delete(ending) - start_char = find_next_statement_start(params.location.end_char) - - bodystmt.bind( - self, - start_char, - start_char - line_counts[params.location.start_line - 1].start, - ending.location.start_char, - ending.location.start_column - ) - - DefNode.new( - target: nil, - operator: nil, - name: name, - params: params, - bodystmt: bodystmt, - location: beginning.location.to(ending.location) - ) - else - # In Ruby >= 3.1.0, this is a BodyStmt that wraps a single statement in - # the statements list. Before, it was just the individual statement. - statement = bodystmt.is_a?(BodyStmt) ? bodystmt.statements : bodystmt - - DefNode.new( - target: nil, - operator: nil, - name: name, - params: params, - bodystmt: statement, - location: beginning.location.to(bodystmt.location) - ) - end - end - - # :call-seq: - # on_defined: (untyped value) -> Defined - def on_defined(value) - beginning = consume_keyword(:defined?) - ending = value - - range = beginning.location.end_char...value.location.start_char - if source[range].include?("(") - consume_token(LParen) - ending = consume_token(RParen) - end - - Defined.new( - value: value, - location: beginning.location.to(ending.location) - ) - end - - # :call-seq: - # on_defs: ( - # untyped target, - # (Op | Period) operator, - # (Backtick | Const | Ident | Kw | Op) name, - # (Params | Paren) params, - # BodyStmt bodystmt - # ) -> DefNode - 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 - end_column = name.location.end_column - location = - Location.new( - start_line: params.location.start_line, - start_char: end_char, - start_column: end_column, - end_line: params.location.end_line, - end_char: end_char, - end_column: end_column - ) - - params = Params.new(location: location) - end - - beginning = consume_keyword(:def) - ending = find_keyword(:end) - - if ending - tokens.delete(ending) - start_char = find_next_statement_start(params.location.end_char) - - bodystmt.bind( - self, - start_char, - start_char - line_counts[params.location.start_line - 1].start, - ending.location.start_char, - ending.location.start_column - ) - - DefNode.new( - target: target, - operator: operator, - name: name, - params: params, - bodystmt: bodystmt, - location: beginning.location.to(ending.location) - ) - else - # In Ruby >= 3.1.0, this is a BodyStmt that wraps a single statement in - # the statements list. Before, it was just the individual statement. - statement = bodystmt.is_a?(BodyStmt) ? bodystmt.statements : bodystmt - - DefNode.new( - target: target, - operator: operator, - name: name, - params: params, - bodystmt: statement, - location: beginning.location.to(bodystmt.location) - ) - end - end - - # :call-seq: - # on_do_block: (BlockVar block_var, BodyStmt bodystmt) -> BlockNode - def on_do_block(block_var, bodystmt) - beginning = consume_keyword(:do) - ending = consume_keyword(:end) - location = (block_var || beginning).location - start_char = find_next_statement_start(location.end_char) - - bodystmt.bind( - self, - start_char, - start_char - line_counts[location.start_line - 1].start, - ending.location.start_char, - ending.location.start_column - ) - - BlockNode.new( - opening: beginning, - block_var: block_var, - bodystmt: bodystmt, - location: beginning.location.to(ending.location) - ) - end - - # :call-seq: - # on_dot2: ((nil | untyped) left, (nil | untyped) right) -> RangeNode - def on_dot2(left, right) - operator = consume_operator(:"..") - - beginning = left || operator - ending = right || operator - - RangeNode.new( - left: left, - operator: operator, - right: right, - location: beginning.location.to(ending.location) - ) - end - - # :call-seq: - # on_dot3: ((nil | untyped) left, (nil | untyped) right) -> RangeNode - def on_dot3(left, right) - operator = consume_operator(:"...") - - beginning = left || operator - ending = right || operator - - RangeNode.new( - left: left, - operator: operator, - right: right, - location: beginning.location.to(ending.location) - ) - end - - # :call-seq: - # on_dyna_symbol: (StringContent string_content) -> DynaSymbol - def on_dyna_symbol(string_content) - if (symbeg = find_token(SymBeg)) - # A normal dynamic symbol - tokens.delete(symbeg) - tstring_end = consume_tstring_end(symbeg.location) - - 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 = consume_token(TStringBeg) - label_end = consume_token(LabelEnd) - - DynaSymbol.new( - parts: string_content.parts, - quote: label_end.value[0], - location: tstring_beg.location.to(label_end.location) - ) - end - end - - # :call-seq: - # on_else: (Statements statements) -> Else - def on_else(statements) - keyword = consume_keyword(: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 - - if index.nil? - message = "Cannot find expected else ending" - raise ParseError.new(message, *find_token_error(keyword.location)) - end - - node = tokens[index] - ending = node.value == "end" ? tokens.delete_at(index) : node - - start_char = find_next_statement_start(keyword.location.end_char) - statements.bind( - self, - start_char, - start_char - line_counts[keyword.location.start_line - 1].start, - ending.location.start_char, - ending.location.start_column - ) - - Else.new( - keyword: keyword, - statements: statements, - location: keyword.location.to(ending.location) - ) - end - - # :call-seq: - # on_elsif: ( - # untyped predicate, - # Statements statements, - # (nil | Elsif | Else) consequent - # ) -> Elsif - def on_elsif(predicate, statements, consequent) - beginning = consume_keyword(:elsif) - ending = consequent || consume_keyword(:end) - - delimiter = - find_keyword_between(:then, predicate, statements) || - find_token_between(Semicolon, predicate, statements) - - tokens.delete(delimiter) if delimiter - start_char = - find_next_statement_start((delimiter || predicate).location.end_char) - - statements.bind( - self, - start_char, - start_char - line_counts[predicate.location.start_line - 1].start, - ending.location.start_char, - ending.location.start_column - ) - - Elsif.new( - predicate: predicate, - statements: statements, - consequent: consequent, - location: beginning.location.to(ending.location) - ) - 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, column: current_column, 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, - start_column: location.start_column, - end_line: lineno, - end_char: char_pos + value.length - 1, - end_column: current_column + value.length - 1 - ) - ) - - @comments << embdoc - @embdoc = nil - - embdoc - 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, - column: current_column, - size: value.size - ) - ) - - tokens << node - node - 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, - column: current_column, - size: value.size - ) - ) - - tokens << node - node - 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, - column: current_column, - size: value.size - ) - ) - - tokens << node - node - end - - # :call-seq: - # on_ensure: (Statements statements) -> Ensure - def on_ensure(statements) - keyword = consume_keyword(:ensure) - - # We don't want to consume the :@kw event, because that would break - # def..ensure..end chains. - ending = find_keyword(:end) - start_char = find_next_statement_start(keyword.location.end_char) - statements.bind( - self, - start_char, - start_char - line_counts[keyword.location.start_line - 1].start, - ending.location.start_char, - ending.location.start_column - ) - - Ensure.new( - keyword: keyword, - statements: statements, - location: keyword.location.to(ending.location) - ) - 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 = consume_token(Comma) - - ExcessedComma.new(value: comma.value, location: comma.location) - end - - # :call-seq: - # on_fcall: ((Const | Ident) value) -> CallNode - def on_fcall(value) - CallNode.new( - receiver: nil, - operator: nil, - message: value, - arguments: nil, - location: value.location - ) - end - - # :call-seq: - # on_field: ( - # untyped parent, - # (:"::" | Op | Period | 73) operator - # (Const | Ident) name - # ) -> Field - def on_field(parent, operator, name) - Field.new( - parent: parent, - operator: operator == 73 ? :"::" : operator, - name: name, - location: parent.location.to(name.location) - ) - end - - # :call-seq: - # on_float: (String value) -> FloatLiteral - def on_float(value) - FloatLiteral.new( - value: value, - location: - Location.token( - line: lineno, - char: char_pos, - column: current_column, - size: value.size - ) - ) - end - - # :call-seq: - # on_fndptn: ( - # (nil | untyped) constant, - # VarField left, - # Array[untyped] values, - # VarField right - # ) -> FndPtn - def on_fndptn(constant, left, values, right) - # The left and right of a find pattern are always going to be splats, so - # we're going to consume the * operators and use their location - # information to extend the location of the splats. - right, left = - [right, left].map do |node| - operator = consume_operator(:*) - location = - if node.value - operator.location.to(node.location) - else - operator.location - end - - node.copy(location: location) - end - - # The opening of this find pattern is either going to be a left bracket, a - # right left parenthesis, or the left splat. We're going to use this to - # determine how to find the closing of the pattern, as well as determining - # the location of the node. - opening = find_token(LBracket) || find_token(LParen) || left - - # The closing is based on the opening, which is either the matched - # punctuation or the right splat. - closing = - case opening - when LBracket - tokens.delete(opening) - consume_token(RBracket) - when LParen - tokens.delete(opening) - consume_token(RParen) - else - right - end - - FndPtn.new( - constant: constant, - left: left, - values: values, - right: right, - location: (constant || opening).location.to(closing.location) - ) - end - - # :call-seq: - # on_for: ( - # (MLHS | VarField) value, - # untyped collection, - # Statements statements - # ) -> For - def on_for(index, collection, statements) - beginning = consume_keyword(:for) - in_keyword = consume_keyword(:in) - ending = consume_keyword(:end) - - delimiter = - find_keyword_between(:do, collection, ending) || - find_token_between(Semicolon, collection, ending) - - tokens.delete(delimiter) if delimiter - - start_char = - find_next_statement_start((delimiter || collection).location.end_char) - - statements.bind( - self, - start_char, - start_char - - line_counts[(delimiter || collection).location.end_line - 1].start, - ending.location.start_char, - ending.location.start_column - ) - - if index.is_a?(MLHS) - comma_range = index.location.end_char...in_keyword.location.start_char - index.comma = true if source[comma_range].strip.start_with?(",") - end - - For.new( - index: index, - collection: collection, - statements: statements, - location: beginning.location.to(ending.location) - ) - end - - # :call-seq: - # on_gvar: (String value) -> GVar - def on_gvar(value) - GVar.new( - value: value, - location: - Location.token( - line: lineno, - char: char_pos, - column: current_column, - size: value.size - ) - ) - end - - # :call-seq: - # on_hash: ((nil | Array[AssocNew | AssocSplat]) assocs) -> HashLiteral - def on_hash(assocs) - lbrace = consume_token(LBrace) - rbrace = consume_token(RBrace) - - HashLiteral.new( - lbrace: lbrace, - assocs: assocs || [], - location: lbrace.location.to(rbrace.location) - ) - end - - # :call-seq: - # on_heredoc_beg: (String value) -> HeredocBeg - def on_heredoc_beg(value) - location = - Location.token( - line: lineno, - char: char_pos, - column: current_column, - size: value.size - ) - - # 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, - dedent: width, - parts: string.parts, - location: heredoc.location - ) - end - - # :call-seq: - # on_heredoc_end: (String value) -> Heredoc - def on_heredoc_end(value) - heredoc = @heredocs[-1] - - location = - Location.token( - line: lineno, - char: char_pos, - column: current_column, - size: value.size - ) - - heredoc_end = HeredocEnd.new(value: value.chomp, location: location) - - @heredocs[-1] = Heredoc.new( - beginning: heredoc.beginning, - ending: heredoc_end, - dedent: heredoc.dedent, - parts: heredoc.parts, - location: - Location.new( - start_line: heredoc.location.start_line, - start_char: heredoc.location.start_char, - start_column: heredoc.location.start_column, - end_line: location.end_line, - end_char: location.end_char, - end_column: location.end_column - ) - ) - end - - # :call-seq: - # on_hshptn: ( - # (nil | untyped) constant, - # Array[[Label | StringContent, untyped]] keywords, - # (nil | VarField) keyword_rest - # ) -> HshPtn - def on_hshptn(constant, keywords, keyword_rest) - keywords = - (keywords || []).map do |(label, value)| - if label.is_a?(Label) - [label, value] - else - tstring_beg_index = - tokens.rindex do |token| - token.is_a?(TStringBeg) && - token.location.start_char < label.location.start_char - end - - tstring_beg = tokens.delete_at(tstring_beg_index) - - label_end_index = - tokens.rindex do |token| - token.is_a?(LabelEnd) && - token.location.start_char == label.location.end_char - end - - label_end = tokens.delete_at(label_end_index) - - [ - DynaSymbol.new( - parts: label.parts, - quote: label_end.value[0], - location: tstring_beg.location.to(label_end.location) - ), - value - ] - end - end - - if keyword_rest - # We're doing this to delete the token from the list so that it doesn't - # confuse future patterns by thinking they have an extra ** on the end. - consume_operator(:**) - elsif (token = find_operator(:**)) - tokens.delete(token) - - # Create an artificial VarField if we find an extra ** on the end. This - # means the formatting will be a little more consistent. - keyword_rest = VarField.new(value: nil, location: token.location) - end - - parts = [constant, *keywords.flatten(1), keyword_rest].compact - - # If there's no constant, there may be braces, so we're going to look for - # those to get our bounds. - unless constant - lbrace = find_token(LBrace) - rbrace = find_token(RBrace) - - if lbrace && rbrace - parts = [lbrace, *parts, rbrace] - tokens.delete(lbrace) - tokens.delete(rbrace) - end - end - - HshPtn.new( - constant: constant, - keywords: keywords, - keyword_rest: keyword_rest, - location: parts[0].location.to(parts[-1].location) - ) - end - - # :call-seq: - # on_ident: (String value) -> Ident - def on_ident(value) - Ident.new( - value: value, - location: - Location.token( - line: lineno, - char: char_pos, - column: current_column, - size: value.size - ) - ) - end - - # :call-seq: - # on_if: ( - # untyped predicate, - # Statements statements, - # (nil | Elsif | Else) consequent - # ) -> IfNode - def on_if(predicate, statements, consequent) - beginning = consume_keyword(:if) - ending = consequent || consume_keyword(:end) - - if (keyword = find_keyword_between(:then, predicate, ending)) - tokens.delete(keyword) - end - - start_char = - find_next_statement_start((keyword || predicate).location.end_char) - - statements.bind( - self, - start_char, - start_char - line_counts[predicate.location.end_line - 1].start, - ending.location.start_char, - ending.location.start_column - ) - - IfNode.new( - predicate: predicate, - statements: statements, - consequent: consequent, - location: beginning.location.to(ending.location) - ) - 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 - - # :call-seq: - # on_if_mod: (untyped predicate, untyped statement) -> IfNode - def on_if_mod(predicate, statement) - consume_keyword(:if) - - IfNode.new( - predicate: predicate, - statements: - Statements.new(body: [statement], location: statement.location), - consequent: nil, - location: statement.location.to(predicate.location) - ) - end - - # def on_ignored_nl(value) - # value - # end - - # def on_ignored_sp(value) - # value - # end - - # :call-seq: - # on_imaginary: (String value) -> Imaginary - def on_imaginary(value) - Imaginary.new( - value: value, - location: - Location.token( - line: lineno, - char: char_pos, - column: current_column, - size: value.size - ) - ) - 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 = consume_keyword(:in) - ending = consequent || consume_keyword(:end) - - statements_start = pattern - if (token = find_keyword_between(:then, pattern, statements)) - tokens.delete(token) - statements_start = token - end - - start_char = - find_next_statement_start((token || statements_start).location.end_char) - - # Ripper ignores parentheses on patterns, so we need to do the same in - # order to attach comments correctly to the pattern. - if source[start_char] == ")" - start_char = find_next_statement_start(start_char + 1) - end - - statements.bind( - self, - start_char, - start_char - - line_counts[statements_start.location.start_line - 1].start, - ending.location.start_char, - ending.location.start_column - ) - - node = - In.new( - pattern: pattern, - statements: statements, - consequent: consequent, - location: beginning.location.to(ending.location) - ) - - PinVisitor.visit(node, tokens) - node - end - - # :call-seq: - # on_int: (String value) -> Int - def on_int(value) - Int.new( - value: value, - location: - Location.token( - line: lineno, - char: char_pos, - column: current_column, - size: value.size - ) - ) - end - - # :call-seq: - # on_ivar: (String value) -> IVar - def on_ivar(value) - IVar.new( - value: value, - location: - Location.token( - line: lineno, - char: char_pos, - column: current_column, - size: value.size - ) - ) - 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, - column: current_column, - size: value.size - ) - ) - - tokens << node - node - end - - # :call-seq: - # on_kwrest_param: ((nil | Ident) name) -> KwRestParam - def on_kwrest_param(name) - location = consume_operator(:**).location - location = location.to(name.location) if name - - KwRestParam.new(name: name, location: location) - end - - # :call-seq: - # on_label: (String value) -> Label - def on_label(value) - Label.new( - value: value, - location: - Location.token( - line: lineno, - char: char_pos, - column: current_column, - size: value.size - ) - ) - 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, - column: current_column, - size: value.size - ) - ) - - tokens << node - node - end - - # :call-seq: - # on_lambda: ( - # (Params | Paren) params, - # (BodyStmt | Statements) statements - # ) -> Lambda - def on_lambda(params, statements) - beginning = consume_token(TLambda) - braces = - tokens.any? do |token| - token.is_a?(TLamBeg) && - token.location.start_char > beginning.location.start_char - end - - if braces - opening = consume_token(TLamBeg) - closing = consume_token(RBrace) - else - opening = consume_keyword(:do) - closing = consume_keyword(:end) - end - - # We need to do some special mapping here. Since ripper doesn't support - # capturing lambda vars, we need to normalize all of that here. - params = - if params.is_a?(Paren) - # In this case we've gotten to the parentheses wrapping a set of - # parameters case. Here we need to manually scan for lambda locals. - range = (params.location.start_char + 1)...params.location.end_char - locals = lambda_locals(source[range]) - - location = params.contents.location - location = location.to(locals.last.location) if locals.any? - - node = - Paren.new( - lparen: params.lparen, - contents: - LambdaVar.new( - params: params.contents, - locals: locals, - location: location - ), - location: params.location - ) - - node.comments.concat(params.comments) - node - else - # If there are no parameters, then we didn't have anything to base the - # location information of off. Now that we have an opening of the - # block, we can correct this. - if params.empty? - opening_location = opening.location - location = - Location.fixed( - line: opening_location.start_line, - char: opening_location.start_char, - column: opening_location.start_column - ) - - params = params.copy(location: location) - end - - # In this case we've gotten to the plain set of parameters. In this - # case there cannot be lambda locals, so we will wrap the parameters - # into a lambda var that has no locals. - LambdaVar.new(params: params, locals: [], location: params.location) - end - - start_char = find_next_statement_start(opening.location.end_char) - statements.bind( - self, - start_char, - start_char - line_counts[opening.location.end_line - 1].start, - closing.location.start_char, - closing.location.start_column - ) - - Lambda.new( - params: params, - statements: statements, - location: beginning.location.to(closing.location) - ) - end - - # :call-seq: - # on_lambda_var: (Params params, Array[ Ident ] locals) -> LambdaVar - def on_lambda_var(params, locals) - location = params.location - location = location.to(locals.last.location) if locals.any? - - LambdaVar.new(params: params, locals: locals || [], location: location) - end - - # Ripper doesn't support capturing lambda local variables until 3.2. To - # mitigate this, we have to parse that code for ourselves. We use the range - # from the parentheses to find where we _should_ be looking. Then we check - # if the resulting tokens match a pattern that we determine means that the - # declaration has block-local variables. Once it does, we parse those out - # and convert them into Ident nodes. - def lambda_locals(source) - tokens = Ripper.lex(source) - - # First, check that we have a semi-colon. If we do, then we can start to - # parse the tokens _after_ the semicolon. - index = tokens.rindex { |token| token[1] == :on_semicolon } - return [] unless index - - # Next, map over the tokens and convert them into Ident nodes. Bail out - # midway through if we encounter a token we didn't expect. Basically we're - # making our own mini-parser here. To do that we'll walk through a small - # state machine: - # - # ┌────────┐ ┌────────┐ ┌─────────┐ - # │ │ │ │ │┌───────┐│ - # ──> │ item │ ─── ident ──> │ next │ ─── rparen ──> ││ final ││ - # │ │ <── comma ─── │ │ │└───────┘│ - # └────────┘ └────────┘ └─────────┘ - # │ ^ │ ^ - # └──┘ └──┘ - # ignored_nl, sp nl, sp - # - state = :item - transitions = { - item: { - on_ignored_nl: :item, - on_sp: :item, - on_ident: :next - }, - next: { - on_nl: :next, - on_sp: :next, - on_comma: :item, - on_rparen: :final - }, - final: { - } - } - - parent_line = lineno - 1 - parent_column = - consume_token(Semicolon).location.start_column - tokens[index][0][1] - - tokens[(index + 1)..].each_with_object([]) do |token, locals| - (lineno, column), type, value, = token - column += parent_column if lineno == 1 - lineno += parent_line - - # Make the state transition for the parser. If there isn't a transition - # from the current state to a new state for this type, then we're in a - # pattern that isn't actually locals. In that case we can return []. - state = transitions[state].fetch(type) { return [] } - - # If we hit an identifier, then add it to our list. - next if type != :on_ident - - location = - Location.token( - line: lineno, - char: line_counts[lineno - 1][column], - column: column, - size: value.size - ) - - locals << Ident.new(value: value, location: location) - 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, - column: current_column, - size: value.size - ) - ) - - tokens << node - node - 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, - column: current_column, - size: value.size - ) - ) - - tokens << node - node - 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, - column: current_column, - size: value.size - ) - ) - - tokens << node - node - end - - # def on_magic_comment(key, value) - # [key, value] - # end - - # :call-seq: - # on_massign: ((MLHS | 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 - - # :call-seq: - # on_method_add_arg: ( - # CallNode call, - # (ArgParen | Args) arguments - # ) -> CallNode - def on_method_add_arg(call, arguments) - location = call.location - location = location.to(arguments.location) if arguments.is_a?(ArgParen) - - CallNode.new( - receiver: call.receiver, - operator: call.operator, - message: call.message, - arguments: arguments, - location: location - ) - end - - # :call-seq: - # on_method_add_block: ( - # (Break | Call | Command | CommandCall, Next) call, - # Block block - # ) -> Break | MethodAddBlock - def on_method_add_block(call, block) - location = call.location.to(block.location) - - case call - when Break, Next, ReturnNode - parts = call.arguments.parts - - node = parts.pop - copied = - node.copy(block: block, location: node.location.to(block.location)) - - copied.comments.concat(call.comments) - parts << copied - - call.copy(location: location) - when Command, CommandCall - node = call.copy(block: block, location: location) - node.comments.concat(call.comments) - node - else - MethodAddBlock.new(call: call, block: block, location: location) - end - end - - # :call-seq: - # on_mlhs_add: ( - # MLHS mlhs, - # (ARefField | Field | Ident | MLHSParen | VarField) part - # ) -> MLHS - def on_mlhs_add(mlhs, part) - location = - mlhs.parts.empty? ? part.location : mlhs.location.to(part.location) - - MLHS.new(parts: mlhs.parts << part, location: location) - end - - # :call-seq: - # on_mlhs_add_post: (MLHS left, MLHS right) -> MLHS - def on_mlhs_add_post(left, right) - MLHS.new( - parts: left.parts + right.parts, - location: left.location.to(right.location) - ) - end - - # :call-seq: - # on_mlhs_add_star: ( - # MLHS mlhs, - # (nil | ARefField | Field | Ident | VarField) part - # ) -> MLHS - def on_mlhs_add_star(mlhs, part) - beginning = consume_operator(:*) - ending = part || beginning - - location = beginning.location.to(ending.location) - arg_star = ArgStar.new(value: part, location: location) - - location = mlhs.location.to(location) unless mlhs.parts.empty? - MLHS.new(parts: mlhs.parts << arg_star, location: location) - end - - # :call-seq: - # on_mlhs_new: () -> MLHS - def on_mlhs_new - MLHS.new( - parts: [], - location: - Location.fixed(line: lineno, char: char_pos, column: current_column) - ) - end - - # :call-seq: - # on_mlhs_paren: ((MLHS | MLHSParen) contents) -> MLHSParen - def on_mlhs_paren(contents) - lparen = consume_token(LParen) - rparen = consume_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 - - # :call-seq: - # on_module: ( - # (ConstPathRef | ConstRef | TopConstRef) constant, - # BodyStmt bodystmt - # ) -> ModuleDeclaration - def on_module(constant, bodystmt) - beginning = consume_keyword(:module) - ending = consume_keyword(:end) - start_char = find_next_statement_start(constant.location.end_char) - - bodystmt.bind( - self, - start_char, - start_char - line_counts[constant.location.start_line - 1].start, - ending.location.start_char, - ending.location.start_column - ) - - ModuleDeclaration.new( - constant: constant, - bodystmt: bodystmt, - location: beginning.location.to(ending.location) - ) - end - - # :call-seq: - # on_mrhs_new: () -> MRHS - def on_mrhs_new - MRHS.new( - parts: [], - location: - Location.fixed(line: lineno, char: char_pos, column: current_column) - ) - end - - # :call-seq: - # on_mrhs_add: (MRHS mrhs, untyped part) -> MRHS - def on_mrhs_add(mrhs, part) - location = - (mrhs.parts.empty? ? mrhs.location : mrhs.location.to(part.location)) - - MRHS.new(parts: mrhs.parts << part, location: location) - end - - # :call-seq: - # on_mrhs_add_star: (MRHS mrhs, untyped value) -> MRHS - def on_mrhs_add_star(mrhs, value) - beginning = consume_operator(:*) - ending = value || beginning - - arg_star = - ArgStar.new( - value: value, - location: beginning.location.to(ending.location) - ) - - location = - if mrhs.parts.empty? - arg_star.location - else - mrhs.location.to(arg_star.location) - end - - MRHS.new(parts: mrhs.parts << arg_star, location: location) - end - - # :call-seq: - # on_mrhs_new_from_args: (Args arguments) -> MRHS - def on_mrhs_new_from_args(arguments) - MRHS.new(parts: arguments.parts, location: arguments.location) - end - - # :call-seq: - # on_next: (Args arguments) -> Next - def on_next(arguments) - keyword = consume_keyword(:next) - - location = keyword.location - location = location.to(arguments.location) if arguments.parts.any? - - Next.new(arguments: arguments, location: location) - end - - # def on_nl(value) - # value - # end - - # def on_nokw_param(value) - # value - # 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, - column: current_column, - size: value.size - ) - ) - - tokens << node - node - 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 - - # :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 | ArgsForward | KwRestParam) keyword_rest, - # (nil | :& | BlockArg) block - # ) -> Params - def on_params( - requireds, - optionals, - rest, - posts, - keywords, - keyword_rest, - block - ) - # This is to make it so that required keyword arguments - # have a `nil` for the value instead of a `false`. - keywords&.map! { |(key, value)| [key, value || nil] } - - # Here we're going to build up a list of all of the params so that we can - # determine our location information. - parts = [] - - requireds&.each { |required| parts << required.location } - optionals&.each do |(key, value)| - parts << key.location - parts << value.location if value - end - - parts << rest.location if rest - posts&.each { |post| parts << post.location } - - keywords&.each do |(key, value)| - parts << key.location - parts << value.location if value - end - - if keyword_rest == :nil - # When we get a :nil here, it means that we have **nil syntax, which - # means this set of parameters accepts no more keyword arguments. In - # this case we need to go and find the location of these two tokens. - operator = consume_operator(:**) - parts << operator.location.to(consume_keyword(:nil).location) - elsif keyword_rest - parts << keyword_rest.location - end - - parts << block.location if block && block != :& - parts = parts.compact - - location = - if parts.any? - parts[0].to(parts[-1]) - else - Location.fixed(line: lineno, char: char_pos, column: current_column) - end - - Params.new( - requireds: requireds || [], - optionals: optionals || [], - rest: rest, - posts: posts || [], - keywords: keywords || [], - keyword_rest: keyword_rest, - block: (block if block != :&), - location: location - ) - end - - # :call-seq: - # on_paren: (untyped contents) -> Paren - def on_paren(contents) - lparen = consume_token(LParen) - rparen = consume_token(RParen) - - if contents.is_a?(Params) - location = contents.location - start_char = find_next_statement_start(lparen.location.end_char) - location = - Location.new( - start_line: location.start_line, - start_char: start_char, - start_column: - start_char - line_counts[lparen.location.start_line - 1].start, - end_line: location.end_line, - end_char: rparen.location.start_char, - end_column: rparen.location.start_column - ) - - 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 || nil, - 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 - alias compile_error on_parse_error - - # :call-seq: - # on_period: (String value) -> Period - def on_period(value) - Period.new( - value: value, - location: - Location.token( - line: lineno, - char: char_pos, - column: current_column, - size: value.size - ) - ) - end - - # :call-seq: - # on_program: (Statements statements) -> Program - def on_program(statements) - last_column = source.length - line_counts.last.start - location = - Location.new( - start_line: 1, - start_char: 0, - start_column: 0, - end_line: line_counts.length - 1, - end_char: source.length, - end_column: last_column - ) - - statements.body << @__end__ if @__end__ - statements.bind(self, 0, 0, source.length, last_column) - - program = Program.new(statements: statements, location: location) - attach_comments(program, @comments) - - program - end - - # Attaches comments to the nodes in the tree that most closely correspond to - # the location of the comments. - def attach_comments(program, comments) - comments.each do |comment| - preceding, enclosing, following = nearest_nodes(program, comment) - - if comment.inline? - if preceding - preceding.comments << comment - comment.trailing! - elsif following - following.comments << comment - comment.leading! - elsif enclosing - enclosing.comments << comment - else - program.comments << comment - end - else - # If a comment exists on its own line, prefer a leading comment. - if following - following.comments << comment - comment.leading! - elsif preceding - preceding.comments << comment - comment.trailing! - elsif enclosing - enclosing.comments << comment - else - program.comments << comment - end - end - end - end - - # Responsible for finding the nearest nodes to the given comment within the - # context of the given encapsulating node. - def nearest_nodes(node, comment) - comment_start = comment.location.start_char - comment_end = comment.location.end_char - - child_nodes = node.child_nodes.compact - preceding = nil - following = nil - - left = 0 - right = child_nodes.length - - # This is a custom binary search that finds the nearest nodes to the given - # comment. When it finds a node that completely encapsulates the comment, - # it recursed downward into the tree. - while left < right - middle = (left + right) / 2 - child = child_nodes[middle] - - node_start = child.location.start_char - node_end = child.location.end_char - - if node_start <= comment_start && comment_end <= node_end - # The comment is completely contained by this child node. Abandon the - # binary search at this level. - return nearest_nodes(child, comment) - end - - if node_end <= comment_start - # This child node falls completely before the comment. Because we will - # never consider this node or any nodes before it again, this node - # must be the closest preceding node we have encountered so far. - preceding = child - left = middle + 1 - next - end - - if comment_end <= node_start - # This child node falls completely after the comment. Because we will - # never consider this node or any nodes after it again, this node must - # be the closest following node we have encountered so far. - following = child - right = middle - next - end - - # This should only happen if there is a bug in this parser. - raise "Comment location overlaps with node location" - end - - [preceding, node, following] - end - - # :call-seq: - # on_qsymbols_add: (QSymbols qsymbols, TStringContent element) -> QSymbols - def on_qsymbols_add(qsymbols, element) - QSymbols.new( - beginning: qsymbols.beginning, - elements: qsymbols.elements << element, - location: qsymbols.location.to(element.location) - ) - 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, - column: current_column, - size: value.size - ) - ) - - tokens << node - node - end - - # :call-seq: - # on_qsymbols_new: () -> QSymbols - def on_qsymbols_new - beginning = consume_token(QSymbolsBeg) - - QSymbols.new( - beginning: beginning, - elements: [], - location: beginning.location - ) - end - - # :call-seq: - # on_qwords_add: (QWords qwords, TStringContent element) -> QWords - def on_qwords_add(qwords, element) - QWords.new( - beginning: qwords.beginning, - elements: qwords.elements << element, - location: qwords.location.to(element.location) - ) - 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, - column: current_column, - size: value.size - ) - ) - - tokens << node - node - end - - # :call-seq: - # on_qwords_new: () -> QWords - def on_qwords_new - beginning = consume_token(QWordsBeg) - - QWords.new( - beginning: beginning, - elements: [], - location: beginning.location - ) - end - - # :call-seq: - # on_rational: (String value) -> RationalLiteral - def on_rational(value) - RationalLiteral.new( - value: value, - location: - Location.token( - line: lineno, - char: char_pos, - column: current_column, - size: value.size - ) - ) - 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, - column: current_column, - size: value.size - ) - ) - - tokens << node - node - 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, - column: current_column, - size: value.size - ) - ) - - tokens << node - node - end - - # :call-seq: - # on_redo: () -> Redo - def on_redo - keyword = consume_keyword(:redo) - - Redo.new(location: keyword.location) - 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 - - # :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, - column: current_column, - size: value.size - ) - ) - - tokens << node - node - 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, - column: current_column, - size: value.size - ) - ) - end - - # :call-seq: - # on_regexp_literal: ( - # RegexpContent regexp_content, - # (nil | RegexpEnd) ending - # ) -> RegexpLiteral - def on_regexp_literal(regexp_content, ending) - location = regexp_content.location - - if ending.nil? - message = "Cannot find expected regular expression ending" - raise ParseError.new(message, *find_token_error(location)) - end - - RegexpLiteral.new( - beginning: regexp_content.beginning, - ending: ending.value, - parts: regexp_content.parts, - location: location.to(ending.location) - ) - end - - # :call-seq: - # on_regexp_new: () -> RegexpContent - def on_regexp_new - regexp_beg = consume_token(RegexpBeg) - - RegexpContent.new( - beginning: regexp_beg.value, - parts: [], - location: regexp_beg.location - ) - 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 = consume_keyword(:rescue) - exceptions = exceptions[0] if exceptions.is_a?(Array) - - last_node = variable || exceptions || keyword - start_char = find_next_statement_start(last_node.end_char) - statements.bind( - self, - start_char, - start_char - line_counts[last_node.location.start_line - 1].start, - char_pos, - current_column - ) - - # 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, - start_column: keyword.location.end_column + 1, - end_line: last_node.location.end_line, - end_char: last_node.end_char, - end_column: last_node.location.end_column - ) - ) - end - - Rescue.new( - keyword: keyword, - exception: rescue_ex, - statements: statements, - consequent: consequent, - location: - Location.new( - start_line: keyword.location.start_line, - start_char: keyword.location.start_char, - start_column: keyword.location.start_column, - end_line: lineno, - end_char: char_pos, - end_column: current_column - ) - ) - end - - # :call-seq: - # on_rescue_mod: (untyped statement, untyped value) -> RescueMod - def on_rescue_mod(statement, value) - consume_keyword(:rescue) - - RescueMod.new( - statement: statement, - value: value, - location: statement.location.to(value.location) - ) - end - - # :call-seq: - # on_rest_param: ((nil | Ident) name) -> RestParam - def on_rest_param(name) - location = consume_operator(:*).location - location = location.to(name.location) if name - - RestParam.new(name: name, location: location) - end - - # :call-seq: - # on_retry: () -> Retry - def on_retry - keyword = consume_keyword(:retry) - - Retry.new(location: keyword.location) - end - - # :call-seq: - # on_return: (Args arguments) -> ReturnNode - def on_return(arguments) - keyword = consume_keyword(:return) - - ReturnNode.new( - arguments: arguments, - location: keyword.location.to(arguments.location) - ) - end - - # :call-seq: - # on_return0: () -> ReturnNode - def on_return0 - keyword = consume_keyword(:return) - - ReturnNode.new(arguments: nil, location: keyword.location) - 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, - column: current_column, - size: value.size - ) - ) - - tokens << node - node - end - - # :call-seq: - # on_sclass: (untyped target, BodyStmt bodystmt) -> SClass - def on_sclass(target, bodystmt) - beginning = consume_keyword(:class) - ending = consume_keyword(:end) - start_char = find_next_statement_start(target.location.end_char) - - bodystmt.bind( - self, - start_char, - start_char - line_counts[target.location.start_line - 1].start, - ending.location.start_char, - ending.location.start_column - ) - - SClass.new( - target: target, - bodystmt: bodystmt, - location: beginning.location.to(ending.location) - ) - end - - # Semicolons are tokens that get added to the token list but never get - # attached to the AST. Because of this they only need to track their - # associated location so they can be used for computing bounds. - class Semicolon - attr_reader :location - - def initialize(location) - @location = location - end - end - - # :call-seq: - # on_semicolon: (String value) -> Semicolon - def on_semicolon(value) - tokens << Semicolon.new( - Location.token( - line: lineno, - char: char_pos, - column: current_column, - size: value.size - ) - ) - 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) - location = - if statements.body.empty? - statement.location - else - statements.location.to(statement.location) - end - - Statements.new(body: statements.body << statement, location: location) - end - - # :call-seq: - # on_stmts_new: () -> Statements - def on_stmts_new - Statements.new( - body: [], - location: - Location.fixed(line: lineno, char: char_pos, column: current_column) - ) - end - - # :call-seq: - # on_string_add: ( - # String string, - # (StringEmbExpr | StringDVar | TStringContent) part - # ) -> StringContent - def on_string_add(string, part) - # Due to some eccentricities in how ripper works, you need this here in - # case you have a syntax error with an embedded expression that doesn't - # finish, as in: "#{" - return string if part.is_a?(String) - - location = - string.parts.any? ? string.location.to(part.location) : part.location - - StringContent.new(parts: string.parts << part, location: location) - 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, column: current_column) - ) - end - - # :call-seq: - # on_string_dvar: ((Backref | VarRef) variable) -> StringDVar - def on_string_dvar(variable) - embvar = consume_token(EmbVar) - - StringDVar.new( - variable: variable, - location: embvar.location.to(variable.location) - ) - end - - # :call-seq: - # on_string_embexpr: (Statements statements) -> StringEmbExpr - def on_string_embexpr(statements) - embexpr_beg = consume_token(EmbExprBeg) - embexpr_end = consume_token(EmbExprEnd) - - statements.bind( - self, - embexpr_beg.location.end_char, - embexpr_beg.location.end_column, - embexpr_end.location.start_char, - embexpr_end.location.start_column - ) - - location = - Location.new( - start_line: embexpr_beg.location.start_line, - start_char: embexpr_beg.location.start_char, - start_column: embexpr_beg.location.start_column, - end_line: [ - embexpr_end.location.end_line, - statements.location.end_line - ].max, - end_char: embexpr_end.location.end_char, - end_column: embexpr_end.location.end_column - ) - - StringEmbExpr.new(statements: statements, location: location) - end - - # :call-seq: - # on_string_literal: (String string) -> Heredoc | StringLiteral - def on_string_literal(string) - heredoc = @heredocs[-1] - - if heredoc&.ending - heredoc = @heredocs.pop - - Heredoc.new( - beginning: heredoc.beginning, - ending: heredoc.ending, - dedent: heredoc.dedent, - parts: string.parts, - location: heredoc.location - ) - else - tstring_beg = consume_token(TStringBeg) - tstring_end = consume_tstring_end(tstring_beg.location) - - location = - Location.new( - start_line: tstring_beg.location.start_line, - start_char: tstring_beg.location.start_char, - start_column: tstring_beg.location.start_column, - end_line: [ - tstring_end.location.end_line, - string.location.end_line - ].max, - end_char: tstring_end.location.end_char, - end_column: tstring_end.location.end_column - ) - - StringLiteral.new( - parts: string.parts, - quote: tstring_beg.value, - location: location - ) - end - end - - # :call-seq: - # on_super: ((ArgParen | Args) arguments) -> Super - def on_super(arguments) - keyword = consume_keyword(:super) - - Super.new( - arguments: arguments, - location: keyword.location.to(arguments.location) - ) - 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, - column: current_column, - size: value.size - ) - ) - - tokens << node - node - end - - # :call-seq: - # on_symbol: ( - # (Backtick | Const | CVar | GVar | Ident | IVar | Kw | Op) value - # ) -> SymbolContent - def on_symbol(value) - tokens.delete(value) - - SymbolContent.new(value: value, location: value.location) - end - - # :call-seq: - # on_symbol_literal: ( - # ( - # Backtick | Const | CVar | GVar | Ident | - # IVar | Kw | Op | SymbolContent - # ) value - # ) -> SymbolLiteral - def on_symbol_literal(value) - if value.is_a?(SymbolContent) - symbeg = consume_token(SymBeg) - - SymbolLiteral.new( - value: value.value, - location: symbeg.location.to(value.location) - ) - else - tokens.delete(value) - SymbolLiteral.new(value: value, location: value.location) - end - end - - # :call-seq: - # on_symbols_add: (Symbols symbols, Word word) -> Symbols - def on_symbols_add(symbols, word) - Symbols.new( - beginning: symbols.beginning, - elements: symbols.elements << word, - location: symbols.location.to(word.location) - ) - 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, - column: current_column, - size: value.size - ) - ) - - tokens << node - node - end - - # :call-seq: - # on_symbols_new: () -> Symbols - def on_symbols_new - beginning = consume_token(SymbolsBeg) - - Symbols.new( - beginning: beginning, - elements: [], - location: beginning.location - ) - 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, - column: current_column, - size: value.size - ) - ) - - tokens << node - node - 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, - column: current_column, - size: value.size - ) - ) - - tokens << node - node - 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 - - # :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 - - # :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, - column: current_column, - size: value.size - ) - ) - - tokens << node - node - 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, - column: current_column, - size: value.size - ) - ) - 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, - column: current_column, - size: value.size - ) - ) - - tokens << node - node - 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 = consume_keyword(:not) - ending = statement || beginning - parentheses = source[beginning.location.end_char] == "(" - - if parentheses - consume_token(LParen) - ending = consume_token(RParen) - end - - Not.new( - statement: statement, - parentheses: parentheses, - 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 - - # :call-seq: - # on_undef: (Array[DynaSymbol | SymbolLiteral] symbols) -> Undef - def on_undef(symbols) - keyword = consume_keyword(:undef) - - Undef.new( - symbols: symbols, - location: keyword.location.to(symbols.last.location) - ) - end - - # :call-seq: - # on_unless: ( - # untyped predicate, - # Statements statements, - # ((nil | Elsif | Else) consequent) - # ) -> UnlessNode - def on_unless(predicate, statements, consequent) - beginning = consume_keyword(:unless) - ending = consequent || consume_keyword(:end) - - if (keyword = find_keyword_between(:then, predicate, ending)) - tokens.delete(keyword) - end - - start_char = - find_next_statement_start((keyword || predicate).location.end_char) - - statements.bind( - self, - start_char, - start_char - line_counts[predicate.location.end_line - 1].start, - ending.location.start_char, - ending.location.start_column - ) - - UnlessNode.new( - predicate: predicate, - statements: statements, - consequent: consequent, - location: beginning.location.to(ending.location) - ) - end - - # :call-seq: - # on_unless_mod: (untyped predicate, untyped statement) -> UnlessNode - def on_unless_mod(predicate, statement) - consume_keyword(:unless) - - UnlessNode.new( - predicate: predicate, - statements: - Statements.new(body: [statement], location: statement.location), - consequent: nil, - location: statement.location.to(predicate.location) - ) - end - - # :call-seq: - # on_until: (untyped predicate, Statements statements) -> UntilNode - def on_until(predicate, statements) - beginning = consume_keyword(:until) - ending = consume_keyword(:end) - - delimiter = - find_keyword_between(:do, predicate, statements) || - find_token_between(Semicolon, predicate, statements) - - tokens.delete(delimiter) if delimiter - - # Update the Statements location information - start_char = - find_next_statement_start((delimiter || predicate).location.end_char) - - statements.bind( - self, - start_char, - start_char - line_counts[predicate.location.end_line - 1].start, - ending.location.start_char, - ending.location.start_column - ) - - UntilNode.new( - predicate: predicate, - statements: statements, - location: beginning.location.to(ending.location) - ) - end - - # :call-seq: - # on_until_mod: (untyped predicate, untyped statement) -> UntilNode - def on_until_mod(predicate, statement) - consume_keyword(:until) - - UntilNode.new( - predicate: predicate, - statements: - Statements.new(body: [statement], location: statement.location), - location: statement.location.to(predicate.location) - ) - end - - # :call-seq: - # on_var_alias: (GVar left, (Backref | GVar) right) -> AliasNode - def on_var_alias(left, right) - keyword = consume_keyword(:alias) - - AliasNode.new( - left: left, - right: right, - location: keyword.location.to(right.location) - ) - end - - # :call-seq: - # on_var_field: ( - # (nil | Const | CVar | GVar | Ident | IVar) value - # ) -> VarField - def on_var_field(value) - location = - if value && value != :nil - 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, column: current_column) - end - - VarField.new(value: value, location: location) - 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 - - # :call-seq: - # on_vcall: (Ident ident) -> VCall - def on_vcall(ident) - VCall.new(value: ident, location: ident.location) - end - - # :call-seq: - # on_void_stmt: () -> VoidStmt - def on_void_stmt - VoidStmt.new( - location: - Location.fixed(line: lineno, char: char_pos, column: current_column) - ) - end - - # :call-seq: - # on_when: ( - # Args arguments, - # Statements statements, - # (nil | Else | When) consequent - # ) -> When - def on_when(arguments, statements, consequent) - beginning = consume_keyword(:when) - ending = consequent || consume_keyword(:end) - - statements_start = arguments - if (token = find_keyword(:then)) - tokens.delete(token) - statements_start = token - end - - start_char = - find_next_statement_start((token || statements_start).location.end_char) - - statements.bind( - self, - start_char, - start_char - - line_counts[statements_start.location.start_line - 1].start, - ending.location.start_char, - ending.location.start_column - ) - - When.new( - arguments: arguments, - statements: statements, - consequent: consequent, - location: beginning.location.to(ending.location) - ) - end - - # :call-seq: - # on_while: (untyped predicate, Statements statements) -> WhileNode - def on_while(predicate, statements) - beginning = consume_keyword(:while) - ending = consume_keyword(:end) - - delimiter = - find_keyword_between(:do, predicate, statements) || - find_token_between(Semicolon, predicate, statements) - - tokens.delete(delimiter) if delimiter - - # Update the Statements location information - start_char = - find_next_statement_start((delimiter || predicate).location.end_char) - - statements.bind( - self, - start_char, - start_char - line_counts[predicate.location.end_line - 1].start, - ending.location.start_char, - ending.location.start_column - ) - - WhileNode.new( - predicate: predicate, - statements: statements, - location: beginning.location.to(ending.location) - ) - end - - # :call-seq: - # on_while_mod: (untyped predicate, untyped statement) -> WhileNode - def on_while_mod(predicate, statement) - consume_keyword(:while) - - WhileNode.new( - predicate: predicate, - statements: - Statements.new(body: [statement], location: statement.location), - location: statement.location.to(predicate.location) - ) - 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, column: current_column) - ) - end - - # :call-seq: - # on_words_add: (Words words, Word word) -> Words - def on_words_add(words, word) - Words.new( - beginning: words.beginning, - elements: words.elements << word, - location: words.location.to(word.location) - ) - 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, - column: current_column, - size: value.size - ) - ) - - tokens << node - node - end - - # :call-seq: - # on_words_new: () -> Words - def on_words_new - beginning = consume_token(WordsBeg) - - Words.new( - beginning: beginning, - elements: [], - location: beginning.location - ) - end - - # def on_words_sep(value) - # value - # 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 - consume_token(Backtick).location - end - - XString.new(parts: [], location: location) - 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, - dedent: heredoc.dedent, - parts: xstring.parts, - location: heredoc.location - ) - else - ending = consume_tstring_end(xstring.location) - - XStringLiteral.new( - parts: xstring.parts, - location: xstring.location.to(ending.location) - ) - end - end - - # :call-seq: - # on_yield: ((Args | Paren) arguments) -> YieldNode - def on_yield(arguments) - keyword = consume_keyword(:yield) - - YieldNode.new( - arguments: arguments, - location: keyword.location.to(arguments.location) - ) - end - - # :call-seq: - # on_yield0: () -> YieldNode - def on_yield0 - keyword = consume_keyword(:yield) - - YieldNode.new(arguments: nil, location: keyword.location) - end - - # :call-seq: - # on_zsuper: () -> ZSuper - def on_zsuper - keyword = consume_keyword(:super) - - ZSuper.new(location: keyword.location) - end - end -end diff --git a/lib/syntax_tree/plugin/disable_auto_ternary.rb b/lib/syntax_tree/plugin/disable_auto_ternary.rb deleted file mode 100644 index dd38c783..00000000 --- a/lib/syntax_tree/plugin/disable_auto_ternary.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -module SyntaxTree - class Formatter - DISABLE_AUTO_TERNARY = true - end -end diff --git a/lib/syntax_tree/plugin/single_quotes.rb b/lib/syntax_tree/plugin/single_quotes.rb deleted file mode 100644 index c7405e2c..00000000 --- a/lib/syntax_tree/plugin/single_quotes.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -module SyntaxTree - class Formatter - SINGLE_QUOTES = true - end -end diff --git a/lib/syntax_tree/plugin/trailing_comma.rb b/lib/syntax_tree/plugin/trailing_comma.rb deleted file mode 100644 index 1ae2b96d..00000000 --- a/lib/syntax_tree/plugin/trailing_comma.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -module SyntaxTree - class Formatter - TRAILING_COMMA = true - end -end diff --git a/lib/syntax_tree/rake.rb b/lib/syntax_tree/rake.rb new file mode 100644 index 00000000..e7fd5243 --- /dev/null +++ b/lib/syntax_tree/rake.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "syntax_tree" +require "rake" +require "rake/tasklib" + +module SyntaxTree + module Rake + # A parent Rake task that runs a command on a set of source files. + class Task < ::Rake::TaskLib + # Name of the task. + attr_accessor :name + + # Glob pattern to match source files. Defaults to 'lib/**/*.rb'. + attr_accessor :source_files + + # Glob pattern to ignore source files. String. Optional. + attr_accessor :ignore_files + + # List of plugins to load. Array of strings. Optional. + attr_accessor :plugins + + # Print width for the formatter. Integer. Optional. + attr_accessor :print_width + + # Preferred quote style. " or '. Optional. + attr_accessor :preferred_quote + + # Trailing comma style. Boolean. Optional. + attr_accessor :trailing_comma + + def initialize( + name = :"stree:#{command}", + source_files = ::Rake::FileList["lib/**/*.rb"], + ignore_files = "", + plugins = [], + print_width = :default, + preferred_quote = :default, + trailing_comma = :default + ) + @name = name + + @source_files = source_files + @ignore_files = ignore_files + @plugins = plugins + + @print_width = print_width + @preferred_quote = preferred_quote + @trailing_comma = trailing_comma + + yield self if block_given? + define_task + end + + private + + def command + raise NotImplementedError + end + + def define_task + desc "Runs `stree #{command}` over source files" + task(name) do + arguments = [command] + + arguments << "--ignore-files=#{ignore_files}" if ignore_files != "" + arguments << "--plugins=#{plugins.join(",")}" if plugins.any? + + arguments << "--print-width=#{print_width}" if print_width != :default + arguments << "--preferred-quote=#{preferred_quote}" if preferred_quote != :default + + if trailing_comma != :default + arguments << "--#{"no-" unless trailing_comma}trailing-comma" + end + + arguments.concat(Array(source_files)) + abort if CLI.run(arguments) != 0 + end + end + end + + private_constant :Task + + # A Rake task that runs check on a set of source files. + # + # Example: + # + # require "syntax_tree/rake" + # + # SyntaxTree::Rake::CheckTask.new do |t| + # t.source_files = "{app,config,lib}/**/*.rb" + # end + # + # This will create a task that can be run with: + # + # rake stree:check + # + class CheckTask < Task + private + + def command + "check" + end + end + + # A Rake task that runs write on a set of source files. + # + # Example: + # + # require "syntax_tree/rake" + # + # SyntaxTree::Rake::WriteTask.new do |t| + # t.source_files = "{app,config,lib}/**/*.rb" + # end + # + # This will create a task that can be run with: + # + # rake stree:write + # + class WriteTask < Task + private + + def command + "write" + end + end + end +end diff --git a/lib/syntax_tree/rake/check_task.rb b/lib/syntax_tree/rake/check_task.rb deleted file mode 100644 index 5b441a5b..00000000 --- a/lib/syntax_tree/rake/check_task.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -require_relative "task" - -module SyntaxTree - module Rake - # A Rake task that runs check on a set of source files. - # - # Example: - # - # require "syntax_tree/rake/check_task" - # - # SyntaxTree::Rake::CheckTask.new do |t| - # t.source_files = "{app,config,lib}/**/*.rb" - # end - # - # This will create task that can be run with: - # - # rake stree:check - # - class CheckTask < Task - private - - def command - "check" - end - end - end -end diff --git a/lib/syntax_tree/rake/task.rb b/lib/syntax_tree/rake/task.rb deleted file mode 100644 index e9a20433..00000000 --- a/lib/syntax_tree/rake/task.rb +++ /dev/null @@ -1,85 +0,0 @@ -# frozen_string_literal: true - -require "rake" -require "rake/tasklib" - -require "syntax_tree" -require "syntax_tree/cli" - -module SyntaxTree - module Rake - # A parent Rake task that runs a command on a set of source files. - class Task < ::Rake::TaskLib - # Name of the task. - attr_accessor :name - - # Glob pattern to match source files. - # Defaults to 'lib/**/*.rb'. - attr_accessor :source_files - - # The set of plugins to require. - # Defaults to []. - attr_accessor :plugins - - # Max line length. - # Defaults to 80. - attr_accessor :print_width - - # The target Ruby version to use for formatting. - # Defaults to Gem::Version.new(RUBY_VERSION). - attr_accessor :target_ruby_version - - # Glob pattern to ignore source files. - # Defaults to ''. - attr_accessor :ignore_files - - def initialize( - name = :"stree:#{command}", - source_files = ::Rake::FileList["lib/**/*.rb"], - plugins = [], - print_width = DEFAULT_PRINT_WIDTH, - target_ruby_version = Gem::Version.new(RUBY_VERSION), - ignore_files = "" - ) - @name = name - @source_files = source_files - @plugins = plugins - @print_width = print_width - @target_ruby_version = target_ruby_version - @ignore_files = ignore_files - - yield self if block_given? - define_task - end - - private - - # This method needs to be overridden in the child tasks. - def command - raise NotImplementedError - end - - def define_task - desc "Runs `stree #{command}` over source files" - task(name) { run_task } - end - - def run_task - arguments = [command] - arguments << "--plugins=#{plugins.join(",")}" if plugins.any? - - if print_width != DEFAULT_PRINT_WIDTH - arguments << "--print-width=#{print_width}" - end - - if target_ruby_version != Gem::Version.new(RUBY_VERSION) - arguments << "--target-ruby-version=#{target_ruby_version}" - end - - arguments << "--ignore-files=#{ignore_files}" if ignore_files != "" - - abort if SyntaxTree::CLI.run(arguments + Array(source_files)) != 0 - end - end - end -end diff --git a/lib/syntax_tree/rake/write_task.rb b/lib/syntax_tree/rake/write_task.rb deleted file mode 100644 index 8037792e..00000000 --- a/lib/syntax_tree/rake/write_task.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -require_relative "task" - -module SyntaxTree - module Rake - # A Rake task that runs write on a set of source files. - # - # Example: - # - # require "syntax_tree/rake/write_task" - # - # SyntaxTree::Rake::WriteTask.new do |t| - # t.source_files = "{app,config,lib}/**/*.rb" - # end - # - # This will create task that can be run with: - # - # rake stree:write - # - class WriteTask < Task - private - - def command - "write" - end - end - end -end diff --git a/lib/syntax_tree/rake_tasks.rb b/lib/syntax_tree/rake_tasks.rb deleted file mode 100644 index b53743e5..00000000 --- a/lib/syntax_tree/rake_tasks.rb +++ /dev/null @@ -1,4 +0,0 @@ -# frozen_string_literal: true - -require_relative "rake/check_task" -require_relative "rake/write_task" diff --git a/syntax_tree.gemspec b/syntax_tree.gemspec index f6c4a734..58f6e292 100644 --- a/syntax_tree.gemspec +++ b/syntax_tree.gemspec @@ -8,28 +8,37 @@ Gem::Specification.new do |spec| spec.authors = ["Kevin Newton"] spec.email = ["kddnewton@gmail.com"] - spec.summary = "A parser based on ripper" - spec.homepage = "https://github.com/kddnewton/syntax_tree" + spec.summary = "A Ruby formatter" + spec.homepage = "https://github.com/ruby-syntax-tree/syntax_tree" spec.license = "MIT" - spec.metadata = { "rubygems_mfa_required" => "true" } - - spec.files = - Dir.chdir(__dir__) do - `git ls-files -z`.split("\x0") - .reject { |f| f.match(%r{^(test|spec|features)/}) } - end - - spec.required_ruby_version = ">= 2.7.0" + spec.metadata = { + "rubygems_mfa_required" => "true", + "allowed_push_host" => "https://rubygems.org", + "source_code_uri" => spec.homepage, + "changelog_uri" => "#{spec.homepage}/blob/main/CHANGELOG.md" + } + + spec.files = %w[ + CHANGELOG.md + CODE_OF_CONDUCT.md + LICENSE + README.md + config/rubocop.yml + doc/logo.svg + exe/stree + lib/prism/format.rb + lib/syntax_tree.rb + lib/syntax_tree/cli.rb + lib/syntax_tree/lsp.rb + lib/syntax_tree/rake.rb + lib/syntax_tree/version.rb + syntax_tree.gemspec + ] + + spec.required_ruby_version = ">= 3.2.0" spec.bindir = "exe" spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = %w[lib] - - spec.add_dependency "prettier_print", ">= 1.2.0" - - spec.add_development_dependency "bundler" - spec.add_development_dependency "minitest" - spec.add_development_dependency "rake" - spec.add_development_dependency "rubocop" - spec.add_development_dependency "simplecov" + spec.add_dependency "prism" end diff --git a/test/cli_test.rb b/test/cli_test.rb index a81aa8cb..d9921968 100644 --- a/test/cli_test.rb +++ b/test/cli_test.rb @@ -25,27 +25,10 @@ def test_handler file = Tempfile.new(%w[test- .test]) file.puts("test") - result = run_cli("ast", contents: file) - assert_equal("\"test\\n\" + \"test\\n\"\n", result.stdio) + result = run_cli("format", contents: file) + assert_equal("Formatted test\n", result.stdio) ensure - SyntaxTree::HANDLERS.delete(".test") - end - - def test_ast - result = run_cli("ast") - assert_includes(result.stdio, "\"test\"") - end - - def test_ast_ignore - result = run_cli("ast", "--ignore-files='*/test*'") - assert_equal(0, result.status) - assert_empty(result.stdio) - end - - def test_ast_syntax_error - result = run_cli("ast", contents: "foo\n<>\nbar\n") - assert_includes(result.stderr, "syntax error") - refute_equal(0, result.status) + SyntaxTree.unregister_handler(".test") end def test_check @@ -65,11 +48,6 @@ def test_check_print_width assert_includes(result.stdio, "match") end - def test_check_target_ruby_version - result = run_cli("check", "--target-ruby-version=2.6.0") - assert_includes(result.stdio, "match") - end - def test_debug result = run_cli("debug") assert_includes(result.stdio, "idempotently") @@ -86,11 +64,6 @@ def test_debug_non_idempotent_format end end - def test_doc - result = run_cli("doc") - assert_includes(result.stdio, "test") - end - def test_format result = run_cli("format") assert_equal("test\n", result.stdio) @@ -117,7 +90,7 @@ def test_write_syntax_tree def test_write_script args = ["write", "-e", "1 + 2"] - stdout, stderr = capture_io { SyntaxTree::CLI.run(args) } + stdout, stderr = capture_cli_io { SyntaxTree::CLI.run(args) } assert_includes stdout, "script" assert_empty stderr @@ -128,7 +101,7 @@ def test_write_stdin $stdin = StringIO.new("1 + 2") begin - stdout, stderr = capture_io { SyntaxTree::CLI.run(["write"]) } + stdout, stderr = capture_cli_io { SyntaxTree::CLI.run(["write"]) } assert_includes stdout, "stdin" assert_empty stderr @@ -138,13 +111,13 @@ def test_write_stdin end def test_help - stdio, = capture_io { SyntaxTree::CLI.run(["help"]) } + stdio, = capture_cli_io { SyntaxTree::CLI.run(["help"]) } assert_includes(stdio, "stree help") end def test_help_default status = 0 - *, stderr = capture_io { status = SyntaxTree::CLI.run(["foobar"]) } + *, stderr = capture_cli_io { status = SyntaxTree::CLI.run(["foobar"]) } assert_includes(stderr, "stree help") refute_equal(0, status) end @@ -153,42 +126,39 @@ def test_no_arguments stdin = $stdin $stdin = StringIO.new("1+1") - stdio, = capture_io { SyntaxTree::CLI.run(["format"]) } + stdio, = capture_cli_io { SyntaxTree::CLI.run(["format"]) } assert_equal("1 + 1\n", stdio) ensure $stdin = stdin end def test_inline_script - stdio, = capture_io { SyntaxTree::CLI.run(%w[format -e 1+1]) } + stdio, = capture_cli_io { SyntaxTree::CLI.run(%w[format -e 1+1]) } assert_equal("1 + 1\n", stdio) end def test_multiple_inline_scripts - stdio, = capture_io { SyntaxTree::CLI.run(%w[format -e 1+1 -e 2+2]) } + stdio, = capture_cli_io { SyntaxTree::CLI.run(%w[format -e 1+1 -e 2+2]) } assert_equal(["1 + 1", "2 + 2"], stdio.split("\n").sort) end def test_format_script_with_custom_handler SyntaxTree.register_handler(".test", TestHandler.new) - stdio, = - capture_io do - SyntaxTree::CLI.run(%w[format --extension=test -e ]) - end + stdio, = capture_cli_io { SyntaxTree::CLI.run(%w[format --extension=test -e ]) } assert_equal("Formatted \n", stdio) ensure - SyntaxTree::HANDLERS.delete(".test") + SyntaxTree.unregister_handler(".test") end def test_format_stdin_with_custom_handler SyntaxTree.register_handler(".test", TestHandler.new) stdin = $stdin $stdin = StringIO.new("") - stdio, = capture_io { SyntaxTree::CLI.run(%w[format --extension=test]) } + stdio, = capture_cli_io { SyntaxTree::CLI.run(%w[format --extension=test]) } assert_equal("Formatted \n", stdio) ensure $stdin = stdin - SyntaxTree::HANDLERS.delete(".test") + SyntaxTree.unregister_handler(".test") end def test_generic_error @@ -208,13 +178,12 @@ def test_plugins end end - def test_language_server + def test_lsp prev_stdin = $stdin prev_stdout = $stdout request = { method: "shutdown" }.merge(jsonrpc: "2.0").to_json - $stdin = - StringIO.new("Content-Length: #{request.bytesize}\r\n\r\n#{request}") + $stdin = StringIO.new("Content-Length: #{request.bytesize}\r\n\r\n#{request}") $stdout = StringIO.new assert_equal(0, SyntaxTree::CLI.run(["lsp"])) @@ -308,9 +277,7 @@ def test_config_file_custom_path_space_separated end def test_config_file_nonexistent_path - assert_raises(ArgumentError) do - run_cli("format", "--config=/nonexistent/path.streerc") - end + assert_raises(ArgumentError) { run_cli("format", "--config=/nonexistent/path.streerc") } end Result = Struct.new(:status, :stdio, :stderr, keyword_init: true) @@ -332,7 +299,7 @@ def run_cli(command, *args, contents: :default) status = nil stdio, stderr = - capture_io do + capture_cli_io do status = begin SyntaxTree::CLI.run([command, *args, tempfile.path]) @@ -347,6 +314,12 @@ def run_cli(command, *args, contents: :default) tempfile.unlink end + if Process.respond_to?(:fork) + alias capture_cli_io capture_subprocess_io + else + alias capture_cli_io capture_io + end + def with_config_file(contents, filepath = nil) filepath ||= File.join(Dir.pwd, SyntaxTree::CLI::ConfigFile::FILENAME) File.write(filepath, contents) diff --git a/test/encoded.rb b/test/encoded.rb deleted file mode 100644 index a67aebf3..00000000 --- a/test/encoded.rb +++ /dev/null @@ -1,2 +0,0 @@ -# encoding: Shift_JIS -# frozen_string_literal: true diff --git a/test/fixtures/alias.rb b/test/fixtures/alias.rb index 8962cc32..44dbfc15 100644 --- a/test/fixtures/alias.rb +++ b/test/fixtures/alias.rb @@ -13,11 +13,11 @@ % alias :"foo" :bar - -alias :"foo" bar +alias foo bar % alias :foo :"bar" - -alias foo :"bar" +alias foo bar % alias foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo bar - @@ -31,3 +31,5 @@ % alias foo # comment1 bar # comment2 +% +alias :"foo#{1}" :"bar#{1}" diff --git a/test/fixtures/altpattern.rb b/test/fixtures/altpattern.rb new file mode 100644 index 00000000..9c4f4c13 --- /dev/null +++ b/test/fixtures/altpattern.rb @@ -0,0 +1,4 @@ +% +case foo +in bar | baz +end diff --git a/test/fixtures/array_literal.rb b/test/fixtures/array_literal.rb index 391d2eae..92eddfb9 100644 --- a/test/fixtures/array_literal.rb +++ b/test/fixtures/array_literal.rb @@ -87,5 +87,7 @@ [:foo, "bar"] % [:foo, :"bar"] +- +%i[foo bar] % [foo, bar] # comment diff --git a/test/fixtures/assoc.rb b/test/fixtures/assoc.rb index 83a4887a..44643060 100644 --- a/test/fixtures/assoc.rb +++ b/test/fixtures/assoc.rb @@ -42,8 +42,6 @@ { foo: } % { "foo": "bar" } -- -{ foo: "bar" } % { "foo #{bar}": "baz" } % diff --git a/test/fixtures/bare_assoc_hash.rb b/test/fixtures/bare_assoc_hash.rb index d25d0bf4..f76a8863 100644 --- a/test/fixtures/bare_assoc_hash.rb +++ b/test/fixtures/bare_assoc_hash.rb @@ -15,7 +15,7 @@ % foo(bar => bar, "baz": baz) - -foo(bar => bar, :"baz" => baz) +foo(bar => bar, :baz => baz) % foo(bar: barrrrrrrrrrrrrrrrrrrrrrrrrrrrrr, baz: bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzz) - diff --git a/test/fixtures/def.rb b/test/fixtures/def.rb index 0cc49e0a..8852f33f 100644 --- a/test/fixtures/def.rb +++ b/test/fixtures/def.rb @@ -29,3 +29,9 @@ def foo( # comment =end a end +% +def foo +rescue StandardError +else +ensure +end diff --git a/test/fixtures/def_endless.rb b/test/fixtures/def_endless.rb index 8d1f9d33..d1b4a63a 100644 --- a/test/fixtures/def_endless.rb +++ b/test/fixtures/def_endless.rb @@ -28,7 +28,7 @@ def a() =end =1 - -def a() = +def a() =begin =end - 1 += 1 diff --git a/test/fixtures/dyna_symbol.rb b/test/fixtures/dyna_symbol.rb index 7ac74a31..103e444f 100644 --- a/test/fixtures/dyna_symbol.rb +++ b/test/fixtures/dyna_symbol.rb @@ -1,7 +1,5 @@ % :'foo' -- -:"foo" % :"foo" % @@ -10,8 +8,6 @@ :"foo #{bar}" % %s[foo #{bar}] -- -:'foo #{bar}' % { %s[foo] => bar } - diff --git a/test/fixtures/hash.rb b/test/fixtures/hash.rb index 70e89f69..e3e046a6 100644 --- a/test/fixtures/hash.rb +++ b/test/fixtures/hash.rb @@ -17,7 +17,7 @@ % { bar => bar, "baz": baz } - -{ bar => bar, :"baz" => baz } +{ bar => bar, :baz => baz } % { bar: barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr, baz: bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz } - diff --git a/test/fixtures/hshptn.rb b/test/fixtures/hshptn.rb index 02d1cf75..455c48a5 100644 --- a/test/fixtures/hshptn.rb +++ b/test/fixtures/hshptn.rb @@ -2,14 +2,26 @@ case foo in ** then end +- +case foo +in { ** } +end % case foo in bar: end +- +case foo +in { bar: } +end % case foo in bar: bar end +- +case foo +in { bar: bar } +end % case foo in bar:, baz: @@ -30,6 +42,10 @@ case foo in **bar end +- +case foo +in { **bar } +end % # >= 2.7.3 case foo in { @@ -74,6 +90,10 @@ case foo in **nil end +- +case foo +in { **nil } +end % case foo in bar, { baz:, **nil } @@ -82,5 +102,5 @@ - case foo in [bar, { baz:, **nil }] -in qux: +in { qux: } end diff --git a/test/fixtures/if.rb b/test/fixtures/if.rb index b25386b9..0fd39bc0 100644 --- a/test/fixtures/if.rb +++ b/test/fixtures/if.rb @@ -47,8 +47,6 @@ else c end -- -not(a) ? b : c % (if foo then bar else baz end) - diff --git a/test/fixtures/label.rb b/test/fixtures/label.rb index 14de6874..8921e437 100644 --- a/test/fixtures/label.rb +++ b/test/fixtures/label.rb @@ -4,3 +4,7 @@ case foo in bar: end +- +case foo +in { bar: } +end diff --git a/test/fixtures/lambda.rb b/test/fixtures/lambda.rb index 8b922ef0..d8ea860d 100644 --- a/test/fixtures/lambda.rb +++ b/test/fixtures/lambda.rb @@ -68,14 +68,6 @@ ->(; a, b) {} % ->(a = (b; c)) {} -- -->( - a = ( - b - c - ) -) do -end % -> do # comment1 # comment2 diff --git a/test/fixtures/mlhs_paren.rb b/test/fixtures/mlhs_paren.rb index 3a3b777b..25871c39 100644 --- a/test/fixtures/mlhs_paren.rb +++ b/test/fixtures/mlhs_paren.rb @@ -1,7 +1,5 @@ % (foo, bar) = baz -- -foo, bar = baz % foo, (bar, baz) = baz % @@ -10,5 +8,3 @@ foo, (bar, baz,) = baz % ((foo,)) = bar -- -foo, = bar diff --git a/test/fixtures/not.rb b/test/fixtures/not.rb index 6c0451ae..1f3615d6 100644 --- a/test/fixtures/not.rb +++ b/test/fixtures/not.rb @@ -1,5 +1,7 @@ % not() +- +not () % not () % @@ -14,8 +16,6 @@ else baz end -- -foo ? not(bar) : baz % if foooooooooooooooooooooooooooooooooooooooooo not bar diff --git a/test/fixtures/string_concat.rb b/test/fixtures/string_concat.rb index 7bb55baf..ea725aa8 100644 --- a/test/fixtures/string_concat.rb +++ b/test/fixtures/string_concat.rb @@ -2,3 +2,5 @@ "foo" \ "bar" \ "baz" +- +"foo" "bar" "baz" diff --git a/test/fixtures/string_embexpr.rb b/test/fixtures/string_embexpr.rb index fd5e8cfc..3ec3421b 100644 --- a/test/fixtures/string_embexpr.rb +++ b/test/fixtures/string_embexpr.rb @@ -10,3 +10,11 @@ "#{foo; bar}" % "#{if foo; foooooooooooooooooooooooooooooooooooooo; else; barrrrrrrrrrrrrrrr; end}" +- +"#{ + if foo + foooooooooooooooooooooooooooooooooooooo + else + barrrrrrrrrrrrrrrr + end +}" diff --git a/test/fixtures/undef.rb b/test/fixtures/undef.rb index 73986b97..446a7c9c 100644 --- a/test/fixtures/undef.rb +++ b/test/fixtures/undef.rb @@ -23,3 +23,5 @@ undef foo, bar # comment % undef :"foo", :"bar" +- +undef foo, bar diff --git a/test/fixtures_test.rb b/test/fixtures_test.rb new file mode 100644 index 00000000..bb23a9a0 --- /dev/null +++ b/test/fixtures_test.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +# There are a bunch of fixtures defined in test/fixtures. They exercise every +# possible combination of syntax that leads to variations in the types of nodes. +# They are used for testing various parts of Syntax Tree, including formatting, +# serialization, and parsing. This module provides a single each_fixture method +# that can be used to drive tests on each fixture. +module Fixtures + FIXTURES_3_0_0 = %w[command_def_endless def_endless fndptn rassign rassign_rocket].freeze + + FIXTURES_3_1_0 = %w[pinned_begin var_field_rassign].freeze + + Fixture = Struct.new(:name, :source, :formatted, keyword_init: true) + + def self.each_fixture + ruby_version = Gem::Version.new(RUBY_VERSION) + + # First, get a list of the basenames of all of the fixture files. + fixtures = + Dir[File.expand_path("fixtures/*.rb", __dir__)].map do |filepath| + File.basename(filepath, ".rb") + end + + # Next, subtract out any fixtures that aren't supported by the current Ruby + # version. + fixtures -= FIXTURES_3_1_0 if ruby_version < Gem::Version.new("3.1.0") + fixtures -= FIXTURES_3_0_0 if ruby_version < Gem::Version.new("3.0.0") + + delimiter = /%(?: # (.+?))?\n/ + fixtures.each do |fixture| + filepath = File.expand_path("fixtures/#{fixture}.rb", __dir__) + + # For each fixture in the fixture file yield a Fixture object. + File + .readlines(filepath) + .slice_before(delimiter) + .each_with_index do |source, index| + comment = source.shift.match(delimiter)[1] + source, formatted = source.join.split("-\n") + + # If there's a comment starting with >= that starts after the % that + # delineates the test, then we're going to check if the version + # satisfies that constraint. + next if comment&.start_with?(">=") && ruby_version < Gem::Version.new(comment.split[1]) + + name = :"#{fixture}_#{index}" + yield(Fixture.new(name: name, source: source, formatted: formatted || source)) + end + end + end +end + +module SyntaxTree + class FixturesTest < Minitest::Test + Fixtures.each_fixture do |fixture| + define_method(:"test_formatted_#{fixture.name}") do + options = SyntaxTree.options(print_width: 80) + assert_equal(fixture.formatted, SyntaxTree.format(fixture.source, options)) + end + end + end +end diff --git a/test/formatting_test.rb b/test/formatting_test.rb deleted file mode 100644 index 5e5f9e9f..00000000 --- a/test/formatting_test.rb +++ /dev/null @@ -1,64 +0,0 @@ -# frozen_string_literal: true - -require_relative "test_helper" - -module SyntaxTree - class FormattingTest < Minitest::Test - Fixtures.each_fixture do |fixture| - define_method(:"test_formatted_#{fixture.name}") do - assert_equal(fixture.formatted, SyntaxTree.format(fixture.source)) - assert_syntax_tree(SyntaxTree.parse(fixture.source)) - end - end - - def test_format_class_level - source = "1+1" - - assert_equal( - "1 + 1\n", - Formatter.format(source, SyntaxTree.parse(source)) - ) - end - - def test_stree_ignore - source = <<~SOURCE - # stree-ignore - 1+1 - SOURCE - - assert_equal(source, SyntaxTree.format(source)) - end - - def test_formatting_with_different_indentation_level - source = <<~SOURCE - def foo - puts "a" - end - SOURCE - - # Default indentation - assert_equal(source, SyntaxTree.format(source)) - - # Level 2 - assert_equal(<<-EXPECTED.chomp, SyntaxTree.format(source, 80, 2).rstrip) - def foo - puts "a" - end - EXPECTED - - # Level 4 - assert_equal(<<-EXPECTED.chomp, SyntaxTree.format(source, 80, 4).rstrip) - def foo - puts "a" - end - EXPECTED - - # Level 6 - assert_equal(<<-EXPECTED.chomp, SyntaxTree.format(source, 80, 6).rstrip) - def foo - puts "a" - end - EXPECTED - end - end -end diff --git a/test/idempotency_test.rb b/test/idempotency_test.rb index 32d9d196..c2c7ba6f 100644 --- a/test/idempotency_test.rb +++ b/test/idempotency_test.rb @@ -1,14 +1,14 @@ # frozen_string_literal: true return if !ENV["CI"] || RUBY_ENGINE == "truffleruby" + require_relative "test_helper" module SyntaxTree class IdempotencyTest < Minitest::Test Dir[File.join(RbConfig::CONFIG["libdir"], "**/*.rb")].each do |filepath| define_method(:"test_#{filepath}") do - source = SyntaxTree.read(filepath) - formatted = SyntaxTree.format(source) + formatted = SyntaxTree.format_file(filepath) assert_equal( formatted, diff --git a/test/language_server_test.rb b/test/language_server_test.rb deleted file mode 100644 index 58f8bc6a..00000000 --- a/test/language_server_test.rb +++ /dev/null @@ -1,261 +0,0 @@ -# frozen_string_literal: true - -require_relative "test_helper" -require "syntax_tree/language_server" - -module SyntaxTree - # stree-ignore - class LanguageServerTest < Minitest::Test - class Initialize - attr_reader :id - - def initialize(id) - @id = id - end - - def to_hash - { method: "initialize", id: id } - end - end - - class Shutdown - attr_reader :id - - def initialize(id) - @id = id - end - - def to_hash - { method: "shutdown", id: id } - end - end - - class TextDocumentDidOpen - attr_reader :uri, :text - - def initialize(uri, text) - @uri = uri - @text = text - end - - def to_hash - { - method: "textDocument/didOpen", - params: { textDocument: { uri: uri, text: text } } - } - end - end - - class TextDocumentDidChange - attr_reader :uri, :text - - def initialize(uri, text) - @uri = uri - @text = text - end - - def to_hash - { - method: "textDocument/didChange", - params: { - textDocument: { uri: uri }, - contentChanges: [{ text: text }] - } - } - end - end - - class TextDocumentDidClose - attr_reader :uri - - def initialize(uri) - @uri = uri - end - - def to_hash - { - method: "textDocument/didClose", - params: { textDocument: { uri: uri } } - } - end - end - - class TextDocumentFormatting - attr_reader :id, :uri - - def initialize(id, uri) - @id = id - @uri = uri - end - - def to_hash - { - method: "textDocument/formatting", - id: id, - params: { textDocument: { uri: uri } } - } - end - end - - def test_formatting - responses = run_server([ - Initialize.new(1), - TextDocumentDidOpen.new("file:///path/to/file.rb", "class Foo; end"), - TextDocumentDidChange.new("file:///path/to/file.rb", "class Bar; end"), - TextDocumentFormatting.new(2, "file:///path/to/file.rb"), - TextDocumentDidClose.new("file:///path/to/file.rb"), - Shutdown.new(3) - ]) - - shape = LanguageServer::Request[[ - { id: 1, result: { capabilities: Hash } }, - { id: 2, result: [{ newText: :any }] }, - { id: 3, result: {} } - ]] - - assert_operator(shape, :===, responses) - assert_equal("class Bar\nend\n", responses.dig(1, :result, 0, :newText)) - end - - def test_formatting_ignore - responses = run_server([ - Initialize.new(1), - TextDocumentDidOpen.new("file:///path/to/file.rb", "class Foo; end"), - TextDocumentFormatting.new(2, "file:///path/to/file.rb"), - Shutdown.new(3) - ], ignore_files: ["path/**/*.rb"]) - - shape = LanguageServer::Request[[ - { id: 1, result: { capabilities: Hash } }, - { id: 2, result: :any }, - { id: 3, result: {} } - ]] - - assert_operator(shape, :===, responses) - assert_nil(responses.dig(1, :result)) - end - - def test_formatting_failure - responses = run_server([ - Initialize.new(1), - TextDocumentDidOpen.new("file:///path/to/file.rb", "<>"), - TextDocumentFormatting.new(2, "file:///path/to/file.rb"), - Shutdown.new(3) - ]) - - shape = LanguageServer::Request[[ - { id: 1, result: { capabilities: Hash } }, - { id: 2, result: :any }, - { id: 3, result: {} } - ]] - - assert_operator(shape, :===, responses) - assert_nil(responses.dig(1, :result)) - end - - def test_formatting_print_width - contents = "#{"a" * 40} + #{"b" * 40}\n" - responses = run_server([ - Initialize.new(1), - TextDocumentDidOpen.new("file:///path/to/file.rb", contents), - TextDocumentFormatting.new(2, "file:///path/to/file.rb"), - TextDocumentDidClose.new("file:///path/to/file.rb"), - Shutdown.new(3) - ], print_width: 100) - - shape = LanguageServer::Request[[ - { id: 1, result: { capabilities: Hash } }, - { id: 2, result: [{ newText: :any }] }, - { id: 3, result: {} } - ]] - - assert_operator(shape, :===, responses) - assert_equal(contents, responses.dig(1, :result, 0, :newText)) - end - - def test_reading_file - Tempfile.open(%w[test- .rb]) do |file| - file.write("class Foo; end") - file.rewind - - responses = run_server([ - Initialize.new(1), - TextDocumentFormatting.new(2, "file://#{file.path}"), - Shutdown.new(3) - ]) - - shape = LanguageServer::Request[[ - { id: 1, result: { capabilities: Hash } }, - { id: 2, result: [{ newText: :any }] }, - { id: 3, result: {} } - ]] - - assert_operator(shape, :===, responses) - assert_equal("class Foo\nend\n", responses.dig(1, :result, 0, :newText)) - end - end - - def test_bogus_request - assert_raises(ArgumentError) do - run_server([{ method: "textDocument/bogus" }]) - end - end - - def test_clean_shutdown - responses = run_server([Initialize.new(1), Shutdown.new(2)]) - - shape = LanguageServer::Request[[ - { id: 1, result: { capabilities: Hash } }, - { id: 2, result: {} } - ]] - - assert_operator(shape, :===, responses) - end - - def test_file_that_does_not_exist - responses = run_server([ - Initialize.new(1), - TextDocumentFormatting.new(2, "file:///path/to/file.rb"), - Shutdown.new(3) - ]) - - shape = LanguageServer::Request[[ - { id: 1, result: { capabilities: Hash } }, - { id: 2, result: :any }, - { id: 3, result: {} } - ]] - - assert_operator(shape, :===, responses) - end - - private - - def write(content) - request = content.to_hash.merge(jsonrpc: "2.0").to_json - "Content-Length: #{request.bytesize}\r\n\r\n#{request}" - end - - def read(content) - [].tap do |messages| - while (headers = content.gets("\r\n\r\n")) - source = content.read(headers[/Content-Length: (\d+)/i, 1].to_i) - messages << JSON.parse(source, symbolize_names: true) - end - end - end - - def run_server(messages, print_width: DEFAULT_PRINT_WIDTH, ignore_files: []) - input = StringIO.new(messages.map { |message| write(message) }.join) - output = StringIO.new - - LanguageServer.new( - input: input, - output: output, - print_width: print_width, - ignore_files: ignore_files - ).run - - read(output.tap(&:rewind)) - end - end -end diff --git a/test/location_test.rb b/test/location_test.rb deleted file mode 100644 index 26831fb1..00000000 --- a/test/location_test.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -require_relative "test_helper" - -module SyntaxTree - class LocationTest < Minitest::Test - def test_lines - location = Location.fixed(line: 1, char: 0, column: 0) - location = location.to(Location.fixed(line: 3, char: 3, column: 3)) - - assert_equal(1..3, location.lines) - end - - def test_deconstruct - location = Location.fixed(line: 1, char: 0, column: 0) - - assert_equal(1, location.start_line) - assert_equal(0, location.start_char) - assert_equal(0, location.start_column) - end - - def test_deconstruct_keys - location = Location.fixed(line: 1, char: 0, column: 0) - - assert_equal(1, location.start_line) - end - end -end diff --git a/test/lsp_test.rb b/test/lsp_test.rb new file mode 100644 index 00000000..29d09de1 --- /dev/null +++ b/test/lsp_test.rb @@ -0,0 +1,214 @@ +# frozen_string_literal: true + +require_relative "test_helper" +require "json" + +module SyntaxTree + # stree-ignore + class LSPTest < Minitest::Test + def test_formatting + responses = run_server([ + request_initialize(1), + request_open("file:///path/to/file.rb", "class Foo; end"), + request_change("file:///path/to/file.rb", "class Bar; end"), + request_formatting(2, "file:///path/to/file.rb"), + request_close("file:///path/to/file.rb"), + request_shutdown(3) + ]) + + new_text = nil + assert_pattern do + responses => [ + { id: 1, result: { capabilities: Hash } }, + { id: 2, result: [{ newText: new_text }] }, + { id: 3, result: {} } + ] + end + + assert_equal("class Bar\nend\n", new_text) + end + + def test_formatting_ignore + responses = run_server([ + request_initialize(1), + request_open("file:///path/to/file.rb", "class Foo; end"), + request_formatting(2, "file:///path/to/file.rb"), + request_shutdown(3) + ], ignore_files: ["path/**/*.rb"]) + + result = nil + assert_pattern do + responses => [ + { id: 1, result: { capabilities: Hash } }, + { id: 2, result: }, + { id: 3, result: {} } + ] + end + + assert_nil(result) + end + + def test_formatting_failure + responses = run_server([ + request_initialize(1), + request_open("file:///path/to/file.rb", "<>"), + request_formatting(2, "file:///path/to/file.rb"), + request_shutdown(3) + ]) + + result = nil + assert_pattern do + responses => [ + { id: 1, result: { capabilities: Hash } }, + { id: 2, result: }, + { id: 3, result: {} } + ] + end + + assert_nil(result) + end + + def test_formatting_print_width + contents = "#{"a" * 40} + #{"b" * 40}\n" + responses = run_server([ + request_initialize(1), + request_open("file:///path/to/file.rb", contents), + request_formatting(2, "file:///path/to/file.rb"), + request_close("file:///path/to/file.rb"), + request_shutdown(3) + ], print_width: 100) + + new_text = nil + assert_pattern do + responses => [ + { id: 1, result: { capabilities: Hash } }, + { id: 2, result: [{ newText: new_text }] }, + { id: 3, result: {} } + ] + end + + assert_equal(contents, new_text) + end + + def test_reading_file + Tempfile.open(%w[test- .rb]) do |file| + file.write("class Foo; end") + file.rewind + + responses = run_server([ + request_initialize(1), + request_formatting(2, "file://#{file.path}"), + request_shutdown(3) + ]) + + new_text = nil + assert_pattern do + responses => [ + { id: 1, result: { capabilities: Hash } }, + { id: 2, result: [{ newText: new_text }] }, + { id: 3, result: {} } + ] + end + + assert_equal("class Foo\nend\n", new_text) + end + end + + def test_bogus_request + assert_raises(ArgumentError) do + run_server([{ method: "textDocument/bogus" }]) + end + end + + def test_clean_shutdown + responses = run_server([request_initialize(1), request_shutdown(2)]) + + assert_pattern do + responses => [ + { id: 1, result: { capabilities: Hash } }, + { id: 2, result: {} } + ] + end + end + + def test_file_that_does_not_exist + responses = run_server([ + request_initialize(1), + request_formatting(2, "file:///path/to/file.rb"), + request_shutdown(3) + ]) + + assert_pattern do + responses => [ + { id: 1, result: { capabilities: Hash } }, + { id: 2, result: _ }, + { id: 3, result: {} } + ] + end + end + + private + + def request_initialize(id) + { method: "initialize", id: id } + end + + def request_shutdown(id) + { method: "shutdown", id: id } + end + + def request_open(uri, text) + { + method: "textDocument/didOpen", + params: { textDocument: { uri: uri, text: text } } + } + end + + def request_change(uri, text) + { + method: "textDocument/didChange", + params: { + textDocument: { uri: uri }, + contentChanges: [{ text: text }] + } + } + end + + def request_close(uri) + { + method: "textDocument/didClose", + params: { textDocument: { uri: uri } } + } + end + + def request_formatting(id, uri) + { + method: "textDocument/formatting", + id: id, + params: { textDocument: { uri: uri } } + } + end + + def run_server(messages, print_width: :default, ignore_files: []) + input = StringIO.new + output = StringIO.new + + messages.each do |message| + request = JSON.generate(message.merge(jsonrpc: "2.0")) + input.write("Content-Length: #{request.bytesize}\r\n\r\n#{request}") + end + + input.rewind + options = SyntaxTree.options(print_width: print_width) + LSP.new(input, output, options: options, ignore_files: ignore_files).run + output.rewind + + results = [] + while (headers = output.gets("\r\n\r\n")) + body = output.read(Integer(headers[/Content-Length: (\d+)/i, 1])) + results << JSON.parse(body, symbolize_names: true) + end + results + end + end +end diff --git a/test/node_test.rb b/test/node_test.rb deleted file mode 100644 index 41f6271d..00000000 --- a/test/node_test.rb +++ /dev/null @@ -1,1450 +0,0 @@ -# frozen_string_literal: true - -require_relative "test_helper" - -module SyntaxTree - class NodeTest < Minitest::Test - def self.guard_version(version) - yield if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new(version) - end - - def test_BEGIN - assert_node(BEGINBlock, "BEGIN {}") - end - - def test_CHAR - assert_node(CHAR, "?a") - end - - def test_END - assert_node(ENDBlock, "END {}") - end - - def test___end__ - source = <<~SOURCE - a + 1 - __END__ - content - SOURCE - - at = location(lines: 2..2, chars: 6..14) - assert_node(EndContent, source, at: at) - end - - def test_alias - assert_node(AliasNode, "alias left right") - end - - def test_aref - assert_node(ARef, "collection[index]") - end - - def test_aref_field - source = "collection[index] = value" - - at = location(chars: 0..17) - assert_node(ARefField, source, at: at, &:target) - end - - def test_arg_paren - source = "method(argument)" - - at = location(chars: 6..16) - assert_node(ArgParen, source, at: at, &:arguments) - end - - def test_arg_paren_heredoc - source = <<~SOURCE - method(<<~ARGUMENT) - value - ARGUMENT - SOURCE - - at = location(lines: 1..3, chars: 6..37) - assert_node(ArgParen, source, at: at, &:arguments) - end - - def test_args - source = "method(first, second, third)" - - at = location(chars: 7..27) - assert_node(Args, source, at: at) { |node| node.arguments.arguments } - end - - def test_arg_block - source = "method(argument, &block)" - - at = location(chars: 17..23) - assert_node(ArgBlock, source, at: at) do |node| - node.arguments.arguments.parts[1] - end - end - - guard_version("3.1.0") do - def test_arg_block_anonymous - source = <<~SOURCE - def method(&) - child_method(&) - end - SOURCE - - at = location(lines: 2..2, chars: 29..30) - assert_node(ArgBlock, source, at: at) do |node| - node.bodystmt.statements.body.first.arguments.arguments.parts[0] - end - end - end - - def test_arg_star - source = "method(prefix, *arguments, suffix)" - - at = location(chars: 15..25) - assert_node(ArgStar, source, at: at) do |node| - node.arguments.arguments.parts[1] - end - end - - guard_version("2.7.3") do - def test_args_forward - source = <<~SOURCE - def get(...) - request(:GET, ...) - end - SOURCE - - at = location(lines: 2..2, chars: 29..32) - assert_node(ArgsForward, source, at: at) do |node| - node.bodystmt.statements.body.first.arguments.arguments.parts.last - end - end - end - - def test_array - assert_node(ArrayLiteral, "[1]") - end - - def test_aryptn - source = <<~SOURCE - case [1, 2, 3] - in Container[Integer, *, Integer] - 'matched' - end - SOURCE - - at = location(lines: 2..2, chars: 18..48) - assert_node(AryPtn, source, at: at) { |node| node.consequent.pattern } - end - - def test_assign - assert_node(Assign, "variable = value") - end - - def test_assoc - source = "{ key1: value1, key2: value2 }" - - at = location(chars: 2..14) - assert_node(Assoc, source, at: at) { |node| node.assocs.first } - end - - guard_version("3.1.0") do - def test_assoc_no_value - source = "{ key1:, key2: }" - - at = location(chars: 2..7) - assert_node(Assoc, source, at: at) { |node| node.assocs.first } - end - end - - def test_assoc_splat - source = "{ **pairs }" - - at = location(chars: 2..9) - assert_node(AssocSplat, source, at: at) { |node| node.assocs.first } - end - - def test_backref - assert_node(Backref, "$1") - end - - def test_backtick - at = location(chars: 4..5) - assert_node(Backtick, "def `() end", at: at, &:name) - end - - def test_bare_assoc_hash - source = "method(key1: value1, key2: value2)" - - at = location(chars: 7..33) - assert_node(BareAssocHash, source, at: at) do |node| - node.arguments.arguments.parts.first - end - end - - guard_version("3.1.0") do - def test_pinned_begin - source = <<~SOURCE - case value - in ^(expression) - end - SOURCE - - at = location(lines: 2..2, chars: 14..27, columns: 3..16) - assert_node(PinnedBegin, source, at: at) do |node| - node.consequent.pattern - end - end - end - - def test_begin - source = <<~SOURCE - begin - value - end - SOURCE - - assert_node(Begin, source) - end - - def test_begin_clauses - source = <<~SOURCE - begin - begun - rescue - rescued - else - elsed - ensure - ensured - end - SOURCE - - assert_node(Begin, source) - end - - def test_binary - assert_node(Binary, "collection << value") - end - - def test_block_var - source = <<~SOURCE - method do |positional, optional = value, keyword:, █ local| - end - SOURCE - - at = location(chars: 10..65) - assert_node(BlockVar, source, at: at) { |node| node.block.block_var } - end - - def test_blockarg - source = "def method(&block); end" - - at = location(chars: 11..17) - assert_node(BlockArg, source, at: at) do |node| - node.params.contents.block - end - end - - guard_version("3.1.0") do - def test_blockarg_anonymous - source = "def method(&); end" - - at = location(chars: 11..12) - assert_node(BlockArg, source, at: at) do |node| - node.params.contents.block - end - end - end - - def test_bodystmt - source = <<~SOURCE - begin - begun - rescue - rescued - else - elsed - ensure - ensured - end - SOURCE - - at = location(lines: 2..9, chars: 5..64) - assert_node(BodyStmt, source, at: at, &:bodystmt) - end - - def test_brace_block - source = "method { |variable| variable + 1 }" - - at = location(chars: 7..34) - assert_node(BlockNode, source, at: at, &:block) - end - - def test_break - at = location(chars: 6..17) - assert_node(Break, "tap { break value }", at: at) do |node| - node.block.bodystmt.body.first - end - end - - def test_call - assert_node(CallNode, "receiver.message") - end - - def test_case - source = <<~SOURCE - case value - when 1 - "one" - end - SOURCE - - assert_node(Case, source) - end - - guard_version("3.0.0") do - def test_rassign_in - assert_node(RAssign, "value in pattern") - end - - def test_rassign_rocket - assert_node(RAssign, "value => pattern") - end - end - - def test_class - assert_node(ClassDeclaration, "class Child < Parent; end") - end - - def test_command - assert_node(Command, "method argument") - end - - def test_command_call - assert_node(CommandCall, "object.method argument") - end - - def test_comment - assert_node(Comment, "# comment", at: location(chars: 0..8)) - end - - # This test is to ensure that comments get parsed and printed properly in - # all of the visitors. We do this by checking against a node that we're sure - # will have comments attached to it in order to exercise all of the various - # comments methods on the visitors. - def test_comment_attached - source = <<~SOURCE - def method # comment - end - SOURCE - - at = location(chars: 10..10) - assert_node(Params, source, at: at, &:params) - end - - def test_const - assert_node(Const, "Constant", &:value) - end - - def test_const_path_field - source = "object::Const = value" - - at = location(chars: 0..13) - assert_node(ConstPathField, source, at: at, &:target) - end - - def test_const_path_ref - assert_node(ConstPathRef, "object::Const") - end - - def test_const_ref - source = "class Container; end" - - at = location(chars: 6..15) - assert_node(ConstRef, source, at: at, &:constant) - end - - def test_cvar - assert_node(CVar, "@@variable", &:value) - end - - def test_def - assert_node(DefNode, "def method(param) result end") - end - - def test_def_paramless - source = <<~SOURCE - def method - end - SOURCE - - assert_node(DefNode, source) - end - - guard_version("3.0.0") do - def test_def_endless - assert_node(DefNode, "def method = result") - end - end - - guard_version("3.1.0") do - def test_def_endless_command - assert_node(DefNode, "def method = result argument") - end - end - - def test_defined - assert_node(Defined, "defined?(variable)") - end - - def test_defs - assert_node(DefNode, "def object.method(param) result end") - end - - def test_defs_paramless - source = <<~SOURCE - def object.method - end - SOURCE - - assert_node(DefNode, source) - end - - def test_do_block - source = "method do |variable| variable + 1 end" - - at = location(chars: 7..37) - assert_node(BlockNode, source, at: at, &:block) - end - - def test_dot2 - assert_node(RangeNode, "1..3") - end - - def test_dot3 - assert_node(RangeNode, "1...3") - end - - def test_dyna_symbol - assert_node(DynaSymbol, ':"#{variable}"') - end - - def test_dyna_symbol_hash_key - source = '{ "#{key}": value }' - - at = location(chars: 2..11) - assert_node(DynaSymbol, source, at: at) { |node| node.assocs.first.key } - end - - def test_else - source = <<~SOURCE - if value - else - end - SOURCE - - at = location(lines: 2..3, chars: 9..17) - assert_node(Else, source, at: at, &:consequent) - end - - def test_elsif - source = <<~SOURCE - if first - elsif second - else - end - SOURCE - - at = location(lines: 2..4, chars: 9..30) - assert_node(Elsif, source, at: at, &:consequent) - end - - def test_embdoc - source = <<~SOURCE - =begin - first line - second line - =end - SOURCE - - assert_node(EmbDoc, source) - end - - def test_ensure - source = <<~SOURCE - begin - ensure - end - SOURCE - - at = location(lines: 2..3, chars: 6..16) - assert_node(Ensure, source, at: at) { |node| node.bodystmt.ensure_clause } - end - - def test_excessed_comma - source = "proc { |x,| }" - - at = location(chars: 9..10) - assert_node(ExcessedComma, source, at: at) do |node| - node.block.block_var.params.rest - end - end - - def test_fcall - assert_node(CallNode, "method(argument)") - end - - def test_field - source = "object.variable = value" - - at = location(chars: 0..15) - assert_node(Field, source, at: at, &:target) - end - - def test_float_literal - assert_node(FloatLiteral, "1.0") - end - - guard_version("3.0.0") do - def test_fndptn - source = <<~SOURCE - case value - in Container[*, 7, *] - end - SOURCE - - at = location(lines: 2..2, chars: 14..32) - assert_node(FndPtn, source, at: at) { |node| node.consequent.pattern } - end - end - - def test_for - assert_node(For, "for value in list do end") - end - - def test_gvar - assert_node(GVar, "$variable", &:value) - end - - def test_hash - assert_node(HashLiteral, "{ key => value }") - end - - def test_heredoc - source = <<~SOURCE - <<~HEREDOC - contents - HEREDOC - SOURCE - - at = location(lines: 1..3, chars: 0..30) - assert_node(Heredoc, source, at: at) - end - - def test_heredoc_beg - source = <<~SOURCE - <<~HEREDOC - contents - HEREDOC - SOURCE - - at = location(chars: 0..10) - assert_node(HeredocBeg, source, at: at, &:beginning) - end - - def test_heredoc_end - source = <<~SOURCE - <<~HEREDOC - contents - HEREDOC - SOURCE - - at = location(lines: 3..3, chars: 22..30, columns: 0..8) - assert_node(HeredocEnd, source, at: at, &:ending) - end - - def test_hshptn - source = <<~SOURCE - case value - in Container[key:, **keys] - end - SOURCE - - at = location(lines: 2..2, chars: 14..36) - assert_node(HshPtn, source, at: at) { |node| node.consequent.pattern } - end - - def test_ident - assert_node(Ident, "value", &:value) - end - - def test_if - assert_node(IfNode, "if value then else end") - end - - def test_if_op - assert_node(IfOp, "value ? true : false") - end - - def test_if_mod - assert_node(IfNode, "expression if predicate") - end - - def test_imaginary - assert_node(Imaginary, "1i") - end - - def test_in - source = <<~SOURCE - case value - in first - in second - end - SOURCE - - at = location(lines: 2..4, chars: 11..33) - assert_node(In, source, at: at, &:consequent) - end - - def test_int - assert_node(Int, "1") - end - - def test_ivar - assert_node(IVar, "@variable", &:value) - end - - def test_kw - at = location(chars: 1..3) - assert_node(Kw, ":if", at: at, &:value) - end - - def test_kwrest_param - source = "def method(**kwargs) end" - - at = location(chars: 11..19) - assert_node(KwRestParam, source, at: at) do |node| - node.params.contents.keyword_rest - end - end - - def test_label - source = "{ key: value }" - - at = location(chars: 2..6) - assert_node(Label, source, at: at) { |node| node.assocs.first.key } - end - - def test_lambda - source = "->(value) { value * 2 }" - - assert_node(Lambda, source) - end - - def test_lambda_do - source = "->(value) do value * 2 end" - - assert_node(Lambda, source) - end - - def test_lbrace - source = "method {}" - - at = location(chars: 7..8) - assert_node(LBrace, source, at: at) { |node| node.block.opening } - end - - def test_lparen - source = "(1 + 1)" - - at = location(chars: 0..1) - assert_node(LParen, source, at: at, &:lparen) - end - - def test_massign - assert_node(MAssign, "first, second, third = value") - end - - def test_method_add_block - assert_node(MethodAddBlock, "method {}") - end - - def test_mlhs - source = "left, right = value" - - at = location(chars: 0..11) - assert_node(MLHS, source, at: at, &:target) - end - - def test_mlhs_add_post - source = "left, *middle, right = values" - - at = location(chars: 0..20) - assert_node(MLHS, source, at: at, &:target) - end - - def test_mlhs_paren - source = "(left, right) = value" - - at = location(chars: 0..13) - assert_node(MLHSParen, source, at: at, &:target) - end - - def test_module - source = <<~SOURCE - module Container - end - SOURCE - - assert_node(ModuleDeclaration, source) - end - - def test_mrhs - source = "values = first, second, third" - - at = location(chars: 9..29) - assert_node(MRHS, source, at: at, &:value) - end - - def test_mrhs_add_star - source = "values = first, *rest" - - at = location(chars: 9..21) - assert_node(MRHS, source, at: at, &:value) - end - - def test_next - at = location(chars: 6..17) - assert_node(Next, "tap { next(value) }", at: at) do |node| - node.block.bodystmt.body.first - end - end - - def test_op - at = location(chars: 4..5) - assert_node(Op, "def +(value) end", at: at, &:name) - end - - def test_opassign - assert_node(OpAssign, "variable += value") - end - - def test_params - source = <<~SOURCE - def method( - one, two, - three = 3, four = 4, - *five, - six:, seven: 7, - **eight, - &nine - ) end - SOURCE - - at = location(lines: 2..7, chars: 11..93) - assert_node(Params, source, at: at) { |node| node.params.contents } - end - - def test_params_posts - source = "def method(*rest, post) end" - - at = location(chars: 11..22) - assert_node(Params, source, at: at) { |node| node.params.contents } - end - - def test_paren - assert_node(Paren, "(1 + 2)") - end - - def test_period - at = location(chars: 6..7) - assert_node(Period, "object.method", at: at, &:operator) - end - - def test_program - parser = SyntaxTree::Parser.new("variable") - program = parser.parse - refute(parser.error?) - - statements = program.statements.body - assert_equal 1, statements.size - assert_kind_of(VCall, statements.first) - - assert_kind_of(Program, program) - assert_equal(location(chars: 0..8), program.location) - end - - def test_qsymbols - assert_node(QSymbols, "%i[one two three]") - end - - def test_qwords - assert_node(QWords, "%w[one two three]") - end - - def test_rational - assert_node(RationalLiteral, "1r") - end - - def test_redo - assert_node(Redo, "tap { redo }", at: location(chars: 6..10)) do |node| - node.block.bodystmt.body.first - end - end - - def test_regexp_literal - assert_node(RegexpLiteral, "/abc/") - end - - def test_rescue_ex - source = <<~SOURCE - begin - rescue Exception => exception - end - SOURCE - - at = location(lines: 2..2, chars: 13..35) - assert_node(RescueEx, source, at: at) do |node| - node.bodystmt.rescue_clause.exception - end - end - - def test_rescue - source = <<~SOURCE - begin - rescue First - rescue Second, Third - rescue *Fourth - end - SOURCE - - at = location(lines: 2..5, chars: 6..58) - assert_node(Rescue, source, at: at) { |node| node.bodystmt.rescue_clause } - end - - def test_rescue_mod - assert_node(RescueMod, "expression rescue value") - end - - def test_rest_param - source = "def method(*rest) end" - - at = location(chars: 11..16) - assert_node(RestParam, source, at: at) do |node| - node.params.contents.rest - end - end - - def test_retry - at = location(chars: 15..20) - assert_node(Retry, "begin; rescue; retry; end", at: at) do |node| - node.bodystmt.rescue_clause.statements.body.first - end - end - - def test_return - assert_node(ReturnNode, "return value") - end - - def test_return0 - assert_node(ReturnNode, "return") - end - - def test_sclass - assert_node(SClass, "class << self; end") - end - - def test_statements - at = location(chars: 1..6) - assert_node(Statements, "(value)", at: at, &:contents) - end - - def test_string_concat - source = <<~SOURCE - 'left' \ - 'right' - SOURCE - - assert_node(StringConcat, source) - end - - def test_string_dvar - at = location(chars: 1..11) - assert_node(StringDVar, '"#@variable"', at: at) do |node| - node.parts.first - end - end - - def test_string_embexpr - source = '"#{variable}"' - - at = location(chars: 1..12) - assert_node(StringEmbExpr, source, at: at) { |node| node.parts.first } - end - - def test_string_literal - assert_node(StringLiteral, "\"string\"") - end - - def test_super - assert_node(Super, "super value") - end - - def test_symbol_literal - assert_node(SymbolLiteral, ":symbol") - end - - def test_symbols - assert_node(Symbols, "%I[one two three]") - end - - def test_top_const_field - source = "::Constant = value" - - at = location(chars: 0..10) - assert_node(TopConstField, source, at: at, &:target) - end - - def test_top_const_ref - assert_node(TopConstRef, "::Constant") - end - - def test_tstring_content - source = "\"string\"" - - at = location(chars: 1..7) - assert_node(TStringContent, source, at: at) { |node| node.parts.first } - end - - def test_not - assert_node(Not, "not(value)") - end - - def test_unary - assert_node(Unary, "+value") - end - - def test_undef - assert_node(Undef, "undef value") - end - - def test_unless - assert_node(UnlessNode, "unless value then else end") - end - - def test_unless_mod - assert_node(UnlessNode, "expression unless predicate") - end - - def test_until - assert_node(UntilNode, "until value do end") - end - - def test_until_mod - assert_node(UntilNode, "expression until predicate") - end - - def test_var_alias - assert_node(AliasNode, "alias $new $old") - end - - def test_var_field - at = location(chars: 0..8) - assert_node(VarField, "variable = value", at: at, &:target) - end - - guard_version("3.1.0") do - def test_pinned_var_ref - source = "bar = 1; foo in ^bar" - at = location(chars: 16..20) - - assert_node(PinnedVarRef, source, at: at, &:pattern) - end - end - - def test_var_ref - assert_node(VarRef, "true") - end - - def test_vcall - assert_node(VCall, "variable") - end - - def test_void_stmt - assert_node(VoidStmt, ";;", at: location(chars: 0..0)) - end - - def test_when - source = <<~SOURCE - case value - when one then :one - when two then :two - end - SOURCE - - at = location(lines: 2..4, chars: 11..52) - assert_node(When, source, at: at, &:consequent) - end - - def test_while - assert_node(WhileNode, "while value do end") - end - - def test_while_mod - assert_node(WhileNode, "expression while predicate") - end - - def test_word - at = location(chars: 3..7) - assert_node(Word, "%W[word]", at: at) { |node| node.elements.first } - end - - def test_words - assert_node(Words, "%W[one two three]") - end - - def test_xstring_literal - assert_node(XStringLiteral, "`ls`") - end - - def test_xstring_heredoc - source = <<~SOURCE - <<~`HEREDOC` - ls - HEREDOC - SOURCE - - at = location(lines: 1..3, chars: 0..26) - assert_node(Heredoc, source, at: at) - end - - def test_yield - at = location(lines: 2..2, chars: 10..21) - assert_node(YieldNode, "def foo\n yield value\nend\n", at: at) do |node| - node.bodystmt.statements.body.first - end - end - - def test_yield0 - at = location(lines: 2..2, chars: 10..15) - assert_node(YieldNode, "def foo\n yield\nend\n", at: at) do |node| - node.bodystmt.statements.body.first - end - end - - def test_zsuper - assert_node(ZSuper, "super") - end - - def test_column_positions - source = <<~SOURCE - puts 'Hello' - puts 'Goodbye' - SOURCE - - at = location(lines: 2..2, chars: 13..27, columns: 0..14) - assert_node(Command, source, at: at) - end - - def test_multibyte_column_positions - source = <<~SOURCE - puts "Congrats" - puts "🎉 🎉" - SOURCE - - at = location(lines: 2..2, chars: 16..26, columns: 0..10) - assert_node(Command, source, at: at) - end - - def test_root_class_raises_not_implemented_errors - { - accept: [nil], - child_nodes: [], - deconstruct: [], - deconstruct_keys: [[]], - format: [nil] - }.each do |method, arguments| - assert_raises(NotImplementedError) do - Node.new.public_send(method, *arguments) - end - end - end - - def test_arity_no_args - source = <<~SOURCE - def foo - end - SOURCE - - at = location(chars: 0..11, columns: 0..3, lines: 1..2) - assert_node(DefNode, source, at: at) do |node| - assert_equal(0..0, node.arity) - node - end - end - - def test_arity_positionals - source = <<~SOURCE - def foo(a, b = 1) - end - SOURCE - - at = location(chars: 0..21, columns: 0..3, lines: 1..2) - assert_node(DefNode, source, at: at) do |node| - assert_equal(1..2, node.arity) - node - end - end - - def test_arity_rest - source = <<~SOURCE - def foo(a, *b) - end - SOURCE - - at = location(chars: 0..18, columns: 0..3, lines: 1..2) - assert_node(DefNode, source, at: at) do |node| - assert_equal(1.., node.arity) - node - end - end - - def test_arity_keyword_rest - source = <<~SOURCE - def foo(a, **b) - end - SOURCE - - at = location(chars: 0..19, columns: 0..3, lines: 1..2) - assert_node(DefNode, source, at: at) do |node| - assert_equal(1.., node.arity) - node - end - end - - def test_arity_keywords - source = <<~SOURCE - def foo(a:, b: 1) - end - SOURCE - - at = location(chars: 0..21, columns: 0..3, lines: 1..2) - assert_node(DefNode, source, at: at) do |node| - assert_equal(1..2, node.arity) - node - end - end - - def test_arity_mixed - source = <<~SOURCE - def foo(a, b = 1, c:, d: 2) - end - SOURCE - - at = location(chars: 0..31, columns: 0..3, lines: 1..2) - assert_node(DefNode, source, at: at) do |node| - assert_equal(2..4, node.arity) - node - end - end - - guard_version("2.7.3") do - def test_arity_arg_forward - source = <<~SOURCE - def foo(...) - end - SOURCE - - at = location(chars: 0..16, columns: 0..3, lines: 1..2) - assert_node(DefNode, source, at: at) do |node| - assert_equal(0.., node.arity) - node - end - end - end - - guard_version("3.0.0") do - def test_arity_positional_and_arg_forward - source = <<~SOURCE - def foo(a, ...) - end - SOURCE - - at = location(chars: 0..19, columns: 0..3, lines: 1..2) - assert_node(DefNode, source, at: at) do |node| - assert_equal(1.., node.arity) - node - end - end - end - - def test_arity_no_parenthesis - source = <<~SOURCE - def foo a, b = 1 - end - SOURCE - - at = location(chars: 0..20, columns: 0..3, lines: 1..2) - assert_node(DefNode, source, at: at) do |node| - assert_equal(1..2, node.arity) - node - end - end - - def test_block_arity_positionals - source = <<~SOURCE - [].each do |a, b, c| - end - SOURCE - - at = location(chars: 8..24, columns: 8..3, lines: 1..2) - assert_node(BlockNode, source, at: at) do |node| - block = node.block - assert_equal(3..3, block.arity) - block - end - end - - def test_block_arity_with_optional - source = <<~SOURCE - [].each do |a, b = 1| - end - SOURCE - - at = location(chars: 8..25, columns: 8..3, lines: 1..2) - assert_node(BlockNode, source, at: at) do |node| - block = node.block - assert_equal(1..2, block.arity) - block - end - end - - def test_block_arity_with_optional_keyword - source = <<~SOURCE - [].each do |a, b: 2| - end - SOURCE - - at = location(chars: 8..24, columns: 8..3, lines: 1..2) - assert_node(BlockNode, source, at: at) do |node| - block = node.block - assert_equal(1..2, block.arity) - block - end - end - - def test_call_node_arity_positional_arguments - source = <<~SOURCE - foo(1, 2, 3) - SOURCE - - at = location(chars: 0..12, columns: 0..3, lines: 1..1) - assert_node(CallNode, source, at: at) do |node| - assert_equal(3, node.arity) - node - end - end - - def test_call_node_arity_keyword_arguments - source = <<~SOURCE - foo(bar, something: 123) - SOURCE - - at = location(chars: 0..24, columns: 0..24, lines: 1..1) - assert_node(CallNode, source, at: at) do |node| - assert_equal(2, node.arity) - node - end - end - - def test_call_node_arity_splat_arguments - source = <<~SOURCE - foo(*bar) - SOURCE - - at = location(chars: 0..9, columns: 0..9, lines: 1..1) - assert_node(CallNode, source, at: at) do |node| - assert_equal(Float::INFINITY, node.arity) - node - end - end - - def test_call_node_arity_keyword_rest_arguments - source = <<~SOURCE - foo(**bar) - SOURCE - - at = location(chars: 0..10, columns: 0..10, lines: 1..1) - assert_node(CallNode, source, at: at) do |node| - assert_equal(Float::INFINITY, node.arity) - node - end - end - - guard_version("2.7.3") do - def test_call_node_arity_arg_forward_arguments - source = <<~SOURCE - def foo(...) - bar(...) - end - SOURCE - - at = location(chars: 15..23, columns: 2..10, lines: 2..2) - assert_node(CallNode, source, at: at) do |node| - call = node.bodystmt.statements.body.first - assert_equal(Float::INFINITY, call.arity) - call - end - end - end - - def test_command_arity_positional_arguments - source = <<~SOURCE - foo 1, 2, 3 - SOURCE - - at = location(chars: 0..11, columns: 0..3, lines: 1..1) - assert_node(Command, source, at: at) do |node| - assert_equal(3, node.arity) - node - end - end - - def test_command_arity_keyword_arguments - source = <<~SOURCE - foo bar, something: 123 - SOURCE - - at = location(chars: 0..23, columns: 0..23, lines: 1..1) - assert_node(Command, source, at: at) do |node| - assert_equal(2, node.arity) - node - end - end - - def test_command_arity_splat_arguments - source = <<~SOURCE - foo *bar - SOURCE - - at = location(chars: 0..8, columns: 0..8, lines: 1..1) - assert_node(Command, source, at: at) do |node| - assert_equal(Float::INFINITY, node.arity) - node - end - end - - def test_command_arity_keyword_rest_arguments - source = <<~SOURCE - foo **bar - SOURCE - - at = location(chars: 0..9, columns: 0..9, lines: 1..1) - assert_node(Command, source, at: at) do |node| - assert_equal(Float::INFINITY, node.arity) - node - end - end - - def test_command_call_arity_positional_arguments - source = <<~SOURCE - object.foo 1, 2, 3 - SOURCE - - at = location(chars: 0..18, columns: 0..3, lines: 1..1) - assert_node(CommandCall, source, at: at) do |node| - assert_equal(3, node.arity) - node - end - end - - def test_command_call_arity_keyword_arguments - source = <<~SOURCE - object.foo bar, something: 123 - SOURCE - - at = location(chars: 0..30, columns: 0..30, lines: 1..1) - assert_node(CommandCall, source, at: at) do |node| - assert_equal(2, node.arity) - node - end - end - - def test_command_call_arity_splat_arguments - source = <<~SOURCE - object.foo *bar - SOURCE - - at = location(chars: 0..15, columns: 0..15, lines: 1..1) - assert_node(CommandCall, source, at: at) do |node| - assert_equal(Float::INFINITY, node.arity) - node - end - end - - def test_command_call_arity_keyword_rest_arguments - source = <<~SOURCE - object.foo **bar - SOURCE - - at = location(chars: 0..16, columns: 0..16, lines: 1..1) - assert_node(CommandCall, source, at: at) do |node| - assert_equal(Float::INFINITY, node.arity) - node - end - end - - def test_vcall_arity - source = <<~SOURCE - foo - SOURCE - - at = location(chars: 0..3, columns: 0..3, lines: 1..1) - assert_node(VCall, source, at: at) do |node| - assert_equal(0, node.arity) - node - end - end - - private - - def location(lines: 1..1, chars: 0..0, columns: 0..0) - Location.new( - start_line: lines.begin, - start_char: chars.begin, - start_column: columns.begin, - end_line: lines.end, - end_char: chars.end, - end_column: columns.end - ) - end - - def assert_node(kind, source, at: nil) - at ||= - location( - lines: 1..[1, source.count("\n")].max, - chars: 0..source.chomp.size, - columns: 0..source.chomp.size - ) - - # Parse the example, get the outputted parse tree, and assert that it was - # able to successfully parse. - parser = SyntaxTree::Parser.new(source) - program = parser.parse - refute(parser.error?) - - # Grab the last statement out of the parsed output. If a block is given, - # then yield that statement so that the test can descend further down the - # tree to get to the node it is testing. - node = program.statements.body.last - node = yield(node) if block_given? - - # Assert that the found node is the right type and that it has been found - # at the expected location. - assert_kind_of(kind, node) - assert_equal(at, node.location) - - # Finally, test that this node responds to everything it should. - assert_syntax_tree(node) - end - end -end diff --git a/test/parser_test.rb b/test/parser_test.rb deleted file mode 100644 index 169d5b46..00000000 --- a/test/parser_test.rb +++ /dev/null @@ -1,126 +0,0 @@ -# frozen_string_literal: true - -require_relative "test_helper" - -module SyntaxTree - class ParserTest < Minitest::Test - def test_parses_ripper_methods - # First, get a list of all of the dispatched events from ripper. - events = Ripper::EVENTS - - # Next, subtract all of the events that we have explicitly defined. - events -= - Parser.private_instance_methods(false).grep(/^on_(\w+)/) { $1.to_sym } - - # Next, subtract the list of events that we purposefully skipped. - events -= %i[ - arg_ambiguous - assoclist_from_args - ignored_nl - ignored_sp - magic_comment - nl - nokw_param - operator_ambiguous - semicolon - sp - words_sep - ] - - # Finally, assert that we have no remaining events. - assert_empty(events) - end - - def test_errors_on_missing_token_with_location - error = assert_raises(Parser::ParseError) { SyntaxTree.parse("f+\"foo") } - assert_equal(3, error.column) - end - - def test_errors_on_missing_end_with_location - error = assert_raises(Parser::ParseError) { SyntaxTree.parse("foo do 1") } - assert_equal(4, error.column) - end - - def test_errors_on_missing_regexp_ending - error = - assert_raises(Parser::ParseError) { SyntaxTree.parse("a =~ /foo") } - - assert_equal(6, error.column) - end - - def test_errors_on_missing_token_without_location - assert_raises(Parser::ParseError) { SyntaxTree.parse(":\"foo") } - end - - def test_handles_strings_with_non_terminated_embedded_expressions - assert_raises(Parser::ParseError) { SyntaxTree.parse('"#{"') } - end - - def test_errors_on_else_missing_two_ends - assert_raises(Parser::ParseError) { SyntaxTree.parse(<<~RUBY) } - def foo - if something - else - call do - end - RUBY - end - - def test_does_not_choke_on_invalid_characters_in_source_string - SyntaxTree.parse(<<~RUBY) - # comment - # comment - __END__ - \xC5 - RUBY - end - - def test_lambda_vars_with_parameters_location - tree = SyntaxTree.parse(<<~RUBY) - # comment - # comment - ->(_i; a) { a } - RUBY - - local_location = - tree.statements.body.last.params.contents.locals.first.location - - assert_equal(3, local_location.start_line) - assert_equal(3, local_location.end_line) - assert_equal(7, local_location.start_column) - assert_equal(8, local_location.end_column) - end - - def test_lambda_vars_location - tree = SyntaxTree.parse(<<~RUBY) - # comment - # comment - ->(; a) { a } - RUBY - - local_location = - tree.statements.body.last.params.contents.locals.first.location - - assert_equal(3, local_location.start_line) - assert_equal(3, local_location.end_line) - assert_equal(5, local_location.start_column) - assert_equal(6, local_location.end_column) - end - - def test_multiple_lambda_vars_location - tree = SyntaxTree.parse(<<~RUBY) - # comment - # comment - ->(; a, b, c) { a } - RUBY - - local_location = - tree.statements.body.last.params.contents.locals.last.location - - assert_equal(3, local_location.start_line) - assert_equal(3, local_location.end_line) - assert_equal(11, local_location.start_column) - assert_equal(12, local_location.end_column) - end - end -end diff --git a/test/plugin/disable_auto_ternary_test.rb b/test/plugin/disable_auto_ternary_test.rb deleted file mode 100644 index b2af9d35..00000000 --- a/test/plugin/disable_auto_ternary_test.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -require_relative "../test_helper" - -module SyntaxTree - class DisableTernaryTest < Minitest::Test - def test_short_if_else_unchanged - assert_format(<<~RUBY) - if true - 1 - else - 2 - end - RUBY - end - - def test_short_ternary_unchanged - assert_format("true ? 1 : 2\n") - end - - private - - def assert_format(expected, source = expected) - options = Formatter::Options.new(disable_auto_ternary: true) - formatter = Formatter.new(source, [], options: options) - SyntaxTree.parse(source).format(formatter) - - formatter.flush - assert_equal(expected, formatter.output.join) - end - end -end diff --git a/test/quotes_test.rb b/test/quotes_test.rb deleted file mode 100644 index 2e2e0243..00000000 --- a/test/quotes_test.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -require_relative "test_helper" - -module SyntaxTree - class QuotesTest < Minitest::Test - def test_normalize - content = "'aaa' \"bbb\" \\'ccc\\' \\\"ddd\\\"" - enclosing = "\"" - - result = Quotes.normalize(content, enclosing) - assert_equal "'aaa' \\\"bbb\\\" \\'ccc\\' \\\"ddd\\\"", result - end - end -end diff --git a/test/ractor_test.rb b/test/ractor_test.rb index 40e1eec7..dbf0ea12 100644 --- a/test/ractor_test.rb +++ b/test/ractor_test.rb @@ -1,54 +1,40 @@ # frozen_string_literal: true -# Do not run this test locally, as it messes up coverage. -return unless ENV["CI"] +return if !defined?(Ractor) || Gem.win_platform? -# Don't run this test if we're in a version of Ruby that doesn't have Ractors. -return unless defined?(Ractor) - -# Don't run this version on Ruby 3.0.0. For some reason it just hangs within the -# main Ractor waiting for this children. Not going to investigate it since it's -# already been fixed in 3.1.0. -return if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.1.0") +# Before Ruby 3.4.0, autoloads could not happen on a non-default Ractor. +require "prism/parse_result/comments" if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.4.0") require_relative "test_helper" module SyntaxTree class RactorTest < Minitest::Test - def test_formatting + def test_ractors ractors = - filepaths.map do |filepath| - # At the moment we have to parse in the main Ractor because Ripper is - # not marked as a Ractor-safe extension. - source = SyntaxTree.read(filepath) - program = SyntaxTree.parse(source) - - with_silenced_warnings do - Ractor.new(source, program, name: filepath) do |source, program| - SyntaxTree::Formatter.format(source, program) - end + Dir[File.join(__dir__, "*.rb")].map do |filepath| + without_experimental_warnings do + Ractor.new(filepath) { |filepath| SyntaxTree.format_file(filepath) } end end - ractors.each { |ractor| assert_kind_of String, ractor.take } + ractors.each do |ractor| + # Somewhere in the Ruby 3.5.* series, Ractor#take was removed and + # Ractor#value was added. + value = ractor.respond_to?(:value) ? ractor.value : ractor.take + assert_kind_of(String, value) + end end private - def filepaths - Dir.glob(File.expand_path("../lib/syntax_tree/plugin/*.rb", __dir__)) - end - - # Ractors still warn about usage, so I'm disabling that warning here just to - # have clean test output. - def with_silenced_warnings - previous = $VERBOSE + def without_experimental_warnings + previous = Warning[:experimental] begin - $VERBOSE = nil + Warning[:experimental] = false yield ensure - $VERBOSE = previous + Warning[:experimental] = previous end end end diff --git a/test/rake_test.rb b/test/rake_test.rb index 90662519..e3ab0e30 100644 --- a/test/rake_test.rb +++ b/test/rake_test.rb @@ -1,57 +1,46 @@ # frozen_string_literal: true require_relative "test_helper" -require "syntax_tree/rake_tasks" module SyntaxTree - module Rake - class CheckTaskTest < Minitest::Test - Invocation = Struct.new(:args) - - def test_task_command - assert_raises(NotImplementedError) { Task.new.command } + class RakeTest < Minitest::Test + def test_check_task + source_files = "{app,config,lib}/**/*.rb" + print_width = SyntaxTree.options.print_width + 1 + + Rake::CheckTask.new do |t| + t.source_files = source_files + t.print_width = print_width end - def test_check_task - source_files = "{app,config,lib}/**/*.rb" - - CheckTask.new do |t| - t.source_files = source_files - t.print_width = 100 - t.target_ruby_version = Gem::Version.new("2.6.0") - end - - expected = [ - "check", - "--print-width=100", - "--target-ruby-version=2.6.0", - source_files - ] - - invocation = invoke("stree:check") - assert_equal(expected, invocation.args) - end + expected = ["check", "--print-width=#{print_width}", source_files] + assert_equal(expected, invoke("stree:check")) + end - def test_write_task - source_files = "{app,config,lib}/**/*.rb" - WriteTask.new { |t| t.source_files = source_files } + def test_write_task + source_files = "{app,config,lib}/**/*.rb" + trailing_comma = !SyntaxTree.options.trailing_comma - invocation = invoke("stree:write") - assert_equal(["write", source_files], invocation.args) + Rake::WriteTask.new do |t| + t.source_files = source_files + t.trailing_comma = trailing_comma end - private + expected = ["write", "--#{"no-" unless trailing_comma}trailing-comma", source_files] + assert_equal(expected, invoke("stree:write")) + end - def invoke(task_name) - invocation = nil - stub = ->(args) { invocation = Invocation.new(args) } + private - assert_raises SystemExit do - SyntaxTree::CLI.stub(:run, stub) { ::Rake::Task[task_name].invoke } - end + def invoke(task_name) + invocation = nil + stub = ->(args) { invocation = args } - invocation + assert_raises SystemExit do + SyntaxTree::CLI.stub(:run, stub) { ::Rake::Task[task_name].invoke } end + + invocation end end end diff --git a/test/plugin/single_quotes_test.rb b/test/single_quotes_test.rb similarity index 60% rename from test/plugin/single_quotes_test.rb rename to test/single_quotes_test.rb index b1359ac7..2c0337dc 100644 --- a/test/plugin/single_quotes_test.rb +++ b/test/single_quotes_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "../test_helper" +require_relative "test_helper" module SyntaxTree class SingleQuotesTest < Minitest::Test @@ -24,30 +24,19 @@ def test_string_literal_with_interpolation assert_format("\"\#{foo}\"\n") end - def test_dyna_symbol - assert_format(":'symbol'\n", ":\"symbol\"") - end - def test_single_quote_in_string assert_format("\"str'ing\"\n") end def test_label - assert_format( - "{ foo => foo, :'bar' => bar }\n", - "{ foo => foo, \"bar\": bar }" - ) + assert_format("{ foo => foo, :bar => bar }\n", "{ foo => foo, \"bar\": bar }") end private def assert_format(expected, source = expected) - options = Formatter::Options.new(quote: "'") - formatter = Formatter.new(source, [], options: options) - SyntaxTree.parse(source).format(formatter) - - formatter.flush - assert_equal(expected, formatter.output.join) + options = SyntaxTree.options(preferred_quote: "'") + assert_equal(expected, SyntaxTree.format(source, options)) end end end diff --git a/test/syntax_tree_test.rb b/test/syntax_tree_test.rb index 27aa6851..aa6bd3ac 100644 --- a/test/syntax_tree_test.rb +++ b/test/syntax_tree_test.rb @@ -4,50 +4,43 @@ module SyntaxTree class SyntaxTreeTest < Minitest::Test - def test_empty - void_stmt = SyntaxTree.parse("").statements.body.first - assert_kind_of(VoidStmt, void_stmt) + def test_configure + default_print_width = SyntaxTree.options.print_width + SyntaxTree.configure { |config| config.print_width = default_print_width } end - def test_multibyte - assign = SyntaxTree.parse("🎉 + 🎉").statements.body.first - assert_equal(5, assign.location.end_char) + def test_format + assert_equal("1 + 1\n", SyntaxTree.format("1+1")) end - def test_next_statement_start - source = <<~SOURCE - def method # comment - expression - end - SOURCE - - bodystmt = SyntaxTree.parse(source).statements.body.first.bodystmt - assert_equal(20, bodystmt.start_char) - end - - def test_parse_error - assert_raises(Parser::ParseError) { SyntaxTree.parse("<>") } - end - - def test_marshalable - node = SyntaxTree.parse("1 + 2") - assert_operator(node, :===, Marshal.load(Marshal.dump(node))) + def test_format_file + assert_kind_of(String, SyntaxTree.format_file(__FILE__)) end - def test_maxwidth_format - assert_equal("foo +\n bar\n", SyntaxTree.format("foo + bar", 5)) + def test_format_print_width + options = Options.new(print_width: 5) + assert_equal("foo +\n bar\n", SyntaxTree.format("foo + bar", options)) end - def test_read - source = SyntaxTree.read(__FILE__) - assert_equal(Encoding.default_external, source.encoding) + def test_format_stree_ignore + source = <<~SOURCE + # stree-ignore + 1+1 + SOURCE - source = SyntaxTree.read(File.expand_path("encoded.rb", __dir__)) - assert_equal(Encoding::Shift_JIS, source.encoding) + assert_equal(source, SyntaxTree.format(source)) end def test_version refute_nil(VERSION) end + + def test_visit_methods + expected = Prism::Visitor.public_instance_methods.grep(/\Avisit_.+_node\z/).sort + actual = Prism::Format.public_instance_methods.grep(/\Avisit_.+_node\z/).sort + + assert_empty(expected - actual) + assert_empty(actual - expected) + end end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 3a39ae38..f88716ab 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -3,8 +3,6 @@ unless RUBY_ENGINE == "truffleruby" require "simplecov" SimpleCov.start do - add_filter("idempotency_test.rb") unless ENV["CI"] - add_filter("ractor_test.rb") unless ENV["CI"] add_group("lib", "lib") add_group("test", "test") end @@ -12,112 +10,6 @@ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) require "syntax_tree" -require "syntax_tree/cli" -require "json" require "tempfile" -require "pp" require "minitest/autorun" - -module SyntaxTree - module Assertions - class Recorder - attr_reader :called - - def initialize - @called = nil - end - - def method_missing(called, *, **) - @called = called - end - end - - private - - # This is a special kind of assertion that is going to get loaded into all - # of test cases. It asserts against a whole bunch of stuff that every node - # type should be able to handle. It's here so that we can use it in a bunch - # of tests. - def assert_syntax_tree(node) - recorder = Recorder.new - node.accept(recorder) - - visitor = Parser::Visitor.new - assert_respond_to(visitor, recorder.called) - - assert_kind_of(node.class, node.copy) - assert_operator(node, :===, node) - assert_kind_of(Array, node.child_nodes) - assert_kind_of(Array, node.deconstruct) - assert_kind_of(Hash, node.deconstruct_keys([])) - end - - Minitest::Test.include(self) - end -end - -# There are a bunch of fixtures defined in test/fixtures. They exercise every -# possible combination of syntax that leads to variations in the types of nodes. -# They are used for testing various parts of Syntax Tree, including formatting, -# serialization, and parsing. This module provides a single each_fixture method -# that can be used to drive tests on each fixture. -module Fixtures - FIXTURES_3_0_0 = %w[ - command_def_endless - def_endless - fndptn - rassign - rassign_rocket - ].freeze - - FIXTURES_3_1_0 = %w[pinned_begin var_field_rassign].freeze - - Fixture = Struct.new(:name, :source, :formatted, keyword_init: true) - - def self.each_fixture - ruby_version = Gem::Version.new(RUBY_VERSION) - - # First, get a list of the basenames of all of the fixture files. - fixtures = - Dir[File.expand_path("fixtures/*.rb", __dir__)].map do |filepath| - File.basename(filepath, ".rb") - end - - # Next, subtract out any fixtures that aren't supported by the current Ruby - # version. - fixtures -= FIXTURES_3_1_0 if ruby_version < Gem::Version.new("3.1.0") - fixtures -= FIXTURES_3_0_0 if ruby_version < Gem::Version.new("3.0.0") - - delimiter = /%(?: # (.+?))?\n/ - fixtures.each do |fixture| - filepath = File.expand_path("fixtures/#{fixture}.rb", __dir__) - - # For each fixture in the fixture file yield a Fixture object. - File - .readlines(filepath) - .slice_before(delimiter) - .each_with_index do |source, index| - comment = source.shift.match(delimiter)[1] - source, formatted = source.join.split("-\n") - - # If there's a comment starting with >= that starts after the % that - # delineates the test, then we're going to check if the version - # satisfies that constraint. - if comment&.start_with?(">=") && - ruby_version < Gem::Version.new(comment.split[1]) - next - end - - name = :"#{fixture}_#{index}" - yield( - Fixture.new( - name: name, - source: source, - formatted: formatted || source - ) - ) - end - end - end -end diff --git a/test/plugin/trailing_comma_test.rb b/test/trailing_comma_test.rb similarity index 84% rename from test/plugin/trailing_comma_test.rb rename to test/trailing_comma_test.rb index 7f6e49a8..89e8b13f 100644 --- a/test/plugin/trailing_comma_test.rb +++ b/test/trailing_comma_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "../test_helper" +require_relative "test_helper" module SyntaxTree class TrailingCommaTest < Minitest::Test @@ -80,12 +80,8 @@ def test_hash_literal_break private def assert_format(expected, source = expected) - options = Formatter::Options.new(trailing_comma: true) - formatter = Formatter.new(source, [], options: options) - SyntaxTree.parse(source).format(formatter) - - formatter.flush - assert_equal(expected, formatter.output.join) + options = SyntaxTree.options(print_width: 80, trailing_comma: true) + assert_equal(expected, SyntaxTree.format(source, options)) end end end