From 585612a2992e7fed70570f1ebf5ff0c2cd8539ee Mon Sep 17 00:00:00 2001 From: Kevin Newton Date: Tue, 19 Aug 2025 13:59:18 -0400 Subject: [PATCH 1/9] Remove the WithScope extension --- README.md | 21 -- lib/syntax_tree.rb | 1 - lib/syntax_tree/with_scope.rb | 311 ------------------- test/with_scope_test.rb | 567 ---------------------------------- 4 files changed, 900 deletions(-) delete mode 100644 lib/syntax_tree/with_scope.rb delete mode 100644 test/with_scope_test.rb diff --git a/README.md b/README.md index c238620e..828f6a44 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,6 @@ It is built with only standard library dependencies. It additionally ships with - [visit_methods](#visit_methods) - [BasicVisitor](#basicvisitor) - [MutationVisitor](#mutationvisitor) - - [WithScope](#withscope) - [Language server](#language-server) - [textDocument/formatting](#textdocumentformatting) - [textDocument/inlayHint](#textdocumentinlayhint) @@ -621,26 +620,6 @@ SyntaxTree::Formatter.format(source, program.accept(visitor)) # => "if (a = 1)\nend\n" ``` -### WithScope - -The `WithScope` module can be included in visitors to automatically keep track of local variables and arguments defined inside each scope. A `current_scope` accessor is made available to the request, allowing it to find all usages and definitions of a local. - -```ruby -class MyVisitor < Visitor - prepend WithScope - - def visit_ident(node) - # find_local will return a Local for any local variables or arguments - # present in the current environment or nil if the identifier is not a local - local = current_scope.find_local(node) - - puts local.type # the type of the local (:variable or :argument) - puts local.definitions # the array of locations where this local is defined - puts local.usages # the array of locations where this local occurs - end -end -``` - ## Language server Syntax Tree additionally ships with a language server conforming to the [language server protocol](https://microsoft.github.io/language-server-protocol/). It can be invoked through the CLI by running: diff --git a/lib/syntax_tree.rb b/lib/syntax_tree.rb index 90fb7fe7..6696b56f 100644 --- a/lib/syntax_tree.rb +++ b/lib/syntax_tree.rb @@ -34,7 +34,6 @@ module SyntaxTree autoload :Pattern, "syntax_tree/pattern" autoload :PrettyPrintVisitor, "syntax_tree/pretty_print_visitor" autoload :Search, "syntax_tree/search" - autoload :WithScope, "syntax_tree/with_scope" # This holds references to objects that respond to both #parse and #format # so that we can use them in the CLI. diff --git a/lib/syntax_tree/with_scope.rb b/lib/syntax_tree/with_scope.rb deleted file mode 100644 index 8c4908f3..00000000 --- a/lib/syntax_tree/with_scope.rb +++ /dev/null @@ -1,311 +0,0 @@ -# frozen_string_literal: true - -module SyntaxTree - # WithScope is a module intended to be included in classes inheriting from - # Visitor. The module overrides a few visit methods to automatically keep - # track of local variables and arguments defined in the current scope. - # Example usage: - # - # class MyVisitor < Visitor - # include WithScope - # - # def visit_ident(node) - # # Check if we're visiting an identifier for an argument, a local - # # variable or something else - # local = current_scope.find_local(node) - # - # if local.type == :argument - # # handle identifiers for arguments - # elsif local.type == :variable - # # handle identifiers for variables - # else - # # handle other identifiers, such as method names - # end - # end - # end - # - module WithScope - # The scope class is used to keep track of local variables and arguments - # inside a particular scope. - class Scope - # This class tracks the occurrences of a local variable or argument. - class Local - # [Symbol] The type of the local (e.g. :argument, :variable) - attr_reader :type - - # [Array[Location]] The locations of all definitions and assignments of - # this local - attr_reader :definitions - - # [Array[Location]] The locations of all usages of this local - attr_reader :usages - - def initialize(type) - @type = type - @definitions = [] - @usages = [] - end - - def add_definition(location) - @definitions << location - end - - def add_usage(location) - @usages << location - end - end - - # [Integer] a unique identifier for this scope - attr_reader :id - - # [scope | nil] The parent scope - attr_reader :parent - - # [Hash[String, Local]] The local variables and arguments defined in this - # scope - attr_reader :locals - - def initialize(id, parent = nil) - @id = id - @parent = parent - @locals = {} - end - - # Adding a local definition will either insert a new entry in the locals - # hash or append a new definition location to an existing local. Notice - # that it's not possible to change the type of a local after it has been - # registered. - def add_local_definition(identifier, type) - name = identifier.value.delete_suffix(":") - - local = - if type == :argument - locals[name] ||= Local.new(type) - else - resolve_local(name, type) - end - - local.add_definition(identifier.location) - end - - # Adding a local usage will either insert a new entry in the locals - # hash or append a new usage location to an existing local. Notice that - # it's not possible to change the type of a local after it has been - # registered. - def add_local_usage(identifier, type) - name = identifier.value.delete_suffix(":") - resolve_local(name, type).add_usage(identifier.location) - end - - # Try to find the local given its name in this scope or any of its - # parents. - def find_local(name) - locals[name] || parent&.find_local(name) - end - - private - - def resolve_local(name, type) - local = find_local(name) - - unless local - local = Local.new(type) - locals[name] = local - end - - local - end - end - - attr_reader :current_scope - - def initialize(*args, **kwargs, &block) - super - - @current_scope = Scope.new(0) - @next_scope_id = 0 - end - - # Visits for nodes that create new scopes, such as classes, modules - # and method definitions. - def visit_class(node) - with_scope { super } - end - - def visit_module(node) - with_scope { super } - end - - # When we find a method invocation with a block, only the code that happens - # inside of the block needs a fresh scope. The method invocation - # itself happens in the same scope. - def visit_method_add_block(node) - visit(node.call) - with_scope(current_scope) { visit(node.block) } - end - - def visit_def(node) - with_scope { super } - end - - # Visit for keeping track of local arguments, such as method and block - # arguments. - def visit_params(node) - add_argument_definitions(node.requireds) - add_argument_definitions(node.posts) - - node.keywords.each do |param| - current_scope.add_local_definition(param.first, :argument) - end - - node.optionals.each do |param| - current_scope.add_local_definition(param.first, :argument) - end - - super - end - - def visit_rest_param(node) - name = node.name - current_scope.add_local_definition(name, :argument) if name - - super - end - - def visit_kwrest_param(node) - name = node.name - current_scope.add_local_definition(name, :argument) if name - - super - end - - def visit_blockarg(node) - name = node.name - current_scope.add_local_definition(name, :argument) if name - - 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 - current_scope.add_local_definition(value, :variable) if value.is_a?(Ident) - - super - end - - # Visit for keeping track of local variable definitions - def visit_pinned_var_ref(node) - value = node.value - current_scope.add_local_usage(value, :variable) if value.is_a?(Ident) - - super - end - - # Visits for keeping track of variable and argument usages - def visit_var_ref(node) - value = node.value - - if value.is_a?(Ident) - definition = current_scope.find_local(value.value) - current_scope.add_local_usage(value, definition.type) if definition - end - - 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| - 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) - end - end - end - - def next_scope_id - @next_scope_id += 1 - end - - def with_scope(parent_scope = nil) - previous_scope = @current_scope - @current_scope = Scope.new(next_scope_id, parent_scope) - yield - ensure - @current_scope = previous_scope - end - end -end diff --git a/test/with_scope_test.rb b/test/with_scope_test.rb deleted file mode 100644 index 6b48d17d..00000000 --- a/test/with_scope_test.rb +++ /dev/null @@ -1,567 +0,0 @@ -# frozen_string_literal: true - -require_relative "test_helper" - -module SyntaxTree - class WithScopeTest < Minitest::Test - class Collector < Visitor - prepend WithScope - - attr_reader :arguments, :variables - - def initialize - @arguments = {} - @variables = {} - end - - def self.collect(source) - new.tap { SyntaxTree.parse(source).accept(_1) } - end - - visit_methods do - def visit_ident(node) - value = node.value.delete_suffix(":") - local = current_scope.find_local(node.value) - - case local&.type - when :argument - arguments[[current_scope.id, value]] = local - when :variable - variables[[current_scope.id, value]] = local - end - end - - def visit_label(node) - value = node.value.delete_suffix(":") - local = current_scope.find_local(value) - - if local&.type == :argument - 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 - - def test_collecting_simple_variables - collector = Collector.collect(<<~RUBY) - def foo - a = 1 - a - end - RUBY - - assert_equal(1, collector.variables.length) - assert_variable(collector, "a", definitions: [2], usages: [3]) - end - - def test_collecting_aref_variables - collector = Collector.collect(<<~RUBY) - def foo - a = [] - a[1] - end - RUBY - - assert_equal(1, collector.variables.length) - assert_variable(collector, "a", definitions: [2], usages: [3]) - end - - def test_collecting_multi_assign_variables - collector = Collector.collect(<<~RUBY) - def foo - a, b = [1, 2] - puts a - puts b - end - RUBY - - assert_equal(2, collector.variables.length) - assert_variable(collector, "a", definitions: [2], usages: [3]) - assert_variable(collector, "b", definitions: [2], usages: [4]) - end - - def test_collecting_pattern_matching_variables - collector = Collector.collect(<<~RUBY) - def foo - case [1, 2] - in Integer => a, Integer - puts a - end - end - RUBY - - # There are two occurrences, one on line 3 for pinning and one on line 4 - # for reference - assert_equal(1, collector.variables.length) - assert_variable(collector, "a", definitions: [3], usages: [4]) - end - - def test_collecting_pinned_variables - collector = Collector.collect(<<~RUBY) - def foo - a = 18 - case [1, 2] - in ^a, *rest - puts a - puts rest - end - end - RUBY - - assert_equal(2, collector.variables.length) - assert_variable(collector, "a", definitions: [2], usages: [4, 5]) - assert_variable(collector, "rest", definitions: [4], usages: [6]) - end - - if RUBY_VERSION >= "3.1" - def test_collecting_one_line_pattern_matching_variables - collector = Collector.collect(<<~RUBY) - def foo - [1] => a - puts a - end - RUBY - - assert_equal(1, collector.variables.length) - assert_variable(collector, "a", definitions: [2], usages: [3]) - end - - def test_collecting_endless_method_arguments - collector = Collector.collect(<<~RUBY) - def foo(a) = puts a - RUBY - - assert_equal(1, collector.arguments.length) - assert_argument(collector, "a", definitions: [1], usages: [1]) - end - end - - def test_collecting_method_arguments - collector = Collector.collect(<<~RUBY) - def foo(a) - puts a - end - RUBY - - assert_equal(1, collector.arguments.length) - assert_argument(collector, "a", definitions: [1], usages: [2]) - end - - def test_collecting_methods_with_destructured_post_arguments - collector = Collector.collect(<<~RUBY) - def foo(optional = 1, (bin, bag)) - end - RUBY - - assert_equal(3, collector.arguments.length) - assert_argument(collector, "optional", definitions: [1], usages: []) - assert_argument(collector, "bin", definitions: [1], usages: []) - assert_argument(collector, "bag", definitions: [1], usages: []) - end - - def test_collecting_methods_with_desctructured_post_using_splat - collector = Collector.collect(<<~RUBY) - def foo(optional = 1, (bin, bag, *)) - end - RUBY - - assert_equal(3, collector.arguments.length) - assert_argument(collector, "optional", definitions: [1], usages: []) - assert_argument(collector, "bin", definitions: [1], usages: []) - assert_argument(collector, "bag", definitions: [1], usages: []) - end - - def test_collecting_methods_with_nested_desctructured - collector = Collector.collect(<<~RUBY) - def foo(optional = 1, (bin, (bag))) - end - RUBY - - assert_equal(3, collector.arguments.length) - assert_argument(collector, "optional", definitions: [1], usages: []) - assert_argument(collector, "bin", definitions: [1], usages: []) - assert_argument(collector, "bag", definitions: [1], usages: []) - end - - def test_collecting_singleton_method_arguments - collector = Collector.collect(<<~RUBY) - def self.foo(a) - puts a - end - RUBY - - assert_equal(1, collector.arguments.length) - assert_argument(collector, "a", definitions: [1], usages: [2]) - end - - def test_collecting_method_arguments_all_types - collector = Collector.collect(<<~RUBY) - def foo(a, b = 1, *c, d, e: 1, **f, &block) - puts a - puts b - puts c - puts d - puts e - puts f - block.call - end - RUBY - - assert_equal(7, collector.arguments.length) - assert_argument(collector, "a", definitions: [1], usages: [2]) - assert_argument(collector, "b", definitions: [1], usages: [3]) - assert_argument(collector, "c", definitions: [1], usages: [4]) - assert_argument(collector, "d", definitions: [1], usages: [5]) - assert_argument(collector, "e", definitions: [1], usages: [6]) - assert_argument(collector, "f", definitions: [1], usages: [7]) - assert_argument(collector, "block", definitions: [1], usages: [8]) - end - - def test_collecting_block_arguments - collector = Collector.collect(<<~RUBY) - def foo - [].each do |i| - puts i - end - end - RUBY - - assert_equal(1, collector.arguments.length) - 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 - [].each { |i| puts i } - end - RUBY - - assert_equal(1, collector.arguments.length) - assert_argument(collector, "i", definitions: [2], usages: [2]) - end - - def test_collecting_shadowed_block_arguments - collector = Collector.collect(<<~RUBY) - def foo - i = "something" - - [].each do |i| - puts i - end - - i - end - RUBY - - assert_equal(1, collector.arguments.length) - assert_argument(collector, "i", definitions: [4], usages: [5]) - - assert_equal(1, collector.variables.length) - assert_variable(collector, "i", definitions: [2], usages: [8]) - end - - def test_collecting_shadowed_local_variables - collector = Collector.collect(<<~RUBY) - def foo(a) - puts a - a = 123 - a - end - RUBY - - # All occurrences are considered arguments, despite overriding the - # argument value - assert_equal(1, collector.arguments.length) - assert_equal(0, collector.variables.length) - assert_argument(collector, "a", definitions: [1, 3], usages: [2, 4]) - end - - def test_variables_in_the_top_level - collector = Collector.collect(<<~RUBY) - a = 123 - a - RUBY - - assert_equal(0, collector.arguments.length) - assert_equal(1, collector.variables.length) - assert_variable(collector, "a", definitions: [1], usages: [2]) - end - - def test_aref_field - collector = Collector.collect(<<~RUBY) - object = {} - object["name"] = "something" - RUBY - - assert_equal(0, collector.arguments.length) - assert_equal(1, collector.variables.length) - assert_variable(collector, "object", definitions: [1], usages: [2]) - end - - def test_aref_on_a_method_call - collector = Collector.collect(<<~RUBY) - object = MyObject.new - object.attributes["name"] = "something" - RUBY - - assert_equal(0, collector.arguments.length) - assert_equal(1, collector.variables.length) - assert_variable(collector, "object", definitions: [1], usages: [2]) - end - - def test_aref_with_two_accesses - collector = Collector.collect(<<~RUBY) - object = MyObject.new - object["first"]["second"] ||= [] - RUBY - - assert_equal(0, collector.arguments.length) - assert_equal(1, collector.variables.length) - assert_variable(collector, "object", definitions: [1], usages: [2]) - end - - def test_aref_on_a_method_call_with_arguments - collector = Collector.collect(<<~RUBY) - object = MyObject.new - object.instance_variable_get(:@attributes)[:something] = :other_thing - RUBY - - assert_equal(0, collector.arguments.length) - assert_equal(1, collector.variables.length) - assert_variable(collector, "object", definitions: [1], usages: [2]) - end - - def test_double_aref_on_method_call - collector = Collector.collect(<<~RUBY) - object = MyObject.new - object["attributes"].find { |a| a["field"] == "expected" }["value"] = "changed" - RUBY - - assert_equal(1, collector.arguments.length) - assert_argument(collector, "a", definitions: [2], usages: [2]) - - assert_equal(1, collector.variables.length) - assert_variable(collector, "object", definitions: [1], usages: [2]) - end - - def test_nested_arguments - collector = Collector.collect(<<~RUBY) - [[1, [2, 3]]].each do |one, (two, three)| - one - two - three - end - RUBY - - assert_equal(3, collector.arguments.length) - assert_equal(0, collector.variables.length) - - assert_argument(collector, "one", definitions: [1], usages: [2]) - assert_argument(collector, "two", definitions: [1], usages: [3]) - assert_argument(collector, "three", definitions: [1], usages: [4]) - end - - def test_double_nested_arguments - collector = Collector.collect(<<~RUBY) - [[1, [2, 3]]].each do |one, (two, (three, four))| - one - two - three - four - end - RUBY - - assert_equal(4, collector.arguments.length) - assert_equal(0, collector.variables.length) - - assert_argument(collector, "one", definitions: [1], usages: [2]) - assert_argument(collector, "two", definitions: [1], usages: [3]) - assert_argument(collector, "three", definitions: [1], usages: [4]) - 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 - - attr_reader :locals - - def initialize - @locals = [] - end - - visit_methods do - def visit_assign(node) - super.tap do - level = 0 - name = node.target.value.value - - scope = current_scope - while !scope.locals.key?(name) && !scope.parent.nil? - level += 1 - scope = scope.parent - end - - locals << [name, level] - end - end - end - end - - def test_resolver - source = <<~RUBY - module Level0 - level0 = 0 - - class Level1 - level1 = 1 - - def level2 - level2 = 2 - - tap do |level3| - level2 = 2 - level3 = 3 - - tap do |level4| - level2 = 2 - level4 = 4 - end - end - end - end - end - RUBY - - resolver = Resolver.new - SyntaxTree.parse(source).accept(resolver) - - expected = [ - ["level0", 0], - ["level1", 0], - ["level2", 0], - ["level2", 1], - ["level3", 0], - ["level2", 2], - ["level4", 0] - ] - - assert_equal expected, resolver.locals - end - - private - - def assert_collected(field, name, definitions: [], usages: []) - keys = field.keys.select { |key| key[1] == name } - assert_equal(1, keys.length) - - variable = field[keys.first] - - assert_equal(definitions.length, variable.definitions.length) - definitions.each_with_index do |definition, index| - assert_equal(definition, variable.definitions[index].start_line) - end - - assert_equal(usages.length, variable.usages.length) - usages.each_with_index do |usage, index| - assert_equal(usage, variable.usages[index].start_line) - end - end - - def assert_argument(collector, name, definitions: [], usages: []) - assert_collected( - collector.arguments, - name, - definitions: definitions, - usages: usages - ) - end - - def assert_variable(collector, name, definitions: [], usages: []) - assert_collected( - collector.variables, - name, - definitions: definitions, - usages: usages - ) - end - end -end From 496e9a41c81b6805923b368204c8731b740872cd Mon Sep 17 00:00:00 2001 From: Kevin Newton Date: Tue, 19 Aug 2025 14:00:14 -0400 Subject: [PATCH 2/9] Remove the Database module --- lib/syntax_tree.rb | 1 - lib/syntax_tree/database.rb | 331 ------------------------------------ 2 files changed, 332 deletions(-) delete mode 100644 lib/syntax_tree/database.rb diff --git a/lib/syntax_tree.rb b/lib/syntax_tree.rb index 6696b56f..f1e33a46 100644 --- a/lib/syntax_tree.rb +++ b/lib/syntax_tree.rb @@ -21,7 +21,6 @@ module SyntaxTree # CLI. Requiring those features takes time, so we autoload as many constants # as possible in order to keep the CLI as fast as possible. - autoload :Database, "syntax_tree/database" autoload :DSL, "syntax_tree/dsl" autoload :FieldVisitor, "syntax_tree/field_visitor" autoload :Index, "syntax_tree/index" diff --git a/lib/syntax_tree/database.rb b/lib/syntax_tree/database.rb deleted file mode 100644 index c9981f35..00000000 --- a/lib/syntax_tree/database.rb +++ /dev/null @@ -1,331 +0,0 @@ -# frozen_string_literal: true - -module SyntaxTree - # Provides the ability to index source files into a database, then query for - # the nodes. - module Database - class IndexingVisitor < SyntaxTree::FieldVisitor - attr_reader :database, :filepath, :node_id - - def initialize(database, filepath) - @database = database - @filepath = filepath - @node_id = nil - end - - private - - def comments(node) - end - - def field(name, value) - return unless value.is_a?(SyntaxTree::Node) - - binds = [node_id, visit(value), name] - database.execute(<<~SQL, binds) - INSERT INTO edges (from_id, to_id, name) - VALUES (?, ?, ?) - SQL - end - - def list(name, values) - values.each_with_index do |value, index| - binds = [node_id, visit(value), name, index] - database.execute(<<~SQL, binds) - INSERT INTO edges (from_id, to_id, name, list_index) - VALUES (?, ?, ?, ?) - SQL - end - end - - def node(node, _name) - previous = node_id - binds = [ - node.class.name.delete_prefix("SyntaxTree::"), - filepath, - node.location.start_line, - node.location.start_column - ] - - database.execute(<<~SQL, binds) - INSERT INTO nodes (type, path, line, column) - VALUES (?, ?, ?, ?) - SQL - - begin - @node_id = database.last_insert_row_id - yield - @node_id - ensure - @node_id = previous - end - end - - def text(name, value) - end - - def pairs(name, values) - values.each_with_index do |(key, value), index| - binds = [node_id, visit(key), "#{name}[0]", index] - database.execute(<<~SQL, binds) - INSERT INTO edges (from_id, to_id, name, list_index) - VALUES (?, ?, ?, ?) - SQL - - binds = [node_id, visit(value), "#{name}[1]", index] - database.execute(<<~SQL, binds) - INSERT INTO edges (from_id, to_id, name, list_index) - VALUES (?, ?, ?, ?) - SQL - end - end - end - - # Query for a specific type of node. - class TypeQuery - attr_reader :type - - def initialize(type) - @type = type - end - - def each(database, &block) - sql = "SELECT * FROM nodes WHERE type = ?" - database.execute(sql, type).each(&block) - end - end - - # Query for the attributes of a node, optionally also filtering by type. - class AttrQuery - attr_reader :type, :attrs - - def initialize(type, attrs) - @type = type - @attrs = attrs - end - - def each(database, &block) - joins = [] - binds = [] - - attrs.each do |name, query| - ids = query.each(database).map { |row| row[0] } - joins << <<~SQL - JOIN edges AS #{name} - ON #{name}.from_id = nodes.id - AND #{name}.name = ? - AND #{name}.to_id IN (#{(["?"] * ids.size).join(", ")}) - SQL - - binds.push(name).concat(ids) - end - - sql = +"SELECT nodes.* FROM nodes, edges #{joins.join(" ")}" - - if type - sql << " WHERE nodes.type = ?" - binds << type - end - - sql << " GROUP BY nodes.id" - database.execute(sql, binds).each(&block) - end - end - - # Query for the results of either query. - class OrQuery - attr_reader :left, :right - - def initialize(left, right) - @left = left - @right = right - end - - def each(database, &block) - left.each(database, &block) - right.each(database, &block) - end - end - - # A lazy query result. - class QueryResult - attr_reader :database, :query - - def initialize(database, query) - @database = database - @query = query - end - - def each(&block) - return enum_for(__method__) unless block_given? - query.each(database, &block) - end - end - - # A pattern matching expression that will be compiled into a query. - class Pattern - class CompilationError < StandardError - end - - attr_reader :query - - def initialize(query) - @query = query - end - - def compile - program = - begin - SyntaxTree.parse("case nil\nin #{query}\nend") - rescue Parser::ParseError - raise CompilationError, query - end - - compile_node(program.statements.body.first.consequent.pattern) - end - - private - - def compile_error(node) - raise CompilationError, PP.pp(node, +"").chomp - end - - # Shortcut for combining two queries into one that returns the results of - # if either query matches. - def combine_or(left, right) - OrQuery.new(left, right) - end - - # in foo | bar - def compile_binary(node) - compile_error(node) if node.operator != :| - - combine_or(compile_node(node.left), compile_node(node.right)) - end - - # in Ident - def compile_const(node) - value = node.value - - if SyntaxTree.const_defined?(value, false) - clazz = SyntaxTree.const_get(value) - TypeQuery.new(clazz.name.delete_prefix("SyntaxTree::")) - else - compile_error(node) - end - end - - # in SyntaxTree::Ident - def compile_const_path_ref(node) - parent = node.parent - if !parent.is_a?(SyntaxTree::VarRef) || - !parent.value.is_a?(SyntaxTree::Const) - compile_error(node) - end - - if parent.value.value == "SyntaxTree" - compile_node(node.constant) - else - compile_error(node) - end - end - - # in Ident[value: String] - def compile_hshptn(node) - compile_error(node) unless node.keyword_rest.nil? - - attrs = {} - node.keywords.each do |keyword, value| - compile_error(node) unless keyword.is_a?(SyntaxTree::Label) - attrs[keyword.value.chomp(":")] = compile_node(value) - end - - type = node.constant ? compile_node(node.constant).type : nil - AttrQuery.new(type, attrs) - end - - # in Foo - def compile_var_ref(node) - value = node.value - - if value.is_a?(SyntaxTree::Const) - compile_node(value) - else - compile_error(node) - end - end - - def compile_node(node) - case node - when SyntaxTree::Binary - compile_binary(node) - when SyntaxTree::Const - compile_const(node) - when SyntaxTree::ConstPathRef - compile_const_path_ref(node) - when SyntaxTree::HshPtn - compile_hshptn(node) - when SyntaxTree::VarRef - compile_var_ref(node) - else - compile_error(node) - end - end - end - - class Connection - attr_reader :raw_connection - - def initialize(raw_connection) - @raw_connection = raw_connection - end - - def execute(query, binds = []) - raw_connection.execute(query, binds) - end - - def index_file(filepath) - program = SyntaxTree.parse(SyntaxTree.read(filepath)) - program.accept(IndexingVisitor.new(self, filepath)) - end - - def last_insert_row_id - raw_connection.last_insert_row_id - end - - def prepare - raw_connection.execute(<<~SQL) - CREATE TABLE nodes ( - id integer primary key, - type varchar(20), - path varchar(200), - line integer, - column integer - ); - SQL - - raw_connection.execute(<<~SQL) - CREATE INDEX nodes_type ON nodes (type); - SQL - - raw_connection.execute(<<~SQL) - CREATE TABLE edges ( - id integer primary key, - from_id integer, - to_id integer, - name varchar(20), - list_index integer - ); - SQL - - raw_connection.execute(<<~SQL) - CREATE INDEX edges_name ON edges (name); - SQL - end - - def search(query) - QueryResult.new(self, Pattern.new(query).compile) - end - end - end -end From 3841a1d683d89d9991e7173e1b07959fe6f31b90 Mon Sep 17 00:00:00 2001 From: Kevin Newton Date: Tue, 19 Aug 2025 14:01:23 -0400 Subject: [PATCH 3/9] Remove the sorbet rake task --- Rakefile | 2 - tasks/sorbet.rake | 373 ---------------------------------------------- 2 files changed, 375 deletions(-) delete mode 100644 tasks/sorbet.rake diff --git a/Rakefile b/Rakefile index fb4f8847..69bb0f1c 100644 --- a/Rakefile +++ b/Rakefile @@ -4,8 +4,6 @@ require "bundler/gem_tasks" require "rake/testtask" require "syntax_tree/rake_tasks" -Rake.add_rakelib "tasks" - Rake::TestTask.new(:test) do |t| t.libs << "test" t.libs << "lib" diff --git a/tasks/sorbet.rake b/tasks/sorbet.rake deleted file mode 100644 index 05f48874..00000000 --- a/tasks/sorbet.rake +++ /dev/null @@ -1,373 +0,0 @@ -# frozen_string_literal: true - -module SyntaxTree - class RBI - include DSL - - attr_reader :body, :line - - def initialize - @body = [] - @line = 1 - end - - def generate - require "syntax_tree/reflection" - - body << Comment("# typed: strict", false, location) - @line += 2 - - generate_parent - Reflection.nodes.sort.each { |(_, node)| generate_node(node) } - - body << ClassDeclaration( - ConstPathRef(VarRef(Const("SyntaxTree")), Const("BasicVisitor")), - nil, - BodyStmt( - Statements(generate_visitor("overridable")), - nil, - nil, - nil, - nil - ), - location - ) - - body << ClassDeclaration( - ConstPathRef(VarRef(Const("SyntaxTree")), Const("Visitor")), - ConstPathRef(VarRef(Const("SyntaxTree")), Const("BasicVisitor")), - BodyStmt(Statements(generate_visitor("override")), nil, nil, nil, nil), - location - ) - - Formatter.format(nil, Program(Statements(body))) - end - - private - - def generate_comments(comment) - comment - .lines(chomp: true) - .map { |line| Comment("# #{line}", false, location).tap { @line += 1 } } - end - - def generate_parent - attribute = Reflection.nodes[:Program].attributes[:location] - class_location = location - - node_body = generate_comments(attribute.comment) - node_body << sig_block { sig_returns { sig_type_for(attribute.type) } } - @line += 1 - - node_body << Command( - Ident("attr_reader"), - Args([SymbolLiteral(Ident("location"))]), - nil, - location - ) - @line += 1 - - body << ClassDeclaration( - ConstPathRef(VarRef(Const("SyntaxTree")), Const("Node")), - nil, - BodyStmt(Statements(node_body), nil, nil, nil, nil), - class_location - ) - @line += 2 - end - - def generate_node(node) - body.concat(generate_comments(node.comment)) - class_location = location - @line += 2 - - body << ClassDeclaration( - ConstPathRef(VarRef(Const("SyntaxTree")), Const(node.name.to_s)), - ConstPathRef(VarRef(Const("SyntaxTree")), Const("Node")), - BodyStmt(Statements(generate_node_body(node)), nil, nil, nil, nil), - class_location - ) - - @line += 2 - end - - def generate_node_body(node) - node_body = [] - node.attributes.sort.each do |(name, attribute)| - next if name == :location - - node_body.concat(generate_comments(attribute.comment)) - node_body << sig_block { sig_returns { sig_type_for(attribute.type) } } - @line += 1 - - node_body << Command( - Ident("attr_reader"), - Args([SymbolLiteral(Ident(attribute.name.to_s))]), - nil, - location - ) - @line += 2 - end - - node_body.concat(generate_initialize(node)) - - node_body << sig_block do - CallNode( - sig_params do - BareAssocHash( - [Assoc(Label("visitor:"), sig_type_for(BasicVisitor))] - ) - end, - Period("."), - Ident("returns"), - ArgParen( - Args( - [CallNode(VarRef(Const("T")), Period("."), Ident("untyped"), nil)] - ) - ) - ) - end - @line += 1 - - node_body << generate_def_node( - "accept", - Paren( - LParen("("), - Params.new(requireds: [Ident("visitor")], location: location) - ) - ) - @line += 2 - - node_body << generate_child_nodes - @line += 1 - - node_body << generate_def_node("child_nodes", nil) - @line += 2 - - node_body << sig_block do - CallNode( - sig_params do - BareAssocHash( - [ - Assoc( - Label("other:"), - CallNode( - VarRef(Const("T")), - Period("."), - Ident("untyped"), - nil - ) - ) - ] - ) - end, - Period("."), - sig_returns { ConstPathRef(VarRef(Const("T")), Const("Boolean")) }, - nil - ) - end - @line += 1 - - node_body << generate_def_node( - "==", - Paren( - LParen("("), - Params.new(location: location, requireds: [Ident("other")]) - ) - ) - @line += 2 - - node_body - end - - def generate_initialize(node) - parameters = - SyntaxTree.const_get(node.name).instance_method(:initialize).parameters - - assocs = - parameters.map do |(_, name)| - Assoc(Label("#{name}:"), sig_type_for(node.attributes[name].type)) - end - - node_body = [] - node_body << sig_block do - CallNode( - sig_params { BareAssocHash(assocs) }, - Period("."), - Ident("void"), - nil - ) - end - @line += 1 - - params = Params.new(location: location) - parameters.each do |(type, name)| - case type - when :req - params.requireds << Ident(name.to_s) - when :keyreq - params.keywords << [Label("#{name}:"), nil] - when :key - params.keywords << [ - Label("#{name}:"), - CallNode( - VarRef(Const("T")), - Period("."), - Ident("unsafe"), - ArgParen(Args([VarRef(Kw("nil"))])) - ) - ] - else - raise - end - end - - node_body << generate_def_node("initialize", Paren(LParen("("), params)) - @line += 2 - - node_body - end - - def generate_child_nodes - type = - Reflection::Type::ArrayType.new( - Reflection::Type::UnionType.new([NilClass, Node]) - ) - - sig_block { sig_returns { sig_type_for(type) } } - end - - def generate_def_node(name, params) - DefNode( - nil, - nil, - Ident(name), - params, - BodyStmt(Statements([VoidStmt()]), nil, nil, nil, nil), - location - ) - end - - def generate_visitor(override) - body = [] - - Reflection.nodes.each do |name, node| - body << sig_block do - CallNode( - CallNode( - Ident(override), - Period("."), - sig_params do - BareAssocHash( - [ - Assoc( - Label("node:"), - sig_type_for(SyntaxTree.const_get(name)) - ) - ] - ) - end, - nil - ), - Period("."), - sig_returns do - CallNode(VarRef(Const("T")), Period("."), Ident("untyped"), nil) - end, - nil - ) - end - - body << generate_def_node( - node.visitor_method, - Paren( - LParen("("), - Params.new(requireds: [Ident("node")], location: location) - ) - ) - - @line += 2 - end - - body - end - - def sig_block - MethodAddBlock( - CallNode(nil, nil, Ident("sig"), nil), - BlockNode( - LBrace("{"), - nil, - BodyStmt(Statements([yield]), nil, nil, nil, nil) - ), - location - ) - end - - def sig_params - CallNode(nil, nil, Ident("params"), ArgParen(Args([yield]))) - end - - def sig_returns - CallNode(nil, nil, Ident("returns"), ArgParen(Args([yield]))) - end - - def sig_type_for(type) - case type - when Reflection::Type::ArrayType - ARef( - ConstPathRef(VarRef(Const("T")), Const("Array")), - sig_type_for(type.type) - ) - when Reflection::Type::TupleType - ArrayLiteral(LBracket("["), Args(type.types.map { sig_type_for(_1) })) - when Reflection::Type::UnionType - if type.types.include?(NilClass) - selected = type.types.reject { _1 == NilClass } - subtype = - if selected.size == 1 - selected.first - else - Reflection::Type::UnionType.new(selected) - end - - CallNode( - VarRef(Const("T")), - Period("."), - Ident("nilable"), - ArgParen(Args([sig_type_for(subtype)])) - ) - else - CallNode( - VarRef(Const("T")), - Period("."), - Ident("any"), - ArgParen(Args(type.types.map { sig_type_for(_1) })) - ) - end - when Symbol - ConstRef(Const("Symbol")) - else - *parents, constant = type.name.split("::").map { Const(_1) } - - if parents.empty? - ConstRef(constant) - else - [*parents[1..], constant].inject( - VarRef(parents.first) - ) { |accum, const| ConstPathRef(accum, const) } - end - end - end - - def location - Location.fixed(line: line, char: 0, column: 0) - end - end -end - -namespace :sorbet do - desc "Generate RBI files for Sorbet" - task :rbi do - puts SyntaxTree::RBI.new.generate - end -end From 838019abe18a2d55ded1743f16583dd6e6487619 Mon Sep 17 00:00:00 2001 From: Kevin Newton Date: Tue, 19 Aug 2025 14:01:49 -0400 Subject: [PATCH 4/9] Remove the DSL module --- lib/syntax_tree.rb | 1 - lib/syntax_tree/dsl.rb | 1016 ---------------------------------------- 2 files changed, 1017 deletions(-) delete mode 100644 lib/syntax_tree/dsl.rb diff --git a/lib/syntax_tree.rb b/lib/syntax_tree.rb index f1e33a46..69d2252a 100644 --- a/lib/syntax_tree.rb +++ b/lib/syntax_tree.rb @@ -21,7 +21,6 @@ module SyntaxTree # CLI. Requiring those features takes time, so we autoload as many constants # as possible in order to keep the CLI as fast as possible. - autoload :DSL, "syntax_tree/dsl" autoload :FieldVisitor, "syntax_tree/field_visitor" autoload :Index, "syntax_tree/index" autoload :JSONVisitor, "syntax_tree/json_visitor" diff --git a/lib/syntax_tree/dsl.rb b/lib/syntax_tree/dsl.rb deleted file mode 100644 index 4506aa04..00000000 --- a/lib/syntax_tree/dsl.rb +++ /dev/null @@ -1,1016 +0,0 @@ -# frozen_string_literal: true - -module SyntaxTree - # This module provides shortcuts for creating AST nodes. - module DSL - # Create a new BEGINBlock node. - def BEGINBlock(lbrace, statements) - BEGINBlock.new( - lbrace: lbrace, - statements: statements, - location: Location.default - ) - end - - # Create a new CHAR node. - def CHAR(value) - CHAR.new(value: value, location: Location.default) - end - - # Create a new ENDBlock node. - def ENDBlock(lbrace, statements) - ENDBlock.new( - lbrace: lbrace, - statements: statements, - location: Location.default - ) - end - - # Create a new EndContent node. - def EndContent(value) - EndContent.new(value: value, location: Location.default) - end - - # Create a new AliasNode node. - def AliasNode(left, right) - AliasNode.new(left: left, right: right, location: Location.default) - end - - # Create a new ARef node. - def ARef(collection, index) - ARef.new(collection: collection, index: index, location: Location.default) - end - - # Create a new ARefField node. - def ARefField(collection, index) - ARefField.new( - collection: collection, - index: index, - location: Location.default - ) - end - - # Create a new ArgParen node. - def ArgParen(arguments) - ArgParen.new(arguments: arguments, location: Location.default) - end - - # Create a new Args node. - def Args(parts) - Args.new(parts: parts, location: Location.default) - end - - # Create a new ArgBlock node. - def ArgBlock(value) - ArgBlock.new(value: value, location: Location.default) - end - - # Create a new ArgStar node. - def ArgStar(value) - ArgStar.new(value: value, location: Location.default) - end - - # Create a new ArgsForward node. - def ArgsForward - ArgsForward.new(location: Location.default) - end - - # Create a new ArrayLiteral node. - def ArrayLiteral(lbracket, contents) - ArrayLiteral.new( - lbracket: lbracket, - contents: contents, - location: Location.default - ) - end - - # Create a new AryPtn node. - def AryPtn(constant, requireds, rest, posts) - AryPtn.new( - constant: constant, - requireds: requireds, - rest: rest, - posts: posts, - location: Location.default - ) - end - - # Create a new Assign node. - def Assign(target, value) - Assign.new(target: target, value: value, location: Location.default) - end - - # Create a new Assoc node. - def Assoc(key, value) - Assoc.new(key: key, value: value, location: Location.default) - end - - # Create a new AssocSplat node. - def AssocSplat(value) - AssocSplat.new(value: value, location: Location.default) - end - - # Create a new Backref node. - def Backref(value) - Backref.new(value: value, location: Location.default) - end - - # Create a new Backtick node. - def Backtick(value) - Backtick.new(value: value, location: Location.default) - end - - # Create a new BareAssocHash node. - def BareAssocHash(assocs) - BareAssocHash.new(assocs: assocs, location: Location.default) - end - - # Create a new Begin node. - def Begin(bodystmt) - Begin.new(bodystmt: bodystmt, location: Location.default) - end - - # Create a new PinnedBegin node. - def PinnedBegin(statement) - PinnedBegin.new(statement: statement, location: Location.default) - end - - # Create a new Binary node. - def Binary(left, operator, right) - Binary.new( - left: left, - operator: operator, - right: right, - location: Location.default - ) - end - - # Create a new BlockVar node. - def BlockVar(params, locals) - BlockVar.new(params: params, locals: locals, location: Location.default) - end - - # Create a new BlockArg node. - def BlockArg(name) - BlockArg.new(name: name, location: Location.default) - end - - # Create a new BodyStmt node. - def BodyStmt( - statements, - rescue_clause, - else_keyword, - else_clause, - ensure_clause - ) - BodyStmt.new( - statements: statements, - rescue_clause: rescue_clause, - else_keyword: else_keyword, - else_clause: else_clause, - ensure_clause: ensure_clause, - location: Location.default - ) - end - - # Create a new Break node. - def Break(arguments) - Break.new(arguments: arguments, location: Location.default) - end - - # Create a new CallNode node. - def CallNode(receiver, operator, message, arguments) - CallNode.new( - receiver: receiver, - operator: operator, - message: message, - arguments: arguments, - location: Location.default - ) - end - - # Create a new Case node. - def Case(keyword, value, consequent) - Case.new( - keyword: keyword, - value: value, - consequent: consequent, - location: Location.default - ) - end - - # Create a new RAssign node. - def RAssign(value, operator, pattern) - RAssign.new( - value: value, - operator: operator, - pattern: pattern, - location: Location.default - ) - end - - # Create a new ClassDeclaration node. - def ClassDeclaration( - constant, - superclass, - bodystmt, - location = Location.default - ) - ClassDeclaration.new( - constant: constant, - superclass: superclass, - bodystmt: bodystmt, - location: location - ) - end - - # Create a new Comma node. - def Comma(value) - Comma.new(value: value, location: Location.default) - end - - # Create a new Command node. - def Command(message, arguments, block, location = Location.default) - Command.new( - message: message, - arguments: arguments, - block: block, - location: location - ) - end - - # Create a new CommandCall node. - def CommandCall(receiver, operator, message, arguments, block) - CommandCall.new( - receiver: receiver, - operator: operator, - message: message, - arguments: arguments, - block: block, - location: Location.default - ) - end - - # Create a new Comment node. - def Comment(value, inline, location = Location.default) - Comment.new(value: value, inline: inline, location: location) - end - - # Create a new Const node. - def Const(value) - Const.new(value: value, location: Location.default) - end - - # Create a new ConstPathField node. - def ConstPathField(parent, constant) - ConstPathField.new( - parent: parent, - constant: constant, - location: Location.default - ) - end - - # Create a new ConstPathRef node. - def ConstPathRef(parent, constant) - ConstPathRef.new( - parent: parent, - constant: constant, - location: Location.default - ) - end - - # Create a new ConstRef node. - def ConstRef(constant) - ConstRef.new(constant: constant, location: Location.default) - end - - # Create a new CVar node. - def CVar(value) - CVar.new(value: value, location: Location.default) - end - - # Create a new DefNode node. - def DefNode( - target, - operator, - name, - params, - bodystmt, - location = Location.default - ) - DefNode.new( - target: target, - operator: operator, - name: name, - params: params, - bodystmt: bodystmt, - location: location - ) - end - - # Create a new Defined node. - def Defined(value) - Defined.new(value: value, location: Location.default) - end - - # Create a new BlockNode node. - def BlockNode(opening, block_var, bodystmt) - BlockNode.new( - opening: opening, - block_var: block_var, - bodystmt: bodystmt, - location: Location.default - ) - end - - # Create a new RangeNode node. - def RangeNode(left, operator, right) - RangeNode.new( - left: left, - operator: operator, - right: right, - location: Location.default - ) - end - - # Create a new DynaSymbol node. - def DynaSymbol(parts, quote) - DynaSymbol.new(parts: parts, quote: quote, location: Location.default) - end - - # Create a new Else node. - def Else(keyword, statements) - Else.new( - keyword: keyword, - statements: statements, - location: Location.default - ) - end - - # Create a new Elsif node. - def Elsif(predicate, statements, consequent) - Elsif.new( - predicate: predicate, - statements: statements, - consequent: consequent, - location: Location.default - ) - end - - # Create a new EmbDoc node. - def EmbDoc(value) - EmbDoc.new(value: value, location: Location.default) - end - - # Create a new EmbExprBeg node. - def EmbExprBeg(value) - EmbExprBeg.new(value: value, location: Location.default) - end - - # Create a new EmbExprEnd node. - def EmbExprEnd(value) - EmbExprEnd.new(value: value, location: Location.default) - end - - # Create a new EmbVar node. - def EmbVar(value) - EmbVar.new(value: value, location: Location.default) - end - - # Create a new Ensure node. - def Ensure(keyword, statements) - Ensure.new( - keyword: keyword, - statements: statements, - location: Location.default - ) - end - - # Create a new ExcessedComma node. - def ExcessedComma(value) - ExcessedComma.new(value: value, location: Location.default) - end - - # Create a new Field node. - def Field(parent, operator, name) - Field.new( - parent: parent, - operator: operator, - name: name, - location: Location.default - ) - end - - # Create a new FloatLiteral node. - def FloatLiteral(value) - FloatLiteral.new(value: value, location: Location.default) - end - - # Create a new FndPtn node. - def FndPtn(constant, left, values, right) - FndPtn.new( - constant: constant, - left: left, - values: values, - right: right, - location: Location.default - ) - end - - # Create a new For node. - def For(index, collection, statements) - For.new( - index: index, - collection: collection, - statements: statements, - location: Location.default - ) - end - - # Create a new GVar node. - def GVar(value) - GVar.new(value: value, location: Location.default) - end - - # Create a new HashLiteral node. - def HashLiteral(lbrace, assocs) - HashLiteral.new( - lbrace: lbrace, - assocs: assocs, - location: Location.default - ) - end - - # Create a new Heredoc node. - def Heredoc(beginning, ending, dedent, parts) - Heredoc.new( - beginning: beginning, - ending: ending, - dedent: dedent, - parts: parts, - location: Location.default - ) - end - - # Create a new HeredocBeg node. - def HeredocBeg(value) - HeredocBeg.new(value: value, location: Location.default) - end - - # Create a new HeredocEnd node. - def HeredocEnd(value) - HeredocEnd.new(value: value, location: Location.default) - end - - # Create a new HshPtn node. - def HshPtn(constant, keywords, keyword_rest) - HshPtn.new( - constant: constant, - keywords: keywords, - keyword_rest: keyword_rest, - location: Location.default - ) - end - - # Create a new Ident node. - def Ident(value) - Ident.new(value: value, location: Location.default) - end - - # Create a new IfNode node. - def IfNode(predicate, statements, consequent) - IfNode.new( - predicate: predicate, - statements: statements, - consequent: consequent, - location: Location.default - ) - end - - # Create a new IfOp node. - def IfOp(predicate, truthy, falsy) - IfOp.new( - predicate: predicate, - truthy: truthy, - falsy: falsy, - location: Location.default - ) - end - - # Create a new Imaginary node. - def Imaginary(value) - Imaginary.new(value: value, location: Location.default) - end - - # Create a new In node. - def In(pattern, statements, consequent) - In.new( - pattern: pattern, - statements: statements, - consequent: consequent, - location: Location.default - ) - end - - # Create a new Int node. - def Int(value) - Int.new(value: value, location: Location.default) - end - - # Create a new IVar node. - def IVar(value) - IVar.new(value: value, location: Location.default) - end - - # Create a new Kw node. - def Kw(value) - Kw.new(value: value, location: Location.default) - end - - # Create a new KwRestParam node. - def KwRestParam(name) - KwRestParam.new(name: name, location: Location.default) - end - - # Create a new Label node. - def Label(value) - Label.new(value: value, location: Location.default) - end - - # Create a new LabelEnd node. - def LabelEnd(value) - LabelEnd.new(value: value, location: Location.default) - end - - # Create a new Lambda node. - def Lambda(params, statements) - Lambda.new( - params: params, - statements: statements, - location: Location.default - ) - end - - # Create a new LambdaVar node. - def LambdaVar(params, locals) - LambdaVar.new(params: params, locals: locals, location: Location.default) - end - - # Create a new LBrace node. - def LBrace(value) - LBrace.new(value: value, location: Location.default) - end - - # Create a new LBracket node. - def LBracket(value) - LBracket.new(value: value, location: Location.default) - end - - # Create a new LParen node. - def LParen(value) - LParen.new(value: value, location: Location.default) - end - - # Create a new MAssign node. - def MAssign(target, value) - MAssign.new(target: target, value: value, location: Location.default) - end - - # Create a new MethodAddBlock node. - def MethodAddBlock(call, block, location = Location.default) - MethodAddBlock.new(call: call, block: block, location: location) - end - - # Create a new MLHS node. - def MLHS(parts, comma) - MLHS.new(parts: parts, comma: comma, location: Location.default) - end - - # Create a new MLHSParen node. - def MLHSParen(contents, comma) - MLHSParen.new( - contents: contents, - comma: comma, - location: Location.default - ) - end - - # Create a new ModuleDeclaration node. - def ModuleDeclaration(constant, bodystmt) - ModuleDeclaration.new( - constant: constant, - bodystmt: bodystmt, - location: Location.default - ) - end - - # Create a new MRHS node. - def MRHS(parts) - MRHS.new(parts: parts, location: Location.default) - end - - # Create a new Next node. - def Next(arguments) - Next.new(arguments: arguments, location: Location.default) - end - - # Create a new Op node. - def Op(value) - Op.new(value: value, location: Location.default) - end - - # Create a new OpAssign node. - def OpAssign(target, operator, value) - OpAssign.new( - target: target, - operator: operator, - value: value, - location: Location.default - ) - end - - # Create a new Params node. - def Params(requireds, optionals, rest, posts, keywords, keyword_rest, block) - Params.new( - requireds: requireds, - optionals: optionals, - rest: rest, - posts: posts, - keywords: keywords, - keyword_rest: keyword_rest, - block: block, - location: Location.default - ) - end - - # Create a new Paren node. - def Paren(lparen, contents) - Paren.new(lparen: lparen, contents: contents, location: Location.default) - end - - # Create a new Period node. - def Period(value) - Period.new(value: value, location: Location.default) - end - - # Create a new Program node. - def Program(statements) - Program.new(statements: statements, location: Location.default) - end - - # Create a new QSymbols node. - def QSymbols(beginning, elements) - QSymbols.new( - beginning: beginning, - elements: elements, - location: Location.default - ) - end - - # Create a new QSymbolsBeg node. - def QSymbolsBeg(value) - QSymbolsBeg.new(value: value, location: Location.default) - end - - # Create a new QWords node. - def QWords(beginning, elements) - QWords.new( - beginning: beginning, - elements: elements, - location: Location.default - ) - end - - # Create a new QWordsBeg node. - def QWordsBeg(value) - QWordsBeg.new(value: value, location: Location.default) - end - - # Create a new RationalLiteral node. - def RationalLiteral(value) - RationalLiteral.new(value: value, location: Location.default) - end - - # Create a new RBrace node. - def RBrace(value) - RBrace.new(value: value, location: Location.default) - end - - # Create a new RBracket node. - def RBracket(value) - RBracket.new(value: value, location: Location.default) - end - - # Create a new Redo node. - def Redo - Redo.new(location: Location.default) - end - - # Create a new RegexpContent node. - def RegexpContent(beginning, parts) - RegexpContent.new( - beginning: beginning, - parts: parts, - location: Location.default - ) - end - - # Create a new RegexpBeg node. - def RegexpBeg(value) - RegexpBeg.new(value: value, location: Location.default) - end - - # Create a new RegexpEnd node. - def RegexpEnd(value) - RegexpEnd.new(value: value, location: Location.default) - end - - # Create a new RegexpLiteral node. - def RegexpLiteral(beginning, ending, parts) - RegexpLiteral.new( - beginning: beginning, - ending: ending, - parts: parts, - location: Location.default - ) - end - - # Create a new RescueEx node. - def RescueEx(exceptions, variable) - RescueEx.new( - exceptions: exceptions, - variable: variable, - location: Location.default - ) - end - - # Create a new Rescue node. - def Rescue(keyword, exception, statements, consequent) - Rescue.new( - keyword: keyword, - exception: exception, - statements: statements, - consequent: consequent, - location: Location.default - ) - end - - # Create a new RescueMod node. - def RescueMod(statement, value) - RescueMod.new( - statement: statement, - value: value, - location: Location.default - ) - end - - # Create a new RestParam node. - def RestParam(name) - RestParam.new(name: name, location: Location.default) - end - - # Create a new Retry node. - def Retry - Retry.new(location: Location.default) - end - - # Create a new ReturnNode node. - def ReturnNode(arguments) - ReturnNode.new(arguments: arguments, location: Location.default) - end - - # Create a new RParen node. - def RParen(value) - RParen.new(value: value, location: Location.default) - end - - # Create a new SClass node. - def SClass(target, bodystmt) - SClass.new(target: target, bodystmt: bodystmt, location: Location.default) - end - - # Create a new Statements node. - def Statements(body) - Statements.new(body: body, location: Location.default) - end - - # Create a new StringContent node. - def StringContent(parts) - StringContent.new(parts: parts, location: Location.default) - end - - # Create a new StringConcat node. - def StringConcat(left, right) - StringConcat.new(left: left, right: right, location: Location.default) - end - - # Create a new StringDVar node. - def StringDVar(variable) - StringDVar.new(variable: variable, location: Location.default) - end - - # Create a new StringEmbExpr node. - def StringEmbExpr(statements) - StringEmbExpr.new(statements: statements, location: Location.default) - end - - # Create a new StringLiteral node. - def StringLiteral(parts, quote) - StringLiteral.new(parts: parts, quote: quote, location: Location.default) - end - - # Create a new Super node. - def Super(arguments) - Super.new(arguments: arguments, location: Location.default) - end - - # Create a new SymBeg node. - def SymBeg(value) - SymBeg.new(value: value, location: Location.default) - end - - # Create a new SymbolContent node. - def SymbolContent(value) - SymbolContent.new(value: value, location: Location.default) - end - - # Create a new SymbolLiteral node. - def SymbolLiteral(value) - SymbolLiteral.new(value: value, location: Location.default) - end - - # Create a new Symbols node. - def Symbols(beginning, elements) - Symbols.new( - beginning: beginning, - elements: elements, - location: Location.default - ) - end - - # Create a new SymbolsBeg node. - def SymbolsBeg(value) - SymbolsBeg.new(value: value, location: Location.default) - end - - # Create a new TLambda node. - def TLambda(value) - TLambda.new(value: value, location: Location.default) - end - - # Create a new TLamBeg node. - def TLamBeg(value) - TLamBeg.new(value: value, location: Location.default) - end - - # Create a new TopConstField node. - def TopConstField(constant) - TopConstField.new(constant: constant, location: Location.default) - end - - # Create a new TopConstRef node. - def TopConstRef(constant) - TopConstRef.new(constant: constant, location: Location.default) - end - - # Create a new TStringBeg node. - def TStringBeg(value) - TStringBeg.new(value: value, location: Location.default) - end - - # Create a new TStringContent node. - def TStringContent(value) - TStringContent.new(value: value, location: Location.default) - end - - # Create a new TStringEnd node. - def TStringEnd(value) - TStringEnd.new(value: value, location: Location.default) - end - - # Create a new Not node. - def Not(statement, parentheses) - Not.new( - statement: statement, - parentheses: parentheses, - location: Location.default - ) - end - - # Create a new Unary node. - def Unary(operator, statement) - Unary.new( - operator: operator, - statement: statement, - location: Location.default - ) - end - - # Create a new Undef node. - def Undef(symbols) - Undef.new(symbols: symbols, location: Location.default) - end - - # Create a new UnlessNode node. - def UnlessNode(predicate, statements, consequent) - UnlessNode.new( - predicate: predicate, - statements: statements, - consequent: consequent, - location: Location.default - ) - end - - # Create a new UntilNode node. - def UntilNode(predicate, statements) - UntilNode.new( - predicate: predicate, - statements: statements, - location: Location.default - ) - end - - # Create a new VarField node. - def VarField(value) - VarField.new(value: value, location: Location.default) - end - - # Create a new VarRef node. - def VarRef(value) - VarRef.new(value: value, location: Location.default) - end - - # Create a new PinnedVarRef node. - def PinnedVarRef(value) - PinnedVarRef.new(value: value, location: Location.default) - end - - # Create a new VCall node. - def VCall(value) - VCall.new(value: value, location: Location.default) - end - - # Create a new VoidStmt node. - def VoidStmt - VoidStmt.new(location: Location.default) - end - - # Create a new When node. - def When(arguments, statements, consequent) - When.new( - arguments: arguments, - statements: statements, - consequent: consequent, - location: Location.default - ) - end - - # Create a new WhileNode node. - def WhileNode(predicate, statements) - WhileNode.new( - predicate: predicate, - statements: statements, - location: Location.default - ) - end - - # Create a new Word node. - def Word(parts) - Word.new(parts: parts, location: Location.default) - end - - # Create a new Words node. - def Words(beginning, elements) - Words.new( - beginning: beginning, - elements: elements, - location: Location.default - ) - end - - # Create a new WordsBeg node. - def WordsBeg(value) - WordsBeg.new(value: value, location: Location.default) - end - - # Create a new XString node. - def XString(parts) - XString.new(parts: parts, location: Location.default) - end - - # Create a new XStringLiteral node. - def XStringLiteral(parts) - XStringLiteral.new(parts: parts, location: Location.default) - end - - # Create a new YieldNode node. - def YieldNode(arguments) - YieldNode.new(arguments: arguments, location: Location.default) - end - - # Create a new ZSuper node. - def ZSuper - ZSuper.new(location: Location.default) - end - end -end From 3a60b738e093483cab9128e012cb58607125c211 Mon Sep 17 00:00:00 2001 From: Kevin Newton Date: Tue, 19 Aug 2025 14:02:52 -0400 Subject: [PATCH 5/9] Remove the ctags functionality --- lib/syntax_tree/cli.rb | 91 ------------------------------------------ 1 file changed, 91 deletions(-) diff --git a/lib/syntax_tree/cli.rb b/lib/syntax_tree/cli.rb index e3bac8f1..ab15c7fb 100644 --- a/lib/syntax_tree/cli.rb +++ b/lib/syntax_tree/cli.rb @@ -159,92 +159,6 @@ def failure end end - # An action of the CLI that generates ctags for the given source. - class CTags < Action - attr_reader :entries - - def initialize(options) - super - @entries = [] - end - - def run(item) - lines = item.source.lines(chomp: true) - - SyntaxTree - .index(item.source) - .each do |entry| - line = lines[entry.location.line - 1] - pattern = "/^#{line.gsub("\\", "\\\\\\\\").gsub("/", "\\/")}$/;\"" - - entries << case entry - when SyntaxTree::Index::ModuleDefinition - parts = [entry.name, item.filepath, pattern, "m"] - - if entry.nesting != [[entry.name]] - parts << "class:#{entry.nesting.flatten.tap(&:pop).join(".")}" - end - - parts.join("\t") - when SyntaxTree::Index::ClassDefinition - parts = [entry.name, item.filepath, pattern, "c"] - - if entry.nesting != [[entry.name]] - parts << "class:#{entry.nesting.flatten.tap(&:pop).join(".")}" - end - - unless entry.superclass.empty? - inherits = entry.superclass.join(".").delete_prefix(".") - parts << "inherits:#{inherits}" - end - - parts.join("\t") - when SyntaxTree::Index::MethodDefinition - parts = [entry.name, item.filepath, pattern, "f"] - - unless entry.nesting.empty? - parts << "class:#{entry.nesting.flatten.join(".")}" - end - - parts.join("\t") - when SyntaxTree::Index::SingletonMethodDefinition - parts = [entry.name, item.filepath, pattern, "F"] - - unless entry.nesting.empty? - parts << "class:#{entry.nesting.flatten.join(".")}" - end - - parts.join("\t") - when SyntaxTree::Index::AliasMethodDefinition - parts = [entry.name, item.filepath, pattern, "a"] - - unless entry.nesting.empty? - parts << "class:#{entry.nesting.flatten.join(".")}" - end - - parts.join("\t") - when SyntaxTree::Index::ConstantDefinition - parts = [entry.name, item.filepath, pattern, "C"] - - unless entry.nesting.empty? - parts << "class:#{entry.nesting.flatten.join(".")}" - end - - parts.join("\t") - end - end - end - - def success - puts(<<~HEADER) - !_TAG_FILE_FORMAT 2 /extended format; --format=1 will not append ;" to lines/ - !_TAG_FILE_SORTED 1 /0=unsorted, 1=sorted, 2=foldcase/ - HEADER - - entries.sort.each { |entry| puts(entry) } - end - end - # An action of the CLI that formats the source twice to check if the first # format is not idempotent. class Debug < Action @@ -418,9 +332,6 @@ def run(item) #{Color.bold("stree check [--plugins=...] [--print-width=NUMBER] [-e SCRIPT] FILE")} Check that the given files are formatted as syntax tree would format them - #{Color.bold("stree ctags [-e SCRIPT] FILE")} - Print out a ctags-compatible index of the given files - #{Color.bold("stree debug [--plugins=...] [--print-width=NUMBER] [-e SCRIPT] FILE")} Check that the given files can be formatted idempotently @@ -627,8 +538,6 @@ def run(argv) AST.new(options) when "c", "check" Check.new(options) - when "ctags" - CTags.new(options) when "debug" Debug.new(options) when "doc" From 8518688aa9cbc64febd6bce29e37ac19ad049a35 Mon Sep 17 00:00:00 2001 From: Kevin Newton Date: Tue, 19 Aug 2025 14:43:18 -0400 Subject: [PATCH 6/9] Trim down even further --- README.md | 456 +--------- bin/console | 1 - lib/syntax_tree.rb | 55 -- lib/syntax_tree/cli.rb | 91 -- lib/syntax_tree/field_visitor.rb | 1032 ----------------------- lib/syntax_tree/index.rb | 683 --------------- lib/syntax_tree/json_visitor.rb | 55 -- lib/syntax_tree/language_server.rb | 7 - lib/syntax_tree/match_visitor.rb | 120 --- lib/syntax_tree/mermaid.rb | 177 ---- lib/syntax_tree/mermaid_visitor.rb | 69 -- lib/syntax_tree/mutation_visitor.rb | 924 -------------------- lib/syntax_tree/node.rb | 16 - lib/syntax_tree/pattern.rb | 288 ------- lib/syntax_tree/pretty_print_visitor.rb | 83 -- lib/syntax_tree/reflection.rb | 257 ------ lib/syntax_tree/search.rb | 26 - test/cli_test.rb | 39 +- test/index_test.rb | 183 ---- test/language_server_test.rb | 38 - test/mutation_test.rb | 47 -- test/node_test.rb | 6 - test/ractor_test.rb | 3 + test/search_test.rb | 127 --- test/test_helper.rb | 64 +- 25 files changed, 8 insertions(+), 4839 deletions(-) delete mode 100644 lib/syntax_tree/field_visitor.rb delete mode 100644 lib/syntax_tree/index.rb delete mode 100644 lib/syntax_tree/json_visitor.rb delete mode 100644 lib/syntax_tree/match_visitor.rb delete mode 100644 lib/syntax_tree/mermaid.rb delete mode 100644 lib/syntax_tree/mermaid_visitor.rb delete mode 100644 lib/syntax_tree/mutation_visitor.rb delete mode 100644 lib/syntax_tree/pattern.rb delete mode 100644 lib/syntax_tree/pretty_print_visitor.rb delete mode 100644 lib/syntax_tree/reflection.rb delete mode 100644 lib/syntax_tree/search.rb delete mode 100644 test/index_test.rb delete mode 100644 test/mutation_test.rb delete mode 100644 test/search_test.rb diff --git a/README.md b/README.md index 828f6a44..15ad312a 100644 --- a/README.md +++ b/README.md @@ -13,42 +13,14 @@ It is built with only standard library dependencies. It additionally ships with - [Installation](#installation) - [CLI](#cli) - - [ast](#ast) - [check](#check) - - [ctags](#ctags) - - [expr](#expr) - [format](#format) - - [json](#json) - - [match](#match) - - [search](#search) - [write](#write) - [Configuration](#configuration) - [Globbing](#globbing) -- [Library](#library) - - [SyntaxTree.read(filepath)](#syntaxtreereadfilepath) - - [SyntaxTree.parse(source)](#syntaxtreeparsesource) - - [SyntaxTree.format(source)](#syntaxtreeformatsource) - - [SyntaxTree.mutation(&block)](#syntaxtreemutationblock) - - [SyntaxTree.search(source, query, &block)](#syntaxtreesearchsource-query-block) - - [SyntaxTree.index(source)](#syntaxtreeindexsource) -- [Nodes](#nodes) - - [child_nodes](#child_nodes) - - [copy(**attrs)](#copyattrs) - - [Pattern matching](#pattern-matching) - - [pretty_print(q)](#pretty_printq) - - [to_json(*opts)](#to_jsonopts) - - [format(q)](#formatq) - - [===(other)](#other) - - [construct_keys](#construct_keys) -- [Visitor](#visitor) - - [visit_method](#visit_method) - - [visit_methods](#visit_methods) - - [BasicVisitor](#basicvisitor) - - [MutationVisitor](#mutationvisitor) - [Language server](#language-server) - [textDocument/formatting](#textdocumentformatting) - [textDocument/inlayHint](#textdocumentinlayhint) - - [syntaxTree/visualizing](#syntaxtreevisualizing) - [Customization](#customization) - [Ignoring code](#ignoring-code) - [Plugins](#plugins) @@ -62,7 +34,7 @@ It is built with only standard library dependencies. It additionally ships with ## Installation -Syntax Tree is both a command-line interface and a library. If you're only looking to use the command-line interface, then we recommend installing the gem globally, as in: +To install the gem globally, you can run: ```sh gem install syntax_tree @@ -74,7 +46,7 @@ To run the CLI with the gem installed globally, you would run: stree version ``` -If you're planning on using Syntax Tree as a library within your own project, we recommend installing it as part of your gem bundle. First, add this line to your application's Gemfile: +If you're planning on using Syntax Tree within a project with `bundler`, add this line to your application's Gemfile: ```ruby gem "syntax_tree" @@ -94,24 +66,10 @@ bundle exec stree version ## CLI -Syntax Tree ships with the `stree` CLI, which can be used to inspect and manipulate Ruby code. Below are listed all of the commands built into the CLI that you can use. +Syntax Tree ships with the `stree` CLI, which can be used to format Ruby code. Below are listed all of the commands built into the CLI that you can use. For many commands, file paths are accepted after the configuration options. For all of these commands, you can alternatively pass in content through STDIN or through the `-e` option to specify an inline script. -### ast - -This command will print out a textual representation of the syntax tree associated with each of the files it finds. To execute, run: - -```sh -stree ast path/to/file.rb -``` - -For a file that contains `1 + 1`, you will receive: - -``` -(program (statements (binary (int "1") + (int "1")))) -``` - ### check This command is meant to be used in the context of a continuous integration or git hook. It checks each file given to make sure that it matches the expected format. It can be used to ensure unformatted content never makes it into a codebase. @@ -139,51 +97,6 @@ To change the print width that you are checking against, specify the `--print-wi stree check --print-width=100 path/to/file.rb ``` -### ctags - -This command will output to stdout a set of tags suitable for usage with [ctags](https://github.com/universal-ctags/ctags). - -```sh -stree ctags path/to/file.rb -``` - -For a file containing the following Ruby code: - -```ruby -class Foo -end - -class Bar < Foo -end -``` - -you will receive: - -``` -!_TAG_FILE_FORMAT 2 /extended format; --format=1 will not append ;" to lines/ -!_TAG_FILE_SORTED 1 /0=unsorted, 1=sorted, 2=foldcase/ -Bar test.rb /^class Bar < Foo$/;" c inherits:Foo -Foo test.rb /^class Foo$/;" c -``` - -### expr - -This command will output a Ruby case-match expression that would match correctly against the first expression of the input. - -```sh -stree expr path/to/file.rb -``` - -For a file that contains `1 + 1`, you will receive: - -```ruby -SyntaxTree::Binary[ - left: SyntaxTree::Int[value: "1"], - operator: :+, - right: SyntaxTree::Int[value: "1"] -] -``` - ### format This command will output the formatted version of each of the listed files to stdout. Importantly, it will not write that content back to the source files – for that, you want [`write`](#write). @@ -204,96 +117,6 @@ To change the print width that you are formatting with, specify the `--print-wid stree format --print-width=100 path/to/file.rb ``` -### json - -This command will output a JSON representation of the syntax tree that is functionally equivalent to the input. This is mostly used in contexts where you need to access the tree from JavaScript or serialize it over a network. - -```sh -stree json path/to/file.rb -``` - -For a file that contains `1 + 1`, you will receive: - -```json -{ - "type": "program", - "location": [1, 0, 1, 6], - "statements": { - "type": "statements", - "location": [1, 0, 1, 6], - "body": [ - { - "type": "binary", - "location": [1, 0, 1, 5], - "left": { - "type": "int", - "location": [1, 0, 1, 1], - "value": "1", - "comments": [] - }, - "operator": "+", - "right": { - "type": "int", - "location": [1, 4, 1, 5], - "value": "1", - "comments": [] - }, - "comments": [] - } - ], - "comments": [] - }, - "comments": [] -} -``` - -### match - -This command will output a Ruby case-match expression that would match correctly against the input. - -```sh -stree match path/to/file.rb -``` - -For a file that contains `1 + 1`, you will receive: - -```ruby -SyntaxTree::Program[ - statements: SyntaxTree::Statements[ - body: [ - SyntaxTree::Binary[ - left: SyntaxTree::Int[value: "1"], - operator: :+, - right: SyntaxTree::Int[value: "1"] - ] - ] - ] -] -``` - -### search - -This command will search the given filepaths against the specified pattern to find nodes that match. The pattern is a Ruby pattern-matching expression that is matched against each node in the tree. It can optionally be loaded from a file if you specify a filepath as the pattern argument. - -```sh -stree search VarRef path/to/file.rb -``` - -For a file that contains `Foo + Bar` you will receive: - -``` -path/to/file.rb:1:0: Foo + Bar -path/to/file.rb:1:6: Foo + Bar -``` - -If you put `VarRef` into a file instead (for example, `query.txt`), you would instead run: - -```sh -stree search query.txt path/to/file.rb -``` - -Note that the output of the `match` CLI command creates a valid pattern that can be used as the input for this command. - ### write This command will format the listed files and write that formatted version back to the source files. Note that this overwrites the original content, so be sure to be using a version control system. @@ -351,275 +174,6 @@ Baked into this syntax is the ability to provide exceptions to file name pattern stree write "**/{[!schema]*,*}.rb" ``` -## Library - -Syntax Tree can be used as a library to access the syntax tree underlying Ruby source code. The API is described below. For the full library documentation, see the [RDoc documentation](https://ruby-syntax-tree.github.io/syntax_tree/). - -### SyntaxTree.read(filepath) - -This function takes a filepath and returns a string associated with the content of that file. It is similar in functionality to `File.read`, except that it takes into account Ruby-level file encoding (through magic comments at the top of the file). - -### SyntaxTree.parse(source) - -This function takes an input string containing Ruby code and returns the syntax tree associated with it. The top-level node is always a `SyntaxTree::Program`, which contains a list of top-level expression nodes. - -### SyntaxTree.format(source) - -This function takes an input string containing Ruby code, parses it into its underlying syntax tree, and formats it back out to a string. You can optionally pass a second argument to this method as well that is the maximum width to print. It defaults to `80`. - -### SyntaxTree.mutation(&block) - -This function yields a new mutation visitor to the block, and then returns the initialized visitor. It's effectively a shortcut for creating a `SyntaxTree::MutationVisitor` without having to remember the class name. For more information on that visitor, see the definition below. - -### SyntaxTree.search(source, query, &block) - -This function takes an input string containing Ruby code, an input string containing a valid Ruby `in` clause expression that can be used to match against nodes in the tree (can be generated using `stree expr`, `stree match`, or `Node#construct_keys`), and a block. Each node that matches the given query will be yielded to the block. The block will receive the node as its only argument. - -### SyntaxTree.index(source) - -This function takes an input string containing Ruby code and returns a list of all of the class declarations, module declarations, and method definitions within a file. Each of the entries also has access to its associated comments. This is useful for generating documentation or index information for a file to support something like go-to-definition. - -## Nodes - -There are many different node types in the syntax tree. They are meant to be treated as immutable structs containing links to child nodes with minimal logic contained within their implementation. However, for the most part they all respond to a certain set of APIs, listed below. - -### child_nodes - -One of the easiest ways to descend the tree is to use the `child_nodes` function. It is implemented on every node type (leaf nodes return an empty array). If the goal is to simply walk through the tree, this is the easiest way to go. - -```ruby -program = SyntaxTree.parse("1 + 1") -program.child_nodes.first.child_nodes.first -# => (binary (int "1") :+ (int "1")) -``` - -### copy(**attrs) - -This method returns a copy of the node, with the given attributes replaced. - -```ruby -program = SyntaxTree.parse("1 + 1") - -binary = program.statements.body.first -# => (binary (int "1") + (int "1")) - -binary.copy(operator: :-) -# => (binary (int "1") - (int "1")) -``` - -### Pattern matching - -Pattern matching is another way to descend the tree which is more specific than using `child_nodes`. Using Ruby's built-in pattern matching, you can extract the same information but be as specific about your constraints as you like. For example, with minimal constraints: - -```ruby -program = SyntaxTree.parse("1 + 1") -program => { statements: { body: [binary] } } -binary -# => (binary (int "1") :+ (int "1")) -``` - -Or, with more constraints on the types to ensure we're getting exactly what we expect: - -```ruby -program = SyntaxTree.parse("1 + 1") -program => SyntaxTree::Program[statements: SyntaxTree::Statements[body: [SyntaxTree::Binary => binary]]] -binary -# => (binary (int "1") :+ (int "1")) -``` - -### pretty_print(q) - -Every node responds to the `pretty_print` Ruby interface, which makes it usable by the `pp` library. You _can_ use this API manually, but it's mostly there for compatibility and not meant to be directly invoked. For example: - -```ruby -pp SyntaxTree.parse("1 + 1") -# (program (statements (binary (int "1") + (int "1")))) -``` - -### to_json(*opts) - -Every node responds to the `to_json` Ruby interface, which makes it usable by the `json` library. Much like `pretty_print`, you could use this API manually, but it's mostly used by `JSON` to dump the nodes to a serialized format. For example: - -```ruby -program = SyntaxTree.parse("1 + 1") -program => { statements: { body: [{ left: }] } } -puts JSON.dump(left) -# {"type":"int","value":"1","loc":[1,0,1,1],"cmts":[]} -``` - -### format(q) - -Every node responds to `format`, which formats the content nicely. The API mirrors that used by the `pretty_print` gem in that it accepts a formatter object and calls methods on it to generate its own internal representation of the text that will be outputted. Because of this, it's easier to not use this API directly and instead to call `SyntaxTree.format`. You _can_ however use this directly if you create the formatter yourself, as in: - -```ruby -source = "1+1" -program = SyntaxTree.parse(source) -program => { statements: { body: [binary] } } - -formatter = SyntaxTree::Formatter.new(source, []) -binary.format(formatter) - -formatter.flush -formatter.output.join -# => "1 + 1" -``` - -### ===(other) - -Every node responds to `===`, which is used to check if the given other node matches all of the attributes of the current node except for location and comments. For example: - -```ruby -program1 = SyntaxTree.parse("1 + 1") -program2 = SyntaxTree.parse("1 + 1") - -program1 === program2 -# => true -``` - -### construct_keys - -Every node responds to `construct_keys`, which will return a string that contains a Ruby pattern-matching expression that could be used to match against the current node. It's meant to be used in tooling and through the CLI mostly. - -```ruby -program = SyntaxTree.parse("1 + 1") -puts program.construct_keys - -# SyntaxTree::Program[ -# statements: SyntaxTree::Statements[ -# body: [ -# SyntaxTree::Binary[ -# left: SyntaxTree::Int[value: "1"], -# operator: :+, -# right: SyntaxTree::Int[value: "1"] -# ] -# ] -# ] -# ] -``` - -## Visitor - -If you want to operate over a set of nodes in the tree but don't want to walk the tree manually, the `Visitor` class makes it easy. `SyntaxTree::Visitor` is an implementation of the double dispatch visitor pattern. It works by the user defining visit methods that process nodes in the tree, which then call back to other visit methods to continue the descent. This is easier shown in code. - -Let's say, for instance, that you wanted to find every place in source where you have an arithmetic problem between two integers (this is pretty contrived, but it's just for illustration). You could define a visitor that only explicitly visits the `SyntaxTree::Binary` node, as in: - -```ruby -class ArithmeticVisitor < SyntaxTree::Visitor - def visit_binary(node) - if node in { left: SyntaxTree::Int, operator: :+ | :- | :* | :/, right: SyntaxTree::Int } - puts "The result is: #{node.left.value.to_i.public_send(node.operator, node.right.value.to_i)}" - end - end -end - -visitor = ArithmeticVisitor.new -visitor.visit(SyntaxTree.parse("1 + 1")) -# The result is: 2 -``` - -With visitors, you only define handlers for the nodes that you need. You can find the names of the methods that you will need to define within the base visitor, as they're all aliased to the default behavior (visiting the child nodes). Note that when you define a handler for a node, you have to tell Syntax Tree how to walk further. In the example above, we don't need to go any further because we already know the child nodes are `SyntaxTree::Int`, so they can't possibly contain more `SyntaxTree::Binary` nodes. In other circumstances you may not know though, so you can either: - -* call `super` (which will do the default and visit all child nodes) -* call `visit_child_nodes` manually -* call `visit(child)` with each child that you want to visit -* call nothing if you're sure you don't want to descend further - -There are a couple of visitors that ship with Syntax Tree that can be used as examples. They live in the [lib/syntax_tree](lib/syntax_tree) directory. - -### visit_method - -When you're creating a visitor, it's very easy to accidentally mistype a visit method. Unfortunately, there's no way to tell Ruby to explicitly override a parent method, so it would then be easy to define a method that never gets called. To mitigate this risk, there's `Visitor.visit_method(name)`. This method accepts a symbol that is checked against the list of known visit methods. If it's not in the list, then an error will be raised. It's meant to be used like: - -```ruby -class ArithmeticVisitor < SyntaxTree::Visitor - visit_method def visit_binary(node) - # ... - end -end -``` - -This will only be checked once when the file is first required. If there is a typo in your method name (or the method no longer exists for whatever reason), you will receive an error like so: - -``` -~/syntax_tree/lib/syntax_tree/visitor.rb:46:in `visit_method': Invalid visit method: visit_binar (SyntaxTree::Visitor::VisitMethodError) -Did you mean? visit_binary - visit_in - visit_ivar - from (irb):2:in `' - from (irb):1:in `
' - from bin/console:8:in `
' -``` - -### visit_methods - -Similar to `visit_method`, `visit_methods` also checks that methods defined are valid visit methods. This variation however accepts a block and checks that all methods defined within that block are valid visit methods. It's meant to be used like: - -```ruby -class ArithmeticVisitor < SyntaxTree::Visitor - visit_methods do - def visit_binary(node) - # ... - end - - def visit_int(node) - # ... - end - end -end -``` - -This is only checked when the methods are defined and does not impose any kind of runtime overhead after that. It is very useful for upgrading versions of Syntax Tree in case these methods names change. - -### BasicVisitor - -When you're defining your own visitor, by default it will walk down the tree even if you don't define `visit_*` methods. This is to ensure you can define a subset of the necessary methods in order to only interact with the nodes you're interested in. If you'd like to change this default to instead raise an error if you visit a node you haven't explicitly handled, you can instead inherit from `BasicVisitor`. - -```ruby -class MyVisitor < SyntaxTree::BasicVisitor - def visit_int(node) - # ... - end -end -``` - -The visitor defined above will error out unless it's only visiting a `SyntaxTree::Int` node. This is useful in a couple of ways, e.g., if you're trying to define a visitor to handle the whole tree but it's currently a work-in-progress. - -### MutationVisitor - -The `MutationVisitor` is a visitor that can be used to mutate the tree. It works by defining a default `visit_*` method that returns a copy of the given node with all of its attributes visited. This new node will replace the old node in the tree. Typically, you use the `#mutate` method on it to define mutations using patterns. For example: - -```ruby -# Create a new visitor -visitor = SyntaxTree::MutationVisitor.new - -# Specify that it should mutate If nodes with assignments in their predicates -visitor.mutate("IfNode[predicate: Assign | OpAssign]") do |node| - # Get the existing If's predicate node - predicate = node.predicate - - # Create a new predicate node that wraps the existing predicate node - # in parentheses - predicate = - SyntaxTree::Paren.new( - lparen: SyntaxTree::LParen.default, - contents: predicate, - location: predicate.location - ) - - # Return a copy of this node with the new predicate - node.copy(predicate: predicate) -end - -source = "if a = 1; end" -program = SyntaxTree.parse(source) - -SyntaxTree::Formatter.format(source, program) -# => "if a = 1\nend\n" - -SyntaxTree::Formatter.format(source, program.accept(visitor)) -# => "if (a = 1)\nend\n" -``` - ## Language server Syntax Tree additionally ships with a language server conforming to the [language server protocol](https://microsoft.github.io/language-server-protocol/). It can be invoked through the CLI by running: @@ -648,10 +202,6 @@ Implicitly, the `2 * 3` is going to be executed first because the `*` operator h 1 + ₍2 * 3₎ ``` -### syntaxTree/visualizing - -The language server additionally includes this custom request to return a textual representation of the syntax tree underlying the source code of a file. Language server clients can use this to (for example) open an additional tab with this information displayed. - ## Customization There are multiple ways to customize Syntax Tree's behavior when parsing and formatting code. You can ignore certain sections of the source code, you can register plugins to provide custom formatting behavior, and you can register additional languages to be parsed and formatted. diff --git a/bin/console b/bin/console index 6f35f1ec..1c18bd62 100755 --- a/bin/console +++ b/bin/console @@ -3,7 +3,6 @@ require "bundler/setup" require "syntax_tree" -require "syntax_tree/reflection" require "irb" IRB.start(__FILE__) diff --git a/lib/syntax_tree.rb b/lib/syntax_tree.rb index 69d2252a..77c3f821 100644 --- a/lib/syntax_tree.rb +++ b/lib/syntax_tree.rb @@ -17,21 +17,7 @@ # tools necessary to inspect and manipulate that syntax tree. It can be used to # build formatters, linters, language servers, and more. module SyntaxTree - # Syntax Tree the library has many features that aren't always used by the - # CLI. Requiring those features takes time, so we autoload as many constants - # as possible in order to keep the CLI as fast as possible. - - autoload :FieldVisitor, "syntax_tree/field_visitor" - autoload :Index, "syntax_tree/index" - autoload :JSONVisitor, "syntax_tree/json_visitor" autoload :LanguageServer, "syntax_tree/language_server" - autoload :MatchVisitor, "syntax_tree/match_visitor" - autoload :Mermaid, "syntax_tree/mermaid" - autoload :MermaidVisitor, "syntax_tree/mermaid_visitor" - autoload :MutationVisitor, "syntax_tree/mutation_visitor" - autoload :Pattern, "syntax_tree/pattern" - autoload :PrettyPrintVisitor, "syntax_tree/pretty_print_visitor" - autoload :Search, "syntax_tree/search" # This holds references to objects that respond to both #parse and #format # so that we can use them in the CLI. @@ -92,27 +78,6 @@ def self.format_node( formatter.output.join end - # Indexes the given source code to return a list of all class, module, and - # method definitions. Used to quickly provide indexing capability for IDEs or - # documentation generation. - def self.index(source) - Index.index(source) - end - - # Indexes the given file to return a list of all class, module, and method - # definitions. Used to quickly provide indexing capability for IDEs or - # documentation generation. - def self.index_file(filepath) - Index.index_file(filepath) - end - - # A convenience method for creating a new mutation visitor. - def self.mutation - visitor = MutationVisitor.new - yield visitor - visitor - end - # Parses the given source and returns the syntax tree. def self.parse(source) parser = Parser.new(source) @@ -120,11 +85,6 @@ def self.parse(source) response unless parser.error? end - # Parses the given file and returns the syntax tree. - def self.parse_file(filepath) - parse(read(filepath)) - end - # Returns the source from the given filepath taking into account any potential # magic encoding comments. def self.read(filepath) @@ -145,19 +105,4 @@ def self.read(filepath) def self.register_handler(extension, handler) HANDLERS[extension] = handler end - - # Searches through the given source using the given pattern and yields each - # node in the tree that matches the pattern to the given block. - def self.search(source, query, &block) - pattern = Pattern.new(query).compile - program = parse(source) - - Search.new(pattern).scan(program, &block) - end - - # Searches through the given file using the given pattern and yields each - # node in the tree that matches the pattern to the given block. - def self.search_file(filepath, query, &block) - search(read(filepath), query, &block) - end end diff --git a/lib/syntax_tree/cli.rb b/lib/syntax_tree/cli.rb index ab15c7fb..6437fd3b 100644 --- a/lib/syntax_tree/cli.rb +++ b/lib/syntax_tree/cli.rb @@ -211,21 +211,6 @@ def run(item) end end - # An action of the CLI that outputs a pattern-matching Ruby expression that - # would match the first expression of the input given. - class Expr < Action - def run(item) - program = item.handler.parse(item.source) - - if (expressions = program.statements.body) && expressions.size == 1 - puts expressions.first.construct_keys - else - warn("The input to `stree expr` must be a single expression.") - exit(1) - end - end - end - # An action of the CLI that formats the input source and prints it out. class Format < Action def run(item) @@ -240,61 +225,6 @@ def run(item) end end - # An action of the CLI that converts the source into its equivalent JSON - # representation. - class Json < Action - def run(item) - object = item.handler.parse(item.source).accept(JSONVisitor.new) - puts JSON.pretty_generate(object) - end - end - - # An action of the CLI that outputs a pattern-matching Ruby expression that - # would match the input given. - class Match < Action - def run(item) - puts item.handler.parse(item.source).construct_keys - end - end - - # An action of the CLI that searches for the given pattern matching pattern - # in the given files. - class Search < Action - attr_reader :search - - def initialize(query) - query = File.read(query) if File.readable?(query) - pattern = - begin - Pattern.new(query).compile - rescue Pattern::CompilationError => error - warn(error.message) - exit(1) - end - - @search = SyntaxTree::Search.new(pattern) - end - - def run(item) - search.scan(item.handler.parse(item.source)) do |node| - location = node.location - line = location.start_line - - bold_range = - if line == location.end_line - location.start_column...location.end_column - else - location.start_column.. - end - - source = item.source.lines[line - 1].chomp - source[bold_range] = Color.bold(source[bold_range]).to_s - - puts("#{item.filepath}:#{line}:#{location.start_column}: #{source}") - end - end - end - # An action of the CLI that formats the input source and writes the # formatted output back to the file. class Write < Action @@ -338,28 +268,15 @@ def run(item) #{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 expr [-e SCRIPT] FILE")} - Print out a pattern-matching Ruby expression that would match the first - expression of the given files - #{Color.bold("stree format [--plugins=...] [--print-width=NUMBER] [-e SCRIPT] FILE")} Print out the formatted version of the given files - #{Color.bold("stree json [--plugins=...] [-e SCRIPT] FILE")} - Print out the JSON representation of the given files - - #{Color.bold("stree match [--plugins=...] [-e SCRIPT] FILE")} - Print out a pattern-matching Ruby expression that would match the given files - #{Color.bold("stree help")} Display this help message #{Color.bold("stree lsp [--plugins=...] [--print-width=NUMBER]")} Run syntax tree in language server mode - #{Color.bold("stree search PATTERN [-e SCRIPT] FILE")} - Search for the given pattern in the given files - #{Color.bold("stree version")} Output the current version of syntax tree @@ -542,25 +459,17 @@ def run(argv) Debug.new(options) when "doc" Doc.new(options) - when "e", "expr" - Expr.new(options) when "f", "format" Format.new(options) when "help" puts HELP return 0 - when "j", "json" - Json.new(options) when "lsp" LanguageServer.new( print_width: options.print_width, ignore_files: options.ignore_files ).run return 0 - when "m", "match" - Match.new(options) - when "s", "search" - Search.new(arguments.shift) when "version" puts SyntaxTree::VERSION return 0 diff --git a/lib/syntax_tree/field_visitor.rb b/lib/syntax_tree/field_visitor.rb deleted file mode 100644 index f5607c67..00000000 --- a/lib/syntax_tree/field_visitor.rb +++ /dev/null @@ -1,1032 +0,0 @@ -# frozen_string_literal: true - -module SyntaxTree - # This is the parent class of a lot of built-in visitors for Syntax Tree. It - # reflects visiting each of the fields on every node in turn. It itself does - # not do anything with these fields, it leaves that behavior up to the - # subclass to implement. - # - # In order to properly use this class, you will need to subclass it and - # implement #comments, #field, #list, #node, #pairs, and #text. Those are - # documented here. - # - # == comments(node) - # - # This accepts the node that is being visited and does something depending on - # the comments attached to the node. - # - # == field(name, value) - # - # This accepts the name of the field being visited as a string (like "value") - # and the actual value of that field. The value can be a subclass of Node or - # any other type that can be held within the tree. - # - # == list(name, values) - # - # This accepts the name of the field being visited as well as a list of - # values. This is used, for example, when visiting something like the body of - # a Statements node. - # - # == node(name, node) - # - # This is the parent serialization method for each node. It is called with the - # node itself, as well as the type of the node as a string. The type is an - # internally used value that usually resembles the name of the ripper event - # that generated the node. The method should yield to the given block which - # then calls through to visit each of the fields on the node. - # - # == text(name, value) - # - # This accepts the name of the field being visited as well as a string value - # representing the value of the field. - # - # == pairs(name, values) - # - # This accepts the name of the field being visited as well as a list of pairs - # that represent the value of the field. It is used only in a couple of - # circumstances, like when visiting the list of optional parameters defined on - # a method. - # - class FieldVisitor < BasicVisitor - visit_methods do - def visit_aref(node) - node(node, "aref") do - field("collection", node.collection) - field("index", node.index) - comments(node) - end - end - - def visit_aref_field(node) - node(node, "aref_field") do - field("collection", node.collection) - field("index", node.index) - comments(node) - end - end - - def visit_alias(node) - node(node, "alias") do - field("left", node.left) - field("right", node.right) - comments(node) - end - end - - def visit_arg_block(node) - node(node, "arg_block") do - field("value", node.value) if node.value - comments(node) - end - end - - def visit_arg_paren(node) - node(node, "arg_paren") do - field("arguments", node.arguments) - comments(node) - end - end - - def visit_arg_star(node) - node(node, "arg_star") do - field("value", node.value) - comments(node) - end - end - - def visit_args(node) - node(node, "args") do - list("parts", node.parts) - comments(node) - end - end - - def visit_args_forward(node) - node(node, "args_forward") { comments(node) } - end - - def visit_array(node) - node(node, "array") do - field("contents", node.contents) - comments(node) - end - end - - def visit_aryptn(node) - node(node, "aryptn") do - field("constant", node.constant) if node.constant - list("requireds", node.requireds) if node.requireds.any? - field("rest", node.rest) if node.rest - list("posts", node.posts) if node.posts.any? - comments(node) - end - end - - def visit_assign(node) - node(node, "assign") do - field("target", node.target) - field("value", node.value) - comments(node) - end - end - - def visit_assoc(node) - node(node, "assoc") do - field("key", node.key) - field("value", node.value) if node.value - comments(node) - end - end - - def visit_assoc_splat(node) - node(node, "assoc_splat") do - field("value", node.value) - comments(node) - end - end - - def visit_backref(node) - visit_token(node, "backref") - end - - def visit_backtick(node) - visit_token(node, "backtick") - end - - def visit_bare_assoc_hash(node) - node(node, "bare_assoc_hash") do - list("assocs", node.assocs) - comments(node) - end - end - - def visit_BEGIN(node) - node(node, "BEGIN") do - field("statements", node.statements) - comments(node) - end - end - - def visit_begin(node) - node(node, "begin") do - field("bodystmt", node.bodystmt) - comments(node) - end - end - - def visit_binary(node) - node(node, "binary") do - field("left", node.left) - text("operator", node.operator) - field("right", node.right) - comments(node) - end - end - - def visit_block(node) - node(node, "block") do - field("block_var", node.block_var) if node.block_var - field("bodystmt", node.bodystmt) - comments(node) - end - end - - def visit_blockarg(node) - node(node, "blockarg") do - field("name", node.name) if node.name - comments(node) - end - end - - def visit_block_var(node) - node(node, "block_var") do - field("params", node.params) - list("locals", node.locals) if node.locals.any? - comments(node) - end - end - - def visit_bodystmt(node) - node(node, "bodystmt") do - field("statements", node.statements) - field("rescue_clause", node.rescue_clause) if node.rescue_clause - field("else_clause", node.else_clause) if node.else_clause - field("ensure_clause", node.ensure_clause) if node.ensure_clause - comments(node) - end - end - - def visit_break(node) - node(node, "break") do - field("arguments", node.arguments) - comments(node) - end - end - - def visit_call(node) - node(node, "call") do - field("receiver", node.receiver) - field("operator", node.operator) - field("message", node.message) - field("arguments", node.arguments) if node.arguments - comments(node) - end - end - - def visit_case(node) - node(node, "case") do - field("keyword", node.keyword) - field("value", node.value) if node.value - field("consequent", node.consequent) - comments(node) - end - end - - def visit_CHAR(node) - visit_token(node, "CHAR") - end - - def visit_class(node) - node(node, "class") do - field("constant", node.constant) - field("superclass", node.superclass) if node.superclass - field("bodystmt", node.bodystmt) - comments(node) - end - end - - def visit_comma(node) - node(node, "comma") { field("value", node.value) } - end - - 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 - - def visit_command_call(node) - node(node, "command_call") do - field("receiver", node.receiver) - 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 - - def visit_comment(node) - node(node, "comment") { field("value", node.value) } - end - - def visit_const(node) - visit_token(node, "const") - end - - def visit_const_path_field(node) - node(node, "const_path_field") do - field("parent", node.parent) - field("constant", node.constant) - comments(node) - end - end - - def visit_const_path_ref(node) - node(node, "const_path_ref") do - field("parent", node.parent) - field("constant", node.constant) - comments(node) - end - end - - def visit_const_ref(node) - node(node, "const_ref") do - field("constant", node.constant) - comments(node) - end - end - - def visit_cvar(node) - visit_token(node, "cvar") - end - - def visit_def(node) - node(node, "def") do - field("target", node.target) - field("operator", node.operator) - field("name", node.name) - field("params", node.params) - field("bodystmt", node.bodystmt) - comments(node) - end - end - - def visit_defined(node) - node(node, "defined") do - field("value", node.value) - comments(node) - end - end - - def visit_dyna_symbol(node) - node(node, "dyna_symbol") do - list("parts", node.parts) - comments(node) - end - end - - def visit_END(node) - node(node, "END") do - field("statements", node.statements) - comments(node) - end - end - - def visit_else(node) - node(node, "else") do - field("statements", node.statements) - comments(node) - end - end - - def visit_elsif(node) - node(node, "elsif") do - field("predicate", node.predicate) - field("statements", node.statements) - field("consequent", node.consequent) if node.consequent - comments(node) - end - end - - def visit_embdoc(node) - node(node, "embdoc") { field("value", node.value) } - end - - def visit_embexpr_beg(node) - node(node, "embexpr_beg") { field("value", node.value) } - end - - def visit_embexpr_end(node) - node(node, "embexpr_end") { field("value", node.value) } - end - - def visit_embvar(node) - node(node, "embvar") { field("value", node.value) } - end - - def visit_ensure(node) - node(node, "ensure") do - field("statements", node.statements) - comments(node) - end - end - - def visit_excessed_comma(node) - visit_token(node, "excessed_comma") - end - - def visit_field(node) - node(node, "field") do - field("parent", node.parent) - field("operator", node.operator) - field("name", node.name) - comments(node) - end - end - - def visit_float(node) - visit_token(node, "float") - end - - def visit_fndptn(node) - node(node, "fndptn") do - field("constant", node.constant) if node.constant - field("left", node.left) - list("values", node.values) - field("right", node.right) - comments(node) - end - end - - def visit_for(node) - node(node, "for") do - field("index", node.index) - field("collection", node.collection) - field("statements", node.statements) - comments(node) - end - end - - def visit_gvar(node) - visit_token(node, "gvar") - end - - def visit_hash(node) - node(node, "hash") do - list("assocs", node.assocs) if node.assocs.any? - comments(node) - end - end - - def visit_heredoc(node) - node(node, "heredoc") do - list("parts", node.parts) - comments(node) - end - end - - def visit_heredoc_beg(node) - visit_token(node, "heredoc_beg") - end - - def visit_heredoc_end(node) - visit_token(node, "heredoc_end") - end - - def visit_hshptn(node) - node(node, "hshptn") do - field("constant", node.constant) if node.constant - pairs("keywords", node.keywords) if node.keywords.any? - field("keyword_rest", node.keyword_rest) if node.keyword_rest - comments(node) - end - end - - def visit_ident(node) - visit_token(node, "ident") - end - - def visit_if(node) - node(node, "if") do - field("predicate", node.predicate) - field("statements", node.statements) - field("consequent", node.consequent) if node.consequent - comments(node) - end - end - - def visit_if_op(node) - node(node, "if_op") do - field("predicate", node.predicate) - field("truthy", node.truthy) - field("falsy", node.falsy) - comments(node) - end - end - - def visit_imaginary(node) - visit_token(node, "imaginary") - end - - def visit_in(node) - node(node, "in") do - field("pattern", node.pattern) - field("statements", node.statements) - field("consequent", node.consequent) if node.consequent - comments(node) - end - end - - def visit_int(node) - visit_token(node, "int") - end - - def visit_ivar(node) - visit_token(node, "ivar") - end - - def visit_kw(node) - visit_token(node, "kw") - end - - def visit_kwrest_param(node) - node(node, "kwrest_param") do - field("name", node.name) - comments(node) - end - end - - def visit_label(node) - visit_token(node, "label") - end - - def visit_label_end(node) - node(node, "label_end") { field("value", node.value) } - end - - def visit_lambda(node) - node(node, "lambda") do - field("params", node.params) - field("statements", node.statements) - comments(node) - end - end - - def visit_lambda_var(node) - node(node, "lambda_var") do - field("params", node.params) - list("locals", node.locals) if node.locals.any? - comments(node) - end - end - - def visit_lbrace(node) - visit_token(node, "lbrace") - end - - def visit_lbracket(node) - visit_token(node, "lbracket") - end - - def visit_lparen(node) - visit_token(node, "lparen") - end - - def visit_massign(node) - node(node, "massign") do - field("target", node.target) - field("value", node.value) - comments(node) - end - end - - def visit_method_add_block(node) - node(node, "method_add_block") do - field("call", node.call) - field("block", node.block) - comments(node) - end - end - - def visit_mlhs(node) - node(node, "mlhs") do - list("parts", node.parts) - comments(node) - end - end - - def visit_mlhs_paren(node) - node(node, "mlhs_paren") do - field("contents", node.contents) - comments(node) - end - end - - def visit_module(node) - node(node, "module") do - field("constant", node.constant) - field("bodystmt", node.bodystmt) - comments(node) - end - end - - def visit_mrhs(node) - node(node, "mrhs") do - list("parts", node.parts) - comments(node) - end - end - - def visit_next(node) - node(node, "next") do - field("arguments", node.arguments) - comments(node) - end - end - - def visit_not(node) - node(node, "not") do - field("statement", node.statement) - comments(node) - end - end - - def visit_op(node) - visit_token(node, "op") - end - - def visit_opassign(node) - node(node, "opassign") do - field("target", node.target) - field("operator", node.operator) - field("value", node.value) - comments(node) - end - end - - def visit_params(node) - node(node, "params") do - list("requireds", node.requireds) if node.requireds.any? - pairs("optionals", node.optionals) if node.optionals.any? - field("rest", node.rest) if node.rest - list("posts", node.posts) if node.posts.any? - pairs("keywords", node.keywords) if node.keywords.any? - field("keyword_rest", node.keyword_rest) if node.keyword_rest - field("block", node.block) if node.block - comments(node) - end - end - - def visit_paren(node) - node(node, "paren") do - field("contents", node.contents) - comments(node) - end - end - - def visit_period(node) - visit_token(node, "period") - end - - def visit_pinned_begin(node) - node(node, "pinned_begin") do - field("statement", node.statement) - comments(node) - end - end - - def visit_pinned_var_ref(node) - node(node, "pinned_var_ref") do - field("value", node.value) - comments(node) - end - end - - def visit_program(node) - node(node, "program") do - field("statements", node.statements) - comments(node) - end - end - - def visit_qsymbols(node) - node(node, "qsymbols") do - list("elements", node.elements) - comments(node) - end - end - - def visit_qsymbols_beg(node) - node(node, "qsymbols_beg") { field("value", node.value) } - end - - def visit_qwords(node) - node(node, "qwords") do - list("elements", node.elements) - comments(node) - end - end - - def visit_qwords_beg(node) - node(node, "qwords_beg") { field("value", node.value) } - end - - def visit_range(node) - node(node, "range") do - field("left", node.left) if node.left - field("operator", node.operator) - field("right", node.right) if node.right - comments(node) - end - end - - def visit_rassign(node) - node(node, "rassign") do - field("value", node.value) - field("operator", node.operator) - field("pattern", node.pattern) - comments(node) - end - end - - def visit_rational(node) - visit_token(node, "rational") - end - - def visit_rbrace(node) - node(node, "rbrace") { field("value", node.value) } - end - - def visit_rbracket(node) - node(node, "rbracket") { field("value", node.value) } - end - - def visit_redo(node) - node(node, "redo") { comments(node) } - end - - def visit_regexp_beg(node) - node(node, "regexp_beg") { field("value", node.value) } - end - - def visit_regexp_content(node) - node(node, "regexp_content") { list("parts", node.parts) } - end - - def visit_regexp_end(node) - node(node, "regexp_end") { field("value", node.value) } - end - - def visit_regexp_literal(node) - node(node, "regexp_literal") do - list("parts", node.parts) - field("options", node.options) - comments(node) - end - end - - def visit_rescue(node) - node(node, "rescue") do - field("exception", node.exception) if node.exception - field("statements", node.statements) - field("consequent", node.consequent) if node.consequent - comments(node) - end - end - - def visit_rescue_ex(node) - node(node, "rescue_ex") do - field("exceptions", node.exceptions) - field("variable", node.variable) - comments(node) - end - end - - def visit_rescue_mod(node) - node(node, "rescue_mod") do - field("statement", node.statement) - field("value", node.value) - comments(node) - end - end - - def visit_rest_param(node) - node(node, "rest_param") do - field("name", node.name) - comments(node) - end - end - - def visit_retry(node) - node(node, "retry") { comments(node) } - end - - def visit_return(node) - node(node, "return") do - field("arguments", node.arguments) - comments(node) - end - end - - def visit_rparen(node) - node(node, "rparen") { field("value", node.value) } - end - - def visit_sclass(node) - node(node, "sclass") do - field("target", node.target) - field("bodystmt", node.bodystmt) - comments(node) - end - end - - def visit_statements(node) - node(node, "statements") do - list("body", node.body) - comments(node) - end - end - - def visit_string_concat(node) - node(node, "string_concat") do - field("left", node.left) - field("right", node.right) - comments(node) - end - end - - def visit_string_content(node) - node(node, "string_content") { list("parts", node.parts) } - end - - def visit_string_dvar(node) - node(node, "string_dvar") do - field("variable", node.variable) - comments(node) - end - end - - def visit_string_embexpr(node) - node(node, "string_embexpr") do - field("statements", node.statements) - comments(node) - end - end - - def visit_string_literal(node) - node(node, "string_literal") do - list("parts", node.parts) - comments(node) - end - end - - def visit_super(node) - node(node, "super") do - field("arguments", node.arguments) - comments(node) - end - end - - def visit_symbeg(node) - node(node, "symbeg") { field("value", node.value) } - end - - def visit_symbol_content(node) - node(node, "symbol_content") { field("value", node.value) } - end - - def visit_symbol_literal(node) - node(node, "symbol_literal") do - field("value", node.value) - comments(node) - end - end - - def visit_symbols(node) - node(node, "symbols") do - list("elements", node.elements) - comments(node) - end - end - - def visit_symbols_beg(node) - node(node, "symbols_beg") { field("value", node.value) } - end - - def visit_tlambda(node) - node(node, "tlambda") { field("value", node.value) } - end - - def visit_tlambeg(node) - node(node, "tlambeg") { field("value", node.value) } - end - - def visit_top_const_field(node) - node(node, "top_const_field") do - field("constant", node.constant) - comments(node) - end - end - - def visit_top_const_ref(node) - node(node, "top_const_ref") do - field("constant", node.constant) - comments(node) - end - end - - def visit_tstring_beg(node) - node(node, "tstring_beg") { field("value", node.value) } - end - - def visit_tstring_content(node) - visit_token(node, "tstring_content") - end - - def visit_tstring_end(node) - node(node, "tstring_end") { field("value", node.value) } - end - - def visit_unary(node) - node(node, "unary") do - field("operator", node.operator) - field("statement", node.statement) - comments(node) - end - end - - def visit_undef(node) - node(node, "undef") do - list("symbols", node.symbols) - comments(node) - end - end - - def visit_unless(node) - node(node, "unless") do - field("predicate", node.predicate) - field("statements", node.statements) - field("consequent", node.consequent) if node.consequent - comments(node) - end - end - - def visit_until(node) - node(node, "until") do - field("predicate", node.predicate) - field("statements", node.statements) - comments(node) - end - end - - def visit_var_field(node) - node(node, "var_field") do - field("value", node.value) - comments(node) - end - end - - def visit_var_ref(node) - node(node, "var_ref") do - field("value", node.value) - comments(node) - end - end - - def visit_vcall(node) - node(node, "vcall") do - field("value", node.value) - comments(node) - end - end - - def visit_void_stmt(node) - node(node, "void_stmt") { comments(node) } - end - - def visit_when(node) - node(node, "when") do - field("arguments", node.arguments) - field("statements", node.statements) - field("consequent", node.consequent) if node.consequent - comments(node) - end - end - - def visit_while(node) - node(node, "while") do - field("predicate", node.predicate) - field("statements", node.statements) - comments(node) - end - end - - def visit_word(node) - node(node, "word") do - list("parts", node.parts) - comments(node) - end - end - - def visit_words(node) - node(node, "words") do - list("elements", node.elements) - comments(node) - end - end - - def visit_words_beg(node) - node(node, "words_beg") { field("value", node.value) } - end - - def visit_xstring(node) - node(node, "xstring") { list("parts", node.parts) } - end - - def visit_xstring_literal(node) - node(node, "xstring_literal") do - list("parts", node.parts) - comments(node) - end - end - - def visit_yield(node) - node(node, "yield") do - field("arguments", node.arguments) - comments(node) - end - end - - def visit_zsuper(node) - node(node, "zsuper") { comments(node) } - end - - def visit___end__(node) - visit_token(node, "__end__") - end - end - - private - - def visit_token(node, type) - node(node, type) do - field("value", node.value) - comments(node) - end - end - end -end diff --git a/lib/syntax_tree/index.rb b/lib/syntax_tree/index.rb deleted file mode 100644 index 0280749f..00000000 --- a/lib/syntax_tree/index.rb +++ /dev/null @@ -1,683 +0,0 @@ -# frozen_string_literal: true - -module SyntaxTree - # This class can be used to build an index of the structure of Ruby files. We - # define an index as the list of constants and methods defined within a file. - # - # This index strives to be as fast as possible to better support tools like - # IDEs. Because of that, it has different backends depending on what - # functionality is available. - module Index - # This is a location for an index entry. - class Location - attr_reader :line, :column - - def initialize(line, column) - @line = line - @column = column - end - end - - # This entry represents a class definition using the class keyword. - class ClassDefinition - attr_reader :nesting, :name, :superclass, :location, :comments - - def initialize(nesting, name, superclass, location, comments) - @nesting = nesting - @name = name - @superclass = superclass - @location = location - @comments = comments - end - end - - # This entry represents a constant assignment. - class ConstantDefinition - attr_reader :nesting, :name, :location, :comments - - def initialize(nesting, name, location, comments) - @nesting = nesting - @name = name - @location = location - @comments = comments - end - end - - # This entry represents a module definition using the module keyword. - class ModuleDefinition - attr_reader :nesting, :name, :location, :comments - - def initialize(nesting, name, location, comments) - @nesting = nesting - @name = name - @location = location - @comments = comments - end - end - - # This entry represents a method definition using the def keyword. - class MethodDefinition - attr_reader :nesting, :name, :location, :comments - - def initialize(nesting, name, location, comments) - @nesting = nesting - @name = name - @location = location - @comments = comments - end - end - - # This entry represents a singleton method definition using the def keyword - # with a specified target. - class SingletonMethodDefinition - attr_reader :nesting, :name, :location, :comments - - def initialize(nesting, name, location, comments) - @nesting = nesting - @name = name - @location = location - @comments = comments - end - end - - # This entry represents a method definition that was created using the alias - # keyword. - class AliasMethodDefinition - attr_reader :nesting, :name, :location, :comments - - def initialize(nesting, name, location, comments) - @nesting = nesting - @name = name - @location = location - @comments = comments - end - end - - # When you're using the instruction sequence backend, this class is used to - # lazily parse comments out of the source code. - class FileComments - # We use the ripper library to pull out source comments. - class Parser < Ripper - attr_reader :comments - - def initialize(*) - super - @comments = {} - end - - def on_comment(value) - comments[lineno] = value.chomp - end - end - - # This represents the Ruby source in the form of a file. When it needs to - # be read we'll read the file. - class FileSource - attr_reader :filepath - - def initialize(filepath) - @filepath = filepath - end - - def source - File.read(filepath) - end - end - - # This represents the Ruby source in the form of a string. When it needs - # to be read the string is returned. - class StringSource - attr_reader :source - - def initialize(source) - @source = source - end - end - - attr_reader :source - - def initialize(source) - @source = source - end - - def comments - @comments ||= Parser.new(source.source).tap(&:parse).comments - end - end - - # This class handles parsing comments from Ruby source code in the case that - # we use the instruction sequence backend. Because the instruction sequence - # backend doesn't provide comments (since they are dropped) we provide this - # interface to lazily parse them out. - class EntryComments - include Enumerable - attr_reader :file_comments, :location - - def initialize(file_comments, location) - @file_comments = file_comments - @location = location - end - - def each(&block) - line = location.line - 1 - result = [] - - while line >= 0 && (comment = file_comments.comments[line]) - result.unshift(comment) - line -= 1 - end - - result.each(&block) - end - end - - # This backend creates the index using RubyVM::InstructionSequence, which is - # faster than using the Syntax Tree parser, but is not available on all - # runtimes. - class ISeqBackend - VM_DEFINECLASS_TYPE_CLASS = 0x00 - VM_DEFINECLASS_TYPE_SINGLETON_CLASS = 0x01 - VM_DEFINECLASS_TYPE_MODULE = 0x02 - VM_DEFINECLASS_FLAG_SCOPED = 0x08 - VM_DEFINECLASS_FLAG_HAS_SUPERCLASS = 0x10 - - def index(source) - index_iseq( - RubyVM::InstructionSequence.compile(source).to_a, - FileComments.new(FileComments::StringSource.new(source)) - ) - end - - def index_file(filepath) - index_iseq( - RubyVM::InstructionSequence.compile_file(filepath).to_a, - FileComments.new(FileComments::FileSource.new(filepath)) - ) - end - - private - - def location_for(iseq) - code_location = iseq[4][:code_location] - Location.new(code_location[0], code_location[1]) - end - - def find_constant_path(insns, index) - index -= 1 while index >= 0 && - ( - insns[index].is_a?(Integer) || - ( - insns[index].is_a?(Array) && - %i[swap topn].include?(insns[index][0]) - ) - ) - insn = insns[index] - - if insn.is_a?(Array) && insn[0] == :opt_getconstant_path - # In this case we're on Ruby 3.2+ and we have an opt_getconstant_path - # instruction, so we already know all of the symbols in the nesting. - [index - 1, insn[1]] - elsif insn.is_a?(Symbol) && insn.match?(/\Alabel_\d+/) - # Otherwise, if we have a label then this is very likely the - # destination of an opt_getinlinecache instruction, in which case - # we'll walk backwards to grab up all of the constants. - names = [] - - index -= 1 - until insns[index].is_a?(Array) && - insns[index][0] == :opt_getinlinecache - if insns[index].is_a?(Array) && insns[index][0] == :getconstant - names.unshift(insns[index][1]) - end - - index -= 1 - end - - [index - 1, names] - else - [index, []] - end - end - - def find_attr_arguments(insns, index) - orig_argc = insns[index][1][:orig_argc] - names = [] - - current = index - 1 - while current >= 0 && names.length < orig_argc - if insns[current].is_a?(Array) && insns[current][0] == :putobject - names.unshift(insns[current][1]) - end - - current -= 1 - end - - names if insns[current] == [:putself] && names.length == orig_argc - end - - def method_definition(nesting, name, location, file_comments) - comments = EntryComments.new(file_comments, location) - - if nesting.last == [:singletonclass] - SingletonMethodDefinition.new( - nesting[0...-1], - name, - location, - comments - ) - else - MethodDefinition.new(nesting, name, location, comments) - end - end - - def index_iseq(iseq, file_comments) - results = [] - queue = [[iseq, []]] - - while (current_iseq, current_nesting = queue.shift) - file = current_iseq[5] - line = current_iseq[8] - insns = current_iseq[13] - - insns.each_with_index do |insn, index| - case insn - when Integer - line = insn - next - when Array - # continue on - else - # skip everything else - next - end - - case insn[0] - when :defineclass - _, name, class_iseq, flags = insn - next_nesting = current_nesting.dup - - # This is the index we're going to search for the nested constant - # path within the declaration name. - constant_index = index - 2 - - # This is the superclass of the class being defined. - superclass = [] - - # If there is a superclass, then we're going to find it here and - # then update the constant_index as necessary. - if flags & VM_DEFINECLASS_FLAG_HAS_SUPERCLASS > 0 - constant_index, superclass = - find_constant_path(insns, index - 1) - - if superclass.empty? - warn("#{file}:#{line}: superclass with non constant path") - next - end - end - - if (_, nesting = find_constant_path(insns, constant_index)) - # If there is a constant path in the class name, then we need to - # handle that by updating the nesting. - next_nesting << (nesting << name) - else - # Otherwise we'll add the class name to the nesting. - next_nesting << [name] - end - - if flags == VM_DEFINECLASS_TYPE_SINGLETON_CLASS - # At the moment, we don't support singletons that aren't - # defined on self. We could, but it would require more - # emulation. - if insns[index - 2] != [:putself] - warn( - "#{file}:#{line}: singleton class with non-self receiver" - ) - next - end - elsif flags & VM_DEFINECLASS_TYPE_MODULE > 0 - location = location_for(class_iseq) - results << ModuleDefinition.new( - next_nesting, - name, - location, - EntryComments.new(file_comments, location) - ) - else - location = location_for(class_iseq) - results << ClassDefinition.new( - next_nesting, - name, - superclass, - location, - EntryComments.new(file_comments, location) - ) - end - - queue << [class_iseq, next_nesting] - when :definemethod - location = location_for(insn[2]) - results << method_definition( - current_nesting, - insn[1], - location, - file_comments - ) - when :definesmethod - if insns[index - 1] != [:putself] - warn("#{file}:#{line}: singleton method with non-self receiver") - next - end - - location = location_for(insn[2]) - results << SingletonMethodDefinition.new( - current_nesting, - insn[1], - location, - EntryComments.new(file_comments, location) - ) - when :setconstant - next_nesting = current_nesting.dup - name = insn[1] - - _, nesting = find_constant_path(insns, index - 1) - next_nesting << nesting if nesting.any? - - location = Location.new(line, :unknown) - results << ConstantDefinition.new( - next_nesting, - name, - location, - EntryComments.new(file_comments, location) - ) - when :opt_send_without_block, :send - case insn[1][:mid] - when :attr_reader, :attr_writer, :attr_accessor - attr_names = find_attr_arguments(insns, index) - next unless attr_names - - location = Location.new(line, :unknown) - attr_names.each do |attr_name| - if insn[1][:mid] != :attr_writer - results << method_definition( - current_nesting, - attr_name, - location, - file_comments - ) - end - - if insn[1][:mid] != :attr_reader - results << method_definition( - current_nesting, - :"#{attr_name}=", - location, - file_comments - ) - end - end - when :"core#set_method_alias" - # Now we have to validate that the alias is happening with a - # non-interpolated value. To do this we'll match the specific - # pattern we're expecting. - values = - insns[(index - 4)...index].map do |previous| - previous.is_a?(Array) ? previous[0] : previous - end - if values != - %i[putspecialobject putspecialobject putobject putobject] - next - end - - # Now that we know it's in the structure we want it, we can use - # the values of the putobject to determine the alias. - location = Location.new(line, :unknown) - results << AliasMethodDefinition.new( - current_nesting, - insns[index - 2][1], - location, - EntryComments.new(file_comments, location) - ) - end - end - end - end - - results - end - end - - # This backend creates the index using the Syntax Tree parser and a visitor. - # It is not as fast as using the instruction sequences directly, but is - # supported on all runtimes. - class ParserBackend - class ConstantNameVisitor < Visitor - def visit_const_ref(node) - [node.constant.value.to_sym] - end - - def visit_const_path_ref(node) - visit(node.parent) << node.constant.value.to_sym - end - - def visit_var_ref(node) - [node.value.value.to_sym] - end - end - - class IndexVisitor < Visitor - attr_reader :results, :nesting, :statements - - def initialize - @results = [] - @nesting = [] - @statements = nil - end - - visit_methods do - def visit_alias(node) - if node.left.is_a?(SymbolLiteral) && node.right.is_a?(SymbolLiteral) - location = - Location.new( - node.location.start_line, - node.location.start_column - ) - - results << AliasMethodDefinition.new( - nesting.dup, - node.left.value.value.to_sym, - location, - comments_for(node) - ) - end - - super - end - - def visit_assign(node) - if node.target.is_a?(VarField) && node.target.value.is_a?(Const) - location = - Location.new( - node.location.start_line, - node.location.start_column - ) - - results << ConstantDefinition.new( - nesting.dup, - node.target.value.value.to_sym, - location, - comments_for(node) - ) - end - - super - end - - def visit_class(node) - names = node.constant.accept(ConstantNameVisitor.new) - nesting << names - - location = - Location.new(node.location.start_line, node.location.start_column) - - superclass = - if node.superclass - visited = node.superclass.accept(ConstantNameVisitor.new) - - if visited == [[]] - raise NotImplementedError, "superclass with non constant path" - end - - visited - else - [] - end - - results << ClassDefinition.new( - nesting.dup, - names.last, - superclass, - location, - comments_for(node) - ) - - super - nesting.pop - end - - def visit_command(node) - case node.message.value - when "attr_reader", "attr_writer", "attr_accessor" - comments = comments_for(node) - location = - Location.new( - node.location.start_line, - node.location.start_column - ) - - node.arguments.parts.each do |argument| - next unless argument.is_a?(SymbolLiteral) - name = argument.value.value.to_sym - - if node.message.value != "attr_writer" - results << MethodDefinition.new( - nesting.dup, - name, - location, - comments - ) - end - - if node.message.value != "attr_reader" - results << MethodDefinition.new( - nesting.dup, - :"#{name}=", - location, - comments - ) - end - end - end - - super - end - - def visit_def(node) - name = node.name.value.to_sym - location = - Location.new(node.location.start_line, node.location.start_column) - - results << if node.target.nil? - MethodDefinition.new( - nesting.dup, - name, - location, - comments_for(node) - ) - else - SingletonMethodDefinition.new( - nesting.dup, - name, - location, - comments_for(node) - ) - end - - super - end - - def visit_module(node) - names = node.constant.accept(ConstantNameVisitor.new) - nesting << names - - location = - Location.new(node.location.start_line, node.location.start_column) - - results << ModuleDefinition.new( - nesting.dup, - names.last, - location, - comments_for(node) - ) - - super - nesting.pop - end - - def visit_program(node) - super - results - end - - def visit_statements(node) - @statements = node - super - end - end - - private - - def comments_for(node) - comments = [] - - body = statements.body - line = node.location.start_line - 1 - index = body.index(node) - return comments if index.nil? - - index -= 1 - while index >= 0 && body[index].is_a?(Comment) && - (line - body[index].location.start_line < 2) - comments.unshift(body[index].value) - line = body[index].location.start_line - index -= 1 - end - - comments - end - end - - def index(source) - SyntaxTree.parse(source).accept(IndexVisitor.new) - end - - def index_file(filepath) - index(SyntaxTree.read(filepath)) - end - end - - # The class defined here is used to perform the indexing, depending on what - # functionality is available from the runtime. - INDEX_BACKEND = - defined?(RubyVM::InstructionSequence) ? ISeqBackend : ParserBackend - - # This method accepts source code and then indexes it. - def self.index(source, backend: INDEX_BACKEND.new) - backend.index(source) - end - - # This method accepts a filepath and then indexes it. - def self.index_file(filepath, backend: INDEX_BACKEND.new) - backend.index_file(filepath) - end - end -end diff --git a/lib/syntax_tree/json_visitor.rb b/lib/syntax_tree/json_visitor.rb deleted file mode 100644 index 7ad3fba0..00000000 --- a/lib/syntax_tree/json_visitor.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -require "json" - -module SyntaxTree - # This visitor transforms the AST into a hash that contains only primitives - # that can be easily serialized into JSON. - class JSONVisitor < FieldVisitor - attr_reader :target - - def initialize - @target = nil - end - - private - - def comments(node) - target[:comments] = visit_all(node.comments) - end - - def field(name, value) - target[name] = value.is_a?(Node) ? visit(value) : value - end - - def list(name, values) - target[name] = visit_all(values) - end - - def node(node, type) - previous = @target - @target = { type: type, location: visit_location(node.location) } - yield - @target - ensure - @target = previous - end - - def pairs(name, values) - target[name] = values.map { |(key, value)| [visit(key), visit(value)] } - end - - def text(name, value) - target[name] = value - end - - def visit_location(location) - [ - location.start_line, - location.start_char, - location.end_line, - location.end_char - ] - end - end -end diff --git a/lib/syntax_tree/language_server.rb b/lib/syntax_tree/language_server.rb index aaa64e9a..6da080d2 100644 --- a/lib/syntax_tree/language_server.rb +++ b/lib/syntax_tree/language_server.rb @@ -267,9 +267,6 @@ def run uri = request.dig(:params, :textDocument, :uri) contents = store[uri] write(id: request[:id], result: contents ? inlay_hints(contents) : nil) - when Request[method: "syntaxTree/visualizing", id: :any, params: { textDocument: { uri: :any } }] - uri = request.dig(:params, :textDocument, :uri) - write(id: request[:id], result: PP.pp(SyntaxTree.parse(store[uri]), +"")) when Request[method: %r{\$/.+}] # ignored when Request[method: "textDocument/documentColor", params: { textDocument: { uri: :any } }] @@ -335,9 +332,5 @@ def write(value) output.print("Content-Length: #{response.bytesize}\r\n\r\n#{response}") output.flush end - - def log(message) - write(method: "window/logMessage", params: { type: 4, message: message }) - end end end diff --git a/lib/syntax_tree/match_visitor.rb b/lib/syntax_tree/match_visitor.rb deleted file mode 100644 index ca5bf234..00000000 --- a/lib/syntax_tree/match_visitor.rb +++ /dev/null @@ -1,120 +0,0 @@ -# frozen_string_literal: true - -module SyntaxTree - # This visitor transforms the AST into a Ruby pattern matching expression that - # would match correctly against the AST. - class MatchVisitor < FieldVisitor - attr_reader :q - - def initialize(q) - @q = q - end - - def visit(node) - case node - when Node - super - when String - # pp will split up a string on newlines and concat them together using a - # "+" operator. This breaks the pattern matching expression. So instead - # we're going to check here for strings and manually put the entire - # value into the output buffer. - q.text(node.inspect) - else - node.pretty_print(q) - end - end - - private - - def comments(node) - return if node.comments.empty? - - q.nest(0) do - q.text("comments: [") - q.indent do - q.breakable("") - q.seplist(node.comments) { |comment| visit(comment) } - end - q.breakable("") - q.text("]") - end - end - - def field(name, value) - q.nest(0) do - q.text(name) - q.text(": ") - visit(value) - end - end - - def list(name, values) - q.group do - q.text(name) - q.text(": [") - q.indent do - q.breakable("") - q.seplist(values) { |value| visit(value) } - end - q.breakable("") - q.text("]") - end - end - - def node(node, _type) - items = [] - q.with_target(items) { yield } - - if items.empty? - q.text(node.class.name) - return - end - - q.group do - q.text(node.class.name) - q.text("[") - q.indent do - q.breakable("") - q.seplist(items) { |item| q.target << item } - end - q.breakable("") - q.text("]") - end - end - - def pairs(name, values) - q.group do - q.text(name) - q.text(": [") - q.indent do - q.breakable("") - q.seplist(values) do |(key, value)| - q.group do - q.text("[") - q.indent do - q.breakable("") - visit(key) - q.text(",") - q.breakable - visit(value || nil) - end - q.breakable("") - q.text("]") - end - end - end - q.breakable("") - q.text("]") - end - end - - def text(name, value) - q.nest(0) do - q.text(name) - q.text(": ") - value.pretty_print(q) - end - end - end -end diff --git a/lib/syntax_tree/mermaid.rb b/lib/syntax_tree/mermaid.rb deleted file mode 100644 index 68ea4734..00000000 --- a/lib/syntax_tree/mermaid.rb +++ /dev/null @@ -1,177 +0,0 @@ -# frozen_string_literal: true - -require "cgi" -require "stringio" - -module SyntaxTree - # This module is responsible for rendering mermaid (https://mermaid.js.org/) - # flow charts. - module Mermaid - # This is the main class that handles rendering a flowchart. It keeps track - # of its nodes and links and renders them according to the mermaid syntax. - class FlowChart - attr_reader :output, :prefix, :nodes, :links - - def initialize - @output = StringIO.new - @output.puts("flowchart TD") - @prefix = " " - - @nodes = {} - @links = [] - end - - # Retrieve a node that has already been added to the flowchart by its id. - def fetch(id) - nodes.fetch(id) - end - - # Add a link to the flowchart between two nodes with an optional label. - def link(from, to, label = nil, type: :directed, color: nil) - link = Link.new(from, to, label, type, color) - links << link - - output.puts("#{prefix}#{link.render}") - link - end - - # Add a node to the flowchart with an optional label. - def node(id, label = " ", shape: :rectangle) - node = Node.new(id, label, shape) - nodes[id] = node - - output.puts("#{prefix}#{nodes[id].render}") - node - end - - # Add a subgraph to the flowchart. Within the given block, all of the - # nodes will be rendered within the subgraph. - def subgraph(label) - output.puts("#{prefix}subgraph #{Mermaid.escape(label)}") - - previous = prefix - @prefix = "#{prefix} " - - begin - yield - ensure - @prefix = previous - output.puts("#{prefix}end") - end - end - - # Return the rendered flowchart. - def render - links.each_with_index do |link, index| - if link.color - output.puts("#{prefix}linkStyle #{index} stroke:#{link.color}") - end - end - - output.string - end - end - - # This class represents a link between two nodes in a flowchart. It is not - # meant to be interacted with directly, but rather used as a data structure - # by the FlowChart class. - class Link - TYPES = %i[directed dotted].freeze - COLORS = %i[green red].freeze - - attr_reader :from, :to, :label, :type, :color - - def initialize(from, to, label, type, color) - raise unless TYPES.include?(type) - raise if color && !COLORS.include?(color) - - @from = from - @to = to - @label = label - @type = type - @color = color - end - - def render - left_side, right_side, full_side = sides - - if label - escaped = Mermaid.escape(label) - "#{from.id} #{left_side} #{escaped} #{right_side} #{to.id}" - else - "#{from.id} #{full_side} #{to.id}" - end - end - - private - - def sides - case type - when :directed - %w[-- --> -->] - when :dotted - %w[-. .-> -.->] - end - end - end - - # This class represents a node in a flowchart. Unlike the Link class, it can - # be used directly. It is the return value of the #node method, and is meant - # to be passed around to #link methods to create links between nodes. - class Node - SHAPES = %i[circle rectangle rounded stadium].freeze - - attr_reader :id, :label, :shape - - def initialize(id, label, shape) - raise unless SHAPES.include?(shape) - - @id = id - @label = label - @shape = shape - end - - def render - left_bound, right_bound = bounds - "#{id}#{left_bound}#{Mermaid.escape(label)}#{right_bound}" - end - - private - - def bounds - case shape - when :circle - %w[(( ))] - when :rectangle - ["[", "]"] - when :rounded - %w[( )] - when :stadium - ["([", "])"] - end - end - end - - class << self - # Escape a label to be used in the mermaid syntax. This is used to escape - # HTML entities such that they render properly within the quotes. - def escape(label) - "\"#{CGI.escapeHTML(label)}\"" - end - - # Create a new flowchart. If a block is given, it will be yielded to and - # the flowchart will be rendered. Otherwise, the flowchart will be - # returned. - def flowchart - flowchart = FlowChart.new - - if block_given? - yield flowchart - flowchart.render - else - flowchart - end - end - end - end -end diff --git a/lib/syntax_tree/mermaid_visitor.rb b/lib/syntax_tree/mermaid_visitor.rb deleted file mode 100644 index fc9f6706..00000000 --- a/lib/syntax_tree/mermaid_visitor.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -module SyntaxTree - # This visitor transforms the AST into a mermaid flow chart. - class MermaidVisitor < FieldVisitor - attr_reader :flowchart, :target - - def initialize - @flowchart = Mermaid.flowchart - @target = nil - end - - def visit_program(node) - super - flowchart.render - end - - private - - def comments(node) - # Ignore - end - - def field(name, value) - case value - when nil - # skip - when Node - flowchart.link(target, visit(value), name) - else - to = - flowchart.node("#{target.id}_#{name}", value.inspect, shape: :stadium) - flowchart.link(target, to, name) - end - end - - def list(name, values) - values.each_with_index do |value, index| - field("#{name}[#{index}]", value) - end - end - - def node(node, type) - previous_target = target - - begin - @target = flowchart.node("node_#{node.object_id}", type) - yield - @target - ensure - @target = previous_target - end - end - - def pairs(name, values) - values.each_with_index do |(key, value), index| - to = flowchart.node("#{target.id}_#{name}_#{index}", shape: :circle) - - flowchart.link(target, to, "#{name}[#{index}]") - flowchart.link(to, visit(key), "[0]") - flowchart.link(to, visit(value), "[1]") if value - end - end - - def text(name, value) - field(name, value) - end - end -end diff --git a/lib/syntax_tree/mutation_visitor.rb b/lib/syntax_tree/mutation_visitor.rb deleted file mode 100644 index 0b4b9357..00000000 --- a/lib/syntax_tree/mutation_visitor.rb +++ /dev/null @@ -1,924 +0,0 @@ -# frozen_string_literal: true - -module SyntaxTree - # This visitor walks through the tree and copies each node as it is being - # visited. This is useful for mutating the tree before it is formatted. - class MutationVisitor < BasicVisitor - attr_reader :mutations - - def initialize - @mutations = [] - end - - # Create a new mutation based on the given query that will mutate the node - # using the given block. The block should return a new node that will take - # the place of the given node in the tree. These blocks frequently make use - # of the `copy` method on nodes to create a new node with the same - # properties as the original node. - def mutate(query, &block) - mutations << [Pattern.new(query).compile, block] - end - - # This is the base visit method for each node in the tree. It first creates - # a copy of the node using the visit_* methods defined below. Then it checks - # each mutation in sequence and calls it if it finds a match. - def visit(node) - return unless node - result = node.accept(self) - - mutations.each do |(pattern, mutation)| - result = mutation.call(result) if pattern.call(result) - end - - result - end - - visit_methods do - # Visit a BEGINBlock node. - def visit_BEGIN(node) - node.copy( - lbrace: visit(node.lbrace), - statements: visit(node.statements) - ) - end - - # Visit a CHAR node. - def visit_CHAR(node) - node.copy - end - - # Visit a ENDBlock node. - def visit_END(node) - node.copy( - lbrace: visit(node.lbrace), - statements: visit(node.statements) - ) - end - - # Visit a EndContent node. - def visit___end__(node) - node.copy - end - - # Visit a AliasNode node. - def visit_alias(node) - node.copy(left: visit(node.left), right: visit(node.right)) - end - - # Visit a ARef node. - def visit_aref(node) - node.copy(index: visit(node.index)) - end - - # Visit a ARefField node. - def visit_aref_field(node) - node.copy(index: visit(node.index)) - end - - # Visit a ArgParen node. - def visit_arg_paren(node) - node.copy(arguments: visit(node.arguments)) - end - - # Visit a Args node. - def visit_args(node) - node.copy(parts: visit_all(node.parts)) - end - - # Visit a ArgBlock node. - def visit_arg_block(node) - node.copy(value: visit(node.value)) - end - - # Visit a ArgStar node. - def visit_arg_star(node) - node.copy(value: visit(node.value)) - end - - # Visit a ArgsForward node. - def visit_args_forward(node) - node.copy - end - - # Visit a ArrayLiteral node. - def visit_array(node) - node.copy( - lbracket: visit(node.lbracket), - contents: visit(node.contents) - ) - end - - # Visit a AryPtn node. - def visit_aryptn(node) - node.copy( - constant: visit(node.constant), - requireds: visit_all(node.requireds), - rest: visit(node.rest), - posts: visit_all(node.posts) - ) - end - - # Visit a Assign node. - def visit_assign(node) - node.copy(target: visit(node.target)) - end - - # Visit a Assoc node. - def visit_assoc(node) - node.copy - end - - # Visit a AssocSplat node. - def visit_assoc_splat(node) - node.copy - end - - # Visit a Backref node. - def visit_backref(node) - node.copy - end - - # Visit a Backtick node. - def visit_backtick(node) - node.copy - end - - # Visit a BareAssocHash node. - def visit_bare_assoc_hash(node) - node.copy(assocs: visit_all(node.assocs)) - end - - # Visit a Begin node. - def visit_begin(node) - node.copy(bodystmt: visit(node.bodystmt)) - end - - # Visit a PinnedBegin node. - def visit_pinned_begin(node) - node.copy - end - - # Visit a Binary node. - def visit_binary(node) - node.copy - end - - # Visit a BlockVar node. - def visit_block_var(node) - node.copy(params: visit(node.params), locals: visit_all(node.locals)) - end - - # Visit a BlockArg node. - def visit_blockarg(node) - node.copy(name: visit(node.name)) - end - - # Visit a BodyStmt node. - def visit_bodystmt(node) - node.copy( - statements: visit(node.statements), - rescue_clause: visit(node.rescue_clause), - else_clause: visit(node.else_clause), - ensure_clause: visit(node.ensure_clause) - ) - end - - # Visit a Break node. - def visit_break(node) - node.copy(arguments: visit(node.arguments)) - end - - # Visit a Call node. - def visit_call(node) - node.copy( - receiver: visit(node.receiver), - operator: node.operator == :"::" ? :"::" : visit(node.operator), - message: node.message == :call ? :call : visit(node.message), - arguments: visit(node.arguments) - ) - end - - # Visit a Case node. - def visit_case(node) - node.copy( - keyword: visit(node.keyword), - value: visit(node.value), - consequent: visit(node.consequent) - ) - end - - # Visit a RAssign node. - def visit_rassign(node) - node.copy(operator: visit(node.operator)) - end - - # Visit a ClassDeclaration node. - def visit_class(node) - node.copy( - constant: visit(node.constant), - superclass: visit(node.superclass), - bodystmt: visit(node.bodystmt) - ) - end - - # Visit a Comma node. - def visit_comma(node) - node.copy - end - - # Visit a Command node. - def visit_command(node) - node.copy( - message: visit(node.message), - arguments: visit(node.arguments), - block: visit(node.block) - ) - end - - # Visit a CommandCall node. - def visit_command_call(node) - node.copy( - operator: node.operator == :"::" ? :"::" : visit(node.operator), - message: visit(node.message), - arguments: visit(node.arguments), - block: visit(node.block) - ) - end - - # Visit a Comment node. - def visit_comment(node) - node.copy - end - - # Visit a Const node. - def visit_const(node) - node.copy - end - - # Visit a ConstPathField node. - def visit_const_path_field(node) - node.copy(constant: visit(node.constant)) - end - - # Visit a ConstPathRef node. - def visit_const_path_ref(node) - node.copy(constant: visit(node.constant)) - end - - # Visit a ConstRef node. - def visit_const_ref(node) - node.copy(constant: visit(node.constant)) - end - - # Visit a CVar node. - def visit_cvar(node) - node.copy - end - - # Visit a Def node. - def visit_def(node) - node.copy( - target: visit(node.target), - operator: visit(node.operator), - name: visit(node.name), - params: visit(node.params), - bodystmt: visit(node.bodystmt) - ) - end - - # Visit a Defined node. - def visit_defined(node) - node.copy - end - - # Visit a Block node. - def visit_block(node) - node.copy( - opening: visit(node.opening), - block_var: visit(node.block_var), - bodystmt: visit(node.bodystmt) - ) - end - - # Visit a RangeNode node. - def visit_range(node) - node.copy( - left: visit(node.left), - operator: visit(node.operator), - right: visit(node.right) - ) - end - - # Visit a DynaSymbol node. - def visit_dyna_symbol(node) - node.copy(parts: visit_all(node.parts)) - end - - # Visit a Else node. - def visit_else(node) - node.copy( - keyword: visit(node.keyword), - statements: visit(node.statements) - ) - end - - # Visit a Elsif node. - def visit_elsif(node) - node.copy( - statements: visit(node.statements), - consequent: visit(node.consequent) - ) - end - - # Visit a EmbDoc node. - def visit_embdoc(node) - node.copy - end - - # Visit a EmbExprBeg node. - def visit_embexpr_beg(node) - node.copy - end - - # Visit a EmbExprEnd node. - def visit_embexpr_end(node) - node.copy - end - - # Visit a EmbVar node. - def visit_embvar(node) - node.copy - end - - # Visit a Ensure node. - def visit_ensure(node) - node.copy( - keyword: visit(node.keyword), - statements: visit(node.statements) - ) - end - - # Visit a ExcessedComma node. - def visit_excessed_comma(node) - node.copy - end - - # Visit a Field node. - def visit_field(node) - node.copy( - operator: node.operator == :"::" ? :"::" : visit(node.operator), - name: visit(node.name) - ) - end - - # Visit a FloatLiteral node. - def visit_float(node) - node.copy - end - - # Visit a FndPtn node. - def visit_fndptn(node) - node.copy( - constant: visit(node.constant), - left: visit(node.left), - values: visit_all(node.values), - right: visit(node.right) - ) - end - - # Visit a For node. - def visit_for(node) - node.copy(index: visit(node.index), statements: visit(node.statements)) - end - - # Visit a GVar node. - def visit_gvar(node) - node.copy - end - - # Visit a HashLiteral node. - def visit_hash(node) - node.copy(lbrace: visit(node.lbrace), assocs: visit_all(node.assocs)) - end - - # Visit a Heredoc node. - def visit_heredoc(node) - node.copy( - beginning: visit(node.beginning), - ending: visit(node.ending), - parts: visit_all(node.parts) - ) - end - - # Visit a HeredocBeg node. - def visit_heredoc_beg(node) - node.copy - end - - # Visit a HeredocEnd node. - def visit_heredoc_end(node) - node.copy - end - - # Visit a HshPtn node. - def visit_hshptn(node) - node.copy( - constant: visit(node.constant), - keywords: - node.keywords.map { |label, value| [visit(label), visit(value)] }, - keyword_rest: visit(node.keyword_rest) - ) - end - - # Visit a Ident node. - def visit_ident(node) - node.copy - end - - # Visit a IfNode node. - def visit_if(node) - node.copy( - predicate: visit(node.predicate), - statements: visit(node.statements), - consequent: visit(node.consequent) - ) - end - - # Visit a IfOp node. - def visit_if_op(node) - node.copy - end - - # Visit a Imaginary node. - def visit_imaginary(node) - node.copy - end - - # Visit a In node. - def visit_in(node) - node.copy( - statements: visit(node.statements), - consequent: visit(node.consequent) - ) - end - - # Visit a Int node. - def visit_int(node) - node.copy - end - - # Visit a IVar node. - def visit_ivar(node) - node.copy - end - - # Visit a Kw node. - def visit_kw(node) - node.copy - end - - # Visit a KwRestParam node. - def visit_kwrest_param(node) - node.copy(name: visit(node.name)) - end - - # Visit a Label node. - def visit_label(node) - node.copy - end - - # Visit a LabelEnd node. - def visit_label_end(node) - node.copy - end - - # Visit a Lambda node. - def visit_lambda(node) - node.copy( - params: visit(node.params), - statements: visit(node.statements) - ) - end - - # Visit a LambdaVar node. - def visit_lambda_var(node) - node.copy(params: visit(node.params), locals: visit_all(node.locals)) - end - - # Visit a LBrace node. - def visit_lbrace(node) - node.copy - end - - # Visit a LBracket node. - def visit_lbracket(node) - node.copy - end - - # Visit a LParen node. - def visit_lparen(node) - node.copy - end - - # Visit a MAssign node. - def visit_massign(node) - node.copy(target: visit(node.target)) - end - - # Visit a MethodAddBlock node. - def visit_method_add_block(node) - node.copy(call: visit(node.call), block: visit(node.block)) - end - - # Visit a MLHS node. - def visit_mlhs(node) - node.copy(parts: visit_all(node.parts)) - end - - # Visit a MLHSParen node. - def visit_mlhs_paren(node) - node.copy(contents: visit(node.contents)) - end - - # Visit a ModuleDeclaration node. - def visit_module(node) - node.copy( - constant: visit(node.constant), - bodystmt: visit(node.bodystmt) - ) - end - - # Visit a MRHS node. - def visit_mrhs(node) - node.copy(parts: visit_all(node.parts)) - end - - # Visit a Next node. - def visit_next(node) - node.copy(arguments: visit(node.arguments)) - end - - # Visit a Op node. - def visit_op(node) - node.copy - end - - # Visit a OpAssign node. - def visit_opassign(node) - node.copy(target: visit(node.target), operator: visit(node.operator)) - end - - # Visit a Params node. - def visit_params(node) - node.copy( - requireds: visit_all(node.requireds), - optionals: - node.optionals.map { |ident, value| [visit(ident), visit(value)] }, - rest: visit(node.rest), - posts: visit_all(node.posts), - keywords: - node.keywords.map { |ident, value| [visit(ident), visit(value)] }, - keyword_rest: - node.keyword_rest == :nil ? :nil : visit(node.keyword_rest), - block: visit(node.block) - ) - end - - # Visit a Paren node. - def visit_paren(node) - node.copy(lparen: visit(node.lparen), contents: visit(node.contents)) - end - - # Visit a Period node. - def visit_period(node) - node.copy - end - - # Visit a Program node. - def visit_program(node) - node.copy(statements: visit(node.statements)) - end - - # Visit a QSymbols node. - def visit_qsymbols(node) - node.copy( - beginning: visit(node.beginning), - elements: visit_all(node.elements) - ) - end - - # Visit a QSymbolsBeg node. - def visit_qsymbols_beg(node) - node.copy - end - - # Visit a QWords node. - def visit_qwords(node) - node.copy( - beginning: visit(node.beginning), - elements: visit_all(node.elements) - ) - end - - # Visit a QWordsBeg node. - def visit_qwords_beg(node) - node.copy - end - - # Visit a RationalLiteral node. - def visit_rational(node) - node.copy - end - - # Visit a RBrace node. - def visit_rbrace(node) - node.copy - end - - # Visit a RBracket node. - def visit_rbracket(node) - node.copy - end - - # Visit a Redo node. - def visit_redo(node) - node.copy - end - - # Visit a RegexpContent node. - def visit_regexp_content(node) - node.copy(parts: visit_all(node.parts)) - end - - # Visit a RegexpBeg node. - def visit_regexp_beg(node) - node.copy - end - - # Visit a RegexpEnd node. - def visit_regexp_end(node) - node.copy - end - - # Visit a RegexpLiteral node. - def visit_regexp_literal(node) - node.copy(parts: visit_all(node.parts)) - end - - # Visit a RescueEx node. - def visit_rescue_ex(node) - node.copy(variable: visit(node.variable)) - end - - # Visit a Rescue node. - def visit_rescue(node) - node.copy( - keyword: visit(node.keyword), - exception: visit(node.exception), - statements: visit(node.statements), - consequent: visit(node.consequent) - ) - end - - # Visit a RescueMod node. - def visit_rescue_mod(node) - node.copy - end - - # Visit a RestParam node. - def visit_rest_param(node) - node.copy(name: visit(node.name)) - end - - # Visit a Retry node. - def visit_retry(node) - node.copy - end - - # Visit a Return node. - def visit_return(node) - node.copy(arguments: visit(node.arguments)) - end - - # Visit a RParen node. - def visit_rparen(node) - node.copy - end - - # Visit a SClass node. - def visit_sclass(node) - node.copy(bodystmt: visit(node.bodystmt)) - end - - # Visit a Statements node. - def visit_statements(node) - node.copy(body: visit_all(node.body)) - end - - # Visit a StringContent node. - def visit_string_content(node) - node.copy(parts: visit_all(node.parts)) - end - - # Visit a StringConcat node. - def visit_string_concat(node) - node.copy(left: visit(node.left), right: visit(node.right)) - end - - # Visit a StringDVar node. - def visit_string_dvar(node) - node.copy(variable: visit(node.variable)) - end - - # Visit a StringEmbExpr node. - def visit_string_embexpr(node) - node.copy(statements: visit(node.statements)) - end - - # Visit a StringLiteral node. - def visit_string_literal(node) - node.copy(parts: visit_all(node.parts)) - end - - # Visit a Super node. - def visit_super(node) - node.copy(arguments: visit(node.arguments)) - end - - # Visit a SymBeg node. - def visit_symbeg(node) - node.copy - end - - # Visit a SymbolContent node. - def visit_symbol_content(node) - node.copy(value: visit(node.value)) - end - - # Visit a SymbolLiteral node. - def visit_symbol_literal(node) - node.copy(value: visit(node.value)) - end - - # Visit a Symbols node. - def visit_symbols(node) - node.copy( - beginning: visit(node.beginning), - elements: visit_all(node.elements) - ) - end - - # Visit a SymbolsBeg node. - def visit_symbols_beg(node) - node.copy - end - - # Visit a TLambda node. - def visit_tlambda(node) - node.copy - end - - # Visit a TLamBeg node. - def visit_tlambeg(node) - node.copy - end - - # Visit a TopConstField node. - def visit_top_const_field(node) - node.copy(constant: visit(node.constant)) - end - - # Visit a TopConstRef node. - def visit_top_const_ref(node) - node.copy(constant: visit(node.constant)) - end - - # Visit a TStringBeg node. - def visit_tstring_beg(node) - node.copy - end - - # Visit a TStringContent node. - def visit_tstring_content(node) - node.copy - end - - # Visit a TStringEnd node. - def visit_tstring_end(node) - node.copy - end - - # Visit a Not node. - def visit_not(node) - node.copy(statement: visit(node.statement)) - end - - # Visit a Unary node. - def visit_unary(node) - node.copy - end - - # Visit a Undef node. - def visit_undef(node) - node.copy(symbols: visit_all(node.symbols)) - end - - # Visit a UnlessNode node. - def visit_unless(node) - node.copy( - predicate: visit(node.predicate), - statements: visit(node.statements), - consequent: visit(node.consequent) - ) - end - - # Visit a UntilNode node. - def visit_until(node) - node.copy( - predicate: visit(node.predicate), - statements: visit(node.statements) - ) - end - - # Visit a VarField node. - def visit_var_field(node) - node.copy(value: visit(node.value)) - end - - # Visit a VarRef node. - def visit_var_ref(node) - node.copy(value: visit(node.value)) - end - - # Visit a PinnedVarRef node. - def visit_pinned_var_ref(node) - node.copy(value: visit(node.value)) - end - - # Visit a VCall node. - def visit_vcall(node) - node.copy(value: visit(node.value)) - end - - # Visit a VoidStmt node. - def visit_void_stmt(node) - node.copy - end - - # Visit a When node. - def visit_when(node) - node.copy( - arguments: visit(node.arguments), - statements: visit(node.statements), - consequent: visit(node.consequent) - ) - end - - # Visit a WhileNode node. - def visit_while(node) - node.copy( - predicate: visit(node.predicate), - statements: visit(node.statements) - ) - end - - # Visit a Word node. - def visit_word(node) - node.copy(parts: visit_all(node.parts)) - end - - # Visit a Words node. - def visit_words(node) - node.copy( - beginning: visit(node.beginning), - elements: visit_all(node.elements) - ) - end - - # Visit a WordsBeg node. - def visit_words_beg(node) - node.copy - end - - # Visit a XString node. - def visit_xstring(node) - node.copy(parts: visit_all(node.parts)) - end - - # Visit a XStringLiteral node. - def visit_xstring_literal(node) - node.copy(parts: visit_all(node.parts)) - end - - # Visit a YieldNode node. - def visit_yield(node) - node.copy(arguments: visit(node.arguments)) - end - - # Visit a ZSuper node. - def visit_zsuper(node) - node.copy - end - end - end -end diff --git a/lib/syntax_tree/node.rb b/lib/syntax_tree/node.rb index 96241bb1..2117bfcf 100644 --- a/lib/syntax_tree/node.rb +++ b/lib/syntax_tree/node.rb @@ -133,22 +133,6 @@ def start_char def end_char location.end_char end - - def pretty_print(q) - accept(PrettyPrintVisitor.new(q)) - end - - def to_json(*opts) - accept(JSONVisitor.new).to_json(*opts) - end - - def to_mermaid - accept(MermaidVisitor.new) - end - - def construct_keys - PrettierPrint.format(+"") { |q| accept(MatchVisitor.new(q)) } - end end # When we're implementing the === operator for a node, we oftentimes need to diff --git a/lib/syntax_tree/pattern.rb b/lib/syntax_tree/pattern.rb deleted file mode 100644 index a5e88bfa..00000000 --- a/lib/syntax_tree/pattern.rb +++ /dev/null @@ -1,288 +0,0 @@ -# frozen_string_literal: true - -module SyntaxTree - # A pattern is an object that wraps a Ruby pattern matching expression. The - # expression would normally be passed to an `in` clause within a `case` - # expression or a rightward assignment expression. For example, in the - # following snippet: - # - # case node - # in Const[value: "SyntaxTree"] - # end - # - # the pattern is the `Const[value: "SyntaxTree"]` expression. Within Syntax - # Tree, every node generates these kinds of expressions using the - # #construct_keys method. - # - # The pattern gets compiled into an object that responds to call by running - # the #compile method. This method itself will run back through Syntax Tree to - # parse the expression into a tree, then walk the tree to generate the - # necessary callable objects. For example, if you wanted to compile the - # expression above into a callable, you would: - # - # callable = SyntaxTree::Pattern.new("Const[value: 'SyntaxTree']").compile - # callable.call(node) - # - # The callable object returned by #compile is guaranteed to respond to #call - # with a single argument, which is the node to match against. It also is - # guaranteed to respond to #===, which means it itself can be used in a `case` - # expression, as in: - # - # case node - # when callable - # end - # - # If the query given to the initializer cannot be compiled into a valid - # matcher (either because of a syntax error or because it is using syntax we - # do not yet support) then a SyntaxTree::Pattern::CompilationError will be - # raised. - class Pattern - # Raised when the query given to a pattern is either invalid Ruby syntax or - # is using syntax that we don't yet support. - class CompilationError < StandardError - def initialize(repr) - super(<<~ERROR) - Syntax Tree was unable to compile the pattern you provided to search - into a usable expression. It failed on to understand the node - represented by: - - #{repr} - - Note that not all syntax supported by Ruby's pattern matching syntax - is also supported by Syntax Tree's code search. If you're using some - syntax that you believe should be supported, please open an issue on - GitHub at https://github.com/ruby-syntax-tree/syntax_tree/issues/new. - ERROR - end - end - - attr_reader :query - - def initialize(query) - @query = query - end - - def compile - program = - begin - SyntaxTree.parse("case nil\nin #{query}\nend") - rescue Parser::ParseError - raise CompilationError, query - end - - raise CompilationError, query if program.nil? - compile_node(program.statements.body.first.consequent.pattern) - end - - private - - # Shortcut for combining two procs into one that returns true if both return - # true. - def combine_and(left, right) - ->(other) { left.call(other) && right.call(other) } - end - - # Shortcut for combining two procs into one that returns true if either - # returns true. - def combine_or(left, right) - ->(other) { left.call(other) || right.call(other) } - end - - # Raise an error because the given node is not supported. - def compile_error(node) - raise CompilationError, PP.pp(node, +"").chomp - end - - # There are a couple of nodes (string literals, dynamic symbols, and regexp) - # that contain list of parts. This can include plain string content, - # interpolated expressions, and interpolated variables. We only support - # plain string content, so this method will extract out the plain string - # content if it is the only element in the list. - def extract_string(node) - parts = node.parts - - if parts.length == 1 && (part = parts.first) && part.is_a?(TStringContent) - part.value - end - end - - # in [foo, bar, baz] - def compile_aryptn(node) - compile_error(node) if !node.rest.nil? || node.posts.any? - - constant = node.constant - compiled_constant = compile_node(constant) if constant - - preprocessed = node.requireds.map { |required| compile_node(required) } - - compiled_requireds = ->(other) do - deconstructed = other.deconstruct - - deconstructed.length == preprocessed.length && - preprocessed - .zip(deconstructed) - .all? { |(matcher, value)| matcher.call(value) } - end - - if compiled_constant - combine_and(compiled_constant, compiled_requireds) - else - compiled_requireds - end - end - - # in foo | bar - def compile_binary(node) - compile_error(node) if node.operator != :| - - combine_or(compile_node(node.left), compile_node(node.right)) - end - - # in Ident - # in String - def compile_const(node) - value = node.value - - if SyntaxTree.const_defined?(value, false) - clazz = SyntaxTree.const_get(value) - - ->(other) { clazz === other } - elsif Object.const_defined?(value, false) - clazz = Object.const_get(value) - - ->(other) { clazz === other } - else - compile_error(node) - end - end - - # in SyntaxTree::Ident - def compile_const_path_ref(node) - parent = node.parent - compile_error(node) if !parent.is_a?(VarRef) || !parent.value.is_a?(Const) - - if parent.value.value == "SyntaxTree" - compile_node(node.constant) - else - compile_error(node) - end - end - - # in :"" - # in :"foo" - def compile_dyna_symbol(node) - if node.parts.empty? - symbol = :"" - - ->(other) { symbol === other } - elsif (value = extract_string(node)) - symbol = value.to_sym - - ->(other) { symbol === other } - else - compile_error(node) - end - end - - # in Ident[value: String] - # in { value: String } - def compile_hshptn(node) - compile_error(node) unless node.keyword_rest.nil? - compiled_constant = compile_node(node.constant) if node.constant - - preprocessed = - node.keywords.to_h do |keyword, value| - compile_error(node) unless keyword.is_a?(Label) - [keyword.value.chomp(":").to_sym, compile_node(value)] - end - - compiled_keywords = ->(other) do - deconstructed = other.deconstruct_keys(preprocessed.keys) - - preprocessed.all? do |keyword, matcher| - matcher.call(deconstructed[keyword]) - end - end - - if compiled_constant - combine_and(compiled_constant, compiled_keywords) - else - compiled_keywords - end - end - - # in /foo/ - def compile_regexp_literal(node) - if (value = extract_string(node)) - regexp = /#{value}/ - - ->(attribute) { regexp === attribute } - else - compile_error(node) - end - end - - # in "" - # in "foo" - def compile_string_literal(node) - if node.parts.empty? - ->(attribute) { "" === attribute } - elsif (value = extract_string(node)) - ->(attribute) { value === attribute } - else - compile_error(node) - end - end - - # in :+ - # in :foo - def compile_symbol_literal(node) - symbol = node.value.value.to_sym - - ->(attribute) { symbol === attribute } - end - - # in Foo - # in nil - def compile_var_ref(node) - value = node.value - - if value.is_a?(Const) - compile_node(value) - elsif value.is_a?(Kw) && value.value.nil? - ->(attribute) { nil === attribute } - else - compile_error(node) - end - end - - # Compile any kind of node. Dispatch out to the individual compilation - # methods based on the type of node. - def compile_node(node) - case node - when AryPtn - compile_aryptn(node) - when Binary - compile_binary(node) - when Const - compile_const(node) - when ConstPathRef - compile_const_path_ref(node) - when DynaSymbol - compile_dyna_symbol(node) - when HshPtn - compile_hshptn(node) - when RegexpLiteral - compile_regexp_literal(node) - when StringLiteral - compile_string_literal(node) - when SymbolLiteral - compile_symbol_literal(node) - when VarRef - compile_var_ref(node) - else - compile_error(node) - end - end - end -end diff --git a/lib/syntax_tree/pretty_print_visitor.rb b/lib/syntax_tree/pretty_print_visitor.rb deleted file mode 100644 index 894e0cf4..00000000 --- a/lib/syntax_tree/pretty_print_visitor.rb +++ /dev/null @@ -1,83 +0,0 @@ -# frozen_string_literal: true - -module SyntaxTree - # This visitor pretty-prints the AST into an equivalent s-expression. - class PrettyPrintVisitor < FieldVisitor - attr_reader :q - - def initialize(q) - @q = q - end - - # This is here because we need to make sure the operator is cast to a string - # before we print it out. - def visit_binary(node) - node(node, "binary") do - field("left", node.left) - text("operator", node.operator.to_s) - field("right", node.right) - comments(node) - end - end - - # This is here to make it a little nicer to look at labels since they - # typically have their : at the end of the value. - def visit_label(node) - node(node, "label") do - q.breakable - q.text(":") - q.text(node.value[0...-1]) - comments(node) - end - end - - private - - def comments(node) - return if node.comments.empty? - - q.breakable - q.group(2, "(", ")") do - q.seplist(node.comments) { |comment| q.pp(comment) } - end - end - - def field(_name, value) - q.breakable - q.pp(value) - end - - def list(_name, values) - q.breakable - q.group(2, "(", ")") { q.seplist(values) { |value| q.pp(value) } } - end - - def node(_node, type) - q.group(2, "(", ")") do - q.text(type) - yield - end - end - - def pairs(_name, values) - q.group(2, "(", ")") do - q.seplist(values) do |(key, value)| - q.pp(key) - - if value - q.text("=") - q.group(2) do - q.breakable("") - q.pp(value) - end - end - end - end - end - - def text(_name, value) - q.breakable - q.text(value) - end - end -end diff --git a/lib/syntax_tree/reflection.rb b/lib/syntax_tree/reflection.rb deleted file mode 100644 index 6955aa21..00000000 --- a/lib/syntax_tree/reflection.rb +++ /dev/null @@ -1,257 +0,0 @@ -# frozen_string_literal: true - -module SyntaxTree - # This module is used to provide some reflection on the various types of nodes - # and their attributes. As soon as it is required it collects all of its - # information. - module Reflection - # This module represents the type of the values being passed to attributes - # of nodes. It is used as part of the documentation of the attributes. - module Type - CONSTANTS = SyntaxTree.constants.to_h { [_1, SyntaxTree.const_get(_1)] } - - # Represents an array type that holds another type. - class ArrayType - attr_reader :type - - def initialize(type) - @type = type - end - - def ===(value) - value.is_a?(Array) && value.all? { type === _1 } - end - - def inspect - "Array<#{type.inspect}>" - end - end - - # Represents a tuple type that holds a number of types in order. - class TupleType - attr_reader :types - - def initialize(types) - @types = types - end - - def ===(value) - value.is_a?(Array) && value.length == types.length && - value.zip(types).all? { |item, type| type === item } - end - - def inspect - "[#{types.map(&:inspect).join(", ")}]" - end - end - - # Represents a union type that can be one of a number of types. - class UnionType - attr_reader :types - - def initialize(types) - @types = types - end - - def ===(value) - types.any? { _1 === value } - end - - def inspect - types.map(&:inspect).join(" | ") - end - end - - class << self - def parse(comment) - comment = comment.gsub("\n", " ") - - unless comment.start_with?("[") - raise "Comment does not start with a bracket: #{comment.inspect}" - end - - count = 1 - found = - comment.chars[1..] - .find - .with_index(1) do |char, index| - count += { "[" => 1, "]" => -1 }.fetch(char, 0) - break index if count == 0 - end - - # If we weren't able to find the end of the balanced brackets, then - # the comment is malformed. - if found.nil? - raise "Comment does not have balanced brackets: #{comment.inspect}" - end - - parse_type(comment[1...found].strip) - end - - private - - def parse_type(value) - case value - when "Integer" - Integer - when "String" - String - when "Symbol" - Symbol - when "boolean" - UnionType.new([TrueClass, FalseClass]) - when "nil" - NilClass - when ":\"::\"" - :"::" - when ":call" - :call - when ":nil" - :nil - when /\AArray\[(.+)\]\z/ - ArrayType.new(parse_type($1.strip)) - when /\A\[(.+)\]\z/ - TupleType.new($1.strip.split(/\s*,\s*/).map { parse_type(_1) }) - else - if value.include?("|") - UnionType.new(value.split(/\s*\|\s*/).map { parse_type(_1) }) - else - CONSTANTS.fetch(value.to_sym) - end - end - end - end - end - - # This class represents one of the attributes on a node in the tree. - class Attribute - attr_reader :name, :comment, :type - - def initialize(name, comment) - @name = name - @comment = comment - @type = Type.parse(comment) - end - end - - # This class represents one of our nodes in the tree. We're going to use it - # as a placeholder for collecting all of the various places that nodes are - # used. - class Node - attr_reader :name, :comment, :attributes, :visitor_method - - def initialize(name, comment, attributes, visitor_method) - @name = name - @comment = comment - @attributes = attributes - @visitor_method = visitor_method - end - end - - class << self - # This is going to hold a hash of all of the nodes in the tree. The keys - # are the names of the nodes as symbols. - attr_reader :nodes - - # This expects a node name as a symbol and returns the node object for - # that node. - def node(name) - nodes.fetch(name) - end - - private - - def parse_comments(statements, index) - statements[0...index] - .reverse_each - .take_while { _1.is_a?(SyntaxTree::Comment) } - .reverse_each - .map { _1.value[2..] } - end - end - - @nodes = {} - - # For each node, we're going to parse out its attributes and other metadata. - # We'll use this as the basis for our report. - program = - SyntaxTree.parse(SyntaxTree.read(File.expand_path("node.rb", __dir__))) - - 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) - - # Ensure we're looking at class declarations with superclasses. - superclass = main_statement.superclass - next unless superclass.is_a?(SyntaxTree::VarRef) - - # Ensure we're looking at class declarations that inherit from Node. - next unless superclass.value.value == "Node" - - # All child nodes inherit the location attr_reader from Node, so we'll add - # that to the list of attributes first. - attributes = { - location: - Attribute.new(:location, "[Location] the location of this node") - } - - # This is the name of the method tha gets called on the given visitor when - # the accept method is called on this node. - visitor_method = nil - - statements = main_statement.bodystmt.statements.body - statements.each_with_index do |statement, statement_index| - case statement - when SyntaxTree::Command - # We only use commands in node classes to define attributes. So, we - # can safely assume that we're looking at an attribute definition. - unless %w[attr_reader attr_accessor].include?(statement.message.value) - raise "Unexpected command: #{statement.message.value.inspect}" - end - - # The arguments to the command are the attributes that we're defining. - # We want to ensure that we're only defining one at a time. - if statement.arguments.parts.length != 1 - raise "Declaring more than one attribute at a time is not permitted" - end - - attribute = - Attribute.new( - statement.arguments.parts.first.value.value.to_sym, - "#{parse_comments(statements, statement_index).join("\n")}\n" - ) - - # Ensure that we don't already have an attribute named the same as - # this one, and then add it to the list of attributes. - if attributes.key?(attribute.name) - raise "Duplicate attribute: #{attribute.name}" - end - - attributes[attribute.name] = attribute - when SyntaxTree::DefNode - if statement.name.value == "accept" - call_node = statement.bodystmt.statements.body.first - visitor_method = call_node.message.value.to_sym - end - end - end - - # If we never found a visitor method, then we have an error. - raise if visitor_method.nil? - - # Finally, set it up in the hash of nodes so that we can use it later. - comments = parse_comments(main_statements, main_statement_index) - node = - Node.new( - main_statement.constant.constant.value.to_sym, - "#{comments.join("\n")}\n", - attributes, - visitor_method - ) - - @nodes[node.name] = node - end - end -end diff --git a/lib/syntax_tree/search.rb b/lib/syntax_tree/search.rb deleted file mode 100644 index 9fd52ba1..00000000 --- a/lib/syntax_tree/search.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -module SyntaxTree - # Provides an interface for searching for a pattern of nodes against a - # subtree of an AST. - class Search - attr_reader :pattern - - def initialize(pattern) - @pattern = pattern - end - - def scan(root) - return to_enum(__method__, root) unless block_given? - queue = [root] - - until queue.empty? - node = queue.shift - next unless node - - yield node if pattern.call(node) - queue += node.child_nodes - end - end - end -end diff --git a/test/cli_test.rb b/test/cli_test.rb index a0d6001d..a81aa8cb 100644 --- a/test/cli_test.rb +++ b/test/cli_test.rb @@ -33,7 +33,7 @@ def test_handler def test_ast result = run_cli("ast") - assert_includes(result.stdio, "ident \"test\"") + assert_includes(result.stdio, "\"test\"") end def test_ast_ignore @@ -91,48 +91,11 @@ def test_doc assert_includes(result.stdio, "test") end - def test_expr - result = run_cli("expr") - assert_includes(result.stdio, "SyntaxTree::Ident") - end - - def test_expr_more_than_one - result = run_cli("expr", contents: "1; 2") - assert_includes(result.stderr, "single expression") - refute_equal(0, result.status) - end - def test_format result = run_cli("format") assert_equal("test\n", result.stdio) end - def test_json - result = run_cli("json") - assert_includes(result.stdio, "\"type\": \"program\"") - end - - def test_match - result = run_cli("match") - assert_includes(result.stdio, "SyntaxTree::Program") - end - - def test_search - result = run_cli("search", "VarRef", contents: "Foo + Bar") - assert_equal(2, result.stdio.lines.length) - end - - def test_search_multi_line - result = run_cli("search", "Binary", contents: "1 +\n2") - assert_equal(1, result.stdio.lines.length) - end - - def test_search_invalid - result = run_cli("search", "FooBar") - assert_includes(result.stderr, "unable") - refute_equal(0, result.status) - end - def test_version result = run_cli("version") assert_includes(result.stdio, SyntaxTree::VERSION.to_s) diff --git a/test/index_test.rb b/test/index_test.rb deleted file mode 100644 index 1e2a7fc7..00000000 --- a/test/index_test.rb +++ /dev/null @@ -1,183 +0,0 @@ -# frozen_string_literal: true - -require_relative "test_helper" - -module SyntaxTree - class IndexTest < Minitest::Test - def test_module - index_each("module Foo; end") do |entry| - assert_equal :Foo, entry.name - assert_equal [[:Foo]], entry.nesting - end - end - - def test_module_nested - index_each("module Foo; module Bar; end; end") do |entry| - assert_equal :Bar, entry.name - assert_equal [[:Foo], [:Bar]], entry.nesting - end - end - - def test_module_comments - index_each("# comment1\n# comment2\nmodule Foo; end") do |entry| - assert_equal :Foo, entry.name - assert_equal ["# comment1", "# comment2"], entry.comments.to_a - end - end - - def test_class - index_each("class Foo; end") do |entry| - assert_equal :Foo, entry.name - assert_equal [[:Foo]], entry.nesting - end - end - - def test_class_paths_2 - index_each("class Foo::Bar; end") do |entry| - assert_equal :Bar, entry.name - assert_equal [%i[Foo Bar]], entry.nesting - end - end - - def test_class_paths_3 - index_each("class Foo::Bar::Baz; end") do |entry| - assert_equal :Baz, entry.name - assert_equal [%i[Foo Bar Baz]], entry.nesting - end - end - - def test_class_nested - index_each("class Foo; class Bar; end; end") do |entry| - assert_equal :Bar, entry.name - assert_equal [[:Foo], [:Bar]], entry.nesting - end - end - - def test_class_paths_nested - index_each("class Foo; class Bar::Baz::Qux; end; end") do |entry| - assert_equal :Qux, entry.name - assert_equal [[:Foo], %i[Bar Baz Qux]], entry.nesting - end - end - - def test_class_superclass - index_each("class Foo < Bar; end") do |entry| - assert_equal :Foo, entry.name - assert_equal [[:Foo]], entry.nesting - assert_equal [:Bar], entry.superclass - end - end - - def test_class_path_superclass - index_each("class Foo::Bar < Baz::Qux; end") do |entry| - assert_equal :Bar, entry.name - assert_equal [%i[Foo Bar]], entry.nesting - assert_equal %i[Baz Qux], entry.superclass - end - end - - def test_class_comments - index_each("# comment1\n# comment2\nclass Foo; end") do |entry| - assert_equal :Foo, entry.name - assert_equal ["# comment1", "# comment2"], entry.comments.to_a - end - end - - def test_method - index_each("def foo; end") do |entry| - assert_equal :foo, entry.name - assert_empty entry.nesting - end - end - - def test_method_nested - index_each("class Foo; def foo; end; end") do |entry| - assert_equal :foo, entry.name - assert_equal [[:Foo]], entry.nesting - end - end - - def test_method_comments - index_each("# comment1\n# comment2\ndef foo; end") do |entry| - assert_equal :foo, entry.name - assert_equal ["# comment1", "# comment2"], entry.comments.to_a - end - end - - def test_singleton_method - index_each("def self.foo; end") do |entry| - assert_equal :foo, entry.name - assert_empty entry.nesting - end - end - - def test_singleton_method_nested - index_each("class Foo; def self.foo; end; end") do |entry| - assert_equal :foo, entry.name - assert_equal [[:Foo]], entry.nesting - end - end - - def test_singleton_method_comments - index_each("# comment1\n# comment2\ndef self.foo; end") do |entry| - assert_equal :foo, entry.name - assert_equal ["# comment1", "# comment2"], entry.comments.to_a - end - end - - def test_alias_method - index_each("alias foo bar") do |entry| - assert_equal :foo, entry.name - assert_empty entry.nesting - end - end - - def test_attr_reader - index_each("attr_reader :foo") do |entry| - assert_equal :foo, entry.name - assert_empty entry.nesting - end - end - - def test_attr_writer - index_each("attr_writer :foo") do |entry| - assert_equal :foo=, entry.name - assert_empty entry.nesting - end - end - - def test_attr_accessor - index_each("attr_accessor :foo") do |entry| - assert_equal :foo=, entry.name - assert_empty entry.nesting - end - end - - def test_constant - index_each("FOO = 1") do |entry| - assert_equal :FOO, entry.name - assert_empty entry.nesting - end - end - - def test_this_file - entries = Index.index_file(__FILE__, backend: Index::ParserBackend.new) - - if defined?(RubyVM::InstructionSequence) - entries += Index.index_file(__FILE__, backend: Index::ISeqBackend.new) - end - - entries.map { |entry| entry.comments.to_a } - end - - private - - def index_each(source) - yield Index.index(source, backend: Index::ParserBackend.new).last - - if defined?(RubyVM::InstructionSequence) - yield Index.index(source, backend: Index::ISeqBackend.new).last - end - end - end -end diff --git a/test/language_server_test.rb b/test/language_server_test.rb index 54455c95..6ed351e4 100644 --- a/test/language_server_test.rb +++ b/test/language_server_test.rb @@ -114,23 +114,6 @@ def to_hash end end - class SyntaxTreeVisualizing - attr_reader :id, :uri - - def initialize(id, uri) - @id = id - @uri = uri - end - - def to_hash - { - method: "syntaxTree/visualizing", - id: id, - params: { textDocument: { uri: uri } } - } - end - end - def test_formatting responses = run_server([ Initialize.new(1), @@ -248,27 +231,6 @@ def test_inlay_hint_invalid assert_equal(0, responses.dig(1, :result).size) end - def test_visualizing - responses = run_server([ - Initialize.new(1), - TextDocumentDidOpen.new("file:///path/to/file.rb", "1 + 2"), - SyntaxTreeVisualizing.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_equal( - "(program (statements ((binary (int \"1\") + (int \"2\")))))\n", - responses.dig(1, :result) - ) - end - def test_reading_file Tempfile.open(%w[test- .rb]) do |file| file.write("class Foo; end") diff --git a/test/mutation_test.rb b/test/mutation_test.rb deleted file mode 100644 index ab9dd019..00000000 --- a/test/mutation_test.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -require_relative "test_helper" - -module SyntaxTree - class MutationTest < Minitest::Test - def test_mutates_based_on_patterns - source = <<~RUBY - if a = b - c - end - RUBY - - expected = <<~RUBY - if (a = b) - c - end - RUBY - - program = SyntaxTree.parse(source).accept(build_mutation) - assert_equal(expected, SyntaxTree::Formatter.format(source, program)) - end - - private - - def build_mutation - SyntaxTree.mutation do |mutation| - mutation.mutate("IfNode[predicate: Assign | OpAssign]") do |node| - # Get the existing If's predicate node - predicate = node.predicate - - # Create a new predicate node that wraps the existing predicate node - # in parentheses - predicate = - SyntaxTree::Paren.new( - lparen: SyntaxTree::LParen.default, - contents: predicate, - location: predicate.location - ) - - # Return a copy of this node with the new predicate - node.copy(predicate: predicate) - end - end - end - end -end diff --git a/test/node_test.rb b/test/node_test.rb index f2706b2c..41f6271d 100644 --- a/test/node_test.rb +++ b/test/node_test.rb @@ -769,14 +769,8 @@ def test_program assert_equal 1, statements.size assert_kind_of(VCall, statements.first) - json = JSON.parse(program.to_json) - io = StringIO.new - PP.singleline_pp(program, io) - assert_kind_of(Program, program) assert_equal(location(chars: 0..8), program.location) - assert_equal("program", json["type"]) - assert_match(/^\(program.*\)$/, io.string) end def test_qsymbols diff --git a/test/ractor_test.rb b/test/ractor_test.rb index 7e0201ca..40e1eec7 100644 --- a/test/ractor_test.rb +++ b/test/ractor_test.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +# Do not run this test locally, as it messes up coverage. +return unless ENV["CI"] + # Don't run this test if we're in a version of Ruby that doesn't have Ractors. return unless defined?(Ractor) diff --git a/test/search_test.rb b/test/search_test.rb deleted file mode 100644 index 9f7d89b8..00000000 --- a/test/search_test.rb +++ /dev/null @@ -1,127 +0,0 @@ -# frozen_string_literal: true - -require_relative "test_helper" - -module SyntaxTree - class SearchTest < Minitest::Test - def test_search_invalid_syntax - assert_raises(Pattern::CompilationError) { search("", "<>") } - end - - def test_search_invalid_constant - assert_raises(Pattern::CompilationError) { search("", "Foo") } - end - - def test_search_invalid_nested_constant - assert_raises(Pattern::CompilationError) { search("", "Foo::Bar") } - end - - def test_search_regexp_with_interpolation - assert_raises(Pattern::CompilationError) { search("", "/\#{foo}/") } - end - - def test_search_string_with_interpolation - assert_raises(Pattern::CompilationError) { search("", '"#{foo}"') } - end - - def test_search_symbol_with_interpolation - assert_raises(Pattern::CompilationError) { search("", ":\"\#{foo}\"") } - end - - def test_search_invalid_node - assert_raises(Pattern::CompilationError) { search("", "Int[^foo]") } - end - - def test_search_self - assert_raises(Pattern::CompilationError) { search("", "self") } - end - - def test_search_array_pattern_no_constant - results = search("1 + 2", "[Int, Int]") - - assert_equal 1, results.length - end - - def test_search_array_pattern - results = search("1 + 2", "Binary[Int, Int]") - - assert_equal 1, results.length - end - - def test_search_binary_or - results = search("Foo + Bar + 1", "VarRef | Int") - - assert_equal 3, results.length - assert_equal "1", results.min_by { |node| node.class.name }.value - end - - def test_search_const - results = search("Foo + Bar + Baz", "VarRef") - - assert_equal 3, results.length - assert_equal %w[Bar Baz Foo], results.map { |node| node.value.value }.sort - end - - def test_search_object_const - results = search("1 + 2 + 3", "Int[value: String]") - - assert_equal 3, results.length - end - - def test_search_syntax_tree_const - results = search("Foo + Bar + Baz", "SyntaxTree::VarRef") - - assert_equal 3, results.length - end - - def test_search_hash_pattern_no_constant - results = search("Foo + Bar + Baz", "{ value: Const }") - - assert_equal 3, results.length - end - - def test_search_hash_pattern_string - results = search("Foo + Bar + Baz", "VarRef[value: Const[value: 'Foo']]") - - assert_equal 1, results.length - assert_equal "Foo", results.first.value.value - end - - def test_search_hash_pattern_regexp - results = search("Foo + Bar + Baz", "VarRef[value: Const[value: /^Ba/]]") - - assert_equal 2, results.length - assert_equal %w[Bar Baz], results.map { |node| node.value.value }.sort - end - - def test_search_string_empty - results = search("", "''") - - assert_empty results - end - - def test_search_symbol_empty - results = search("", ":''") - - assert_empty results - end - - def test_search_symbol_plain - results = search("1 + 2", "Binary[operator: :'+']") - - assert_equal 1, results.length - end - - def test_search_symbol - results = search("1 + 2", "Binary[operator: :+]") - - assert_equal 1, results.length - end - - private - - def search(source, query) - SyntaxTree.search(source, query).to_a - end - end -end diff --git a/test/test_helper.rb b/test/test_helper.rb index 787f819d..2987c68e 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -4,6 +4,7 @@ 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 @@ -13,41 +14,6 @@ require "syntax_tree" require "syntax_tree/cli" -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 - - initialize_without_verify(**kwargs) - end - end -end - require "json" require "tempfile" require "pp" @@ -78,10 +44,6 @@ def assert_syntax_tree(node) recorder = Recorder.new node.accept(recorder) - # Next, get the "type" which is effectively an underscored version of - # the name of the class. - type = recorder.called[/^visit_(.+)$/, 1] - # Test that the method that is called when you call accept is a valid # visit method on the visitor. assert_respond_to(Visitor.new, recorder.called) @@ -91,30 +53,6 @@ def assert_syntax_tree(node) assert_kind_of(Array, node.child_nodes) assert_kind_of(Array, node.deconstruct) assert_kind_of(Hash, node.deconstruct_keys([])) - - # Assert that it can be pretty printed to a string. - pretty = PP.singleline_pp(node, +"") - refute_includes(pretty, "#<") - assert_includes(pretty, type) - - # Assert that we can get back a new tree by using the mutation visitor. - assert_operator node, :===, node.accept(MutationVisitor.new) - - # Serialize the node to JSON, parse it back out, and assert that we have - # found the expected type. - json = node.to_json - refute_includes(json, "#<") - assert_equal(type, JSON.parse(json)["type"]) - - # Get a match expression from the node, then assert that it can in fact - # match the node. - # rubocop:disable all - assert(eval(<<~RUBY)) - case node - in #{node.construct_keys} - true - end - RUBY end Minitest::Test.include(self) From af53edb6def7d4ea268959f6280b344cbb06d0c9 Mon Sep 17 00:00:00 2001 From: Kevin Newton Date: Tue, 19 Aug 2025 14:46:29 -0400 Subject: [PATCH 7/9] Remove inlay hints --- README.md | 23 +-- lib/syntax_tree/language_server.rb | 173 ----------------------- test/language_server/inlay_hints_test.rb | 43 ------ test/language_server_test.rb | 58 -------- 4 files changed, 2 insertions(+), 295 deletions(-) delete mode 100644 test/language_server/inlay_hints_test.rb diff --git a/README.md b/README.md index 15ad312a..b5984a63 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,6 @@ It is built with only standard library dependencies. It additionally ships with - [Globbing](#globbing) - [Language server](#language-server) - [textDocument/formatting](#textdocumentformatting) - - [textDocument/inlayHint](#textdocumentinlayhint) - [Customization](#customization) - [Ignoring code](#ignoring-code) - [Plugins](#plugins) @@ -176,31 +175,13 @@ stree write "**/{[!schema]*,*}.rb" ## Language server -Syntax Tree additionally ships with a language server conforming to the [language server protocol](https://microsoft.github.io/language-server-protocol/). It can be invoked through the CLI by running: +Syntax Tree additionally ships with a minimal language server conforming to the [language server protocol](https://microsoft.github.io/language-server-protocol/) that registers a formatter for the Ruby language. It can be invoked through the CLI by running: ```sh stree lsp ``` -By default, the language server is relatively minimal, mostly meant to provide a registered formatter for the Ruby language. However there are a couple of additional niceties baked in. There are related projects that configure and use this language server within IDEs. For example, to use this code with VSCode, see [ruby-syntax-tree/vscode-syntax-tree](https://github.com/ruby-syntax-tree/vscode-syntax-tree). - -### textDocument/formatting - -As mentioned above, the language server responds to formatting requests with the formatted document. It typically responds on the order of tens of milliseconds, so it should be fast enough for any IDE. - -### textDocument/inlayHint - -The language server also responds to the relatively new inlay hints request. This request allows the language server to define additional information that should exist in the source code as helpful hints to the developer. In our case we use it to display things like implicit parentheses. For example, if you had the following code: - -```ruby -1 + 2 * 3 -``` - -Implicitly, the `2 * 3` is going to be executed first because the `*` operator has higher precedence than the `+` operator. To ease mental overhead, our language server includes small parentheses to make this explicit, as in: - -```ruby -1 + ₍2 * 3₎ -``` +There are related projects that configure and use this language server within IDEs. For example, to use this code with VSCode, see [ruby-syntax-tree/vscode-syntax-tree](https://github.com/ruby-syntax-tree/vscode-syntax-tree). ## Customization diff --git a/lib/syntax_tree/language_server.rb b/lib/syntax_tree/language_server.rb index 6da080d2..7d838a0c 100644 --- a/lib/syntax_tree/language_server.rb +++ b/lib/syntax_tree/language_server.rb @@ -12,162 +12,6 @@ module SyntaxTree # stree lsp # class LanguageServer - # This class provides inlay hints for the language server. For more - # information, see the spec here: - # https://github.com/microsoft/language-server-protocol/issues/956. - class InlayHints < Visitor - # This represents a hint that is going to be displayed in the editor. - class Hint - attr_reader :line, :character, :label - - def initialize(line:, character:, label:) - @line = line - @character = character - @label = label - end - - # This is the shape that the LSP expects. - def to_json(*opts) - { - position: { - line: line, - character: character - }, - label: label - }.to_json(*opts) - end - end - - attr_reader :stack, :hints - - def initialize - @stack = [] - @hints = [] - end - - def visit(node) - stack << node - result = super - stack.pop - result - end - - visit_methods do - # Adds parentheses around assignments contained within the default - # values of parameters. For example, - # - # def foo(a = b = c) - # end - # - # becomes - # - # def foo(a = ₍b = c₎) - # end - # - def visit_assign(node) - parentheses(node.location) if stack[-2].is_a?(Params) - super - end - - # Adds parentheses around binary expressions to make it clear which - # subexpression will be evaluated first. For example, - # - # a + b * c - # - # becomes - # - # a + ₍b * c₎ - # - def visit_binary(node) - case stack[-2] - when Assign, OpAssign - parentheses(node.location) - when Binary - parentheses(node.location) if stack[-2].operator != node.operator - end - - super - end - - # Adds parentheses around ternary operators contained within certain - # expressions where it could be confusing which subexpression will get - # evaluated first. For example, - # - # a ? b : c ? d : e - # - # becomes - # - # a ? b : ₍c ? d : e₎ - # - def visit_if_op(node) - case stack[-2] - when Assign, Binary, IfOp, OpAssign - parentheses(node.location) - end - - super - end - - # Adds the implicitly rescued StandardError into a bare rescue clause. - # For example, - # - # begin - # rescue - # end - # - # becomes - # - # begin - # rescue StandardError - # end - # - def visit_rescue(node) - if node.exception.nil? - hints << Hint.new( - line: node.location.start_line - 1, - character: node.location.start_column + "rescue".length, - label: " StandardError" - ) - end - - super - end - - # Adds parentheses around unary statements using the - operator that are - # contained within Binary nodes. For example, - # - # -a + b - # - # becomes - # - # ₍-a₎ + b - # - def visit_unary(node) - if stack[-2].is_a?(Binary) && (node.operator == "-") - parentheses(node.location) - end - - super - end - end - - private - - def parentheses(location) - hints << Hint.new( - line: location.start_line - 1, - character: location.start_column, - label: "₍" - ) - - hints << Hint.new( - line: location.end_line - 1, - character: location.end_column, - label: "₎" - ) - end - end - # 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. @@ -263,10 +107,6 @@ def run end contents = store[uri] write(id: request[:id], result: contents && !ignore ? format(contents, uri.split(".").last) : nil) - when Request[method: "textDocument/inlayHint", id: :any, params: { textDocument: { uri: :any } }] - uri = request.dig(:params, :textDocument, :uri) - contents = store[uri] - write(id: request[:id], result: contents ? inlay_hints(contents) : nil) when Request[method: %r{\$/.+}] # ignored when Request[method: "textDocument/documentColor", params: { textDocument: { uri: :any } }] @@ -283,9 +123,6 @@ def run def capabilities { documentFormattingProvider: true, - inlayHintProvider: { - resolveProvider: false - }, textDocumentSync: { change: 1, openClose: true @@ -317,16 +154,6 @@ def format(source, extension) nil end - def inlay_hints(source) - visitor = InlayHints.new - SyntaxTree.parse(source).accept(visitor) - visitor.hints - rescue Parser::ParseError - # If there is a parse error, then we're not going to return any inlay - # hints for this source. - [] - end - def write(value) response = value.merge(jsonrpc: "2.0").to_json output.print("Content-Length: #{response.bytesize}\r\n\r\n#{response}") diff --git a/test/language_server/inlay_hints_test.rb b/test/language_server/inlay_hints_test.rb deleted file mode 100644 index d3741894..00000000 --- a/test/language_server/inlay_hints_test.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -require_relative "../test_helper" -require "syntax_tree/language_server" - -module SyntaxTree - class LanguageServer - class InlayHintsTest < Minitest::Test - def test_assignments_in_parameters - assert_hints(2, "def foo(a = b = c); end") - end - - def test_operators_in_binaries - assert_hints(2, "1 + 2 * 3") - end - - def test_binaries_in_assignments - assert_hints(2, "a = 1 + 2") - end - - def test_nested_ternaries - assert_hints(2, "a ? b : c ? d : e") - end - - def test_bare_rescue - assert_hints(1, "begin; rescue; end") - end - - def test_unary_in_binary - assert_hints(2, "-a + b") - end - - private - - def assert_hints(expected, source) - visitor = InlayHints.new - SyntaxTree.parse(source).accept(visitor) - - assert_equal(expected, visitor.hints.length) - end - end - end -end diff --git a/test/language_server_test.rb b/test/language_server_test.rb index 6ed351e4..58f8bc6a 100644 --- a/test/language_server_test.rb +++ b/test/language_server_test.rb @@ -97,23 +97,6 @@ def to_hash end end - class TextDocumentInlayHint - attr_reader :id, :uri - - def initialize(id, uri) - @id = id - @uri = uri - end - - def to_hash - { - method: "textDocument/inlayHint", - id: id, - params: { textDocument: { uri: uri } } - } - end - end - def test_formatting responses = run_server([ Initialize.new(1), @@ -190,47 +173,6 @@ def test_formatting_print_width assert_equal(contents, responses.dig(1, :result, 0, :newText)) end - def test_inlay_hint - responses = run_server([ - Initialize.new(1), - TextDocumentDidOpen.new("file:///path/to/file.rb", <<~RUBY), - begin - 1 + 2 * 3 - rescue - end - RUBY - TextDocumentInlayHint.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_equal(3, responses.dig(1, :result).size) - end - - def test_inlay_hint_invalid - responses = run_server([ - Initialize.new(1), - TextDocumentDidOpen.new("file:///path/to/file.rb", "<>"), - TextDocumentInlayHint.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_equal(0, responses.dig(1, :result).size) - end - def test_reading_file Tempfile.open(%w[test- .rb]) do |file| file.write("class Foo; end") From 476435eb10692e8ad7bc4a98bb72977b1f7a7112 Mon Sep 17 00:00:00 2001 From: Kevin Newton Date: Sun, 26 Oct 2025 12:31:30 -0400 Subject: [PATCH 8/9] Trim down even more --- lib/syntax_tree.rb | 6 +- lib/syntax_tree/basic_visitor.rb | 117 -------- lib/syntax_tree/node.rb | 4 - lib/syntax_tree/parser.rb | 479 ++++++++++++++++++++++++++++++- lib/syntax_tree/visitor.rb | 458 ----------------------------- test/test_helper.rb | 15 +- test/visitor_test.rb | 73 ----- 7 files changed, 480 insertions(+), 672 deletions(-) delete mode 100644 lib/syntax_tree/basic_visitor.rb delete mode 100644 lib/syntax_tree/visitor.rb delete mode 100644 test/visitor_test.rb diff --git a/lib/syntax_tree.rb b/lib/syntax_tree.rb index 77c3f821..c5458c90 100644 --- a/lib/syntax_tree.rb +++ b/lib/syntax_tree.rb @@ -1,14 +1,10 @@ # frozen_string_literal: true require "prettier_print" -require "pp" require "ripper" -require_relative "syntax_tree/node" -require_relative "syntax_tree/basic_visitor" -require_relative "syntax_tree/visitor" - require_relative "syntax_tree/formatter" +require_relative "syntax_tree/node" require_relative "syntax_tree/parser" require_relative "syntax_tree/version" diff --git a/lib/syntax_tree/basic_visitor.rb b/lib/syntax_tree/basic_visitor.rb deleted file mode 100644 index bd8ea5f2..00000000 --- a/lib/syntax_tree/basic_visitor.rb +++ /dev/null @@ -1,117 +0,0 @@ -# frozen_string_literal: true - -module SyntaxTree - # BasicVisitor is the parent class of the Visitor class that provides the - # ability to walk down the tree. It does not define any handlers, so you - # should extend this class if you want your visitor to raise an error if you - # attempt to visit a node that you don't handle. - class BasicVisitor - # This is raised when you use the Visitor.visit_method method and it fails. - # It is correctable to through DidYouMean. - class VisitMethodError < StandardError - attr_reader :visit_method - - def initialize(visit_method) - @visit_method = visit_method - super("Invalid visit method: #{visit_method}") - end - end - - # This class is used by DidYouMean to offer corrections to invalid visit - # method names. - class VisitMethodChecker - attr_reader :visit_method - - def initialize(error) - @visit_method = error.visit_method - end - - def corrections - @corrections ||= - DidYouMean::SpellChecker.new( - dictionary: BasicVisitor.valid_visit_methods - ).correct(visit_method) - end - - # In some setups with Ruby you can turn off DidYouMean, so we're going to - # respect that setting here. - if defined?(DidYouMean.correct_error) - DidYouMean.correct_error(VisitMethodError, self) - end - end - - # This module is responsible for checking all of the methods defined within - # a given block to ensure that they are valid visit methods. - class VisitMethodsChecker < Module - Status = Struct.new(:checking) - - # This is the status of the checker. It's used to determine whether or not - # we should be checking the methods that are defined. It is kept as an - # instance variable so that it can be disabled later. - attr_reader :status - - def initialize - # We need the status to be an instance variable so that it can be - # accessed by the disable! method, but also a local variable so that it - # can be captured by the define_method block. - status = @status = Status.new(true) - - define_method(:method_added) do |name| - BasicVisitor.visit_method(name) if status.checking - super(name) - end - end - - def disable! - status.checking = false - end - end - - class << self - # This is the list of all of the valid visit methods. - def valid_visit_methods - @valid_visit_methods ||= - Visitor.instance_methods.grep(/^visit_(?!child_nodes)/) - end - - # This method is here to help folks write visitors. - # - # It's not always easy to ensure you're writing the correct method name in - # the visitor since it's perfectly valid to define methods that don't - # override these parent methods. - # - # If you use this method, you can ensure you're writing the correct method - # name. It will raise an error if the visit method you're defining isn't - # actually a method on the parent visitor. - def visit_method(method_name) - return if valid_visit_methods.include?(method_name) - - raise VisitMethodError, method_name - end - - # This method is here to help folks write visitors. - # - # Within the given block, every method that is defined will be checked to - # ensure it's a valid visit method using the BasicVisitor::visit_method - # method defined above. - def visit_methods - checker = VisitMethodsChecker.new - extend(checker) - yield - checker.disable! - end - end - - 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 - end -end diff --git a/lib/syntax_tree/node.rb b/lib/syntax_tree/node.rb index 2117bfcf..495dc7b0 100644 --- a/lib/syntax_tree/node.rb +++ b/lib/syntax_tree/node.rb @@ -529,10 +529,6 @@ def format(q) def ===(other) other.is_a?(AliasNode) && left === other.left && right === other.right end - - def var_alias? - left.is_a?(GVar) - end end # ARef represents when you're pulling a value out of a collection at a diff --git a/lib/syntax_tree/parser.rb b/lib/syntax_tree/parser.rb index ace077ee..3869dd9d 100644 --- a/lib/syntax_tree/parser.rb +++ b/lib/syntax_tree/parser.rb @@ -640,6 +640,473 @@ def on_array(contents) 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 @@ -668,13 +1135,11 @@ def visit(node) stack.pop end - visit_methods do - def visit_var_ref(node) - if node.start_char > pins.first.start_char - node.pin(stack[-2], pins.shift) - else - super - end + def visit_var_ref(node) + if node.start_char > pins.first.start_char + node.pin(stack[-2], pins.shift) + else + super end end diff --git a/lib/syntax_tree/visitor.rb b/lib/syntax_tree/visitor.rb deleted file mode 100644 index eb57acd2..00000000 --- a/lib/syntax_tree/visitor.rb +++ /dev/null @@ -1,458 +0,0 @@ -# frozen_string_literal: true - -module SyntaxTree - # 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 < BasicVisitor - # 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 -end diff --git a/test/test_helper.rb b/test/test_helper.rb index 2987c68e..3a39ae38 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -40,16 +40,14 @@ def method_missing(called, *, **) # 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) - # First, get the visit method name. recorder = Recorder.new node.accept(recorder) - # Test that the method that is called when you call accept is a valid - # visit method on the visitor. - assert_respond_to(Visitor.new, recorder.called) + visitor = Parser::Visitor.new + assert_respond_to(visitor, recorder.called) - # Test that you can call child_nodes and the pattern matching methods on - # this class. + 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([])) @@ -106,8 +104,9 @@ def self.each_fixture # 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?(">=") - next if ruby_version < Gem::Version.new(comment.split[1]) + if comment&.start_with?(">=") && + ruby_version < Gem::Version.new(comment.split[1]) + next end name = :"#{fixture}_#{index}" diff --git a/test/visitor_test.rb b/test/visitor_test.rb deleted file mode 100644 index d9637df0..00000000 --- a/test/visitor_test.rb +++ /dev/null @@ -1,73 +0,0 @@ -# frozen_string_literal: true - -require_relative "test_helper" - -module SyntaxTree - class VisitorTest < Minitest::Test - def test_visit_tree - parsed_tree = SyntaxTree.parse(<<~RUBY) - class Foo - def foo; end - - class Bar - def bar; end - end - end - - def baz; end - RUBY - - visitor = DummyVisitor.new - visitor.visit(parsed_tree) - assert_equal(%w[Foo foo Bar bar baz], visitor.visited_nodes) - end - - class DummyVisitor < Visitor - attr_reader :visited_nodes - - def initialize - super - @visited_nodes = [] - end - - visit_methods do - def visit_class(node) - @visited_nodes << node.constant.constant.value - super - end - - def visit_def(node) - @visited_nodes << node.name.value - end - end - end - - if defined?(DidYouMean.correct_error) - def test_visit_method_correction - error = assert_raises { Visitor.visit_method(:visit_binar) } - message = - if Exception.method_defined?(:detailed_message) - error.detailed_message - else - error.message - end - - assert_match(/visit_binary/, message) - end - end - - class VisitMethodsTestVisitor < BasicVisitor - end - - def test_visit_methods - VisitMethodsTestVisitor.visit_methods do - assert_raises(BasicVisitor::VisitMethodError) do - # In reality, this would be a method defined using the def keyword, - # but we're using method_added here to trigger the checker so that we - # aren't defining methods dynamically in the test suite. - VisitMethodsTestVisitor.method_added(:visit_foo) - end - end - end - end -end From 2b8545c21b6b391c6c0f504e6e0392143d414cb8 Mon Sep 17 00:00:00 2001 From: Kevin Newton Date: Mon, 27 Oct 2025 09:04:51 -0400 Subject: [PATCH 9/9] 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 | 4760 ++++++ 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, 5838 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..ea8b07c2 --- /dev/null +++ b/lib/syntax_tree/format.rb @@ -0,0 +1,4760 @@ +# 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) + previous = stack[end_index] + stack[0...end_index].reverse_each.any? do |parent| + case parent.type + when :statements_node + return false + when :call_node + if !parent.opening_loc + return true if parent.arguments + elsif parent.arguments&.arguments&.include?(previous) + return false + end + end + + previous = parent + 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| + stack << node + + 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 + + stack.pop + 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