From 61e76133c2cfbf476f9c7a72a133fe937d85bbc2 Mon Sep 17 00:00:00 2001 From: tompng Date: Wed, 24 Jul 2024 02:11:09 +0900 Subject: [PATCH 01/14] Add a new ruby parser RDoc::Parser::PrismRuby --- lib/rdoc/markup/pre_process.rb | 15 +- lib/rdoc/parser/prism_ruby.rb | 1081 ++++++++++++++++++++++++++++++++ lib/rdoc/parser/ruby.rb | 6 + rdoc.gemspec | 1 + 4 files changed, 1097 insertions(+), 6 deletions(-) create mode 100644 lib/rdoc/parser/prism_ruby.rb diff --git a/lib/rdoc/markup/pre_process.rb b/lib/rdoc/markup/pre_process.rb index 88078c9cef..979f2eadae 100644 --- a/lib/rdoc/markup/pre_process.rb +++ b/lib/rdoc/markup/pre_process.rb @@ -97,15 +97,18 @@ def initialize(input_file_name, include_path) # RDoc::CodeObject#metadata for details. def handle text, code_object = nil, &block + first_line = 1 if RDoc::Comment === text then comment = text text = text.text + first_line = comment.line || 1 end # regexp helper (square brackets for optional) # $1 $2 $3 $4 $5 # [prefix][\]:directive:[spaces][param]newline - text = text.gsub(/^([ \t]*(?:#|\/?\*)?[ \t]*)(\\?):(\w+):([ \t]*)(.+)?(\r?\n|$)/) do + text = text.lines.map.with_index(first_line) do |line, num| + next line unless line =~ /\A([ \t]*(?:#|\/?\*)?[ \t]*)(\\?):([\w-]+):([ \t]*)(.+)?(\r?\n|$)/ # skip something like ':toto::' next $& if $4.empty? and $5 and $5[0, 1] == ':' @@ -120,8 +123,8 @@ def handle text, code_object = nil, &block next "#{$1.strip}\n" end - handle_directive $1, $3, $5, code_object, text.encoding, &block - end + handle_directive $1, $3, $5, code_object, text.encoding, num, &block + end.join if comment then comment.text = text @@ -148,7 +151,7 @@ def handle text, code_object = nil, &block # When 1.8.7 support is ditched prefix can be defaulted to '' def handle_directive prefix, directive, param, code_object = nil, - encoding = nil + encoding = nil, line = nil blankline = "#{prefix.strip}\n" directive = directive.downcase @@ -220,11 +223,11 @@ def handle_directive prefix, directive, param, code_object = nil, # remove parameter &block code_object.params = code_object.params.sub(/,?\s*&\w+/, '') if code_object.params - code_object.block_params = param + code_object.block_params = param || '' blankline else - result = yield directive, param if block_given? + result = yield directive, param, line if block_given? case result when nil then diff --git a/lib/rdoc/parser/prism_ruby.rb b/lib/rdoc/parser/prism_ruby.rb new file mode 100644 index 0000000000..0d97a84e24 --- /dev/null +++ b/lib/rdoc/parser/prism_ruby.rb @@ -0,0 +1,1081 @@ +# frozen_string_literal: true + +require 'prism' +require_relative 'ripper_state_lex' + +## +# Extracts code elements from a source file returning a TopLevel object +# containing the constituent file elements. +# +# RubyParser understands how to document: +# * classes +# * modules +# * methods +# * constants +# * aliases +# * private, public, protected +# * private_class_function, public_class_function +# * private_constant, public_constant +# * module_function +# * attr, attr_reader, attr_writer, attr_accessor +# * extra accessors given on the command line +# * metaprogrammed methods +# * require +# * include +# +# == Method Arguments +# +#-- +# NOTE: I don't think this works, needs tests, remove the paragraph following +# this block when known to work +# +# The parser extracts the arguments from the method definition. You can +# override this with a custom argument definition using the :args: directive: +# +# ## +# # This method tries over and over until it is tired +# +# def go_go_go(thing_to_try, tries = 10) # :args: thing_to_try +# puts thing_to_try +# go_go_go thing_to_try, tries - 1 +# end +# +# If you have a more-complex set of overrides you can use the :call-seq: +# directive: +#++ +# +# The parser extracts the arguments from the method definition. You can +# override this with a custom argument definition using the :call-seq: +# directive: +# +# ## +# # This method can be called with a range or an offset and length +# # +# # :call-seq: +# # my_method(Range) +# # my_method(offset, length) +# +# def my_method(*args) +# end +# +# The parser extracts +yield+ expressions from method bodies to gather the +# yielded argument names. If your method manually calls a block instead of +# yielding or you want to override the discovered argument names use +# the :yields: directive: +# +# ## +# # My method is awesome +# +# def my_method(&block) # :yields: happy, times +# block.call 1, 2 +# end +# +# == Metaprogrammed Methods +# +# To pick up a metaprogrammed method, the parser looks for a comment starting +# with '##' before an identifier: +# +# ## +# # This is a meta-programmed method! +# +# add_my_method :meta_method, :arg1, :arg2 +# +# The parser looks at the token after the identifier to determine the name, in +# this example, :meta_method. If a name cannot be found, a warning is printed +# and 'unknown is used. +# +# You can force the name of a method using the :method: directive: +# +# ## +# # :method: some_method! +# +# By default, meta-methods are instance methods. To indicate that a method is +# a singleton method instead use the :singleton-method: directive: +# +# ## +# # :singleton-method: +# +# You can also use the :singleton-method: directive with a name: +# +# ## +# # :singleton-method: some_method! +# +# You can define arguments for metaprogrammed methods via either the +# :call-seq:, :arg: or :args: directives. +# +# Additionally you can mark a method as an attribute by +# using :attr:, :attr_reader:, :attr_writer: or :attr_accessor:. Just like +# for :method:, the name is optional. +# +# ## +# # :attr_reader: my_attr_name +# +# == Hidden methods and attributes +# +# You can provide documentation for methods that don't appear using +# the :method:, :singleton-method: and :attr: directives: +# +# ## +# # :attr_writer: ghost_writer +# # There is an attribute here, but you can't see it! +# +# ## +# # :method: ghost_method +# # There is a method here, but you can't see it! +# +# ## +# # this is a comment for a regular method +# +# def regular_method() end +# +# Note that by default, the :method: directive will be ignored if there is a +# standard rdocable item following it. + +class RDoc::Parser::PrismRuby < RDoc::Parser + + parse_files_matching(/\.rbw?$/) if ENV['RDOC_USE_PRISM_PARSER'] + + attr_accessor :visibility + attr_reader :container, :singleton + + def initialize(top_level, file_name, content, options, stats) + super + + content = handle_tab_width(content) + + @size = 0 + @token_listeners = nil + content = RDoc::Encoding.remove_magic_comment content + @content = content + @markup = @options.markup + @track_visibility = :nodoc != @options.visibility + @encoding = @options.encoding + + @module_nesting = [top_level] + @container = top_level + @visibility = :public + @singleton = false + end + + # Dive into another container + + def with_container(container, singleton: false) + old_container = @container + old_visibility = @visibility + old_singleton = @singleton + @visibility = :public + @container = container + @singleton = singleton + unless singleton + @module_nesting.push container + + # Need to update module parent chain to emulate Module.nesting. + # This mechanism is inaccurate and needs to be fixed. + container.parent = old_container + end + yield container + ensure + @container = old_container + @visibility = old_visibility + @singleton = old_singleton + @module_nesting.pop unless singleton + end + + # Records the location of this +container+ in the file for this parser and + # adds it to the list of classes and modules in the file. + + def record_location container # :nodoc: + case container + when RDoc::ClassModule then + @top_level.add_to_classes_or_modules container + end + + container.record_location @top_level + end + + # Scans this Ruby file for Ruby constructs + + def scan + @tokens = RDoc::Parser::RipperStateLex.parse(@content) + @lines = @content.lines + result = Prism.parse(@content) + @program_node = result.value + @line_nodes = {} + prepare_line_nodes(@program_node) + prepare_comments(result.comments) + return if @top_level.done_documenting + + @first_non_meta_comment = nil + if (_line_no, start_line, rdoc_comment = @unprocessed_comments.first) + @first_non_meta_comment = rdoc_comment if start_line < @program_node.location.start_line + end + + @program_node.accept(RDocVisitor.new(self, @top_level, @store)) + process_comments_until(@lines.size + 1) + end + + def should_document?(code_object) # :nodoc: + return true unless @track_visibility + return false if code_object.parent&.document_children == false + code_object.document_self + end + + # Assign AST node to a line. + # This is used to show meta-method source code in the documentation. + + def prepare_line_nodes(node) # :nodoc: + case node + when Prism::CallNode, Prism::DefNode + @line_nodes[node.location.start_line] ||= node + end + node.compact_child_nodes.each do |child| + prepare_line_nodes(child) + end + end + + # Prepares comments for processing. Comments are grouped into consecutive. + # Consecutive comment is linked to the next non-blank line. + # + # Example: + # 01| class A # modifier comment 1 + # 02| def foo; end # modifier comment 2 + # 03| + # 04| # consecutive comment 1 start_line: 4 + # 05| # consecutive comment 1 linked to line: 7 + # 06| + # 07| # consecutive comment 2 start_line: 7 + # 08| # consecutive comment 2 linked to line: 10 + # 09| + # 10| def bar; end # consecutive comment 2 linked to this line + # 11| end + + def prepare_comments(comments) + current = [] + consecutive_comments = [current] + @modifier_comments = {} + comments.each do |comment| + if comment.is_a? Prism::EmbDocComment + consecutive_comments << [comment] << (current = []) + elsif comment.location.start_line_slice.match?(/\S/) + @modifier_comments[comment.location.start_line] = RDoc::Comment.new(comment.slice, @top_level, :ruby) + elsif current.empty? || current.last.location.end_line + 1 == comment.location.start_line + current << comment + else + consecutive_comments << (current = [comment]) + end + end + + # Example: line_no = 5, start_line = 2, comment_text = "# comment_start_line\n# comment\n" + # 1| class A + # 2| # comment_start_line + # 3| # comment + # 4| + # 5| def f; end # comment linked to this line + # 6| end + @unprocessed_comments = consecutive_comments.reject(&:empty?).map do |comments| + start_line = comments.first.location.start_line + line_no = comments.last.location.end_line + (comments.last.location.end_column == 0 ? 0 : 1) + texts = comments.map do |c| + c.is_a?(Prism::EmbDocComment) ? c.slice.lines[1...-1].join : c.slice + end + text = RDoc::Encoding.change_encoding(texts.join("\n"), @encoding) if @encoding + line_no += 1 while @lines[line_no - 1]&.match?(/\A\s*$/) + comment = RDoc::Comment.new(text, @top_level, :ruby) + comment.line = start_line + [line_no, start_line, comment] + end + + # The first comment is special. It defines markup for the rest of the comments. + _, first_comment_start_line, first_comment_text = @unprocessed_comments.first + if first_comment_text && @lines[0...first_comment_start_line - 1].all? { |l| l.match?(/\A\s*$/) } + comment = RDoc::Comment.new(first_comment_text.text, @top_level, :ruby) + handle_consecutive_comment_directive(@container, comment) + @markup = comment.format + end + @unprocessed_comments.each do |_, _, comment| + comment.format = @markup + end + end + + # Creates an RDoc::Method on +container+ from +comment+ if there is a + # Signature section in the comment + + def parse_comment_tomdoc(container, comment, line_no, start_line) + return unless signature = RDoc::TomDoc.signature(comment) + + name, = signature.split %r%[ \(]%, 2 + + meth = RDoc::GhostMethod.new comment.text, name + record_location(meth) + meth.line = start_line + meth.call_seq = signature + return unless meth.name + + meth.start_collecting_tokens + node = @line_nodes[line_no] + tokens = node ? visible_tokens_from_location(node.location) : [file_line_comment_token(start_line)] + tokens.each { |token| meth.token_stream << token } + + container.add_method meth + comment.remove_private + comment.normalize + meth.comment = comment + @stats.add_method meth + end + + def handle_modifier_directive(code_object, line_no) # :nodoc: + comment = @modifier_comments[line_no] + @preprocess.handle(comment.text, code_object) if comment + end + + def handle_consecutive_comment_directive(code_object, comment) # :nodoc: + return unless comment + @preprocess.handle(comment, code_object) do |directive, param| + case directive + when 'method', 'singleton-method', + 'attr', 'attr_accessor', 'attr_reader', 'attr_writer' then + # handled elsewhere + '' + when 'section' then + @container.set_current_section(param, comment.dup) + comment.text = '' + break + end + end + comment.remove_private + end + + def call_node_name_arguments(call_node) # :nodoc: + return [] unless call_node.arguments + call_node.arguments.arguments.map do |arg| + case arg + when Prism::SymbolNode + arg.value + when Prism::StringNode + arg.unescaped + end + end || [] + end + + # Handles meta method comments + + def handle_meta_method_comment(comment, node) + is_call_node = node.is_a?(Prism::CallNode) + singleton_method = false + visibility = @visibility + attributes = rw = line_no = method_name = nil + + processed_comment = comment.dup + @preprocess.handle(processed_comment, @container) do |directive, param, line| + case directive + when 'attr', 'attr_reader', 'attr_writer', 'attr_accessor' + attributes = [param] if param + attributes ||= call_node_name_arguments(node) if is_call_node + rw = directive == 'attr_writer' ? 'W' : directive == 'attr_accessor' ? 'RW' : 'R' + '' + when 'method' + method_name = param + line_no = node ? node.location.start_line : line + '' + when 'singleton-method' + method_name = param + line_no = node ? node.location.start_line : line + singleton_method = true + visibility = :public + '' + when 'section' then + @container.set_current_section(param, comment.dup) + return # If the comment contains :section:, it is not a meta method comment + end + end + + if attributes + attributes.each do |attr| + a = RDoc::Attr.new(@container, attr, rw, processed_comment) + a.store = @store + a.line = line_no + a.singleton = @singleton + record_location(a) + @container.add_attribute(a) + a.visibility = visibility + end + else + method_name ||= call_node_name_arguments(node).first if is_call_node + meth = RDoc::AnyMethod.new(@container, method_name) + meth.singleton = @singleton || singleton_method + handle_consecutive_comment_directive(meth, comment) + comment.normalize + comment.extract_call_seq(meth) + meth.name ||= meth.call_seq[/\A[^()\s]+/] if meth.call_seq + meth.name ||= 'unknown' + meth.params ||= '()' + meth.comment = comment + meth.store = @store + meth.line = node ? node.location.start_line : line_no + record_location(meth) + if node + meth.start_collecting_tokens + visible_tokens_from_location(node.location).each do |token| + meth.token_stream << token + end + end + @container.add_method(meth) + meth.visibility = visibility + end + end + + def normal_comment_treat_as_ghost_method_for_now?(comment_text, line_no) # :nodoc: + # Meta method comment should start with `##` but some comments does not follow this rule. + # For now, RDoc accepts them as a meta method comment if there is no node linked to it. + !@line_nodes[line_no] && comment_text.match?(/^#\s+:(method|singleton-method|attr|attr_reader|attr_writer|attr_accessor):/) + end + + def handle_standalone_consecutive_comment_directive(comment, line_no, start_line) # :nodoc: + if @markup == 'tomdoc' + parse_comment_tomdoc(@container, comment, line_no, start_line) + return + end + + if comment.text =~ /\A#\#$/ && comment != @first_non_meta_comment + node = @line_nodes[line_no] + handle_meta_method_comment(comment, node) + elsif normal_comment_treat_as_ghost_method_for_now?(comment.text, line_no) && comment != @first_non_meta_comment + handle_meta_method_comment(comment, nil) + else + handle_consecutive_comment_directive(@container, comment) + end + end + + # Processes consecutive comments that were not linked to any documentable code until the given line number + + def process_comments_until(line_no_until) + while !@unprocessed_comments.empty? && @unprocessed_comments.first[0] <= line_no_until + line_no, start_line, rdoc_comment = @unprocessed_comments.shift + handle_standalone_consecutive_comment_directive(rdoc_comment, line_no, start_line) + end + end + + # Skips all undocumentable consecutive comments until the given line number. + # Undocumentable comments are comments written inside `def` or inside undocumentable class/module + + def skip_comments_until(line_no_until) + while !@unprocessed_comments.empty? && @unprocessed_comments.first[0] <= line_no_until + @unprocessed_comments.shift + end + end + + # Returns consecutive comment linked to the given line number + + def consecutive_comment(line_no) + if @unprocessed_comments.first&.first == line_no + @unprocessed_comments.shift.last + end + end + + def slice_tokens(start_pos, end_pos) # :nodoc: + start_index = @tokens.bsearch_index { |t| ([t.line_no, t.char_no] <=> start_pos) >= 0 } + end_index = @tokens.bsearch_index { |t| ([t.line_no, t.char_no] <=> end_pos) >= 0 } + tokens = @tokens[start_index...end_index] + tokens.pop if tokens.last&.kind == :on_nl + tokens + end + + def file_line_comment_token(line_no) # :nodoc: + position_comment = RDoc::Parser::RipperStateLex::Token.new(line_no - 1, 0, :on_comment) + position_comment[:text] = "# File #{@top_level.relative_name}, line #{line_no}" + position_comment + end + + # Returns tokens from the given location + + def visible_tokens_from_location(location) + position_comment = file_line_comment_token(location.start_line) + newline_token = RDoc::Parser::RipperStateLex::Token.new(0, 0, :on_nl, "\n") + indent_token = RDoc::Parser::RipperStateLex::Token.new(location.start_line, 0, :on_sp, ' ' * location.start_character_column) + tokens = slice_tokens( + [location.start_line, location.start_character_column], + [location.end_line, location.end_character_column] + ) + [position_comment, newline_token, indent_token, *tokens] + end + + # Handles `public :foo, :bar` `private :foo, :bar` and `protected :foo, :bar` + + def change_method_visibility(names, visibility, singleton: @singleton) + new_methods = [] + @container.methods_matching(names, singleton) do |m| + if m.parent != @container + m = m.dup + record_location(m) + new_methods << m + else + m.visibility = visibility + end + end + new_methods.each do |method| + case method + when RDoc::AnyMethod then + @container.add_method(method) + when RDoc::Attr then + @container.add_attribute(method) + end + method.visibility = visibility + end + end + + # Handles `module_function :foo, :bar` + + def change_method_to_module_function(names) + @container.set_visibility_for(names, :private, false) + new_methods = [] + @container.methods_matching(names) do |m| + s_m = m.dup + record_location(s_m) + s_m.singleton = true + new_methods << s_m + end + new_methods.each do |method| + case method + when RDoc::AnyMethod then + @container.add_method(method) + when RDoc::Attr then + @container.add_attribute(method) + end + method.visibility = :public + end + end + + # Handles `alias foo bar` and `alias_method :foo, :bar` + + def add_alias_method(old_name, new_name, line_no) + comment = consecutive_comment(line_no) + handle_consecutive_comment_directive(@container, comment) + visibility = @container.find_method(old_name, @singleton)&.visibility || :public + a = RDoc::Alias.new(nil, old_name, new_name, comment, @singleton) + a.comment = comment + handle_modifier_directive(a, line_no) + a.store = @store + a.line = line_no + record_location(a) + if should_document?(a) + @container.add_alias(a) + @container.find_method(new_name, @singleton)&.visibility = visibility + end + end + + # Handles `attr :a, :b`, `attr_reader :a, :b`, `attr_writer :a, :b` and `attr_accessor :a, :b` + + def add_attributes(names, rw, line_no) + comment = consecutive_comment(line_no) + handle_consecutive_comment_directive(@container, comment) + return unless @container.document_children + + names.each do |symbol| + a = RDoc::Attr.new(nil, symbol.to_s, rw, comment) + a.store = @store + a.line = line_no + a.singleton = @singleton + record_location(a) + handle_modifier_directive(a, line_no) + @container.add_attribute(a) if should_document?(a) + a.visibility = visibility # should set after adding to container + end + end + + def add_includes_extends(names, rdoc_class, line_no) # :nodoc: + comment = consecutive_comment(line_no) + handle_consecutive_comment_directive(@container, comment) + names.each do |name| + ie = @container.add(rdoc_class, name, '') + ie.store = @store + ie.line = line_no + ie.comment = comment + record_location(ie) + end + end + + # Handle `include Foo, Bar` + + def add_includes(names, line_no) # :nodoc: + add_includes_extends(names, RDoc::Include, line_no) + end + + # Handle `extend Foo, Bar` + + def add_extends(names, line_no) # :nodoc: + add_includes_extends(names, RDoc::Extend, line_no) + end + + # Adds a method defined by `def` syntax + + def add_method(name, receiver_name:, receiver_fallback_type:, visibility:, singleton:, signature:, tokens:, start_line:, end_line:) + receiver = receiver_name ? find_or_create_module_path(receiver_name, receiver_fallback_type) : @container + meth = RDoc::AnyMethod.new(nil, name) + if (comment = consecutive_comment(start_line)) + handle_consecutive_comment_directive(@container, comment) + handle_consecutive_comment_directive(meth, comment) + + comment.normalize + comment.extract_call_seq(meth) + meth.comment = comment + end + handle_modifier_directive(meth, start_line) + handle_modifier_directive(meth, end_line) + return unless should_document?(meth) + + meth.store = @store + meth.line = start_line + + if meth.name == 'initialize' && !singleton + if meth.dont_rename_initialize + visibility = :protected + else + meth.name = 'new' + singleton = true + visibility = :public + end + end + + meth.singleton = singleton + receiver.add_method(meth) # should add after setting singleton and before setting visibility + meth.visibility = visibility + if signature + meth.params ||= signature.params + meth.calls_super = signature.calls_super + meth.block_params ||= signature.yields.first unless signature.yields.empty? + end + record_location(meth) + meth.start_collecting_tokens + tokens.each do |token| + meth.token_stream << token + end + end + + # Find or create module or class from a given module name. + # If module or class does not exist, creates a module or a class according to `create_mode` argument. + + def find_or_create_module_path(module_name, create_mode) + root_name, *path, name = module_name.split('::') + add_module = ->(mod, name, mode) { + case mode + when :class + mod.add_class(RDoc::NormalClass, name, 'Object').tap { |m| m.store = @store } + when :module + mod.add_module(RDoc::NormalModule, name).tap { |m| m.store = @store } + end + } + if root_name.empty? + mod = @top_level + else + @module_nesting.reverse_each do |nesting| + mod = nesting.find_module_named(root_name) + break if mod + end + return mod || add_module.call(@top_level, root_name, create_mode) unless name + mod ||= add_module.call(@top_level, root_name, :module) + end + path.each do |name| + mod = mod.find_module_named(name) || add_module.call(mod, name, :module) + end + mod.find_module_named(name) || add_module.call(mod, name, create_mode) + end + + # Resolves constant path to a full path by searching module nesting + + def resolve_constant_path(constant_path) + owner_name, path = constant_path.split('::', 2) + return constant_path if owner_name.empty? # ::Foo, ::Foo::Bar + mod = nil + @module_nesting.reverse_each do |nesting| + mod = nesting.find_module_named(owner_name) + break if mod + end + mod ||= @top_level.find_module_named(owner_name) + [mod.full_name, path].compact.join('::') if mod + end + + # Returns a pair of owner module and constant name from a given constant path. + # Creates owner module if it does not exist. + + def find_or_create_constant_owner_name(constant_path) + const_path, colon, name = constant_path.rpartition('::') + if colon.empty? # class Foo + [@container, name] + elsif const_path.empty? # class ::Foo + [@top_level, name] + else # `class Foo::Bar` or `class ::Foo::Bar` + [find_or_create_module_path(const_path, :module), name] + end + end + + # Adds a constant + + def add_constant(constant_name, rhs_name, start_line, end_line) + comment = consecutive_comment(start_line) + handle_consecutive_comment_directive(@container, comment) + owner, name = find_or_create_constant_owner_name(constant_name) + constant = RDoc::Constant.new(name, rhs_name, comment) + constant.store = @store + constant.line = start_line + record_location(constant) + handle_modifier_directive(constant, start_line) + handle_modifier_directive(constant, end_line) + owner.add_constant(constant) + mod = + if rhs_name =~ /^::/ + @store.find_class_or_module(rhs_name) + else + @container.find_module_named(rhs_name) + end + if mod && constant.document_self + a = @container.add_module_alias(mod, rhs_name, constant, @top_level) + a.store = @store + a.line = start_line + record_location(a) + end + end + + # Adds module or class + + def add_module_or_class(module_name, start_line, end_line, is_class: false, superclass_name: nil) + comment = consecutive_comment(start_line) + handle_consecutive_comment_directive(@container, comment) + return unless @container.document_children + + owner, name = find_or_create_constant_owner_name(module_name) + if is_class + mod = owner.classes_hash[name] || owner.add_class(RDoc::NormalClass, name, superclass_name || '::Object') + + # RDoc::NormalClass resolves superclass name despite of the lack of module nesting information. + # We need to fix it when RDoc::NormalClass resolved to a wrong constant name + if superclass_name + superclass_full_path = resolve_constant_path(superclass_name) + superclass = @store.find_class_or_module(superclass_full_path) if superclass_full_path + superclass_full_path ||= superclass_name + if superclass + mod.superclass = superclass + elsif mod.superclass.is_a?(String) && mod.superclass != superclass_full_path + mod.superclass = superclass_full_path + end + end + else + mod = owner.modules_hash[name] || owner.add_module(RDoc::NormalModule, name) + end + + mod.store = @store + mod.line = start_line + record_location(mod) + handle_modifier_directive(mod, start_line) + handle_modifier_directive(mod, end_line) + mod.add_comment(comment, @top_level) if comment + mod + end + + class RDocVisitor < Prism::Visitor # :nodoc: + DSL = { + attr: -> (v, call_node) { v.visit_call_attr_reader_writer_accessor(call_node, 'R') }, + attr_reader: -> (v, call_node) { v.visit_call_attr_reader_writer_accessor(call_node, 'R') }, + attr_writer: -> (v, call_node) { v.visit_call_attr_reader_writer_accessor(call_node, 'W') }, + attr_accessor: -> (v, call_node) { v.visit_call_attr_reader_writer_accessor(call_node, 'RW') }, + include: -> (v, call_node) { v.visit_call_include(call_node) }, + extend: -> (v, call_node) { v.visit_call_extend(call_node) }, + public: -> (v, call_node, &block) { v.visit_call_public_private_protected(call_node, :public, &block) }, + private: -> (v, call_node, &block) { v.visit_call_public_private_protected(call_node, :private, &block) }, + protected: -> (v, call_node, &block) { v.visit_call_public_private_protected(call_node, :protected, &block) }, + private_constant: -> (v, call_node) { v.visit_call_private_constant(call_node) }, + public_constant: -> (v, call_node) { v.visit_call_public_constant(call_node) }, + require: -> (v, call_node) { v.visit_call_require(call_node) }, + alias_method: -> (v, call_node) { v.visit_call_alias_method(call_node) }, + module_function: -> (v, call_node, &block) { v.visit_call_module_function(call_node, &block) }, + public_class_method: -> (v, call_node, &block) { v.visit_call_public_private_class_method(call_node, :public, &block) }, + private_class_method: -> (v, call_node, &block) { v.visit_call_public_private_class_method(call_node, :private, &block) }, + } + + def initialize(scanner, top_level, store) + @scanner = scanner + @top_level = top_level + @store = store + end + + def visit_call_node(node) + @scanner.process_comments_until(node.location.start_line - 1) + if node.receiver.nil? && (dsl_proc = DSL[node.name]) + dsl_proc.call(self, node) { super } + else + super + end + end + + def visit_call_require(call_node) + return unless call_node.arguments&.arguments&.size == 1 + arg = call_node.arguments.arguments.first + return unless arg.is_a?(Prism::StringNode) + @scanner.container.add_require(RDoc::Require.new(arg.unescaped, nil)) + end + + def visit_call_module_function(call_node) + yield + return if @scanner.singleton + names = visibility_method_arguments(call_node, singleton: false)&.map(&:to_s) + @scanner.change_method_to_module_function(names) if names + end + + def visit_call_public_private_class_method(call_node, visibility) + yield + return if @scanner.singleton + names = visibility_method_arguments(call_node, singleton: true) + @scanner.change_method_visibility(names, visibility, singleton: true) if names + end + + def visit_call_public_private_protected(call_node, visibility) + arguments_node = call_node.arguments + if arguments_node.nil? # `public` `private` + @scanner.visibility = visibility + else # `public :foo, :bar`, `private def foo; end` + yield + names = visibility_method_arguments(call_node, singleton: @scanner.singleton) + @scanner.change_method_visibility(names, visibility) if names + end + end + + def visit_call_alias_method(call_node) + new_name, old_name, *rest = symbol_arguments(call_node) + return unless old_name && new_name && rest.empty? + @scanner.add_alias_method(old_name.to_s, new_name.to_s, call_node.location.start_line) + end + + def visit_call_include(call_node) + names = constant_arguments_names(call_node) + line_no = call_node.location.start_line + return unless names + + if @scanner.singleton + @scanner.add_extends(names, line_no) + else + @scanner.add_includes(names, line_no) + end + end + + def visit_call_extend(call_node) + names = constant_arguments_names(call_node) + @scanner.add_extends(names, call_node.location.start_line) if names && !@scanner.singleton + end + + def visit_call_public_constant(call_node) + return if @scanner.singleton + names = symbol_arguments(call_node) + @scanner.container.set_constant_visibility_for(names.map(&:to_s), :public) if names + end + + def visit_call_private_constant(call_node) + return if @scanner.singleton + names = symbol_arguments(call_node) + @scanner.container.set_constant_visibility_for(names.map(&:to_s), :private) if names + end + + def visit_call_attr_reader_writer_accessor(call_node, rw) + names = symbol_arguments(call_node) + @scanner.add_attributes(names.map(&:to_s), rw, call_node.location.start_line) if names + end + + def visit_alias_method_node(node) + @scanner.process_comments_until(node.location.start_line - 1) + return unless node.old_name.is_a?(Prism::SymbolNode) && node.new_name.is_a?(Prism::SymbolNode) + @scanner.add_alias_method(node.old_name.value.to_s, node.new_name.value.to_s, node.location.start_line) + end + + def visit_module_node(node) + @scanner.process_comments_until(node.location.start_line - 1) + module_name = constant_path_string(node.constant_path) + mod = @scanner.add_module_or_class(module_name, node.location.start_line, node.location.end_line) if module_name + if mod + @scanner.with_container(mod) do + super + @scanner.process_comments_until(node.location.end_line) + end + else + @scanner.skip_comments_until(node.location.end_line) + end + end + + def visit_class_node(node) + @scanner.process_comments_until(node.location.start_line - 1) + superclass_name = constant_path_string(node.superclass) if node.superclass + class_name = constant_path_string(node.constant_path) + klass = @scanner.add_module_or_class(class_name, node.location.start_line, node.location.end_line, is_class: true, superclass_name: superclass_name) if class_name + if klass + @scanner.with_container(klass) do + super + @scanner.process_comments_until(node.location.end_line) + end + else + @scanner.skip_comments_until(node.location.end_line) + end + end + + def visit_singleton_class_node(node) + @scanner.process_comments_until(node.location.start_line - 1) + + expression = node.expression + expression = expression.body.body.first if expression.is_a?(Prism::ParenthesesNode) && expression.body&.body&.size == 1 + + case expression + when Prism::ConstantWriteNode + # Accept `class << (NameErrorCheckers = Object.new)` as a module which is not actually a module + mod = @scanner.container.add_module(RDoc::NormalModule, expression.name.to_s) + when Prism::ConstantPathNode, Prism::ConstantReadNode + expression_name = constant_path_string(expression) + # If a constant_path does not exist, RDoc creates a module + mod = @scanner.find_or_create_module_path(expression_name, :module) if expression_name + when Prism::SelfNode + mod = @scanner.container if @scanner.container != @top_level + end + if mod + @scanner.with_container(mod, singleton: true) do + super + @scanner.process_comments_until(node.location.end_line) + end + else + @scanner.skip_comments_until(node.location.end_line) + end + end + + def visit_def_node(node) + start_line = node.location.start_line + end_line = node.location.end_line + @scanner.process_comments_until(start_line - 1) + + case node.receiver + when Prism::NilNode, Prism::TrueNode, Prism::FalseNode + visibility = :public + singleton = false + receiver_name = { Prism::NilNode => 'NilClass', Prism::TrueNode => 'TrueClass', Prism::FalseNode => 'FalseClass' }[node.receiver.class] + receiver_fallback_type = :class + when Prism::SelfNode + # singleton method of a singleton class is not documentable + return if @scanner.singleton + visibility = :public + singleton = true + when Prism::ConstantReadNode, Prism::ConstantPathNode + visibility = :public + singleton = true + receiver_name = constant_path_string(node.receiver) + receiver_fallback_type = :module + return unless receiver_name + when nil + visibility = @scanner.visibility + singleton = @scanner.singleton + else + # `def (unknown expression).method_name` is not documentable + return + end + name = node.name.to_s + signature = MethodSignature.new(node) + tokens = @scanner.visible_tokens_from_location(node.location) + + @scanner.add_method( + name, + receiver_name: receiver_name, + receiver_fallback_type: receiver_fallback_type, + visibility: visibility, + singleton: singleton, + signature: signature, + tokens: tokens, + start_line: start_line, + end_line: end_line + ) + ensure + @scanner.skip_comments_until(end_line) + end + + def visit_constant_path_write_node(node) + @scanner.process_comments_until(node.location.start_line - 1) + path = constant_path_string(node.target) + return unless path + + @scanner.add_constant( + path, + constant_path_string(node.value) || node.value.slice, + node.location.start_line, + node.location.end_line + ) + @scanner.skip_comments_until(node.location.end_line) + # Do not traverse rhs not to document `A::B = Struct.new{def undocumentable_method; end}` + end + + def visit_constant_write_node(node) + @scanner.process_comments_until(node.location.start_line - 1) + @scanner.add_constant( + node.name.to_s, + constant_path_string(node.value) || node.value.slice, + node.location.start_line, + node.location.end_line + ) + @scanner.skip_comments_until(node.location.end_line) + # Do not traverse rhs not to document `A = Struct.new{def undocumentable_method; end}` + end + + private + + def constant_arguments_names(call_node) + return unless call_node.arguments + names = call_node.arguments.arguments.map { |arg| constant_path_string(arg) } + names.all? ? names : nil + end + + def symbol_arguments(call_node) + arguments_node = call_node.arguments + return unless arguments_node && arguments_node.arguments.all? { |arg| arg.is_a?(Prism::SymbolNode)} + arguments_node.arguments.map { |arg| arg.value.to_sym } + end + + def visibility_method_arguments(call_node, singleton:) + arguments_node = call_node.arguments + return unless arguments_node + symbols = symbol_arguments(call_node) + return symbols.map(&:to_s) if symbols # module_function :foo, :bar + return unless arguments_node.arguments.size == 1 + arg = arguments_node.arguments.first + return unless arg.is_a?(Prism::DefNode) + # `module_function def foo; end` or `private_class_method def self.foo; end` + [arg.name.to_s] if singleton ? arg.receiver.is_a?(Prism::SelfNode) : arg.receiver.nil? + end + + def constant_path_string(node) + case node + when Prism::ConstantReadNode + node.name.to_s + when Prism::ConstantPathNode + parent_name = node.parent ? constant_path_string(node.parent) : '' + "#{parent_name}::#{node.name}" if parent_name + end + end + end + + class MethodSignature < Prism::Visitor # :nodoc: + attr_reader :yields, :calls_super, :params + def initialize(def_node) + @yields = [] + @calls_super = false + @params = "(#{def_node.parameters&.slice})" + def_node.body&.accept(self) + end + + def visit_def_node(node) + # stop traverse inside nested def + end + + def visit_yield_node(node) + yields << (node.arguments&.slice || '') + end + + def visit_super_node(node) + @calls_super = true + super + end + + def visit_forwarding_super_node(node) + @calls_super = true + end + end +end diff --git a/lib/rdoc/parser/ruby.rb b/lib/rdoc/parser/ruby.rb index 47ad770daf..1e3b401a5d 100644 --- a/lib/rdoc/parser/ruby.rb +++ b/lib/rdoc/parser/ruby.rb @@ -8,6 +8,12 @@ # by Keiju ISHITSUKA (Nippon Rational Inc.) # +if ENV['RDOC_USE_PRISM_PARSER'] + require 'rdoc/parser/prism_ruby' + RDoc::Parser.const_set(:Ruby, RDoc::Parser::PrismRuby) + return +end + require 'ripper' require_relative 'ripper_state_lex' diff --git a/rdoc.gemspec b/rdoc.gemspec index 93a281c8ae..9f6775a187 100644 --- a/rdoc.gemspec +++ b/rdoc.gemspec @@ -233,5 +233,6 @@ RDoc includes the +rdoc+ and +ri+ tools for generating and displaying documentat s.required_ruby_version = Gem::Requirement.new(">= 2.6.0") s.required_rubygems_version = Gem::Requirement.new(">= 2.2") + s.add_dependency 'prism', '>= 0.30.0' s.add_dependency 'psych', '>= 4.0.0' end From 76d7786312a77c6c31baf6aa965bd10e8a3719fb Mon Sep 17 00:00:00 2001 From: tompng Date: Wed, 24 Jul 2024 02:52:38 +0900 Subject: [PATCH 02/14] Add a new ruby parser testcase independent from parser's internal implementation --- test/rdoc/test_rdoc_parser_prism_ruby.rb | 1884 ++++++++++++++++++++++ test/rdoc/test_rdoc_parser_ruby.rb | 2 + 2 files changed, 1886 insertions(+) create mode 100644 test/rdoc/test_rdoc_parser_prism_ruby.rb diff --git a/test/rdoc/test_rdoc_parser_prism_ruby.rb b/test/rdoc/test_rdoc_parser_prism_ruby.rb new file mode 100644 index 0000000000..9dc555c6a1 --- /dev/null +++ b/test/rdoc/test_rdoc_parser_prism_ruby.rb @@ -0,0 +1,1884 @@ +# frozen_string_literal: true + +require_relative 'helper' +require 'rdoc/parser' +require 'rdoc/parser/prism_ruby' + +module RDocParserPrismTestCases + def setup + super + + @tempfile = Tempfile.new self.class.name + @filename = @tempfile.path + + @top_level = @store.add_file @filename + + @options = RDoc::Options.new + @options.quiet = true + @options.option_parser = OptionParser.new + + @comment = RDoc::Comment.new '', @top_level + + @stats = RDoc::Stats.new @store, 0 + end + + def teardown + super + + @tempfile.close! + end + + def test_look_for_directives_in_section + util_parser <<~RUBY + # :section: new section + RUBY + section = @top_level.current_section + assert_equal 'new section', section.title + end + + def test_look_for_directives_in_commented + util_parser <<~RUBY + # how to make a section: + # # :section: new section + RUBY + section = @top_level.current_section + assert_nil section.title + end + + def test_look_for_directives_in_unhandled + util_parser <<~RUBY + # :unhandled: blah + RUBY + assert_equal 'blah', @top_level.metadata['unhandled'] + end + + def test_block_comment + util_parser <<~RUBY + =begin rdoc + foo + =end + class A + =begin + bar + baz + =end + def f; end + end + RUBY + klass = @top_level.classes.first + meth = klass.method_list.first + assert_equal 'foo', klass.comment.text.strip + assert_equal "bar\nbaz", meth.comment.text.strip + end + + def test_module + util_parser <<~RUBY + # my module + module Foo + end + RUBY + mod = @top_level.modules.first + assert_equal 'Foo', mod.full_name + assert_equal 'my module', mod.comment.text + assert_equal [@top_level], mod.in_files + end + + def test_nested_module_with_colon + util_parser <<~RUBY + module Foo + module Bar; end + module Bar::Baz1; end + module ::Foo + module Bar2; end + end + end + module ::Baz; end + module Foo::Bar::Baz2 + module ::Foo2 + module Bar; end + end + module Blah; end + end + RUBY + module_names = @store.all_modules.map(&:full_name) + expected = %w[ + Foo Foo::Bar Foo::Bar::Baz1 Foo::Bar2 Baz Foo::Bar::Baz2 Foo2 Foo2::Bar Foo::Bar::Baz2::Blah + ] + assert_equal expected.sort, module_names.sort + end + + def test_class + util_parser <<~RUBY + # my class + class Foo + end + RUBY + klass = @top_level.classes.first + assert_equal 'Foo', klass.full_name + assert_equal 'my class', klass.comment.text + assert_equal [@top_level], klass.in_files + assert_equal 2, klass.line + end + + def test_nested_class_with_colon + util_parser <<~RUBY + class Foo + class Bar; end + class Bar::Baz1; end + class ::Foo + class Bar2; end + end + end + class ::Baz; end + class Foo::Bar::Baz2 + class ::Foo2 + class Bar; end + end + class Blah; end + end + RUBY + class_names = @store.all_classes.map(&:full_name) + expected = %w[ + Foo Foo::Bar Foo::Bar::Baz1 Foo::Bar2 Baz Foo::Bar::Baz2 Foo2 Foo2::Bar Foo::Bar::Baz2::Blah + ] + assert_equal expected.sort, class_names.sort + end + + def test_open_class_with_superclass + util_parser <<~RUBY + class A; end + + class B < A + def m1; end + end + + class B < A + def m2; end + end + + class C < String + def m1; end + end + + class C < String + def m2; end + end + RUBY + classes = @top_level.classes + assert_equal 3, classes.size + _a, b, c = classes + assert_equal 'A', b.superclass.full_name + assert_equal 'String', c.superclass + assert_equal ['m1', 'm2'], b.method_list.map(&:name) + assert_equal ['m1', 'm2'], c.method_list.map(&:name) + end + + def test_confusing_superclass + util_parser <<~RUBY + module A + class B; end + end + + module A + class C1 < A::B; end + end + + class A::C2 < A::B; end + + module A::A + class B; end + end + + module A + class C3 < A::B; end + end + + class A::C4 < A::B; end + RUBY + mod = @top_level.modules.first + classes = mod.classes + assert_equal ['A::B', 'A::C1', 'A::C2', 'A::C3', 'A::C4'], classes.map(&:full_name) + assert_equal ['A::B', 'A::B', 'A::A::B', 'A::B'], classes.drop(1).map(&:superclass).map(&:full_name) + end + + def test_class_module_nodoc + util_parser <<~RUBY + class Foo # :nodoc: + end + + class Bar + end # :nodoc: + + class Baz; end + + class Baz::A; end # :nodoc: + + module MFoo # :nodoc: + end + + module MBar + end # :nodoc: + + module MBaz; end + + module MBaz::M; end; # :nodoc: + RUBY + documentables = @store.all_classes_and_modules.select(&:document_self) + assert_equal ['Baz', 'MBaz'], documentables.map(&:full_name) unless accept_legacy_bug? + end + + def test_class_module_stopdoc + util_parser <<~RUBY + # comment + class Foo + class A; end + # :stopdoc: + class B; end + end + + # comment + module Bar + module A; end + # :stopdoc: + module B; end + end + RUBY + klass = @top_level.classes.first + mod = @top_level.modules.first + assert_equal 'comment', klass.comment.text.strip + assert_equal 'comment', mod.comment.text.strip + assert_equal ['Foo::A'], klass.classes.select(&:document_self).map(&:full_name) + assert_equal ['Bar::A'], mod.modules.select(&:document_self).map(&:full_name) + end + + def test_class_superclass + util_parser <<~RUBY + class Foo; end + class Bar < Foo + end + class Baz < (any expression) + end + RUBY + assert_equal ['Foo', 'Bar', 'Baz'], @top_level.classes.map(&:full_name) + foo, bar, baz = @top_level.classes + assert_equal foo, bar.superclass + assert_equal 'Object', baz.superclass unless accept_legacy_bug? + end + + def test_class_new_notnew + util_parser <<~RUBY + class A + def initialize(*args); end + end + + class B + ## + # :args: a, b, c + def initialize(*args); end + end + + class C + def self.initialize(*args); end + end + + class D + ## + # :args: a, b, c + def initialize(*args); end # :notnew: + end + + class E + def initialize(*args); end # :not-new: + end + + class F + def initialize(*args); end # :not_new: + end + + class G + def initialize(*args) + end # :notnew: + end + RUBY + + expected = [ + 'new(*args)', 'new(a, b, c)', + 'initialize(*args)', 'initialize(a, b, c)', + 'initialize(*args)', 'initialize(*args)', + 'initialize(*args)' + ] + arglists = @top_level.classes.map { |c| c.method_list.first.arglists } + assert_equal expected, arglists + end + + def test_class_mistaken_for_module + util_parser <<~RUBY + class A::Foo; end + class B::Foo; end + module C::Bar; end + module D::Baz; end + class A; end + class X < C; end + RUBY + assert_equal ['A', 'C', 'X'], @top_level.classes.map(&:full_name) + assert_equal ['B', 'D'], @top_level.modules.map(&:full_name) + end + + def test_parenthesized_cdecl + util_parser <<~RUBY + module DidYouMean + # Not a module, but creates a dummy module for document + class << (NameErrorCheckers = Object.new) + def new; end + end + end + RUBY + + mod = @store.find_class_or_module('DidYouMean').modules.first + assert_equal 'DidYouMean::NameErrorCheckers', mod.full_name + assert_equal ['DidYouMean::NameErrorCheckers::new'], mod.method_list.map(&:full_name) + end + + + def test_ghost_method + util_parser <<~RUBY + class Foo + ## + # :method: one + # + # my method one + + ## + # :method: + # :call-seq: + # two(name) + # + # my method two + + ## + # :method: three + # :args: a, b + # + # my method three + + # :stopdoc: + + ## + # :method: hidden1 + # + # comment + + ## + # :method: + # :call-seq: + # hidden2(name) + # + # comment + end + RUBY + + klass = @store.find_class_named 'Foo' + assert_equal 3, klass.method_list.size + one, two, three = klass.method_list + assert_equal 'Foo#one', one.full_name + assert_equal 'Foo#two', two.full_name + assert_equal 'Foo#three', three.full_name + assert_equal 'two(name)', two.call_seq.chomp + assert_equal 'three(a, b)', three.arglists + assert_equal 'my method one', one.comment.text.strip + assert_equal 'my method two', two.comment.text.strip + assert_equal 'my method three', three.comment.text.strip + assert_equal 3, one.line + assert_equal 8, two.line + assert_equal 15, three.line + assert_equal @top_level, one.file + assert_equal @top_level, two.file + assert_equal @top_level, three.file + end + + def test_invalid_meta_method + util_parser <<~RUBY + class Foo + # These are invalid meta method comments + # because meta method comment should start with `##` + # but rdoc accepts them as meta method comments for now. + + # :method: m1 + + # :singleton-method: sm1 + + # :attr: a1 + + # :attr_reader: ar1 + + # :attr_writer: aw1 + + # :attr_accessor: arw1 + + # If there is a node following meta-like normal comment, it is not a meta method comment + + # :method: m2 + add_my_method(name) + + # :singleton-method: sm2 + add_my_singleton_method(name) + + # :method: + add_my_method(:m3) + + # :singleton-method: + add_my_singleton_method(:sm3) + + # :attr: + add_my_attribute(:a2) + + # :attr-reader: + add_my_attribute(:ar2) + + # :attr-writer: + add_my_attribute(:aw2) + + # :attr-accessor: + add_my_attribute(:arw2) + + # :attr: a3 + add_my_attribute_a3 + + # :attr-reader: ar3 + add_my_attribute_ar3 + + # :attr-writer: aw3 + add_my_attribute_aw2 + + # :attr-accessor: arw3 + add_my_attribute_arw3 + end + RUBY + + klass = @store.find_class_named 'Foo' + assert_equal ['m1', 'sm1'], klass.method_list.map(&:name) + assert_equal ['a1', 'ar1', 'aw1', 'arw1'], klass.attributes.map(&:name) + end + + def test_method + util_parser <<~RUBY + class Foo + # my method one + def one; end + # my method two + def two(x); end + # my method three + def three x; end + end + RUBY + + klass = @store.find_class_named 'Foo' + assert_equal 3, klass.method_list.size + one, two, three = klass.method_list + assert_equal 'Foo#one', one.full_name + assert_equal 'Foo#two', two.full_name + assert_equal 'Foo#three', three.full_name + assert_equal 'one()', one.arglists + assert_equal 'two(x)', two.arglists + assert_equal 'three(x)', three.arglists unless accept_legacy_bug? + assert_equal 'my method one', one.comment.text.strip + assert_equal 'my method two', two.comment.text.strip + assert_equal 'my method three', three.comment.text.strip + assert_equal 3, one.line + assert_equal 5, two.line + assert_equal 7, three.line + assert_equal @top_level, one.file + assert_equal @top_level, two.file + assert_equal @top_level, three.file + end + + def test_method_toplevel + util_parser <<~RUBY + # comment + def foo; end + RUBY + + object = @store.find_class_named 'Object' + foo = object.method_list.first + assert_equal 'Object#foo', foo.full_name + assert_equal 'comment', foo.comment.text.strip + assert_equal @top_level, foo.file + end + + def test_meta_method + util_parser <<~RUBY + class Foo + ## + # my method + add_my_method :method_foo, :arg + end + RUBY + + klass = @store.find_class_named 'Foo' + assert_equal 1, klass.method_list.size + method = klass.method_list.first + assert_equal 'Foo#method_foo', method.full_name + assert_equal 'my method', method.comment.text.strip + assert_equal 4, method.line + assert_equal @top_level, method.file + end + + def test_first_comment_is_not_a_meta_method + util_parser <<~RUBY + ## + # first comment is not a meta method + add_my_method :foo + + ## + # this is a meta method + add_my_method :bar + RUBY + + object = @store.find_class_named 'Object' + assert_equal ['bar'], object.method_list.map(&:name) + end + + def test_meta_method_unknown + util_parser <<~RUBY + class Foo + ## + # my method + add_my_method (:foo), :bar + end + RUBY + + klass = @store.find_class_named 'Foo' + assert_equal 1, klass.method_list.size + method = klass.method_list.first + assert_equal 'Foo#unknown', method.full_name + assert_equal 'my method', method.comment.text.strip + assert_equal 4, method.line + assert_equal @top_level, method.file + end + + def test_meta_define_method + util_parser <<~RUBY + class Foo + ## + # comment 1 + define_method :foo do end + ## + # comment 2 + define_method :bar, ->{} + # not a meta comment, not a meta method + define_method :ignored do end + class << self + ## + # comment 3 + define_method :baz do end + end + end + RUBY + + klass = @store.find_class_named 'Foo' + klass.method_list.last.singleton = true if accept_legacy_bug? + assert_equal 3, klass.method_list.size + assert_equal ['Foo#foo', 'Foo#bar', 'Foo::baz'], klass.method_list.map(&:full_name) + assert_equal [false, false, true], klass.method_list.map(&:singleton) + assert_equal ['comment 1', 'comment 2', 'comment 3'], klass.method_list.map { |m| m.comment.text.strip } + assert_equal [4, 7, 13], klass.method_list.map(&:line) + assert_equal [@top_level] * 3, klass.method_list.map(&:file) + end + + def test_method_definition_nested_inside_block + util_parser <<~RUBY + module A + extend ActiveSupport::Concern + included do + ## + # :singleton-method: + # Hello + mattr_accessor :foo + + ## + # :method: bar + # World + add_my_method :bar + end + end + RUBY + mod = @store.find_module_named 'A' + foo, bar = mod.method_list + assert_equal 'A::foo', foo.full_name + assert_equal 'A#bar', bar.full_name + assert_equal 'Hello', foo.comment.text.strip + assert_equal 'World', bar.comment.text.strip + end + + def test_method_yields_directive + util_parser <<~RUBY + class Foo + def f1(a, &b); end + + def f2 + def o.foo + yield :dummy + end + yield + end + + def f3(&b) + yield a, *b, c: 1 + yield 1, 2, 3 + end + + def f4(a, &b) # :yields: d, e + yields 1, 2 + end + + def f5 # :yield: f + yields 1, 2 + end + + def f6; end # :yields: + + ## + # :yields: g, h + add_my_method :f7 + end + RUBY + + klass = @top_level.classes.first + methods = klass.method_list + expected = [ + 'f1(a, &b)', + 'f2() { || ... }', + 'f3() { |a, *b, c: 1| ... }', + 'f4(a) { |d, e| ... }', + 'f5() { |f| ... }', + 'f6() { || ... }', + 'f7() { |g, h| ... }' + ] + assert_equal expected, methods.map(&:arglists) + end + + def test_method_args_directive + util_parser <<~RUBY + class Foo + def method1 # :args: a, b, c + end + + ## + # :args: d, e, f + def method2(*args); end + + ## + # :args: g, h + add_my_method :method3 + end + RUBY + + klass = @top_level.classes.first + methods = klass.method_list + assert_equal ['method1(a, b, c)', 'method2(d, e, f)', 'method3(g, h)'], methods.map(&:arglists) + end + + def test_class_repeatedly + util_parser <<~RUBY + class Foo + def foo; end + end + class Foo + def bar; end + end + RUBY + util_parser <<~RUBY + class Foo + def baz; end + end + RUBY + + klass = @store.find_class_named 'Foo' + assert_equal ['Foo#foo', 'Foo#bar', 'Foo#baz'], klass.method_list.map(&:full_name) + end + + def test_undefined_singleton_class_defines_module + util_parser <<~RUBY + class << Foo + end + class << ::Bar + end + RUBY + + modules = @store.all_modules + assert_equal ['Foo', 'Bar'], modules.map(&:name) + end + + def test_singleton_class + util_parser <<~RUBY + class A; end + class Foo + def self.m1; end + def (any expression).dummy1; end + class << self + def m2; end + def self.dummy2; end + end + class << A + def dummy3; end + end + class << Foo + def m3; end + def self.dummy4; end + end + class << ::Foo + def m4; end + end + class << (any expression) + def dummy5; end + end + end + class << Foo + def m5; end + end + class << ::Foo + def m6; end + end + RUBY + + klass = @store.find_class_named 'Foo' + methods = klass.method_list + methods = methods.reject {|m| m.name =~ /dummy2|dummy4/ } if accept_legacy_bug? + assert_equal ['m1', 'm2', 'm3', 'm4', 'm5', 'm6'], methods.map(&:name) + assert_equal [true] * 6, methods.map(&:singleton) + end + + def test_singleton_class_meta_method + util_parser <<~RUBY + class Foo + ## + # :singleton-method: m1 + + ## + # :singleton-method: + add_my_smethod :m2, :arg + + ## + # :singleton-method: + add_my_smethod 'm3', :arg + + # comment + class << self + ## + # method of a singleton class is a singleton method + # :method: m4 + + ## + # :singleton-method: m5 + end + end + RUBY + + klass = @store.find_class_named 'Foo' + assert_equal ['m1', 'm2', 'm3', 'm4', 'm5'], klass.method_list.map(&:name) + klass.method_list[3].singleton = true if accept_legacy_bug? + assert_equal [true] * 5, klass.method_list.map(&:singleton) + end + + def test_method_nested_visibility + util_parser <<~RUBY + class A + def pub1; end + private + def pri1; end + class B + def pub_b1; end + private + def pri_b1; end + public + def pub_b2; end + end + def pri2; end + end + class A + def pub2; end + private + def pri2; end + end + RUBY + klass_a = @store.find_class_named 'A' + klass_b = klass_a.find_class_named 'B' + public_a = klass_a.method_list.select { |m| m.visibility == :public }.map(&:name) + public_b = klass_b.method_list.select { |m| m.visibility == :public }.map(&:name) + assert_equal ['pub1', 'pub2'], public_a + assert_equal ['pub_b1', 'pub_b2'], public_b + end + + def test_attributes_visibility + util_parser <<~RUBY + class A + attr :pub_a + attr_reader :pub_r + attr_writer :pub_w + attr_accessor :pub_rw + private + attr :pri_a + attr_reader :pri_r + attr_writer :pri_w + attr_accessor :pri_rw + end + RUBY + klass = @store.find_class_named 'A' + assert_equal ['pub_a', 'pub_r', 'pub_w', 'pub_rw', 'pri_a', 'pri_r', 'pri_w', 'pri_rw'], klass.attributes.map(&:name) + assert_equal [:public] * 4 + [:private] * 4, klass.attributes.map(&:visibility) + end + + def test_method_singleton_class_visibility + util_parser <<~RUBY + class A + def self.pub1; end + private + def self.pub2; end + class << self + def pub3; end + private + def pri1; end + public + def pub4; end + private + end + end + RUBY + klass = @store.find_class_named 'A' + public_singleton_methods = klass.method_list.select { |m| m.singleton && m.visibility == :public } + assert_equal ['pub1', 'pub2', 'pub3', 'pub4'], public_singleton_methods.map(&:name) + end + + def test_private_def_public_def + util_parser <<~RUBY + class A + private def m1; end + public def m2; end + private + public def m3; end + end + RUBY + klass = @store.find_class_named 'A' + public_methods = klass.method_list.select { |m| m.visibility == :public } + assert_equal ['m2', 'm3'], public_methods.map(&:name) + end + + def test_define_method_visibility + util_parser <<~RUBY + class A + private + ## + # my private method + define_method :m1 do end + public + ## + # my public method + define_method :m2 do end + end + RUBY + klass = @store.find_class_named 'A' + methods = klass.method_list + assert_equal ['m1', 'm2'], methods.map(&:name) + assert_equal [:private, :public], methods.map(&:visibility) + end + + def test_module_function + util_parser <<~RUBY + class A + def m1; end + def m2; end + def m3; end + module_function :m1, :m3 + module_function def m4; end + end + RUBY + klass = @store.find_class_named 'A' + instance_methods = klass.method_list.reject(&:singleton) + singleton_methods = klass.method_list.select(&:singleton) + if accept_legacy_bug? + instance_methods.last.visibility = :private + singleton_methods << singleton_methods.last.dup + singleton_methods.last.name = 'm4' + end + assert_equal ['m1', 'm2', 'm3', 'm4'], instance_methods.map(&:name) + assert_equal [:private, :public, :private, :private], instance_methods.map(&:visibility) + assert_equal ['m1', 'm3', 'm4'], singleton_methods.map(&:name) + assert_equal [:public, :public, :public], singleton_methods.map(&:visibility) + end + + def test_class_method_visibility + util_parser <<~RUBY + class A + def self.m1; end + def self.m2; end + def self.m3; end + private_class_method :m1, :m2 + public_class_method :m1, :m3 + private_class_method def self.m4; end + public_class_method def self.m5; end + end + RUBY + klass = @store.find_class_named 'A' + public_methods = klass.method_list.select { |m| m.visibility == :public } + assert_equal ['m1', 'm3', 'm5'], public_methods.map(&:name) unless accept_legacy_bug? + end + + def test_method_change_visibility + util_parser <<~RUBY + class A + def m1; end + def m2; end + def m3; end + def m4; end + def m5; end + private :m2, :m3, :m4 + public :m1, :m3 + end + class << A + def m1; end + def m2; end + def m3; end + def m4; end + def m5; end + private :m1, :m2, :m3 + public :m2, :m4 + end + RUBY + klass = @store.find_class_named 'A' + public_methods = klass.method_list.select { |m| !m.singleton && m.visibility == :public } + public_singleton_methods = klass.method_list.select { |m| m.singleton && m.visibility == :public } + assert_equal ['m1', 'm3', 'm5'], public_methods.map(&:name) + assert_equal ['m2', 'm4', 'm5'], public_singleton_methods.map(&:name) + end + + def test_method_visibility_change_in_subclass + pend 'not implemented' if accept_legacy_bug? + util_parser <<~RUBY + class A + def m1; end + def m2; end + private :m2 + end + class B < A + private :m1 + public :m2 + end + RUBY + + superclass = @store.find_class_named('A') + klass = @store.find_class_named('B') + assert_equal ['m1', 'm2'], superclass.method_list.map(&:name) + assert_equal ['m1', 'm2'], klass.method_list.map(&:name) + assert_equal [:public, :private], superclass.method_list.map(&:visibility) + assert_equal [:private, :public], klass.method_list.map(&:visibility) + end + + def test_singleton_method_visibility_change_in_subclass + util_parser <<~RUBY + class A + def self.m1; end + def self.m2; end + private_class_method :m2 + end + class B < A + private_class_method :m1 + public_class_method :m2 + end + RUBY + + superclass = @store.find_class_named('A') + klass = @store.find_class_named('B') + assert_equal ['m1', 'm2'], superclass.method_list.map(&:name) + assert_equal ['m1', 'm2'], klass.method_list.map(&:name) + assert_equal [:public, :private], superclass.method_list.map(&:visibility) + assert_equal [:private, :public], klass.method_list.map(&:visibility) + end + + def test_alias + util_parser <<~RUBY + class Foo + def bar; end + def bar2; alias :dummy :bar; end + # comment + alias :baz1 :bar # :nodoc: + alias :baz2 :bar + # :stopdoc: + alias :baz3 :bar + end + RUBY + klass = @store.find_class_named 'Foo' + assert_equal ['Foo#bar', 'Foo#bar2', 'Foo#baz2'], klass.method_list.map(&:full_name) + m = klass.method_list.last + assert_equal 'Foo#bar', m.is_alias_for.full_name + assert_equal 'Foo#baz2', m.full_name + assert_equal klass, m.parent + end + + def test_alias_singleton + util_parser <<~RUBY + class Foo + class << self + def bar; end + # comment + alias :baz :bar + # :stopdoc: + alias :baz2 :bar + end + end + RUBY + klass = @store.find_class_named 'Foo' + m = klass.class_method_list.last + assert_equal 'Foo::bar', m.is_alias_for.full_name + assert_equal 'Foo::baz', m.full_name + assert_equal 'comment', m.comment.text + assert_equal klass, m.parent + end + + def test_alias_method + util_parser <<~RUBY + class Foo + def foo; end + private + alias_method :foo2, :foo + def bar; end + public + alias_method :bar2, :bar + private :foo + public :bar + end + RUBY + foo, foo2, bar, bar2 = @top_level.classes.first.method_list + assert_equal 'foo', foo.name + assert_equal 'bar', bar.name + assert_equal 'foo2', foo2.name + assert_equal 'bar2', bar2.name + assert_equal 'foo', foo2.is_alias_for.name + assert_equal 'bar', bar2.is_alias_for.name + unless accept_legacy_bug? + assert_equal :private, foo.visibility + assert_equal :public, foo2.visibility + assert_equal :public, bar.visibility + assert_equal :private, bar2.visibility + end + end + + def test_alias_method_stopdoc_nodoc + util_parser <<~RUBY + class Foo + def foo; end + # :stopdoc: + alias_method :foo2, :foo + # :startdoc: + alias_method :foo3, :foo # :nodoc: + alias_method :foo4, :foo + end + RUBY + assert_equal ['foo', 'foo4'], @top_level.classes.first.method_list.map(&:name) + end + + def test_attributes + util_parser <<~RUBY + class Foo + # attrs + attr :attr1, :attr2 + # readers + attr_reader :reader1, :reader2 + # writers + attr_writer :writer1, :writer2 + # accessors + attr_accessor :accessor1, :accessor2 + # :stopdoc: + attr :attr3, :attr4 + attr_reader :reader3, :reader4 + attr_writer :write3, :writer4 + attr_accessor :accessor3, :accessor4 + end + RUBY + klass = @store.find_class_named 'Foo' + if accept_legacy_bug? + a, r1, r2, w1, w2, rw1, rw2 = klass.attributes + a1 = a.dup + a2 = a.dup + a1.rw = a2.rw = 'R' + a2.name = 'attr2' + else + assert_equal 8, klass.attributes.size + a1, a2, r1, r2, w1, w2, rw1, rw2 = klass.attributes + end + assert_equal ['attr1', 'attr2'], [a1.name, a2.name] + assert_equal ['reader1', 'reader2'], [r1.name, r2.name] + assert_equal ['writer1', 'writer2'], [w1.name, w2.name] + assert_equal ['accessor1', 'accessor2'], [rw1.name, rw2.name] + assert_equal ['R', 'R'], [a1.rw, a2.rw] + assert_equal ['R', 'R'], [r1.rw, r2.rw] + assert_equal ['W', 'W'], [w1.rw, w2.rw] + assert_equal ['RW', 'RW'], [rw1.rw, rw2.rw] + assert_equal ['attrs', 'attrs'], [a1.comment.text, a2.comment.text] + assert_equal ['readers', 'readers'], [r1.comment.text, r2.comment.text] + assert_equal ['writers', 'writers'], [w1.comment.text, w2.comment.text] + assert_equal ['accessors', 'accessors'], [rw1.comment.text, rw2.comment.text] + assert_equal [3, 3], [a1.line, a2.line] + assert_equal [5, 5], [r1.line, r2.line] + assert_equal [7, 7], [w1.line, w2.line] + assert_equal [9, 9], [rw1.line, rw2.line] + assert_equal [@top_level] * 8, [a1, a2, r1, r2, w1, w2, rw1, rw2].map(&:file) + end + + def test_singleton_class_attributes + util_parser <<~RUBY + class Foo + class << self + attr :a + attr_reader :r + attr_writer :w + attr_accessor :rw + end + end + RUBY + klass = @store.find_class_named 'Foo' + attributes = klass.attributes + assert_equal ['a', 'r', 'w', 'rw'], attributes.map(&:name) + assert_equal [true] * 4, attributes.map(&:singleton) + end + + def test_attributes_nodoc + util_parser <<~RUBY + class Foo + attr :attr1, :attr2 # :nodoc: + attr :attr3 + attr_reader :reader1, :reader2 # :nodoc: + attr_reader :reader3 + attr_writer :writer1, :writer2 # :nodoc: + attr_writer :writer3 + attr_accessor :accessor1, :accessor2 # :nodoc: + attr_accessor :accessor3 + end + RUBY + klass = @store.find_class_named 'Foo' + unless accept_legacy_bug? + assert_equal 4, klass.attributes.size + end + end + + def test_attributes_nodoc_track + @options.visibility = :nodoc + util_parser <<~RUBY + class Foo + attr :attr1, :attr2 # :nodoc: + attr :attr3 + attr_reader :reader1, :reader2 # :nodoc: + attr_reader :reader3 + attr_writer :writer1, :writer2 # :nodoc: + attr_writer :writer3 + attr_accessor :accessor1, :accessor2 # :nodoc: + attr_accessor :accessor3 + end + RUBY + klass = @store.find_class_named 'Foo' + unless accept_legacy_bug? + assert_equal 12, klass.attributes.size + end + end + + def test_method_nodoc_stopdoc + util_parser <<~RUBY + class Foo + def doc1; end + def nodoc1; end # :nodoc: + def doc2; end + def nodoc2 # :nodoc: + end + def doc3; end + def nodoc3 + end # :nodoc: + def doc4; end + # :stopdoc: + def nodoc4; end + end + RUBY + klass = @store.find_class_named 'Foo' + assert_equal ['doc1', 'doc2', 'doc3', 'doc4'], klass.method_list.map(&:name) + end + + def test_method_nodoc_track + @options.visibility = :nodoc + util_parser <<~RUBY + class Foo + def doc1; end + def nodoc1; end # :nodoc: + def doc2; end + def nodoc2 # :nodoc: + end + def doc3; end + def nodoc3 + end # :nodoc: + def doc4; end + end + RUBY + klass = @store.find_class_named 'Foo' + assert_equal ['doc1', 'nodoc1', 'doc2', 'nodoc2', 'doc3', 'nodoc3', 'doc4'], klass.method_list.map(&:name) + assert_equal [true, nil, true, nil, true, nil, true], klass.method_list.map(&:document_self) + end + + def test_meta_attributes + util_parser <<~RUBY + class Foo + ## + # :attr: + # attrs + add_my_method :attr1, :attr2 + ## + # :attr_reader: + # readers + add_my_method :reader1, :reader2 + ## + # :attr_writer: + # writers + add_my_method :writer1, :writer2 + ## + # :attr_accessor: + # accessors + add_my_method :accessor1, :accessor2 + + # :stopdoc: + + ## + # :attr: + add_my_method :attr3 + ## + # :attr_reader: + add_my_method :reader3 + ## + # :attr_writer: + add_my_method :writer3 + ## + # :attr_accessor: + add_my_method :accessor3 + end + RUBY + klass = @store.find_class_named 'Foo' + assert_equal 8, klass.attributes.size + a1, a2, r1, r2, w1, w2, rw1, rw2 = klass.attributes + assert_equal ['attr1', 'attr2'], [a1.name, a2.name] + assert_equal ['reader1', 'reader2'], [r1.name, r2.name] + assert_equal ['writer1', 'writer2'], [w1.name, w2.name] + assert_equal ['accessor1', 'accessor2'], [rw1.name, rw2.name] + a1.rw = a2.rw = 'R' if accept_legacy_bug? + assert_equal ['R', 'R'], [a1.rw, a2.rw] + assert_equal ['R', 'R'], [r1.rw, r2.rw] + assert_equal ['W', 'W'], [w1.rw, w2.rw] + assert_equal ['RW', 'RW'], [rw1.rw, rw2.rw] + assert_equal ['attrs', 'attrs'], [a1.comment.text, a2.comment.text] + assert_equal ['readers', 'readers'], [r1.comment.text, r2.comment.text] + assert_equal ['writers', 'writers'], [w1.comment.text, w2.comment.text] + assert_equal ['accessors', 'accessors'], [rw1.comment.text, rw2.comment.text] + assert_equal [@top_level] * 8, [a1, a2, r1, r2, w1, w2, rw1, rw2].map(&:file) + end + + def test_meta_attributes_named + util_parser <<~RUBY + class Foo + ## + # comment a + # :attr: attr1 + add_my_method :a1 + ## + # comment r + # :attr_reader: reader1 + add_my_method :r1 + ## + # comment w + # :attr_writer: writer1 + add_my_method :w1 + ## + # comment rw + # :attr_accessor: accessor1 + add_my_method :rw1 + + # :stopdoc: + + ## + # :attr: attr2 + add_my_method :a2 + ## + # :attr_reader: reader2 + add_my_method :r2 + ## + # :attr_writer: writer2 + add_my_method :w2 + ## + # :attr_accessor: accessor2 + add_my_method :rw2 + end + RUBY + klass = @store.find_class_named 'Foo' + assert_equal 4, klass.attributes.size + a, r, w, rw = klass.attributes + assert_equal 'attr1', a.name + assert_equal 'reader1', r.name + assert_equal 'writer1', w.name + assert_equal 'accessor1', rw.name + a.rw = 'R' if accept_legacy_bug? + assert_equal 'R', a.rw + assert_equal 'R', r.rw + assert_equal 'W', w.rw + assert_equal 'RW', rw.rw + assert_equal 'comment a', a.comment.text + assert_equal 'comment r', r.comment.text + assert_equal 'comment w', w.comment.text + assert_equal 'comment rw', rw.comment.text + assert_equal [@top_level] * 4, [a, r, w, rw].map(&:file) + end + + def test_constant + util_parser <<~RUBY + class Foo + A = (any expression 1) + def f + DUMMY1 = (any expression 2) + end + class Bar; end + Bar::B = (any expression 3) + ::C = (any expression 4) + # :stopdoc: + DUMMY2 = 1 + # :startdoc: + D = (any expression 5) + E = (any expression 6) # :nodoc: + F = ( + any expression 7 + ) # :nodoc: + end + G = (any expression 8) + RUBY + foo = @top_level.classes.first + bar = foo.classes.first + object = @top_level.find_class_or_module('Object') + assert_equal ['A', 'D', 'E', 'F'], foo.constants.map(&:name) unless accept_legacy_bug? + assert_equal '(any expression 1)', foo.constants.first.value + assert_equal ['B'], bar.constants.map(&:name) + assert_equal ['C', 'G'], object.constants.map(&:name) unless accept_legacy_bug? + all_constants = foo.constants + bar.constants + object.constants + assert_equal [@top_level] * 7, all_constants.map(&:file) unless accept_legacy_bug? + assert_equal [2, 12, 13, 14, 7, 8, 18], all_constants.map(&:line) unless accept_legacy_bug? + end + + def test_nodoc_constant_assigned_without_nodoc_comment + util_parser <<~RUBY + module Foo + A = 1 + B = 1 # :nodoc: + begin + C = 1 # :nodoc: + rescue + C = 2 + end + end + Foo::B = 2 + Foo::D = 2 + RUBY + mod = @top_level.modules.first + assert_equal ['A', 'B', 'C', 'D'], mod.constants.map(&:name) + assert_equal [false, true, true, false], mod.constants.map(&:received_nodoc) + end + + def test_constant_visibility + util_parser <<~RUBY + class C + A = 1 + B = 2 + C = 3 + private_constant :A + private_constant :B, :C + public_constant :B + end + RUBY + klass = @store.find_class_named 'C' + const_a, const_b, const_c = klass.constants.sort_by(&:name) + + assert_equal 'A', const_a.name + assert_equal :private, const_a.visibility + + assert_equal 'B', const_b.name + assert_equal :public, const_b.visibility + + assert_equal 'C', const_c.name + assert_equal :private, const_c.visibility + end + + def test_constant_assignment_to_undefined_module_path + util_parser <<~RUBY + A::B::C = 1 + RUBY + a = @top_level.find_module_named 'A' + b = a.find_module_named 'B' + c = b.constants.first + assert_equal 'A::B::C', c.full_name + end + + def test_constant_alias + util_parser <<~RUBY + class Foo + class Bar; end + A = Bar + # B = ::Foo # master branch has bug + C = Foo::Bar + end + RUBY + klass = @top_level.classes.first + assert_equal [], klass.modules.map(&:full_name) + assert_equal ['Foo::Bar', 'Foo::A', 'Foo::C'], klass.classes.map(&:full_name) + assert_equal ['Foo::A', 'Foo::C'], klass.constants.map(&:full_name) + assert_equal 'Foo::A', klass.find_module_named('A').full_name + assert_equal 'Foo::C', klass.find_module_named('C').full_name + end + + def test_constant_method + util_parser <<~RUBY + def Object.foo; end + class A + class B + class C + def B.bar; end + end + end + end + def UNKNOWN.baz; end + RUBY + + object = @store.find_class_named 'Object' + klass = @store.find_class_named 'A::B' + unknown = @store.find_module_named('UNKNOWN') + assert_equal 'Object::foo', object.method_list.first.full_name + assert_equal 'A::B::bar', klass.method_list.first.full_name + assert_equal 'UNKNOWN::baz', unknown.method_list.first.full_name + end + + def test_true_false_nil_method + util_parser <<~RUBY + def nil.foo; end + def true.bar; end + def false.baz; end + RUBY + sep = accept_legacy_bug? ? '::' : '#' + assert_equal "NilClass#{sep}foo", @store.find_class_named('NilClass').method_list.first.full_name + assert_equal "TrueClass#{sep}bar", @store.find_class_named('TrueClass').method_list.first.full_name + assert_equal "FalseClass#{sep}baz", @store.find_class_named('FalseClass').method_list.first.full_name + end + + def test_include_extend + util_parser <<~RUBY + module I; end + module E; end + class C + # my include + include I + # my extend + extend E + end + module M + include I + extend E + end + RUBY + klass = @store.find_class_named 'C' + mod = @store.find_module_named 'M' + assert_equal ['I'], klass.includes.map(&:name) + assert_equal ['E'], klass.extends.map(&:name) + assert_equal ['I'], mod.includes.map(&:name) + assert_equal ['E'], mod.extends.map(&:name) + assert_equal 'my include', klass.includes.first.comment.text.strip + assert_equal 'my extend', klass.extends.first.comment.text.strip + end + + def test_include_extend_to_singleton_class + pend 'not implemented' if accept_legacy_bug? + util_parser <<~RUBY + class Foo + class << self + # include to singleton class is extend + include I + # extend to singleton class is not documentable + extend E + end + end + RUBY + + klass = @top_level.classes.first + assert_equal [], klass.includes.map(&:name) + assert_equal ['I'], klass.extends.map(&:name) + end + + def test_include_with_module_nesting + util_parser <<~RUBY + module A + module M; end + module B + module M; end + module C + module M; end + module D + module M; end + end + end + end + end + + module A::B + class C::D::Foo + include M + end + end + # TODO: make test pass with the following code appended + # module A::B::C + # class D::Foo + # include M + # end + # end + RUBY + klass = @store.find_class_named 'A::B::C::D::Foo' + assert_equal 'A::B::M', klass.includes.first.module.full_name + end + + def test_require + util_parser <<~RUBY + require + require 'foo/bar' + require_relative 'is/not/supported/yet' + require "\#{embed}" + require (any expression) + RUBY + assert_equal ['foo/bar'], @top_level.requires.map(&:name) + end + + def test_statements_identifier_alias_method_before_original_method + # This is not strictly legal Ruby code, but it simulates finding an alias + # for a method before finding the original method, which might happen + # to rdoc if the alias is in a different file than the original method + # and rdoc processes the alias' file first. + util_parser <<~RUBY + class Foo + alias_method :foo2, :foo + alias_method :foo3, :foo + end + + class Foo + def foo(); end + alias_method :foo4, :foo + alias_method :foo5, :unknown + end + RUBY + + foo = @top_level.classes.first.method_list[0] + assert_equal 'foo', foo.name + + foo2 = @top_level.classes.first.method_list[1] + assert_equal 'foo2', foo2.name + assert_equal 'foo', foo2.is_alias_for.name + + foo3 = @top_level.classes.first.method_list[2] + assert_equal 'foo3', foo3.name + assert_equal 'foo', foo3.is_alias_for.name + + foo4 = @top_level.classes.first.method_list.last + assert_equal 'foo4', foo4.name + assert_equal 'foo', foo4.is_alias_for.name + + assert_equal 'unknown', @top_level.classes.first.external_aliases[0].old_name + end + + def test_class_definition_encountered_after_class_reference + # The code below is not legal Ruby (Foo must have been defined before + # Foo.bar is encountered), but RDoc might encounter Foo.bar before Foo if + # they live in different files. + + util_parser <<-RUBY + def Foo.bar + end + + class Foo < IO + end + RUBY + + assert_empty @store.modules_hash + assert_empty @store.all_modules + + klass = @top_level.classes.first + assert_equal 'Foo', klass.full_name + assert_equal 'IO', klass.superclass + + assert_equal 'bar', klass.method_list.first.name + end + + def test_scan_duplicate_module + util_parser <<~RUBY + # comment a + module Foo + end + + # comment b + module Foo + end + RUBY + + mod = @top_level.modules.first + + expected = [ + RDoc::Comment.new('comment a', @top_level), + RDoc::Comment.new('comment b', @top_level) + ] + + assert_equal expected, mod.comment_location.map { |c, _l| c } + end + + def test_enddoc + util_parser <<~RUBY + class A + class B; end + # :enddoc: + # :startdoc: + class C; end + end + class D; end + # :enddoc: + # :startdoc: + class E; end + RUBY + + assert_equal ['A', 'A::B', 'D'], @store.all_classes.reject(&:ignored?).map(&:full_name) + end + + def test_top_level_enddoc + util_parser <<~RUBY + class A; end + # :enddoc: + class B; end + # :startdoc: + class C; end + RUBY + + assert_equal ['A'], @top_level.classes.reject(&:ignored?).map(&:name) + end + + def test_section + util_parser <<~RUBY + class Foo + # :section: section1 + attr :a1 + def m1; end + # :section: + def m2; end + attr :a2 + # :section: section2 + def m3; end + attr :a3 + module Bar + def m4; end + attr :a4 + # :section: section3 + def m5; end + attr :a5 + end + attr :a6 + def m6; end + end + RUBY + foo = @top_level.classes.first + bar = foo.modules.first + assert_equal ['section1', nil, 'section2', 'section2'], foo.attributes.map { |m| m.section.title } + assert_equal ['section1', nil, 'section2', 'section2'], foo.method_list.map { |m| m.section.title } + assert_equal [nil, 'section3'], bar.attributes.map { |m| m.section.title } + assert_equal [nil, 'section3'], bar.method_list.map { |m| m.section.title } + end + + def test_category + util_parser <<~RUBY + class A + # :category: cat1 + + # comment + attr :a1 + attr :a2 + def m1; end + # :category: cat2 + + # comment + def m2; end + def m3; end + attr :a3 + + # :category: + attr :a4 + # :category: + def m4; end + + ## + # :category: cat3 + def m5; end + + ## + # :category: cat4 + # :method: m6 + end + RUBY + klass = @top_level.classes.first + assert_equal ['cat1', nil, nil, nil], klass.attributes.map { |m| m.section.title } + assert_equal [nil, 'cat2', nil, nil, 'cat3', 'cat4'], klass.method_list.map { |m| m.section.title } + end + + def test_ignore_constant_assign_rhs + # Struct is not supported yet. Right hand side of constant assignment should be ignored. + util_parser <<~RUBY + module Foo + def a; end + Bar = Struct.new do + def b; end + ## + # :method: c + end + Bar::Baz = Struct.new do + def d; end + ## + # :method: e + end + ## + # :method: f + end + RUBY + mod = @top_level.modules.first + assert_equal ['a', 'f'], mod.method_list.map(&:name) + end + + def test_multibyte_method_name + content = <<~RUBY + class Foo + # comment ω + def ω() end + end + RUBY + util_parser content + assert_equal Encoding::UTF_8, content.encoding + method = @top_level.classes.first.method_list.first + assert_equal 'comment ω', method.comment.text.strip + assert_equal 'ω', method.name + end + + def test_options_encoding + @options.encoding = Encoding::CP852 + util_parser <<~RUBY + class Foo + ## + # this is my method + add_my_method :foo + end + RUBY + foo = @top_level.classes.first.method_list.first + assert_equal 'foo', foo.name + assert_equal 'this is my method', foo.comment.text + assert_equal Encoding::CP852, foo.comment.text.encoding + end + + def test_read_directive_linear_performance + assert_linear_performance((1..5).map{|i|10**i}) do |i| + util_parser '# ' + '0'*i + '=000:' + "\n def f; end" + end + end + + + def test_markup_first_comment + util_parser <<~RUBY + # :markup: rd + + # ((*awesome*)) + class C + # ((*radical*)) + def m + end + end + RUBY + + c = @top_level.classes.first + assert_equal 'rd', c.comment.format + assert_equal 'rd', c.method_list.first.comment.format + end + + def test_markup_override + util_parser <<~RUBY + # *awesome* + class C + # :markup: rd + # ((*radical*)) + def m1; end + + # *awesome* + def m2; end + end + RUBY + + c = @top_level.classes.first + + assert_equal 'rdoc', c.comment.format + + assert_equal ['rd', 'rdoc'], c.method_list.map { |m| m.comment.format } + end + + def test_tomdoc_meta + util_parser <<~RUBY + # :markup: tomdoc + + class C + + # Signature + # + # find_by_[_and_...](args) + # + # field - A field name. + + end + RUBY + + c = @top_level.classes.first + + m = c.method_list.first + + assert_equal "find_by_[_and_...]", m.name + assert_equal "find_by_[_and_...](args)\n", m.call_seq + + expected = + doc( + head(3, 'Signature'), + list(:NOTE, + item(%w[field], + para('A field name.')))) + expected.file = @top_level + + assert_equal expected, m.comment.parse + end +end + +class TestRDocParserPrismRuby < RDoc::TestCase + include RDocParserPrismTestCases + + def accept_legacy_bug? + false + end + + def util_parser(content) + @parser = RDoc::Parser::PrismRuby.new @top_level, @filename, content, @options, @stats + @parser.scan + end +end + +# Run the same test with the original RDoc::Parser::Ruby +class TestRDocParserRubyWithPrismRubyTestCases < RDoc::TestCase + include RDocParserPrismTestCases + + def accept_legacy_bug? + true + end + + def util_parser(content) + @parser = RDoc::Parser::Ruby.new @top_level, @filename, content, @options, @stats + @parser.scan + end +end unless ENV['RDOC_USE_PRISM_PARSER'] diff --git a/test/rdoc/test_rdoc_parser_ruby.rb b/test/rdoc/test_rdoc_parser_ruby.rb index 3e2a85ffba..cf02a035a6 100644 --- a/test/rdoc/test_rdoc_parser_ruby.rb +++ b/test/rdoc/test_rdoc_parser_ruby.rb @@ -2,6 +2,8 @@ require_relative 'helper' +return if ENV['RDOC_USE_PRISM_PARSER'] + class TestRDocParserRuby < RDoc::TestCase def setup From 021d547ec4f196cb64e24cc507ff25e113ac4ed3 Mon Sep 17 00:00:00 2001 From: tompng Date: Mon, 29 Jul 2024 02:00:37 +0900 Subject: [PATCH 03/14] unknown meta method --- lib/rdoc/parser/prism_ruby.rb | 22 +++++++++++++--------- test/rdoc/test_rdoc_parser_prism_ruby.rb | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/lib/rdoc/parser/prism_ruby.rb b/lib/rdoc/parser/prism_ruby.rb index 0d97a84e24..759971cf94 100644 --- a/lib/rdoc/parser/prism_ruby.rb +++ b/lib/rdoc/parser/prism_ruby.rb @@ -375,11 +375,11 @@ def handle_meta_method_comment(comment, node) '' when 'method' method_name = param - line_no = node ? node.location.start_line : line + line_no = line '' when 'singleton-method' method_name = param - line_no = node ? node.location.start_line : line + line_no = line singleton_method = true visibility = :public '' @@ -399,7 +399,7 @@ def handle_meta_method_comment(comment, node) @container.add_attribute(a) a.visibility = visibility end - else + elsif line_no || node method_name ||= call_node_name_arguments(node).first if is_call_node meth = RDoc::AnyMethod.new(@container, method_name) meth.singleton = @singleton || singleton_method @@ -411,13 +411,17 @@ def handle_meta_method_comment(comment, node) meth.params ||= '()' meth.comment = comment meth.store = @store - meth.line = node ? node.location.start_line : line_no - record_location(meth) if node - meth.start_collecting_tokens - visible_tokens_from_location(node.location).each do |token| - meth.token_stream << token - end + meth.line = node.location.start_line + tokens = visible_tokens_from_location(node.location) + else + meth.line = line_no + tokens = [file_line_comment_token(line_no)] + end + record_location(meth) + meth.start_collecting_tokens + tokens.each do |token| + meth.token_stream << token end @container.add_method(meth) meth.visibility = visibility diff --git a/test/rdoc/test_rdoc_parser_prism_ruby.rb b/test/rdoc/test_rdoc_parser_prism_ruby.rb index 9dc555c6a1..0cd4e84b6f 100644 --- a/test/rdoc/test_rdoc_parser_prism_ruby.rb +++ b/test/rdoc/test_rdoc_parser_prism_ruby.rb @@ -460,6 +460,29 @@ class Foo assert_equal ['a1', 'ar1', 'aw1', 'arw1'], klass.attributes.map(&:name) end + def test_unknown_meta_method + util_parser <<~RUBY + class Foo + ## + # :call-seq: + # two(name) + # + # method or singleton-method directive is missing + end + + class Bar + ## + # unknown meta method + add_my_method("foo" + "bar") + end + RUBY + + foo = @store.find_class_named 'Foo' + bar = @store.find_class_named 'Bar' + assert_equal [], foo.method_list.map(&:name) + assert_equal ['unknown'], bar.method_list.map(&:name) + end + def test_method util_parser <<~RUBY class Foo From c44bd1f66ad65a23469df26d1211869f42e749ef Mon Sep 17 00:00:00 2001 From: tompng Date: Mon, 29 Jul 2024 02:15:57 +0900 Subject: [PATCH 04/14] Use MethodSignatureVisitor only to scan params, block_params and calls_super --- lib/rdoc/parser/prism_ruby.rb | 68 ++++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 29 deletions(-) diff --git a/lib/rdoc/parser/prism_ruby.rb b/lib/rdoc/parser/prism_ruby.rb index 759971cf94..5773efd8a9 100644 --- a/lib/rdoc/parser/prism_ruby.rb +++ b/lib/rdoc/parser/prism_ruby.rb @@ -612,7 +612,7 @@ def add_extends(names, line_no) # :nodoc: # Adds a method defined by `def` syntax - def add_method(name, receiver_name:, receiver_fallback_type:, visibility:, singleton:, signature:, tokens:, start_line:, end_line:) + def add_method(name, receiver_name:, receiver_fallback_type:, visibility:, singleton:, params:, calls_super:, block_params:, tokens:, start_line:, end_line:) receiver = receiver_name ? find_or_create_module_path(receiver_name, receiver_fallback_type) : @container meth = RDoc::AnyMethod.new(nil, name) if (comment = consecutive_comment(start_line)) @@ -643,11 +643,9 @@ def add_method(name, receiver_name:, receiver_fallback_type:, visibility:, singl meth.singleton = singleton receiver.add_method(meth) # should add after setting singleton and before setting visibility meth.visibility = visibility - if signature - meth.params ||= signature.params - meth.calls_super = signature.calls_super - meth.block_params ||= signature.yields.first unless signature.yields.empty? - end + meth.params ||= params + meth.calls_super = calls_super + meth.block_params ||= block_params if block_params record_location(meth) meth.start_collecting_tokens tokens.each do |token| @@ -974,7 +972,7 @@ def visit_def_node(node) return end name = node.name.to_s - signature = MethodSignature.new(node) + params, block_params, calls_super = MethodSignatureVisitor.scan_signature(node) tokens = @scanner.visible_tokens_from_location(node.location) @scanner.add_method( @@ -983,7 +981,9 @@ def visit_def_node(node) receiver_fallback_type: receiver_fallback_type, visibility: visibility, singleton: singleton, - signature: signature, + params: params, + block_params: block_params, + calls_super: calls_super, tokens: tokens, start_line: start_line, end_line: end_line @@ -1054,32 +1054,42 @@ def constant_path_string(node) "#{parent_name}::#{node.name}" if parent_name end end - end - class MethodSignature < Prism::Visitor # :nodoc: - attr_reader :yields, :calls_super, :params - def initialize(def_node) - @yields = [] - @calls_super = false - @params = "(#{def_node.parameters&.slice})" - def_node.body&.accept(self) - end + class MethodSignatureVisitor < Prism::Visitor # :nodoc: + class << self + def scan_signature(def_node) + visitor = new + def_node.body&.accept(visitor) + params = "(#{def_node.parameters&.slice})" + block_params = visitor.yields.first + [params, block_params, visitor.calls_super] + end + end - def visit_def_node(node) - # stop traverse inside nested def - end + attr_reader :params, :yields, :calls_super - def visit_yield_node(node) - yields << (node.arguments&.slice || '') - end + def initialize + @params = nil + @calls_super = false + @yields = [] + end - def visit_super_node(node) - @calls_super = true - super - end + def visit_def_node(node) + # stop traverse inside nested def + end + + def visit_yield_node(node) + @yields << (node.arguments&.slice || '') + end - def visit_forwarding_super_node(node) - @calls_super = true + def visit_super_node(node) + @calls_super = true + super + end + + def visit_forwarding_super_node(node) + @calls_super = true + end end end end From 83b0fdefae3e292c76f26f0f7b434a2e8dee6bcc Mon Sep 17 00:00:00 2001 From: tompng Date: Mon, 29 Jul 2024 02:17:39 +0900 Subject: [PATCH 05/14] Add calls_super test --- test/rdoc/test_rdoc_parser_prism_ruby.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/rdoc/test_rdoc_parser_prism_ruby.rb b/test/rdoc/test_rdoc_parser_prism_ruby.rb index 0cd4e84b6f..b26cc60b7c 100644 --- a/test/rdoc/test_rdoc_parser_prism_ruby.rb +++ b/test/rdoc/test_rdoc_parser_prism_ruby.rb @@ -680,6 +680,22 @@ def f6; end # :yields: assert_equal expected, methods.map(&:arglists) end + def test_calls_super + util_parser <<~RUBY + class A + def m1; foo; bar; end + def m2; if cond; super(a); end; end # SuperNode + def m3; tap do; super; end; end # ForwardingSuperNode + def m4; def a.b; super; end; end # super inside another method + end + RUBY + + klass = @store.find_class_named 'A' + methods = klass.method_list + assert_equal ['m1', 'm2', 'm3', 'm4'], methods.map(&:name) + assert_equal [false, true, true, false], methods.map(&:calls_super) + end + def test_method_args_directive util_parser <<~RUBY class Foo From aca20e134c2e072e663c10aef6bd8cbe09fb3481 Mon Sep 17 00:00:00 2001 From: tompng Date: Mon, 29 Jul 2024 02:20:55 +0900 Subject: [PATCH 06/14] Drop ruby 2.6. Prism requires ruby >= 2.7 --- rdoc.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rdoc.gemspec b/rdoc.gemspec index 9f6775a187..e3ceea9e0a 100644 --- a/rdoc.gemspec +++ b/rdoc.gemspec @@ -230,7 +230,7 @@ RDoc includes the +rdoc+ and +ri+ tools for generating and displaying documentat s.rdoc_options = ["--main", "README.rdoc"] s.extra_rdoc_files += s.files.grep(%r[\A[^\/]+\.(?:rdoc|md)\z]) - s.required_ruby_version = Gem::Requirement.new(">= 2.6.0") + s.required_ruby_version = Gem::Requirement.new(">= 2.7.0") s.required_rubygems_version = Gem::Requirement.new(">= 2.2") s.add_dependency 'prism', '>= 0.30.0' From ec8ba9e0dde71c47776ae9b01db727aa89bb9673 Mon Sep 17 00:00:00 2001 From: tompng Date: Mon, 29 Jul 2024 02:54:48 +0900 Subject: [PATCH 07/14] Remove duplicated documentation comment from prism_ruby.rb --- lib/rdoc/parser/prism_ruby.rb | 134 ++-------------------------------- 1 file changed, 7 insertions(+), 127 deletions(-) diff --git a/lib/rdoc/parser/prism_ruby.rb b/lib/rdoc/parser/prism_ruby.rb index 5773efd8a9..6e222743c6 100644 --- a/lib/rdoc/parser/prism_ruby.rb +++ b/lib/rdoc/parser/prism_ruby.rb @@ -3,133 +3,13 @@ require 'prism' require_relative 'ripper_state_lex' -## -# Extracts code elements from a source file returning a TopLevel object -# containing the constituent file elements. -# -# RubyParser understands how to document: -# * classes -# * modules -# * methods -# * constants -# * aliases -# * private, public, protected -# * private_class_function, public_class_function -# * private_constant, public_constant -# * module_function -# * attr, attr_reader, attr_writer, attr_accessor -# * extra accessors given on the command line -# * metaprogrammed methods -# * require -# * include -# -# == Method Arguments -# -#-- -# NOTE: I don't think this works, needs tests, remove the paragraph following -# this block when known to work -# -# The parser extracts the arguments from the method definition. You can -# override this with a custom argument definition using the :args: directive: -# -# ## -# # This method tries over and over until it is tired -# -# def go_go_go(thing_to_try, tries = 10) # :args: thing_to_try -# puts thing_to_try -# go_go_go thing_to_try, tries - 1 -# end -# -# If you have a more-complex set of overrides you can use the :call-seq: -# directive: -#++ -# -# The parser extracts the arguments from the method definition. You can -# override this with a custom argument definition using the :call-seq: -# directive: -# -# ## -# # This method can be called with a range or an offset and length -# # -# # :call-seq: -# # my_method(Range) -# # my_method(offset, length) -# -# def my_method(*args) -# end -# -# The parser extracts +yield+ expressions from method bodies to gather the -# yielded argument names. If your method manually calls a block instead of -# yielding or you want to override the discovered argument names use -# the :yields: directive: -# -# ## -# # My method is awesome -# -# def my_method(&block) # :yields: happy, times -# block.call 1, 2 -# end -# -# == Metaprogrammed Methods -# -# To pick up a metaprogrammed method, the parser looks for a comment starting -# with '##' before an identifier: -# -# ## -# # This is a meta-programmed method! -# -# add_my_method :meta_method, :arg1, :arg2 -# -# The parser looks at the token after the identifier to determine the name, in -# this example, :meta_method. If a name cannot be found, a warning is printed -# and 'unknown is used. -# -# You can force the name of a method using the :method: directive: -# -# ## -# # :method: some_method! -# -# By default, meta-methods are instance methods. To indicate that a method is -# a singleton method instead use the :singleton-method: directive: -# -# ## -# # :singleton-method: -# -# You can also use the :singleton-method: directive with a name: -# -# ## -# # :singleton-method: some_method! -# -# You can define arguments for metaprogrammed methods via either the -# :call-seq:, :arg: or :args: directives. -# -# Additionally you can mark a method as an attribute by -# using :attr:, :attr_reader:, :attr_writer: or :attr_accessor:. Just like -# for :method:, the name is optional. -# -# ## -# # :attr_reader: my_attr_name -# -# == Hidden methods and attributes -# -# You can provide documentation for methods that don't appear using -# the :method:, :singleton-method: and :attr: directives: -# -# ## -# # :attr_writer: ghost_writer -# # There is an attribute here, but you can't see it! -# -# ## -# # :method: ghost_method -# # There is a method here, but you can't see it! -# -# ## -# # this is a comment for a regular method -# -# def regular_method() end -# -# Note that by default, the :method: directive will be ignored if there is a -# standard rdocable item following it. +# Unlike lib/rdoc/parser/ruby.rb, this file is not based on rtags and does not contain code from +# rtags.rb - +# ruby-lex.rb - ruby lexcal analyzer +# ruby-token.rb - ruby tokens + +# Parse and collect document from Ruby source code. +# RDoc::Parser::PrismRuby is compatible with RDoc::Parser::Ruby and aims to replace it. class RDoc::Parser::PrismRuby < RDoc::Parser From 65317242a1089171047c03d4b6ef4b2a30a1e432 Mon Sep 17 00:00:00 2001 From: tompng Date: Mon, 29 Jul 2024 03:34:21 +0900 Subject: [PATCH 08/14] Add test for wrong argument passed to metaprogramming method --- test/rdoc/test_rdoc_parser_prism_ruby.rb | 56 ++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/test/rdoc/test_rdoc_parser_prism_ruby.rb b/test/rdoc/test_rdoc_parser_prism_ruby.rb index b26cc60b7c..63029172e8 100644 --- a/test/rdoc/test_rdoc_parser_prism_ruby.rb +++ b/test/rdoc/test_rdoc_parser_prism_ruby.rb @@ -990,6 +990,18 @@ def m5; end assert_equal ['m2', 'm4', 'm5'], public_singleton_methods.map(&:name) end + def test_undocumentable_change_visibility + pend if accept_legacy_bug? + util_parser <<~RUBY + class A + def m1; end + private 42, :m1 # maybe not Module#private + end + RUBY + klass = @store.find_class_named 'A' + assert_equal [:public], klass.method_list.map(&:visibility) + end + def test_method_visibility_change_in_subclass pend 'not implemented' if accept_legacy_bug? util_parser <<~RUBY @@ -1101,6 +1113,20 @@ def bar; end end end + def test_invalid_alias_method + pend if accept_legacy_bug? + util_parser <<~RUBY + class Foo + def foo; end + alias_method + alias_method :foo + alias_method :foo, :bar, :baz + alias_method 42, :foo + end + RUBY + assert_equal ['foo'], @top_level.classes.first.method_list.map(&:name) + end + def test_alias_method_stopdoc_nodoc util_parser <<~RUBY class Foo @@ -1163,6 +1189,18 @@ class Foo assert_equal [@top_level] * 8, [a1, a2, r1, r2, w1, w2, rw1, rw2].map(&:file) end + def test_undocumentable_attributes + util_parser <<~RUBY + class Foo + attr + attr 42, :foo + end + RUBY + klass = @store.find_class_named 'Foo' + assert_empty klass.method_list + assert_empty klass.attributes + end + def test_singleton_class_attributes util_parser <<~RUBY class Foo @@ -1427,6 +1465,8 @@ class C A = 1 B = 2 C = 3 + private_constant + private_constant foo private_constant :A private_constant :B, :C public_constant :B @@ -1579,6 +1619,22 @@ class C::D::Foo assert_equal 'A::B::M', klass.includes.first.module.full_name end + def test_various_argument_include + pend 'not implemented' if accept_legacy_bug? + util_parser <<~RUBY + module A; end + module B; end + module C; end + class A + include + include A, B + include 42, C # Maybe not Module#include + end + RUBY + klass = @top_level.classes.first + assert_equal ['A', 'B'], klass.includes.map(&:name) + end + def test_require util_parser <<~RUBY require From f8a7c8fdebf34d70e7cbfbed02fe7e7e7e2c794a Mon Sep 17 00:00:00 2001 From: tompng Date: Mon, 29 Jul 2024 20:04:43 +0900 Subject: [PATCH 09/14] Rename visit_call_[DSL_METHOD_NAME] to make it distinguishable from visit_[NODE_TYPE]_node --- lib/rdoc/parser/prism_ruby.rb | 52 +++++++++++++++++------------------ 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/lib/rdoc/parser/prism_ruby.rb b/lib/rdoc/parser/prism_ruby.rb index 6e222743c6..ed1b3de41a 100644 --- a/lib/rdoc/parser/prism_ruby.rb +++ b/lib/rdoc/parser/prism_ruby.rb @@ -655,22 +655,22 @@ def add_module_or_class(module_name, start_line, end_line, is_class: false, supe class RDocVisitor < Prism::Visitor # :nodoc: DSL = { - attr: -> (v, call_node) { v.visit_call_attr_reader_writer_accessor(call_node, 'R') }, - attr_reader: -> (v, call_node) { v.visit_call_attr_reader_writer_accessor(call_node, 'R') }, - attr_writer: -> (v, call_node) { v.visit_call_attr_reader_writer_accessor(call_node, 'W') }, - attr_accessor: -> (v, call_node) { v.visit_call_attr_reader_writer_accessor(call_node, 'RW') }, - include: -> (v, call_node) { v.visit_call_include(call_node) }, - extend: -> (v, call_node) { v.visit_call_extend(call_node) }, - public: -> (v, call_node, &block) { v.visit_call_public_private_protected(call_node, :public, &block) }, - private: -> (v, call_node, &block) { v.visit_call_public_private_protected(call_node, :private, &block) }, - protected: -> (v, call_node, &block) { v.visit_call_public_private_protected(call_node, :protected, &block) }, - private_constant: -> (v, call_node) { v.visit_call_private_constant(call_node) }, - public_constant: -> (v, call_node) { v.visit_call_public_constant(call_node) }, - require: -> (v, call_node) { v.visit_call_require(call_node) }, - alias_method: -> (v, call_node) { v.visit_call_alias_method(call_node) }, - module_function: -> (v, call_node, &block) { v.visit_call_module_function(call_node, &block) }, - public_class_method: -> (v, call_node, &block) { v.visit_call_public_private_class_method(call_node, :public, &block) }, - private_class_method: -> (v, call_node, &block) { v.visit_call_public_private_class_method(call_node, :private, &block) }, + attr: -> (v, call_node) { v._visit_call_attr_reader_writer_accessor(call_node, 'R') }, + attr_reader: -> (v, call_node) { v._visit_call_attr_reader_writer_accessor(call_node, 'R') }, + attr_writer: -> (v, call_node) { v._visit_call_attr_reader_writer_accessor(call_node, 'W') }, + attr_accessor: -> (v, call_node) { v._visit_call_attr_reader_writer_accessor(call_node, 'RW') }, + include: -> (v, call_node) { v._visit_call_include(call_node) }, + extend: -> (v, call_node) { v._visit_call_extend(call_node) }, + public: -> (v, call_node, &block) { v._visit_call_public_private_protected(call_node, :public, &block) }, + private: -> (v, call_node, &block) { v._visit_call_public_private_protected(call_node, :private, &block) }, + protected: -> (v, call_node, &block) { v._visit_call_public_private_protected(call_node, :protected, &block) }, + private_constant: -> (v, call_node) { v._visit_call_private_constant(call_node) }, + public_constant: -> (v, call_node) { v._visit_call_public_constant(call_node) }, + require: -> (v, call_node) { v._visit_call_require(call_node) }, + alias_method: -> (v, call_node) { v._visit_call_alias_method(call_node) }, + module_function: -> (v, call_node, &block) { v._visit_call_module_function(call_node, &block) }, + public_class_method: -> (v, call_node, &block) { v._visit_call_public_private_class_method(call_node, :public, &block) }, + private_class_method: -> (v, call_node, &block) { v._visit_call_public_private_class_method(call_node, :private, &block) }, } def initialize(scanner, top_level, store) @@ -688,28 +688,28 @@ def visit_call_node(node) end end - def visit_call_require(call_node) + def _visit_call_require(call_node) return unless call_node.arguments&.arguments&.size == 1 arg = call_node.arguments.arguments.first return unless arg.is_a?(Prism::StringNode) @scanner.container.add_require(RDoc::Require.new(arg.unescaped, nil)) end - def visit_call_module_function(call_node) + def _visit_call_module_function(call_node) yield return if @scanner.singleton names = visibility_method_arguments(call_node, singleton: false)&.map(&:to_s) @scanner.change_method_to_module_function(names) if names end - def visit_call_public_private_class_method(call_node, visibility) + def _visit_call_public_private_class_method(call_node, visibility) yield return if @scanner.singleton names = visibility_method_arguments(call_node, singleton: true) @scanner.change_method_visibility(names, visibility, singleton: true) if names end - def visit_call_public_private_protected(call_node, visibility) + def _visit_call_public_private_protected(call_node, visibility) arguments_node = call_node.arguments if arguments_node.nil? # `public` `private` @scanner.visibility = visibility @@ -720,13 +720,13 @@ def visit_call_public_private_protected(call_node, visibility) end end - def visit_call_alias_method(call_node) + def _visit_call_alias_method(call_node) new_name, old_name, *rest = symbol_arguments(call_node) return unless old_name && new_name && rest.empty? @scanner.add_alias_method(old_name.to_s, new_name.to_s, call_node.location.start_line) end - def visit_call_include(call_node) + def _visit_call_include(call_node) names = constant_arguments_names(call_node) line_no = call_node.location.start_line return unless names @@ -738,24 +738,24 @@ def visit_call_include(call_node) end end - def visit_call_extend(call_node) + def _visit_call_extend(call_node) names = constant_arguments_names(call_node) @scanner.add_extends(names, call_node.location.start_line) if names && !@scanner.singleton end - def visit_call_public_constant(call_node) + def _visit_call_public_constant(call_node) return if @scanner.singleton names = symbol_arguments(call_node) @scanner.container.set_constant_visibility_for(names.map(&:to_s), :public) if names end - def visit_call_private_constant(call_node) + def _visit_call_private_constant(call_node) return if @scanner.singleton names = symbol_arguments(call_node) @scanner.container.set_constant_visibility_for(names.map(&:to_s), :private) if names end - def visit_call_attr_reader_writer_accessor(call_node, rw) + def _visit_call_attr_reader_writer_accessor(call_node, rw) names = symbol_arguments(call_node) @scanner.add_attributes(names.map(&:to_s), rw, call_node.location.start_line) if names end From c6341ecc1f93c0a2853d441cf7fc02a5865be266 Mon Sep 17 00:00:00 2001 From: tompng Date: Mon, 29 Jul 2024 20:09:13 +0900 Subject: [PATCH 10/14] Method receiver switch of true/false/nil to a case statement --- lib/rdoc/parser/prism_ruby.rb | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/rdoc/parser/prism_ruby.rb b/lib/rdoc/parser/prism_ruby.rb index ed1b3de41a..4a92089abb 100644 --- a/lib/rdoc/parser/prism_ruby.rb +++ b/lib/rdoc/parser/prism_ruby.rb @@ -831,7 +831,15 @@ def visit_def_node(node) when Prism::NilNode, Prism::TrueNode, Prism::FalseNode visibility = :public singleton = false - receiver_name = { Prism::NilNode => 'NilClass', Prism::TrueNode => 'TrueClass', Prism::FalseNode => 'FalseClass' }[node.receiver.class] + receiver_name = + case node.receiver + when Prism::NilNode + 'NilClass' + when Prism::TrueNode + 'TrueClass' + when Prism::FalseNode + 'FalseClass' + end receiver_fallback_type = :class when Prism::SelfNode # singleton method of a singleton class is not documentable From 00e164d9acda1ab9946d5e9eb195a1660988cdd4 Mon Sep 17 00:00:00 2001 From: tompng Date: Tue, 30 Jul 2024 02:48:38 +0900 Subject: [PATCH 11/14] Extract common part of add_method(by def keyword) and add meta_comment method --- lib/rdoc/parser/prism_ruby.rb | 47 +++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/lib/rdoc/parser/prism_ruby.rb b/lib/rdoc/parser/prism_ruby.rb index 4a92089abb..c6564191ad 100644 --- a/lib/rdoc/parser/prism_ruby.rb +++ b/lib/rdoc/parser/prism_ruby.rb @@ -286,25 +286,24 @@ def handle_meta_method_comment(comment, node) handle_consecutive_comment_directive(meth, comment) comment.normalize comment.extract_call_seq(meth) - meth.name ||= meth.call_seq[/\A[^()\s]+/] if meth.call_seq - meth.name ||= 'unknown' - meth.params ||= '()' meth.comment = comment - meth.store = @store if node - meth.line = node.location.start_line tokens = visible_tokens_from_location(node.location) + line_no = node.location.start_line else - meth.line = line_no tokens = [file_line_comment_token(line_no)] end - record_location(meth) - meth.start_collecting_tokens - tokens.each do |token| - meth.token_stream << token - end - @container.add_method(meth) - meth.visibility = visibility + internal_add_method( + @container, + meth, + line_no: line_no, + visibility: visibility, + singleton: @singleton || singleton_method, + params: '()', + calls_super: false, + block_params: nil, + tokens: tokens + ) end end @@ -507,8 +506,6 @@ def add_method(name, receiver_name:, receiver_fallback_type:, visibility:, singl handle_modifier_directive(meth, end_line) return unless should_document?(meth) - meth.store = @store - meth.line = start_line if meth.name == 'initialize' && !singleton if meth.dont_rename_initialize @@ -520,8 +517,26 @@ def add_method(name, receiver_name:, receiver_fallback_type:, visibility:, singl end end + internal_add_method( + receiver, + meth, + line_no: start_line, + visibility: visibility, + singleton: singleton, + params: params, + calls_super: calls_super, + block_params: block_params, + tokens: tokens + ) + end + + private def internal_add_method(container, meth, line_no:, visibility:, singleton:, params:, calls_super:, block_params:, tokens:) # :nodoc: + meth.name ||= meth.call_seq[/\A[^()\s]+/] if meth.call_seq + meth.name ||= 'unknown' + meth.store = @store + meth.line = line_no meth.singleton = singleton - receiver.add_method(meth) # should add after setting singleton and before setting visibility + container.add_method(meth) # should add after setting singleton and before setting visibility meth.visibility = visibility meth.params ||= params meth.calls_super = calls_super From 24ce586119c2facebfa33adf33d4f5b7390f08f5 Mon Sep 17 00:00:00 2001 From: tompng Date: Tue, 30 Jul 2024 02:54:26 +0900 Subject: [PATCH 12/14] Reuse consecutive comments array when collecting comments --- lib/rdoc/parser/prism_ruby.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/rdoc/parser/prism_ruby.rb b/lib/rdoc/parser/prism_ruby.rb index c6564191ad..5ea596b444 100644 --- a/lib/rdoc/parser/prism_ruby.rb +++ b/lib/rdoc/parser/prism_ruby.rb @@ -144,6 +144,7 @@ def prepare_comments(comments) consecutive_comments << (current = [comment]) end end + consecutive_comments.reject!(&:empty?) # Example: line_no = 5, start_line = 2, comment_text = "# comment_start_line\n# comment\n" # 1| class A @@ -152,7 +153,7 @@ def prepare_comments(comments) # 4| # 5| def f; end # comment linked to this line # 6| end - @unprocessed_comments = consecutive_comments.reject(&:empty?).map do |comments| + @unprocessed_comments = consecutive_comments.map! do |comments| start_line = comments.first.location.start_line line_no = comments.last.location.end_line + (comments.last.location.end_column == 0 ? 0 : 1) texts = comments.map do |c| From 0a8634c852111e118e32060b9d5dfafdc77b329c Mon Sep 17 00:00:00 2001 From: tompng Date: Wed, 31 Jul 2024 13:02:05 +0900 Subject: [PATCH 13/14] Simplify DSL call_node handling --- lib/rdoc/parser/prism_ruby.rb | 201 ++++++++++++----------- test/rdoc/test_rdoc_parser_prism_ruby.rb | 26 ++- 2 files changed, 127 insertions(+), 100 deletions(-) diff --git a/lib/rdoc/parser/prism_ruby.rb b/lib/rdoc/parser/prism_ruby.rb index 5ea596b444..361fd2e6c1 100644 --- a/lib/rdoc/parser/prism_ruby.rb +++ b/lib/rdoc/parser/prism_ruby.rb @@ -670,25 +670,6 @@ def add_module_or_class(module_name, start_line, end_line, is_class: false, supe end class RDocVisitor < Prism::Visitor # :nodoc: - DSL = { - attr: -> (v, call_node) { v._visit_call_attr_reader_writer_accessor(call_node, 'R') }, - attr_reader: -> (v, call_node) { v._visit_call_attr_reader_writer_accessor(call_node, 'R') }, - attr_writer: -> (v, call_node) { v._visit_call_attr_reader_writer_accessor(call_node, 'W') }, - attr_accessor: -> (v, call_node) { v._visit_call_attr_reader_writer_accessor(call_node, 'RW') }, - include: -> (v, call_node) { v._visit_call_include(call_node) }, - extend: -> (v, call_node) { v._visit_call_extend(call_node) }, - public: -> (v, call_node, &block) { v._visit_call_public_private_protected(call_node, :public, &block) }, - private: -> (v, call_node, &block) { v._visit_call_public_private_protected(call_node, :private, &block) }, - protected: -> (v, call_node, &block) { v._visit_call_public_private_protected(call_node, :protected, &block) }, - private_constant: -> (v, call_node) { v._visit_call_private_constant(call_node) }, - public_constant: -> (v, call_node) { v._visit_call_public_constant(call_node) }, - require: -> (v, call_node) { v._visit_call_require(call_node) }, - alias_method: -> (v, call_node) { v._visit_call_alias_method(call_node) }, - module_function: -> (v, call_node, &block) { v._visit_call_module_function(call_node, &block) }, - public_class_method: -> (v, call_node, &block) { v._visit_call_public_private_class_method(call_node, :public, &block) }, - private_class_method: -> (v, call_node, &block) { v._visit_call_public_private_class_method(call_node, :private, &block) }, - } - def initialize(scanner, top_level, store) @scanner = scanner @top_level = top_level @@ -697,85 +678,48 @@ def initialize(scanner, top_level, store) def visit_call_node(node) @scanner.process_comments_until(node.location.start_line - 1) - if node.receiver.nil? && (dsl_proc = DSL[node.name]) - dsl_proc.call(self, node) { super } + if node.receiver.nil? + case node.name + when :attr + _visit_call_attr_reader_writer_accessor(node, 'R') + when :attr_reader + _visit_call_attr_reader_writer_accessor(node, 'R') + when :attr_writer + _visit_call_attr_reader_writer_accessor(node, 'W') + when :attr_accessor + _visit_call_attr_reader_writer_accessor(node, 'RW') + when :include + _visit_call_include(node) + when :extend + _visit_call_extend(node) + when :public + _visit_call_public_private_protected(node, :public) { super } + when :private + _visit_call_public_private_protected(node, :private) { super } + when :protected + _visit_call_public_private_protected(node, :protected) { super } + when :private_constant + _visit_call_private_constant(node) + when :public_constant + _visit_call_public_constant(node) + when :require + _visit_call_require(node) + when :alias_method + _visit_call_alias_method(node) + when :module_function + _visit_call_module_function(node) { super } + when :public_class_method + _visit_call_public_private_class_method(node, :public) { super } + when :private_class_method + _visit_call_public_private_class_method(node, :private) { super } + else + super + end else super end end - def _visit_call_require(call_node) - return unless call_node.arguments&.arguments&.size == 1 - arg = call_node.arguments.arguments.first - return unless arg.is_a?(Prism::StringNode) - @scanner.container.add_require(RDoc::Require.new(arg.unescaped, nil)) - end - - def _visit_call_module_function(call_node) - yield - return if @scanner.singleton - names = visibility_method_arguments(call_node, singleton: false)&.map(&:to_s) - @scanner.change_method_to_module_function(names) if names - end - - def _visit_call_public_private_class_method(call_node, visibility) - yield - return if @scanner.singleton - names = visibility_method_arguments(call_node, singleton: true) - @scanner.change_method_visibility(names, visibility, singleton: true) if names - end - - def _visit_call_public_private_protected(call_node, visibility) - arguments_node = call_node.arguments - if arguments_node.nil? # `public` `private` - @scanner.visibility = visibility - else # `public :foo, :bar`, `private def foo; end` - yield - names = visibility_method_arguments(call_node, singleton: @scanner.singleton) - @scanner.change_method_visibility(names, visibility) if names - end - end - - def _visit_call_alias_method(call_node) - new_name, old_name, *rest = symbol_arguments(call_node) - return unless old_name && new_name && rest.empty? - @scanner.add_alias_method(old_name.to_s, new_name.to_s, call_node.location.start_line) - end - - def _visit_call_include(call_node) - names = constant_arguments_names(call_node) - line_no = call_node.location.start_line - return unless names - - if @scanner.singleton - @scanner.add_extends(names, line_no) - else - @scanner.add_includes(names, line_no) - end - end - - def _visit_call_extend(call_node) - names = constant_arguments_names(call_node) - @scanner.add_extends(names, call_node.location.start_line) if names && !@scanner.singleton - end - - def _visit_call_public_constant(call_node) - return if @scanner.singleton - names = symbol_arguments(call_node) - @scanner.container.set_constant_visibility_for(names.map(&:to_s), :public) if names - end - - def _visit_call_private_constant(call_node) - return if @scanner.singleton - names = symbol_arguments(call_node) - @scanner.container.set_constant_visibility_for(names.map(&:to_s), :private) if names - end - - def _visit_call_attr_reader_writer_accessor(call_node, rw) - names = symbol_arguments(call_node) - @scanner.add_attributes(names.map(&:to_s), rw, call_node.location.start_line) if names - end - def visit_alias_method_node(node) @scanner.process_comments_until(node.location.start_line - 1) return unless node.old_name.is_a?(Prism::SymbolNode) && node.new_name.is_a?(Prism::SymbolNode) @@ -959,6 +903,77 @@ def constant_path_string(node) end end + def _visit_call_require(call_node) + return unless call_node.arguments&.arguments&.size == 1 + arg = call_node.arguments.arguments.first + return unless arg.is_a?(Prism::StringNode) + @scanner.container.add_require(RDoc::Require.new(arg.unescaped, nil)) + end + + def _visit_call_module_function(call_node) + yield + return if @scanner.singleton + names = visibility_method_arguments(call_node, singleton: false)&.map(&:to_s) + @scanner.change_method_to_module_function(names) if names + end + + def _visit_call_public_private_class_method(call_node, visibility) + yield + return if @scanner.singleton + names = visibility_method_arguments(call_node, singleton: true) + @scanner.change_method_visibility(names, visibility, singleton: true) if names + end + + def _visit_call_public_private_protected(call_node, visibility) + arguments_node = call_node.arguments + if arguments_node.nil? # `public` `private` + @scanner.visibility = visibility + else # `public :foo, :bar`, `private def foo; end` + yield + names = visibility_method_arguments(call_node, singleton: @scanner.singleton) + @scanner.change_method_visibility(names, visibility) if names + end + end + + def _visit_call_alias_method(call_node) + new_name, old_name, *rest = symbol_arguments(call_node) + return unless old_name && new_name && rest.empty? + @scanner.add_alias_method(old_name.to_s, new_name.to_s, call_node.location.start_line) + end + + def _visit_call_include(call_node) + names = constant_arguments_names(call_node) + line_no = call_node.location.start_line + return unless names + + if @scanner.singleton + @scanner.add_extends(names, line_no) + else + @scanner.add_includes(names, line_no) + end + end + + def _visit_call_extend(call_node) + names = constant_arguments_names(call_node) + @scanner.add_extends(names, call_node.location.start_line) if names && !@scanner.singleton + end + + def _visit_call_public_constant(call_node) + return if @scanner.singleton + names = symbol_arguments(call_node) + @scanner.container.set_constant_visibility_for(names.map(&:to_s), :public) if names + end + + def _visit_call_private_constant(call_node) + return if @scanner.singleton + names = symbol_arguments(call_node) + @scanner.container.set_constant_visibility_for(names.map(&:to_s), :private) if names + end + + def _visit_call_attr_reader_writer_accessor(call_node, rw) + names = symbol_arguments(call_node) + @scanner.add_attributes(names.map(&:to_s), rw, call_node.location.start_line) if names + end class MethodSignatureVisitor < Prism::Visitor # :nodoc: class << self def scan_signature(def_node) diff --git a/test/rdoc/test_rdoc_parser_prism_ruby.rb b/test/rdoc/test_rdoc_parser_prism_ruby.rb index 63029172e8..82e910f28c 100644 --- a/test/rdoc/test_rdoc_parser_prism_ruby.rb +++ b/test/rdoc/test_rdoc_parser_prism_ruby.rb @@ -615,22 +615,34 @@ module A included do ## # :singleton-method: - # Hello + # comment foo mattr_accessor :foo ## # :method: bar - # World + # comment bar add_my_method :bar end + + tap do + # comment baz1 + def baz1; end + end + + self.tap do + # comment baz2 + def baz2; end + end + + my_decorator def self.baz3; end + + self.my_decorator def baz4; end end RUBY mod = @store.find_module_named 'A' - foo, bar = mod.method_list - assert_equal 'A::foo', foo.full_name - assert_equal 'A#bar', bar.full_name - assert_equal 'Hello', foo.comment.text.strip - assert_equal 'World', bar.comment.text.strip + methods = mod.method_list + assert_equal ['A::foo', 'A#bar', 'A#baz1', 'A#baz2', 'A::baz3', 'A#baz4'], methods.map(&:full_name) + assert_equal ['comment foo', 'comment bar', 'comment baz1', 'comment baz2'], methods.take(4).map { |m| m.comment.text.strip } end def test_method_yields_directive From 1ce26349c053bee474332b9577b556373d7dfd38 Mon Sep 17 00:00:00 2001 From: tompng Date: Wed, 31 Jul 2024 13:37:09 +0900 Subject: [PATCH 14/14] Refactor extracting method visibility arguments --- lib/rdoc/parser/prism_ruby.rb | 24 ++++++++++++++++++------ test/rdoc/test_rdoc_parser_prism_ruby.rb | 10 ++++++++-- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/lib/rdoc/parser/prism_ruby.rb b/lib/rdoc/parser/prism_ruby.rb index 361fd2e6c1..05e98ad6c4 100644 --- a/lib/rdoc/parser/prism_ruby.rb +++ b/lib/rdoc/parser/prism_ruby.rb @@ -885,12 +885,24 @@ def visibility_method_arguments(call_node, singleton:) arguments_node = call_node.arguments return unless arguments_node symbols = symbol_arguments(call_node) - return symbols.map(&:to_s) if symbols # module_function :foo, :bar - return unless arguments_node.arguments.size == 1 - arg = arguments_node.arguments.first - return unless arg.is_a?(Prism::DefNode) - # `module_function def foo; end` or `private_class_method def self.foo; end` - [arg.name.to_s] if singleton ? arg.receiver.is_a?(Prism::SelfNode) : arg.receiver.nil? + if symbols + # module_function :foo, :bar + return symbols.map(&:to_s) + else + return unless arguments_node.arguments.size == 1 + arg = arguments_node.arguments.first + return unless arg.is_a?(Prism::DefNode) + + if singleton + # `private_class_method def foo; end` `private_class_method def not_self.foo; end` should be ignored + return unless arg.receiver.is_a?(Prism::SelfNode) + else + # `module_function def something.foo` should be ignored + return if arg.receiver + end + # `module_function def foo; end` or `private_class_method def self.foo; end` + [arg.name.to_s] + end end def constant_path_string(node) diff --git a/test/rdoc/test_rdoc_parser_prism_ruby.rb b/test/rdoc/test_rdoc_parser_prism_ruby.rb index 82e910f28c..2ff11bb1a7 100644 --- a/test/rdoc/test_rdoc_parser_prism_ruby.rb +++ b/test/rdoc/test_rdoc_parser_prism_ruby.rb @@ -1007,11 +1007,17 @@ def test_undocumentable_change_visibility util_parser <<~RUBY class A def m1; end - private 42, :m1 # maybe not Module#private + def self.m2; end + private 42, :m # maybe not Module#private + # ignore all non-standard `private def` and `private_class_method def` + private def self.m1; end + private_class_method def m2; end + private def to_s.m1; end + private_class_method def to_s.m2; end end RUBY klass = @store.find_class_named 'A' - assert_equal [:public], klass.method_list.map(&:visibility) + assert_equal [:public] * 4, klass.method_list.map(&:visibility) end def test_method_visibility_change_in_subclass