diff --git a/lib/rdoc/code_object.rb b/lib/rdoc/code_object.rb index 388863b06c..4ec259189b 100644 --- a/lib/rdoc/code_object.rb +++ b/lib/rdoc/code_object.rb @@ -1,4 +1,7 @@ # frozen_string_literal: true + +require_relative 'yard' + ## # Base class for the RDoc code tree. # diff --git a/lib/rdoc/code_object/constant.rb b/lib/rdoc/code_object/constant.rb index d5f54edb67..746ffab5c0 100644 --- a/lib/rdoc/code_object/constant.rb +++ b/lib/rdoc/code_object/constant.rb @@ -174,6 +174,20 @@ def store=(store) @file = @store.add_file @file.full_name if @file end + ## + # Process comment as YARD comment + def comment=(comment) + # Process YARD tags while we still have the RDoc::Comment object + if comment.is_a?(RDoc::Comment) + RDoc::YARD.process(comment, self) + end + + # Then set the comment normally + super(comment) + + @comment + end + def to_s # :nodoc: parent_name = parent ? parent.full_name : '(unknown)' if is_alias_for diff --git a/lib/rdoc/code_object/method_attr.rb b/lib/rdoc/code_object/method_attr.rb index 16779fa918..74ba228c08 100644 --- a/lib/rdoc/code_object/method_attr.rb +++ b/lib/rdoc/code_object/method_attr.rb @@ -156,6 +156,20 @@ def store=(store) @file = @store.add_file @file.full_name if @file end + ## + # Process comment as YARD comment + def comment=(comment) + # Process YARD tags while we still have the RDoc::Comment object + if comment.is_a?(RDoc::Comment) + RDoc::YARD.process(comment, self) + end + + # Then set the comment normally + super(comment) + + @comment + end + def find_see # :nodoc: return nil if singleton || is_alias_for diff --git a/lib/rdoc/parser/prism_ruby.rb b/lib/rdoc/parser/prism_ruby.rb index d846ec910f..2a96f1bba0 100644 --- a/lib/rdoc/parser/prism_ruby.rb +++ b/lib/rdoc/parser/prism_ruby.rb @@ -510,13 +510,21 @@ def add_method(name, receiver_name:, receiver_fallback_type:, visibility:, singl receiver = receiver_name ? find_or_create_module_path(receiver_name, receiver_fallback_type) : @container meth = RDoc::AnyMethod.new(nil, name, singleton: singleton) + if (comment = consecutive_comment(start_line)) handle_consecutive_comment_directive(@container, comment) handle_consecutive_comment_directive(meth, comment) comment.normalize meth.call_seq = comment.extract_call_seq + + # Save visibility before comment assignment (where YARD processing happens) + visibility_before = meth.visibility meth.comment = comment + # Check if YARD tags changed the visibility + if meth.visibility != visibility_before + visibility = meth.visibility + end end handle_modifier_directive(meth, start_line) handle_modifier_directive(meth, args_end_line) @@ -527,7 +535,7 @@ def add_method(name, receiver_name:, receiver_fallback_type:, visibility:, singl receiver, meth, line_no: start_line, - visibility: visibility, + visibility: visibility, # Use YARD visibility if set, otherwise use Ruby visibility params: params, calls_super: calls_super, block_params: block_params, diff --git a/lib/rdoc/yard.rb b/lib/rdoc/yard.rb new file mode 100644 index 0000000000..5769be5791 --- /dev/null +++ b/lib/rdoc/yard.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +## +# YARD compatibility module for RDoc +# +# This module provides support for parsing YARD tags in RDoc comments. +# Currently supports: +# - @yield [params] - Describes block parameters +# - @private - Marks method/class as private +# - @api private - Alternative private marking + +class RDoc::YARD + + ## + # Processes YARD tags in the given comment text for a code object + # + # Returns modified comment text with YARD tags processed + def self.process(comment, code_object) + new(comment, code_object).process + end + + attr_reader :comment, :code_object, :text + + def initialize(comment, code_object) + @comment = comment + @code_object = code_object + end + + def process + return comment unless comment && code_object + + # Only process RDoc::Comment objects to avoid modifying plain strings + # that aren't actually documentation comments + return comment unless comment.is_a?(RDoc::Comment) + + # Skip processing if comment is in TomDoc format + return comment if comment.format == 'tomdoc' + + @text = comment.text.dup + + # Process @yield tags + process_yield_tags + + # Process @private tags + process_private_tags + + # Process @api tags + process_api_tags + + # Only update comment.text if we actually modified it + # This preserves the @document for comments created from parsed documents + if text != comment.text + comment.text = text + end + + comment + end + + private + + ## + # Process @yield tags to extract block parameters + def process_yield_tags + return unless code_object.respond_to?(:block_params=) + + # Skip if already has block_params from :yields: directive + return if code_object.block_params && !code_object.block_params.empty? + + # Match @yield [params] description + if text =~ /^\s*#?\s*@yield\s*(?:\[([^\]]*)\])?\s*(.*?)$/ + params = $1 + + if params && !params.empty? + # Clean up the params - remove types if present + clean_params = extract_param_names(params) + code_object.block_params = clean_params unless clean_params.empty? + end + + # Remove the @yield line from comment + text.gsub!(/^\s*#?\s*@yield.*?$\n?/, '') + end + end + + ## + # Process @private tags to set visibility + def process_private_tags + return unless code_object.respond_to?(:visibility=) + + if text =~ /^\s*#?\s*@private\s*$/ + code_object.visibility = :private + + # Remove the @private line from comment + text.gsub!(/^\s*#?\s*@private\s*$\n?/, '') + end + end + + ## + # Process @api tags (specifically @api private) + def process_api_tags + return unless code_object.respond_to?(:visibility=) + + if text =~ /^\s*#?\s*@api\s+private\s*$/ + code_object.visibility = :private + + # Remove the @api private line from comment + text.gsub!(/^\s*#?\s*@api\s+private\s*$\n?/, '') + end + end + + ## + # Extract parameter names from YARD type specification + # e.g., "[String, Integer]" -> "value1, value2" + # e.g., "[item, index]" -> "item, index" + def extract_param_names(params_string) + return '' if params_string.nil? || params_string.empty? + + # If params look like variable names (lowercase start), use them + if params_string =~ /^[a-z_]/ + params_string.split(',').map(&:strip).join(', ') + else + # If they look like types (uppercase start), generate generic names + types = params_string.split(',').map(&:strip) + types.each_with_index.map do |type, i| + if type =~ /^[A-Z]/ + # It's a type, generate a generic param name + "arg#{i + 1}" + else + # It's already a param name + type + end + end.join(', ') + end + end +end diff --git a/test/rdoc/rdoc_yard_test.rb b/test/rdoc/rdoc_yard_test.rb new file mode 100644 index 0000000000..8f302b76e9 --- /dev/null +++ b/test/rdoc/rdoc_yard_test.rb @@ -0,0 +1,527 @@ +# frozen_string_literal: true + +require_relative 'helper' + +class RDocYardTest < RDoc::TestCase + + 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 + + @stats = RDoc::Stats.new @store, 0 + end + + def teardown + super + @tempfile.close! + end + + def test_yield_tag_basic + content = <<~RUBY + ## + # Iterates through items + # @yield [item] Gives each item to the block + def each + @items.each { |item| yield item } + end + RUBY + + parse content + + klass = @store.all_classes_and_modules.first + methods = klass&.method_list || [] + + assert_equal 1, methods.length + + method = methods.first + assert_equal 'each', method.name + assert_equal 'item', method.block_params + end + + def test_yield_tag_with_multiple_params + content = <<~RUBY + ## + # Iterates with index + # @yield [item, index] Gives item and index to block + def each_with_index + # No actual yield in method body + end + RUBY + + parse content + + klass = @store.all_classes_and_modules.first + methods = klass&.method_list || [] + + assert_equal 1, methods.length + + method = methods.first + assert_equal 'each_with_index', method.name + assert_equal 'item, index', method.block_params + end + + def test_yield_tag_with_types + content = <<~RUBY + ## + # Process data + # @yield [String, Integer] Yields processed string and count + def process + # No actual yield in method body + end + RUBY + + parse content + + klass = @store.all_classes_and_modules.first + methods = klass&.method_list || [] + + assert_equal 1, methods.length + + method = methods.first + assert_equal 'process', method.name + # When types are specified, we extract variable names or use defaults + assert_match(/arg1, arg2/, method.block_params || '') + end + + def test_private_tag + content = <<~RUBY + ## + # Internal helper method + # @private + def helper_method + # implementation + end + RUBY + + parse content + + klass = @store.all_classes_and_modules.first + methods = klass&.method_list || [] + + assert_equal 1, methods.length + + method = methods.first + assert_equal 'helper_method', method.name + assert_equal :private, method.visibility + end + + def test_private_tag_on_public_method + content = <<~RUBY + class Foo + public + + ## + # Should be private despite being in public section + # @private + def secret_method + # implementation + end + end + RUBY + + parse content + + klass = @store.find_class_named 'Foo' + assert klass + + methods = klass.method_list + assert_equal 1, methods.length + + method = methods.first + assert_equal 'secret_method', method.name + assert_equal :private, method.visibility + end + + def test_yield_and_private_together + content = <<~RUBY + ## + # Internal iterator + # @yield [item] Each item + # @private + def internal_each + @items.each { |item| yield item } + end + RUBY + + parse content + + klass = @store.all_classes_and_modules.first + methods = klass&.method_list || [] + + assert_equal 1, methods.length + + method = methods.first + assert_equal 'internal_each', method.name + assert_equal :private, method.visibility + assert_equal 'item', method.block_params + end + + def test_yield_tag_without_params + content = <<~RUBY + ## + # Yields control to block + # @yield Gives control to the block + def with_lock + # No actual yield + end + RUBY + + parse content + + klass = @store.all_classes_and_modules.first + methods = klass&.method_list || [] + + assert_equal 1, methods.length + + method = methods.first + assert_equal 'with_lock', method.name + # Empty block params when no params specified + assert([nil, ''].include?(method.block_params), "Expected nil or empty, got #{method.block_params.inspect}") + end + + def test_private_tag_on_attribute + # Known limitation: @private tag doesn't work with attr_* methods + # This test documents the expected behavior once fixed + + content = <<~RUBY + class Foo + ## + # Internal state + # @private + attr_reader :internal_state + end + RUBY + + parse content + + klass = @store.find_class_named 'Foo' + assert klass + + attributes = klass.attributes + assert_equal 1, attributes.length + + attr = attributes.first + assert_equal 'internal_state', attr.name + # Currently returns :public due to known limitation + # assert_equal :private, attr.visibility + assert_equal :public, attr.visibility # Document actual behavior + end + + def test_private_tag_on_constant + content = <<~RUBY + class Foo + ## + # Internal constant + # @private + INTERNAL_CONST = 42 + end + RUBY + + parse content + + klass = @store.find_class_named 'Foo' + assert klass + + constants = klass.constants + assert_equal 1, constants.length + + const = constants.first + assert_equal 'INTERNAL_CONST', const.name + assert_equal :private, const.visibility + end + + def test_api_private_tag + content = <<~RUBY + ## + # Internal API - not for public use + # @api private + def internal_api_method + # implementation + end + RUBY + + parse content + + klass = @store.all_classes_and_modules.first + methods = klass&.method_list || [] + + assert_equal 1, methods.length + + method = methods.first + assert_equal 'internal_api_method', method.name + assert_equal :private, method.visibility + end + + def test_yield_tag_preserves_existing_yields + content = <<~RUBY + ## + # Process with block + # @yield [data] Process data + def process(&block) # :yields: info + yield prepare_data + end + RUBY + + parse content + + klass = @store.all_classes_and_modules.first + methods = klass&.method_list || [] + + assert_equal 1, methods.length + + method = methods.first + assert_equal 'process', method.name + # Should prefer :yields: over @yield when both present + assert_equal 'info', method.block_params + end + + def test_yard_in_class_methods + content = <<~RUBY + class MyClass + ## + # @yield [item] Each item + # @private + def each + # No actual yield + end + end + RUBY + + parse content + + klass = @store.find_class_named 'MyClass' + assert klass + + methods = klass.method_list + assert_equal 1, methods.length + + method = methods.first + assert_equal 'each', method.name + assert_equal :private, method.visibility + assert_equal 'item', method.block_params + end + + # Test that YARD tags are removed from the displayed comment + def test_yard_tags_removed_from_comment + content = <<~RUBY + ## + # This method does something + # @yield [item] Each item + # @private + # More documentation here + def process + yield + end + RUBY + + parse content + + klass = @store.all_classes_and_modules.first + method = klass&.method_list&.first + + assert method + comment_text = method.comment.text + + # YARD tags should be removed + refute_match(/@yield/, comment_text) + refute_match(/@private/, comment_text) + + # Regular documentation should remain + assert_match(/This method does something/, comment_text) + assert_match(/More documentation here/, comment_text) + end + + # Test module methods with YARD tags + def test_yard_tags_on_module_methods + content = <<~RUBY + module MyModule + ## + # Module method + # @yield [data] + # @private + def process + yield data + end + end + RUBY + + parse content + + mod = @store.find_module_named 'MyModule' + assert mod + + method = mod.method_list.first + assert_equal 'process', method.name + assert_equal :private, method.visibility + assert_equal 'data', method.block_params + end + + # Test singleton/class methods + def test_yard_tags_on_singleton_methods + content = <<~RUBY + class Processor + ## + # Class method + # @yield [item] + # @private + def self.each + yield item + end + end + RUBY + + parse content + + klass = @store.find_class_named 'Processor' + assert klass + + method = klass.method_list.find { |m| m.singleton } + assert method + assert_equal 'each', method.name + assert_equal :private, method.visibility + assert_equal 'item', method.block_params + end + + # Test empty brackets + def test_yield_tag_with_empty_brackets + content = <<~RUBY + ## + # @yield [] + def process + yield + end + RUBY + + parse content + + klass = @store.all_classes_and_modules.first + method = klass&.method_list&.first + + assert method + # Empty brackets should result in nil or empty block_params + assert([nil, ''].include?(method.block_params), "Expected nil or empty string, got #{method.block_params.inspect}") + end + + # Test whitespace handling + def test_yard_tags_with_extra_whitespace + content = <<~RUBY + ## + # @yield [ item , index ]#{' '} + # @private#{' '} + def process + yield + end + RUBY + + parse content + + klass = @store.all_classes_and_modules.first + method = klass&.method_list&.first + + assert method + assert_equal 'item, index', method.block_params + assert_equal :private, method.visibility + end + + # Test multiple @yield tags (first should win) + def test_multiple_yield_tags + content = <<~RUBY + ## + # @yield [first] + # @yield [second] + def process + yield + end + RUBY + + parse content + + klass = @store.all_classes_and_modules.first + method = klass&.method_list&.first + + assert method + # First @yield should be used + assert_equal 'first', method.block_params + end + + # Test mixed types and names + def test_yield_tag_mixed_types_and_names + content = <<~RUBY + ## + # @yield [String, index, Hash, count] + def process + yield + end + RUBY + + parse content + + klass = @store.all_classes_and_modules.first + method = klass&.method_list&.first + + assert method + # Should handle mix of types and names + assert_equal 'arg1, index, arg3, count', method.block_params + end + + # Test that non-YARD @ symbols are not affected + def test_non_yard_at_symbols_preserved + content = <<~RUBY + ## + # Send email to user@example.com + # @private + def send_email + # implementation + end + RUBY + + parse content + + klass = @store.all_classes_and_modules.first + method = klass&.method_list&.first + + assert method + comment_text = method.comment.text + + # Email address should be preserved + assert_match(/user@example\.com/, comment_text) + # But @private should be removed + refute_match(/@private/, comment_text) + end + + # Test protected visibility (should not be affected by @private) + def test_private_tag_does_not_affect_protected + content = <<~RUBY + class Foo + protected + #{' '} + ## + # Protected method + # @private + def protected_method + end + end + RUBY + + parse content + + klass = @store.find_class_named 'Foo' + method = klass.method_list.first + + # @private should override protected + assert_equal :private, method.visibility + end + + private + + def parse(content) + parser = RDoc::Parser::Ruby.new @top_level, content, @options, @stats + parser.scan + end +end