diff --git a/.gitignore b/.gitignore index 69755243..3ce1e327 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /coverage/ /pkg/ /rdocs/ +/sorbet/ /spec/reports/ /tmp/ /vendor/ diff --git a/.rubocop.yml b/.rubocop.yml index e74cdc1b..c1c17001 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -7,7 +7,7 @@ AllCops: SuggestExtensions: false TargetRubyVersion: 2.7 Exclude: - - '{.git,.github,bin,coverage,pkg,spec,test/fixtures,vendor,tmp}/**/*' + - '{.git,.github,bin,coverage,pkg,sorbet,spec,test/fixtures,vendor,tmp}/**/*' - test.rb Gemspec/DevelopmentDependencies: diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 00000000..944880fa --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.2.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 960bb0e9..2d3daa58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ## [Unreleased] +## [6.1.0] - 2023-03-20 + +### Added + +- The `stree ctags` command for generating ctags like `universal-ctags` or `ripper-tags` would. +- The `definedivar` YARV instruction has been added to match CRuby's implementation. +- We now generate better Sorbet RBI files for the nodes in the tree and the visitors. +- `SyntaxTree::Reflection.nodes` now includes the visitor method. + +### Changed + +- We now explicitly require `pp` in environments that need it. + ## [6.0.2] - 2023-03-03 ### Added diff --git a/Gemfile.lock b/Gemfile.lock index 735a5025..f69c40d1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - syntax_tree (6.0.2) + syntax_tree (6.1.0) prettier_print (>= 1.2.0) GEM @@ -10,16 +10,16 @@ GEM ast (2.4.2) docile (1.4.0) json (2.6.3) - minitest (5.17.0) + minitest (5.18.0) parallel (1.22.1) - parser (3.2.1.0) + parser (3.2.1.1) ast (~> 2.4.1) - prettier_print (1.2.0) + prettier_print (1.2.1) rainbow (3.1.1) rake (13.0.6) regexp_parser (2.7.0) rexml (3.2.5) - rubocop (1.47.0) + rubocop (1.48.1) json (~> 2.3) parallel (~> 1.10) parser (>= 3.2.0.0) @@ -31,7 +31,7 @@ GEM unicode-display_width (>= 2.4.0, < 3.0) rubocop-ast (1.27.0) parser (>= 3.2.1.0) - ruby-progressbar (1.12.0) + ruby-progressbar (1.13.0) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) diff --git a/README.md b/README.md index 03942d46..3a3f7d2d 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ It is built with only standard library dependencies. It additionally ships with - [CLI](#cli) - [ast](#ast) - [check](#check) + - [ctags](#ctags) - [expr](#expr) - [format](#format) - [json](#json) @@ -139,6 +140,33 @@ 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. @@ -788,6 +816,7 @@ inherit_gem: * [Neovim](https://neovim.io/) - [neovim/nvim-lspconfig](https://github.com/neovim/nvim-lspconfig). * [Vim](https://www.vim.org/) - [dense-analysis/ale](https://github.com/dense-analysis/ale). * [VSCode](https://code.visualstudio.com/) - [ruby-syntax-tree/vscode-syntax-tree](https://github.com/ruby-syntax-tree/vscode-syntax-tree). +* [Emacs](https://www.gnu.org/software/emacs/) - [emacs-format-all-the-code](https://github.com/lassik/emacs-format-all-the-code). ## Contributing diff --git a/lib/syntax_tree.rb b/lib/syntax_tree.rb index 4e183383..24d8426f 100644 --- a/lib/syntax_tree.rb +++ b/lib/syntax_tree.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "prettier_print" +require "pp" require "ripper" require_relative "syntax_tree/node" diff --git a/lib/syntax_tree/cli.rb b/lib/syntax_tree/cli.rb index cbe10446..f2616c87 100644 --- a/lib/syntax_tree/cli.rb +++ b/lib/syntax_tree/cli.rb @@ -154,6 +154,92 @@ 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(options) + @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 @@ -327,6 +413,9 @@ 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 @@ -488,6 +577,8 @@ def run(argv) AST.new(options) when "c", "check" Check.new(options) + when "ctags" + CTags.new(options) when "debug" Debug.new(options) when "doc" diff --git a/lib/syntax_tree/index.rb b/lib/syntax_tree/index.rb index c6973847..0280749f 100644 --- a/lib/syntax_tree/index.rb +++ b/lib/syntax_tree/index.rb @@ -31,6 +31,18 @@ def initialize(nesting, name, superclass, location, 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 @@ -68,6 +80,19 @@ def initialize(nesting, name, location, 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 @@ -178,7 +203,14 @@ def location_for(iseq) end def find_constant_path(insns, index) - index -= 1 while insns[index].is_a?(Integer) + 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 @@ -207,11 +239,43 @@ def find_constant_path(insns, 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] @@ -246,8 +310,8 @@ def index_iseq(iseq, file_comments) find_constant_path(insns, index - 1) if superclass.empty? - raise NotImplementedError, - "superclass with non constant path on line #{line}" + warn("#{file}:#{line}: superclass with non constant path") + next end end @@ -265,8 +329,10 @@ def index_iseq(iseq, file_comments) # defined on self. We could, but it would require more # emulation. if insns[index - 2] != [:putself] - raise NotImplementedError, - "singleton class with non-self receiver" + warn( + "#{file}:#{line}: singleton class with non-self receiver" + ) + next end elsif flags & VM_DEFINECLASS_TYPE_MODULE > 0 location = location_for(class_iseq) @@ -290,16 +356,16 @@ def index_iseq(iseq, file_comments) queue << [class_iseq, next_nesting] when :definemethod location = location_for(insn[2]) - results << MethodDefinition.new( + results << method_definition( current_nesting, insn[1], location, - EntryComments.new(file_comments, location) + file_comments ) when :definesmethod - if current_iseq[13][index - 1] != [:putself] - raise NotImplementedError, - "singleton method with non-self receiver" + if insns[index - 1] != [:putself] + warn("#{file}:#{line}: singleton method with non-self receiver") + next end location = location_for(insn[2]) @@ -309,6 +375,69 @@ def index_iseq(iseq, file_comments) 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 @@ -321,6 +450,20 @@ def index_iseq(iseq, file_comments) # 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 @@ -331,8 +474,46 @@ def initialize 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 = visit(node.constant) + names = node.constant.accept(ConstantNameVisitor.new) nesting << names location = @@ -340,7 +521,7 @@ def visit_class(node) superclass = if node.superclass - visited = visit(node.superclass) + visited = node.superclass.accept(ConstantNameVisitor.new) if visited == [[]] raise NotImplementedError, "superclass with non constant path" @@ -363,12 +544,41 @@ def visit_class(node) nesting.pop end - def visit_const_ref(node) - [node.constant.value.to_sym] - 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 - def visit_const_path_ref(node) - visit(node.parent) << node.constant.value.to_sym + super end def visit_def(node) @@ -391,10 +601,12 @@ def visit_def(node) comments_for(node) ) end + + super end def visit_module(node) - names = visit(node.constant) + names = node.constant.accept(ConstantNameVisitor.new) nesting << names location = @@ -420,10 +632,6 @@ def visit_statements(node) @statements = node super end - - def visit_var_ref(node) - [node.value.value.to_sym] - end end private @@ -433,8 +641,10 @@ def comments_for(node) body = statements.body line = node.location.start_line - 1 - index = body.index(node) - 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) diff --git a/lib/syntax_tree/node.rb b/lib/syntax_tree/node.rb index c4bc1495..3f013b31 100644 --- a/lib/syntax_tree/node.rb +++ b/lib/syntax_tree/node.rb @@ -792,9 +792,10 @@ def arity private def trailing_comma? + arguments = self.arguments return false unless arguments.is_a?(Args) - parts = arguments.parts + 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. @@ -1188,8 +1189,11 @@ def deconstruct_keys(_keys) end def format(q) - if lbracket.comments.empty? && contents && contents.comments.empty? && - contents.parts.length > 1 + 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 @@ -2091,6 +2095,7 @@ def deconstruct_keys(_keys) end def format(q) + left = self.left power = operator == :** q.group do @@ -2307,6 +2312,8 @@ def initialize( 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, @@ -2330,6 +2337,7 @@ def bind(parser, start_char, start_column, end_char, 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 @@ -2735,7 +2743,7 @@ def format(q) children << receiver end when MethodAddBlock - if receiver.call.is_a?(CallNode) && !receiver.call.receiver.nil? + if (call = receiver.call).is_a?(CallNode) && !call.receiver.nil? children << receiver else break @@ -2744,8 +2752,8 @@ def format(q) break end when MethodAddBlock - if child.call.is_a?(CallNode) && !child.call.receiver.nil? - children << child.call + if (call = child.call).is_a?(CallNode) && !call.receiver.nil? + children << call else break end @@ -2767,8 +2775,8 @@ def format(q) # of just Statements nodes. parent = parents[3] if parent.is_a?(BlockNode) && parent.keywords? - if parent.is_a?(MethodAddBlock) && parent.call.is_a?(CallNode) && - parent.call.message.value == "sig" + if parent.is_a?(MethodAddBlock) && + (call = parent.call).is_a?(CallNode) && call.message.value == "sig" threshold = 2 end end @@ -2813,10 +2821,10 @@ def format_chain(q, children) while (child = children.pop) if child.is_a?(CallNode) - if child.receiver.is_a?(CallNode) && - (child.receiver.message != :call) && - (child.receiver.message.value == "where") && - (child.message.value == "not") + if (receiver = child.receiver).is_a?(CallNode) && + (receiver.message != :call) && + (receiver.message.value == "where") && + (message.value == "not") # This is very specialized behavior wherein we group # .where.not calls together because it looks better. For more # information, see @@ -2872,7 +2880,8 @@ def self.chained?(node) when CallNode !node.receiver.nil? when MethodAddBlock - node.call.is_a?(CallNode) && !node.call.receiver.nil? + call = node.call + call.is_a?(CallNode) && !call.receiver.nil? else false end @@ -3629,6 +3638,10 @@ def deconstruct_keys(_keys) end def format(q) + message = self.message + arguments = self.arguments + block = self.block + q.group do doc = q.nest(0) do @@ -3637,7 +3650,7 @@ def format(q) # 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.comments.any?(&:leading?) + if message != :call && message.comments.any?(&:leading?) q.format(CallOperatorFormatter.new(operator), stackable: false) q.indent do q.breakable_empty @@ -4153,6 +4166,9 @@ def deconstruct_keys(_keys) end def format(q) + params = self.params + bodystmt = self.bodystmt + q.group do q.group do q.text("def") @@ -4209,6 +4225,8 @@ def endless? end def arity + params = self.params + case params when Params params.arity @@ -5293,6 +5311,7 @@ def accept(visitor) end def child_nodes + operator = self.operator [parent, (operator if operator != :"::"), name] end @@ -5674,7 +5693,7 @@ def accept(visitor) end def child_nodes - [lbrace] + assocs + [lbrace].concat(assocs) end def copy(lbrace: nil, assocs: nil, location: nil) @@ -5766,7 +5785,7 @@ class Heredoc < Node # [Array[ Comment | EmbDoc ]] the comments attached to this node attr_reader :comments - def initialize(beginning:, ending: nil, dedent: 0, parts: [], location:) + def initialize(beginning:, location:, ending: nil, dedent: 0, parts: []) @beginning = beginning @ending = ending @dedent = dedent @@ -6134,6 +6153,8 @@ def ===(other) 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 @@ -6763,10 +6784,13 @@ def deconstruct_keys(_keys) 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 @@ -7164,6 +7188,8 @@ def deconstruct_keys(_keys) end def format(q) + params = self.params + q.text("->") q.group do if params.is_a?(Paren) @@ -7642,7 +7668,7 @@ class MLHS < Node # [Array[ Comment | EmbDoc ]] the comments attached to this node attr_reader :comments - def initialize(parts:, comma: false, location:) + def initialize(parts:, location:, comma: false) @parts = parts @comma = comma @location = location @@ -7703,7 +7729,7 @@ class MLHSParen < Node # [Array[ Comment | EmbDoc ]] the comments attached to this node attr_reader :comments - def initialize(contents:, comma: false, location:) + def initialize(contents:, location:, comma: false) @contents = contents @comma = comma @location = location @@ -8286,14 +8312,14 @@ def format(q) attr_reader :comments def initialize( + location:, requireds: [], optionals: [], rest: nil, posts: [], keywords: [], keyword_rest: nil, - block: nil, - location: + block: nil ) @requireds = requireds @optionals = optionals @@ -8320,6 +8346,8 @@ def accept(visitor) end def child_nodes + keyword_rest = self.keyword_rest + [ *requireds, *optionals.flatten(1), @@ -8374,16 +8402,19 @@ def deconstruct_keys(_keys) 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 += [ - *posts, - *keywords.map { |(name, value)| KeywordFormatter.new(name, value) } - ] + 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 @@ -8510,6 +8541,8 @@ def deconstruct_keys(_keys) end def format(q) + contents = self.contents + q.group do q.format(lparen) @@ -9424,11 +9457,11 @@ def bind_end(end_char, end_column) end_column: end_column ) - if consequent - consequent.bind_end(end_char, end_column) + if (next_node = consequent) + next_node.bind_end(end_char, end_column) statements.bind_end( - consequent.location.start_char, - consequent.location.start_column + next_node.location.start_char, + next_node.location.start_column ) else statements.bind_end(end_char, end_column) @@ -9871,8 +9904,8 @@ def bind(parser, start_char, start_column, end_char, end_column) end_column: end_column ) - if body[0].is_a?(VoidStmt) - location = body[0].location + if (void_stmt = body[0]).is_a?(VoidStmt) + location = void_stmt.location location = Location.new( start_line: location.start_line, @@ -10351,7 +10384,7 @@ def format(q) opening_quote, closing_quote = if !Quotes.locked?(self, q.quote) [q.quote, q.quote] - elsif quote.start_with?("%") + elsif quote&.start_with?("%") [quote, Quotes.matching(quote[/%[qQ]?(.)/, 1])] else [quote, quote] @@ -11520,7 +11553,7 @@ def accept(visitor) end def child_nodes - [value] + value == :nil ? [] : [value] end def copy(value: nil, location: nil) diff --git a/lib/syntax_tree/parser.rb b/lib/syntax_tree/parser.rb index ed0de408..825cd90e 100644 --- a/lib/syntax_tree/parser.rb +++ b/lib/syntax_tree/parser.rb @@ -2132,13 +2132,20 @@ def on_in(pattern, statements, consequent) ending = consequent || consume_keyword(:end) statements_start = pattern - if (token = find_keyword(:then)) + 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, diff --git a/lib/syntax_tree/reflection.rb b/lib/syntax_tree/reflection.rb index b2ffec6d..aa7b85b6 100644 --- a/lib/syntax_tree/reflection.rb +++ b/lib/syntax_tree/reflection.rb @@ -138,12 +138,13 @@ def initialize(name, comment) # as a placeholder for collecting all of the various places that nodes are # used. class Node - attr_reader :name, :comment, :attributes + attr_reader :name, :comment, :attributes, :visitor_method - def initialize(name, comment, attributes) + def initialize(name, comment, attributes, visitor_method) @name = name @comment = comment @attributes = attributes + @visitor_method = visitor_method end end @@ -183,10 +184,11 @@ def parse_comments(statements, index) next unless main_statement.is_a?(SyntaxTree::ClassDeclaration) # Ensure we're looking at class declarations with superclasses. - next unless main_statement.superclass.is_a?(SyntaxTree::VarRef) + superclass = main_statement.superclass + next unless superclass.is_a?(SyntaxTree::VarRef) # Ensure we're looking at class declarations that inherit from Node. - next unless main_statement.superclass.value.value == "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. @@ -195,6 +197,10 @@ def parse_comments(statements, index) 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 @@ -224,16 +230,25 @@ def parse_comments(statements, index) 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 + attributes, + visitor_method ) @nodes[node.name] = node diff --git a/lib/syntax_tree/version.rb b/lib/syntax_tree/version.rb index ff3db370..3ed889e4 100644 --- a/lib/syntax_tree/version.rb +++ b/lib/syntax_tree/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module SyntaxTree - VERSION = "6.0.2" + VERSION = "6.1.0" end diff --git a/lib/syntax_tree/yarv/compiler.rb b/lib/syntax_tree/yarv/compiler.rb index bd20bc19..0f7e7372 100644 --- a/lib/syntax_tree/yarv/compiler.rb +++ b/lib/syntax_tree/yarv/compiler.rb @@ -875,8 +875,7 @@ def visit_defined(node) when Ident iseq.putobject("local-variable") when IVar - iseq.putnil - iseq.defined(Defined::TYPE_IVAR, name, "instance-variable") + iseq.definedivar(name, iseq.inline_storage, "instance-variable") when Kw case name when :false diff --git a/lib/syntax_tree/yarv/instruction_sequence.rb b/lib/syntax_tree/yarv/instruction_sequence.rb index 45b543e6..7ce7bcdd 100644 --- a/lib/syntax_tree/yarv/instruction_sequence.rb +++ b/lib/syntax_tree/yarv/instruction_sequence.rb @@ -50,7 +50,7 @@ def initialize @tail_node = nil end - def each + def each(&_blk) return to_enum(__method__) unless block_given? each_node { |node| yield node.value } end @@ -673,12 +673,21 @@ def concatstrings(number) push(ConcatStrings.new(number)) end + def defineclass(name, class_iseq, flags) + push(DefineClass.new(name, class_iseq, flags)) + end + def defined(type, name, message) push(Defined.new(type, name, message)) end - def defineclass(name, class_iseq, flags) - push(DefineClass.new(name, class_iseq, flags)) + def definedivar(name, cache, message) + if RUBY_VERSION < "3.3" + push(PutNil.new) + push(Defined.new(Defined::TYPE_IVAR, name, message)) + else + push(DefinedIVar.new(name, cache, message)) + end end def definemethod(name, method_iseq) @@ -1058,6 +1067,8 @@ def self.from(source, options = Compiler::Options.new, parent_iseq = nil) iseq.defineclass(opnds[0], from(opnds[1], options, iseq), opnds[2]) when :defined iseq.defined(opnds[0], opnds[1], opnds[2]) + when :definedivar + iseq.definedivar(opnds[0], opnds[1], opnds[2]) when :definemethod iseq.definemethod(opnds[0], from(opnds[1], options, iseq)) when :definesmethod diff --git a/lib/syntax_tree/yarv/instructions.rb b/lib/syntax_tree/yarv/instructions.rb index 38c80fde..ceb237dc 100644 --- a/lib/syntax_tree/yarv/instructions.rb +++ b/lib/syntax_tree/yarv/instructions.rb @@ -994,6 +994,64 @@ def call(vm) end end + # ### Summary + # + # `definedivar` checks if an instance variable is defined. It is a + # specialization of the `defined` instruction. It accepts three arguments: + # the name of the instance variable, an inline cache, and the string that + # should be pushed onto the stack in the event that the instance variable + # is defined. + # + # ### Usage + # + # ~~~ruby + # defined?(@value) + # ~~~ + # + class DefinedIVar < Instruction + attr_reader :name, :cache, :message + + def initialize(name, cache, message) + @name = name + @cache = cache + @message = message + end + + def disasm(fmt) + fmt.instruction( + "definedivar", + [fmt.object(name), fmt.inline_storage(cache), fmt.object(message)] + ) + end + + def to_a(_iseq) + [:definedivar, name, cache, message] + end + + def deconstruct_keys(_keys) + { name: name, cache: cache, message: message } + end + + def ==(other) + other.is_a?(DefinedIVar) && other.name == name && + other.cache == cache && other.message == message + end + + def length + 4 + end + + def pushes + 1 + end + + def call(vm) + result = (message if vm.frame._self.instance_variable_defined?(name)) + + vm.push(result) + end + end + # ### Summary # # `definemethod` defines a method on the class of the current value of diff --git a/tasks/sorbet.rake b/tasks/sorbet.rake index e4152664..05f48874 100644 --- a/tasks/sorbet.rake +++ b/tasks/sorbet.rake @@ -20,6 +20,26 @@ module SyntaxTree 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 @@ -122,8 +142,41 @@ module SyntaxTree @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 @@ -195,6 +248,49 @@ module SyntaxTree ) 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), diff --git a/test/index_test.rb b/test/index_test.rb index 60c51d9d..1e2a7fc7 100644 --- a/test/index_test.rb +++ b/test/index_test.rb @@ -76,20 +76,6 @@ def test_class_path_superclass end end - def test_class_path_superclass_unknown - source = "class Foo < bar; end" - - assert_raises NotImplementedError do - Index.index(source, backend: Index::ParserBackend.new) - end - - if defined?(RubyVM::InstructionSequence) - assert_raises NotImplementedError do - Index.index(source, backend: Index::ISeqBackend.new) - end - end - end - def test_class_comments index_each("# comment1\n# comment2\nclass Foo; end") do |entry| assert_equal :Foo, entry.name @@ -139,6 +125,41 @@ def test_singleton_method_comments 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) diff --git a/test/language_server_test.rb b/test/language_server_test.rb index 2fe4e60a..f5a6ca57 100644 --- a/test/language_server_test.rb +++ b/test/language_server_test.rb @@ -6,19 +6,38 @@ module SyntaxTree # stree-ignore class LanguageServerTest < Minitest::Test - class Initialize < Struct.new(:id) + class Initialize + attr_reader :id + + def initialize(id) + @id = id + end + def to_hash { method: "initialize", id: id } end end - class Shutdown < Struct.new(:id) + class Shutdown + attr_reader :id + + def initialize(id) + @id = id + end + def to_hash { method: "shutdown", id: id } end end - class TextDocumentDidOpen < Struct.new(:uri, :text) + class TextDocumentDidOpen + attr_reader :uri, :text + + def initialize(uri, text) + @uri = uri + @text = text + end + def to_hash { method: "textDocument/didOpen", @@ -27,7 +46,14 @@ def to_hash end end - class TextDocumentDidChange < Struct.new(:uri, :text) + class TextDocumentDidChange + attr_reader :uri, :text + + def initialize(uri, text) + @uri = uri + @text = text + end + def to_hash { method: "textDocument/didChange", @@ -39,7 +65,13 @@ def to_hash end end - class TextDocumentDidClose < Struct.new(:uri) + class TextDocumentDidClose + attr_reader :uri + + def initialize(uri) + @uri = uri + end + def to_hash { method: "textDocument/didClose", @@ -48,7 +80,14 @@ def to_hash end end - class TextDocumentFormatting < Struct.new(:id, :uri) + class TextDocumentFormatting + attr_reader :id, :uri + + def initialize(id, uri) + @id = id + @uri = uri + end + def to_hash { method: "textDocument/formatting", @@ -58,7 +97,14 @@ def to_hash end end - class TextDocumentInlayHint < Struct.new(:id, :uri) + class TextDocumentInlayHint + attr_reader :id, :uri + + def initialize(id, uri) + @id = id + @uri = uri + end + def to_hash { method: "textDocument/inlayHint", @@ -68,7 +114,14 @@ def to_hash end end - class SyntaxTreeVisualizing < Struct.new(:id, :uri) + class SyntaxTreeVisualizing + attr_reader :id, :uri + + def initialize(id, uri) + @id = id + @uri = uri + end + def to_hash { method: "syntaxTree/visualizing",