diff --git a/CHANGELOG.md b/CHANGELOG.md index 018d5b25..960bb0e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ## [Unreleased] +## [6.0.2] - 2023-03-03 + +### Added + +- The `WithScope` visitor mixin will now additionally report local variables defined through regular expression named captures. +- The `WithScope` visitor mixin now properly handles destructured splat arguments in required positions. + +### Changed + +- Fixed the AST output by adding blocks to `Command` and `CommandCall` nodes in the `FieldVisitor`. +- Fixed the location of lambda local variables (e.g., `->(; a) {}`). + ## [6.0.1] - 2023-02-26 ### Added @@ -572,7 +584,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a - 🎉 Initial release! 🎉 -[unreleased]: https://github.com/ruby-syntax-tree/syntax_tree/compare/v6.0.1...HEAD +[unreleased]: https://github.com/ruby-syntax-tree/syntax_tree/compare/v6.0.2...HEAD +[6.0.2]: https://github.com/ruby-syntax-tree/syntax_tree/compare/v6.0.1...v6.0.2 [6.0.1]: https://github.com/ruby-syntax-tree/syntax_tree/compare/v6.0.0...v6.0.1 [6.0.0]: https://github.com/ruby-syntax-tree/syntax_tree/compare/v5.3.0...v6.0.0 [5.3.0]: https://github.com/ruby-syntax-tree/syntax_tree/compare/v5.2.0...v5.3.0 diff --git a/Gemfile.lock b/Gemfile.lock index c7ffc7d0..735a5025 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - syntax_tree (6.0.1) + syntax_tree (6.0.2) prettier_print (>= 1.2.0) GEM @@ -19,7 +19,7 @@ GEM rake (13.0.6) regexp_parser (2.7.0) rexml (3.2.5) - rubocop (1.46.0) + rubocop (1.47.0) json (~> 2.3) parallel (~> 1.10) parser (>= 3.2.0.0) @@ -29,9 +29,9 @@ GEM rubocop-ast (>= 1.26.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.26.0) + rubocop-ast (1.27.0) parser (>= 3.2.1.0) - ruby-progressbar (1.11.0) + ruby-progressbar (1.12.0) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) diff --git a/lib/syntax_tree/field_visitor.rb b/lib/syntax_tree/field_visitor.rb index ca1df55b..f5607c67 100644 --- a/lib/syntax_tree/field_visitor.rb +++ b/lib/syntax_tree/field_visitor.rb @@ -263,6 +263,7 @@ def visit_command(node) node(node, "command") do field("message", node.message) field("arguments", node.arguments) + field("block", node.block) if node.block comments(node) end end @@ -273,6 +274,7 @@ def visit_command_call(node) field("operator", node.operator) field("message", node.message) field("arguments", node.arguments) if node.arguments + field("block", node.block) if node.block comments(node) end end diff --git a/lib/syntax_tree/parser.rb b/lib/syntax_tree/parser.rb index d0a5bf67..ed0de408 100644 --- a/lib/syntax_tree/parser.rb +++ b/lib/syntax_tree/parser.rb @@ -2391,8 +2391,14 @@ def lambda_locals(source) } } + 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 diff --git a/lib/syntax_tree/reflection.rb b/lib/syntax_tree/reflection.rb index bf4b95f3..b2ffec6d 100644 --- a/lib/syntax_tree/reflection.rb +++ b/lib/syntax_tree/reflection.rb @@ -176,7 +176,8 @@ def parse_comments(statements, index) program = SyntaxTree.parse(SyntaxTree.read(File.expand_path("node.rb", __dir__))) - main_statements = program.statements.body.last.bodystmt.statements.body + program_statements = program.statements + main_statements = program_statements.body.last.bodystmt.statements.body main_statements.each_with_index do |main_statement, main_statement_index| # Ensure we are only looking at class declarations. next unless main_statement.is_a?(SyntaxTree::ClassDeclaration) diff --git a/lib/syntax_tree/version.rb b/lib/syntax_tree/version.rb index 0b3502d1..ff3db370 100644 --- a/lib/syntax_tree/version.rb +++ b/lib/syntax_tree/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module SyntaxTree - VERSION = "6.0.1" + VERSION = "6.0.2" end diff --git a/lib/syntax_tree/with_scope.rb b/lib/syntax_tree/with_scope.rb index 7fcef067..c479fd3e 100644 --- a/lib/syntax_tree/with_scope.rb +++ b/lib/syntax_tree/with_scope.rb @@ -189,6 +189,15 @@ def visit_blockarg(node) super end + def visit_block_var(node) + node.locals.each do |local| + current_scope.add_local_definition(local, :variable) + end + + super + end + alias visit_lambda_var visit_block_var + # Visit for keeping track of local variable definitions def visit_var_field(node) value = node.value @@ -217,11 +226,72 @@ def visit_var_ref(node) super end + # When using regex named capture groups, vcalls might actually be a variable + def visit_vcall(node) + value = node.value + definition = current_scope.find_local(value.value) + current_scope.add_local_usage(value, definition.type) if definition + + super + end + + # Visit for capturing local variables defined in regex named capture groups + def visit_binary(node) + if node.operator == :=~ + left = node.left + + if left.is_a?(RegexpLiteral) && left.parts.length == 1 && + left.parts.first.is_a?(TStringContent) + content = left.parts.first + + value = content.value + location = content.location + start_line = location.start_line + + Regexp + .new(value, Regexp::FIXEDENCODING) + .names + .each do |name| + offset = value.index(/\(\?<#{Regexp.escape(name)}>/) + line = start_line + value[0...offset].count("\n") + + # We need to add 3 to account for these three characters + # prefixing a named capture (?< + column = location.start_column + offset + 3 + if value[0...offset].include?("\n") + column = + value[0...offset].length - value[0...offset].rindex("\n") + + 3 - 1 + end + + ident_location = + Location.new( + start_line: line, + start_char: location.start_char + offset, + start_column: column, + end_line: line, + end_char: location.start_char + offset + name.length, + end_column: column + name.length + ) + + identifier = Ident.new(value: name, location: ident_location) + current_scope.add_local_definition(identifier, :variable) + end + end + end + + super + end + private def add_argument_definitions(list) list.each do |param| - if param.is_a?(SyntaxTree::MLHSParen) + case param + when ArgStar + value = param.value + current_scope.add_local_definition(value, :argument) if value + when MLHSParen add_argument_definitions(param.contents.parts) else current_scope.add_local_definition(param, :argument) diff --git a/test/parser_test.rb b/test/parser_test.rb index 8d6c0a16..7ac07381 100644 --- a/test/parser_test.rb +++ b/test/parser_test.rb @@ -74,5 +74,53 @@ def test_does_not_choke_on_invalid_characters_in_source_string \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/test_helper.rb b/test/test_helper.rb index 2c8f6466..8015be14 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,46 +1,50 @@ # frozen_string_literal: true -require "simplecov" -SimpleCov.start do - add_filter("idempotency_test.rb") unless ENV["CI"] - add_group("lib", "lib") - add_group("test", "test") +unless RUBY_ENGINE == "truffleruby" + require "simplecov" + SimpleCov.start do + add_filter("idempotency_test.rb") unless ENV["CI"] + add_group("lib", "lib") + add_group("test", "test") + end end $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) require "syntax_tree" require "syntax_tree/cli" -# Here we are going to establish type verification whenever a new node is -# created. We do this through the reflection module, which in turn parses the -# source code of the node classes. -require "syntax_tree/reflection" -SyntaxTree::Reflection.nodes.each do |name, node| - next if name == :Statements - - clazz = SyntaxTree.const_get(name) - parameters = clazz.instance_method(:initialize).parameters - - # First, verify that all of the parameters listed in the list of attributes. - # If there are any parameters that aren't listed in the attributes, then - # something went wrong with the parsing in the reflection module. - raise unless (parameters.map(&:last) - node.attributes.keys).empty? - - # Now we're going to use an alias chain to redefine the initialize method to - # include type checking. - clazz.alias_method(:initialize_without_verify, :initialize) - clazz.define_method(:initialize) do |**kwargs| - kwargs.each do |kwarg, value| - attribute = node.attributes.fetch(kwarg) - - unless attribute.type === value - raise TypeError, - "invalid type for #{name}##{kwarg}, expected " \ - "#{attribute.type.inspect}, got #{value.inspect}" +unless RUBY_ENGINE == "truffleruby" + # Here we are going to establish type verification whenever a new node is + # created. We do this through the reflection module, which in turn parses the + # source code of the node classes. + require "syntax_tree/reflection" + SyntaxTree::Reflection.nodes.each do |name, node| + next if name == :Statements + + clazz = SyntaxTree.const_get(name) + parameters = clazz.instance_method(:initialize).parameters + + # First, verify that all of the parameters listed in the list of attributes. + # If there are any parameters that aren't listed in the attributes, then + # something went wrong with the parsing in the reflection module. + raise unless (parameters.map(&:last) - node.attributes.keys).empty? + + # Now we're going to use an alias chain to redefine the initialize method to + # include type checking. + clazz.alias_method(:initialize_without_verify, :initialize) + clazz.define_method(:initialize) do |**kwargs| + kwargs.each do |kwarg, value| + attribute = node.attributes.fetch(kwarg) + + unless attribute.type === value + raise TypeError, + "invalid type for #{name}##{kwarg}, expected " \ + "#{attribute.type.inspect}, got #{value.inspect}" + end end - end - initialize_without_verify(**kwargs) + initialize_without_verify(**kwargs) + end end end diff --git a/test/with_scope_test.rb b/test/with_scope_test.rb index 9675e811..5bf276be 100644 --- a/test/with_scope_test.rb +++ b/test/with_scope_test.rb @@ -39,6 +39,13 @@ def visit_label(node) arguments[[current_scope.id, value]] = node end end + + def visit_vcall(node) + local = current_scope.find_local(node.value) + variables[[current_scope.id, value]] = local if local + + super + end end end @@ -110,11 +117,7 @@ def foo assert_equal(2, collector.variables.length) assert_variable(collector, "a", definitions: [2], usages: [4, 5]) - assert_variable(collector, "rest", definitions: [4]) - - # Rest is considered a vcall by the parser instead of a var_ref - # assert_equal(1, variable_rest.usages.length) - # assert_equal(6, variable_rest.usages[0].start_line) + assert_variable(collector, "rest", definitions: [4], usages: [6]) end if RUBY_VERSION >= "3.1" @@ -198,6 +201,25 @@ def foo assert_argument(collector, "i", definitions: [2], usages: [3]) end + def test_collecting_destructured_block_arguments + collector = Collector.collect(<<~RUBY) + [].each do |(a, *b)| + end + RUBY + + assert_equal(2, collector.arguments.length) + assert_argument(collector, "b", definitions: [1]) + end + + def test_collecting_anonymous_destructured_block_arguments + collector = Collector.collect(<<~RUBY) + [].each do |(a, *)| + end + RUBY + + assert_equal(1, collector.arguments.length) + end + def test_collecting_one_line_block_arguments collector = Collector.collect(<<~RUBY) def foo @@ -349,6 +371,58 @@ def test_double_nested_arguments assert_argument(collector, "four", definitions: [1], usages: [5]) end + def test_block_locals + collector = Collector.collect(<<~RUBY) + [].each do |; a| + end + RUBY + + assert_equal(1, collector.variables.length) + + assert_variable(collector, "a", definitions: [1]) + end + + def test_lambda_locals + collector = Collector.collect(<<~RUBY) + ->(;a) { } + RUBY + + assert_equal(1, collector.variables.length) + + assert_variable(collector, "a", definitions: [1]) + end + + def test_regex_named_capture_groups + collector = Collector.collect(<<~RUBY) + if /(?\\w+)-(?\\w+)/ =~ "something-else" + one + two + end + RUBY + + assert_equal(2, collector.variables.length) + + assert_variable(collector, "one", definitions: [1], usages: [2]) + assert_variable(collector, "two", definitions: [1], usages: [3]) + end + + def test_multiline_regex_named_capture_groups + collector = Collector.collect(<<~RUBY) + if %r{ + (?\\w+)- + (?\\w+) + } =~ "something-else" + one + two + end + RUBY + + assert_equal(2, collector.variables.length) + + assert_variable(collector, "one", definitions: [2], usages: [5]) + assert_variable(collector, "two", definitions: [3], usages: [6]) + end + class Resolver < Visitor prepend WithScope