diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 18e96080..5a7c30c9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,7 +13,7 @@ jobs: - uses: ruby/setup-ruby@v1 with: bundler-cache: true - ruby-version: 3.0 + ruby-version: '3.1' - name: Test run: bundle exec rake test automerge: diff --git a/Gemfile.lock b/Gemfile.lock index 40a8c2d7..be7967ac 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -41,4 +41,4 @@ DEPENDENCIES syntax_tree! BUNDLED WITH - 2.2.15 + 2.2.31 diff --git a/lib/syntax_tree.rb b/lib/syntax_tree.rb index c337c1e7..b487de9b 100644 --- a/lib/syntax_tree.rb +++ b/lib/syntax_tree.rb @@ -1173,7 +1173,7 @@ def on_args_add(arguments, argument) # method(&expression) # class ArgBlock - # [untyped] the expression being turned into a block + # [nil | untyped] the expression being turned into a block attr_reader :value # [Location] the location of this node @@ -1194,15 +1194,17 @@ def child_nodes def format(q) q.text("&") - q.format(value) + q.format(value) if value end def pretty_print(q) q.group(2, "(", ")") do q.text("arg_block") - q.breakable - q.pp(value) + if value + q.breakable + q.pp(value) + end q.pp(Comment::List.new(comments)) end @@ -1221,17 +1223,34 @@ def to_json(*opts) # (false | untyped) block # ) -> Args def on_args_add_block(arguments, block) - return arguments unless block + operator = find_token(Op, "&", consume: false) - arg_block = - ArgBlock.new( - value: block, - location: find_token(Op, "&").location.to(block.location) - ) + # If we can't find the & operator, then there's no block to add to the list, + # so we're just going to return the arguments as-is. + return arguments unless operator + + # 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. + tokens.delete(operator) + + # Construct the location that represents the block argument. + location = operator.location + location = operator.location.to(block.location) if block + + # 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. + if arguments.parts.any? && location.start_char < arguments.location.end_char + return arguments + end + + # 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(arg_block.location) + location: arguments.location.to(location) ) end @@ -1896,7 +1915,7 @@ def child_nodes end def format(q) - if value.is_a?(HashLiteral) + if value&.is_a?(HashLiteral) format_contents(q) else q.group { format_contents(q) } @@ -1910,8 +1929,10 @@ def pretty_print(q) q.breakable q.pp(key) - q.breakable - q.pp(value) + if value + q.breakable + q.pp(value) + end q.pp(Comment::List.new(comments)) end @@ -1931,6 +1952,7 @@ def to_json(*opts) def format_contents(q) q.parent.format_key(q, key) + return unless value if key.comments.empty? && AssignFormatting.skip_indent?(value) q.text(" ") @@ -1947,7 +1969,10 @@ def format_contents(q) # :call-seq: # on_assoc_new: (untyped key, untyped value) -> Assoc def on_assoc_new(key, value) - Assoc.new(key: key, value: value, location: key.location.to(value.location)) + location = key.location + location = location.to(value.location) if value + + Assoc.new(key: key, value: value, location: location) end # AssocSplat represents double-splatting a value into a hash (either a hash @@ -2423,12 +2448,22 @@ def to_json(*opts) # :call-seq: # on_binary: (untyped left, (Op | Symbol) operator, untyped right) -> Binary def on_binary(left, operator, right) - # On most Ruby implementations, operator is a Symbol that represents that - # operation being performed. For instance in the example `1 < 2`, the - # `operator` object would be `:<`. However, on JRuby, it's an `@op` node, - # so here we're going to explicitly convert it into the same normalized - # form. - operator = tokens.delete(operator).value unless operator.is_a?(Symbol) + if operator.is_a?(Symbol) + # Here, we're going to search backward for the nearest token that matches + # the operator so we can delete it from the list. + token = find_token(Op, operator.to_s, consume: false) + + if token && token.location.start_char > left.location.end_char + tokens.delete(token) + end + 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, @@ -2578,7 +2613,7 @@ def on_block_var(params, locals) # def method(&block); end # class BlockArg - # [Ident] the name of the block argument + # [nil | Ident] the name of the block argument attr_reader :name # [Location] the location of this node @@ -2599,15 +2634,17 @@ def child_nodes def format(q) q.text("&") - q.format(name) + q.format(name) if name end def pretty_print(q) q.group(2, "(", ")") do q.text("blockarg") - q.breakable - q.pp(name) + if name + q.breakable + q.pp(name) + end q.pp(Comment::List.new(comments)) end @@ -2625,7 +2662,10 @@ def to_json(*opts) def on_blockarg(name) operator = find_token(Op, "&") - BlockArg.new(name: name, location: operator.location.to(name.location)) + location = operator.location + location = location.to(name.location) if name + + BlockArg.new(name: name, location: location) end # bodystmt can't actually determine its bounds appropriately because it @@ -4423,7 +4463,7 @@ class DefEndless # [Backtick | Const | Ident | Kw | Op] the name of the method attr_reader :name - # [nil | Paren] the parameter declaration for the method + # [nil | Params | Paren] the parameter declaration for the method attr_reader :paren # [untyped] the expression to be executed by the method @@ -4467,7 +4507,12 @@ def format(q) end q.format(name) - q.format(paren) if paren && !paren.contents.empty? + + if paren + params = paren + params = params.contents if params.is_a?(Paren) + q.format(paren) unless params.empty? + end q.text(" =") q.group do @@ -4533,21 +4578,6 @@ def on_def(name, params, bodystmt) # and normal method definitions. beginning = find_token(Kw, "def") - # If we don't have a bodystmt node, then we have a single-line method - unless bodystmt.is_a?(BodyStmt) - node = - DefEndless.new( - target: nil, - operator: nil, - name: name, - paren: params, - statement: bodystmt, - location: beginning.location.to(bodystmt.location) - ) - - return node - end - # If there aren't any params then we need to correct the params node # location information if params.is_a?(Params) && params.empty? @@ -4563,18 +4593,35 @@ def on_def(name, params, bodystmt) params = Params.new(location: location) end - ending = find_token(Kw, "end") - bodystmt.bind( - find_next_statement_start(params.location.end_char), - ending.location.start_char - ) + ending = find_token(Kw, "end", consume: false) - Def.new( - name: name, - params: params, - bodystmt: bodystmt, - location: beginning.location.to(ending.location) - ) + if ending + tokens.delete(ending) + bodystmt.bind( + find_next_statement_start(params.location.end_char), + ending.location.start_char + ) + + Def.new( + name: name, + params: params, + bodystmt: bodystmt, + location: beginning.location.to(ending.location) + ) + 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 + + DefEndless.new( + target: nil, + operator: nil, + name: name, + paren: params, + statement: statement, + location: beginning.location.to(bodystmt.location) + ) + end end # Defined represents the use of the +defined?+ operator. It can be used with @@ -4782,37 +4829,37 @@ def on_defs(target, operator, name, params, bodystmt) end beginning = find_token(Kw, "def") + ending = find_token(Kw, "end", consume: false) - # If we don't have a bodystmt node, then we have a single-line method - unless bodystmt.is_a?(BodyStmt) - node = - DefEndless.new( - target: target, - operator: operator, - name: name, - paren: params, - statement: bodystmt, - location: beginning.location.to(bodystmt.location) - ) - - return node - end - - ending = find_token(Kw, "end") + if ending + tokens.delete(ending) + bodystmt.bind( + find_next_statement_start(params.location.end_char), + ending.location.start_char + ) - bodystmt.bind( - find_next_statement_start(params.location.end_char), - ending.location.start_char - ) + Defs.new( + target: target, + operator: operator, + name: name, + params: params, + bodystmt: bodystmt, + location: beginning.location.to(ending.location) + ) + 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 - Defs.new( - target: target, - operator: operator, - name: name, - params: params, - bodystmt: bodystmt, - location: beginning.location.to(ending.location) - ) + DefEndless.new( + target: target, + operator: operator, + name: name, + paren: params, + statement: statement, + location: beginning.location.to(bodystmt.location) + ) + end end # DoBlock represents passing a block to a method call using the +do+ and +end+ @@ -8931,7 +8978,7 @@ def format(q) end class KeywordRestFormatter - # [:nil | KwRestParam] the value of the parameter + # [:nil | ArgsForward | KwRestParam] the value of the parameter attr_reader :value def initialize(value) @@ -9046,7 +9093,7 @@ def format(q) q.format(rest) if rest && rest.is_a?(ExcessedComma) end - if [Def, Defs].include?(q.parent.class) + if [Def, Defs, DefEndless].include?(q.parent.class) q.group(0, "(", ")") do q.indent do q.breakable("") @@ -9146,8 +9193,8 @@ def to_json(*opts) # (nil | ArgsForward | ExcessedComma | RestParam) rest, # (nil | Array[Ident]) posts, # (nil | Array[[Ident, nil | untyped]]) keywords, - # (nil | :nil | KwRestParam) keyword_rest, - # (nil | BlockArg) block + # (nil | :nil | ArgsForward | KwRestParam) keyword_rest, + # (nil | :& | BlockArg) block # ) -> Params def on_params( requireds, @@ -9165,7 +9212,7 @@ def on_params( *posts, *keywords&.flat_map { |(key, value)| [key, value || nil] }, (keyword_rest if keyword_rest != :nil), - block + (block if block != :&) ].compact location = @@ -9182,7 +9229,7 @@ def on_params( posts: posts || [], keywords: keywords || [], keyword_rest: keyword_rest, - block: block, + block: (block if block != :&), location: location ) end diff --git a/test/fixtures/arg_block.rb b/test/fixtures/arg_block.rb index 74be5b2b..d423efa8 100644 --- a/test/fixtures/arg_block.rb +++ b/test/fixtures/arg_block.rb @@ -14,3 +14,7 @@ foo( &bar.bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz ) +% # >= 3.1.0 +def foo(&) + bar(&) +end diff --git a/test/fixtures/args_forward.rb b/test/fixtures/args_forward.rb index e38a22cc..5ba618a8 100644 --- a/test/fixtures/args_forward.rb +++ b/test/fixtures/args_forward.rb @@ -1,4 +1,8 @@ % -def get(...) - request(:GET, ...) +def foo(...) + bar(:baz, ...) +end +% # >= 3.1.0 +def foo(foo, bar = baz, ...) + bar(:baz, ...) end diff --git a/test/fixtures/assoc.rb b/test/fixtures/assoc.rb index ceed0d0c..43bb2b08 100644 --- a/test/fixtures/assoc.rb +++ b/test/fixtures/assoc.rb @@ -38,3 +38,5 @@ foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo ] } +% # >= 3.1.0 +{ foo: } diff --git a/test/fixtures/def_endless.rb b/test/fixtures/def_endless.rb index 2f316e6c..5e14dbc7 100644 --- a/test/fixtures/def_endless.rb +++ b/test/fixtures/def_endless.rb @@ -6,3 +6,5 @@ def foo(bar) = baz def foo() = bar - def foo = bar +% # >= 3.1.0 +def foo = bar baz diff --git a/test/formatting_test.rb b/test/formatting_test.rb new file mode 100644 index 00000000..8fcb6ae2 --- /dev/null +++ b/test/formatting_test.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class SyntaxTree + class FormattingTest < Minitest::Test + delimiter = /%(?: # (.+?))?\n/ + + Dir[File.join(__dir__, "fixtures", "*.rb")].each do |filepath| + basename = File.basename(filepath, ".rb") + sources = File.readlines(filepath).slice_before(delimiter) + + sources.each_with_index do |source, index| + comment = source.shift.match(delimiter)[1] + original, expected = 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?(">=") + version = Gem::Version.new(comment.split[1]) + next if Gem::Version.new(RUBY_VERSION) < version + end + + define_method(:"test_formatting_#{basename}_#{index}") do + assert_equal(expected || original, SyntaxTree.format(original)) + end + end + end + end +end diff --git a/test/node_test.rb b/test/node_test.rb index 84dc4891..b5b064e6 100644 --- a/test/node_test.rb +++ b/test/node_test.rb @@ -4,6 +4,10 @@ class 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", "BEGIN {}") end @@ -78,6 +82,21 @@ def test_arg_block 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, "arg_block", 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)" @@ -129,6 +148,15 @@ def test_assoc assert_node(Assoc, "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, "assoc", source, at: at) { |node| node.assocs.first } + end + end + def test_assoc_splat source = "{ **pairs }" @@ -207,6 +235,17 @@ def test_blockarg end end + guard_version("3.1.0") do + def test_blockarg_anonymous + source = "def method(&); end" + + at = location(chars: 11..12) + assert_node(BlockArg, "blockarg", source, at: at) do |node| + node.params.contents.block + end + end + end + def test_bodystmt source = <<~SOURCE begin @@ -317,6 +356,12 @@ def test_def_endless assert_node(DefEndless, "def_endless", "def method = result") end + guard_version("3.1.0") do + def test_def_endless_command + assert_node(DefEndless, "def_endless", "def method = result argument") + end + end + def test_defined assert_node(Defined, "defined", "defined?(variable)") end @@ -948,23 +993,6 @@ def test_zsuper assert_node(ZSuper, "zsuper", "super") end - # -------------------------------------------------------------------------- - # Tests for formatting - # -------------------------------------------------------------------------- - - Dir[File.join(__dir__, "fixtures", "*.rb")].each do |filepath| - basename = File.basename(filepath, ".rb") - - File.read(filepath).split(/%(?: #.+?)?\n/).drop( - 1 - ).each_with_index do |source, index| - define_method(:"test_formatting_#{basename}_#{index}") do - original, expected = source.split("-\n") - assert_equal(expected || original, SyntaxTree.format(original)) - end - end - end - private def location(lines: 1..1, chars: 0..0)