From ab88253410a000138af26b862e1ac3c25e44ad30 Mon Sep 17 00:00:00 2001 From: st0012 Date: Sat, 30 May 2026 13:01:21 +0100 Subject: [PATCH 01/10] Add native RBS parser support --- lib/rdoc/parser.rb | 1 + lib/rdoc/parser/rbs.rb | 220 ++++++++++++++++++++++++++++++++++ lib/rdoc/rdoc.rb | 96 ++++++++++----- lib/rdoc/server.rb | 35 +++--- test/rdoc/parser/rbs_test.rb | 148 +++++++++++++++++++++++ test/rdoc/rdoc_rdoc_test.rb | 160 ++++++++++++++++++++++++- test/rdoc/rdoc_server_test.rb | 27 ++++- 7 files changed, 630 insertions(+), 57 deletions(-) create mode 100644 lib/rdoc/parser/rbs.rb create mode 100644 test/rdoc/parser/rbs_test.rb diff --git a/lib/rdoc/parser.rb b/lib/rdoc/parser.rb index f118e19622..8e01ef9379 100644 --- a/lib/rdoc/parser.rb +++ b/lib/rdoc/parser.rb @@ -293,5 +293,6 @@ def handle_tab_width(body) require_relative 'parser/changelog' require_relative 'parser/markdown' require_relative 'parser/rd' +require_relative 'parser/rbs' require_relative 'parser/ruby' require_relative 'parser/ruby_colorizer' diff --git a/lib/rdoc/parser/rbs.rb b/lib/rdoc/parser/rbs.rb new file mode 100644 index 0000000000..95b5a92a89 --- /dev/null +++ b/lib/rdoc/parser/rbs.rb @@ -0,0 +1,220 @@ +# frozen_string_literal: true + +require 'rbs' + +## +# Parse RBS signature files as first-class RDoc input. + +class RDoc::Parser::RBS < RDoc::Parser + RBS_FILE_EXTENSION = /\.rbs$/ + + parse_files_matching RBS_FILE_EXTENSION + + def scan + _, _, decls = ::RBS::Parser.parse_signature(@content) + decls.each do |decl| + parse_decl decl, @top_level + end + @top_level + end + + private + + def record_object_location(object, location) + object.line = location.start_line if location + + if RDoc::ClassModule === object + @top_level.add_to_classes_or_modules object unless + @top_level.classes_or_modules.include? object + object.record_location @top_level + else + object.record_location @top_level + end + + object + end + + def rdoc_comment_for(decl, context) + rbs_comment = decl.comment if decl.respond_to?(:comment) + return unless rbs_comment + + comment = RDoc::Comment.new rbs_comment.string, context + comment.format = 'markdown' + comment + end + + def local_module_name(type_name, namespace) + name = type_name.to_s + return name if name.start_with?('::') + + namespace_names = namespace ? namespace.to_s.split('::') : [] + + namespace_names.length.downto(1) do |length| + qualified_name = namespace_names.take(length).join('::') + if module_name = @top_level.find_module_named("#{qualified_name}::#{name}") + return module_name.full_name + end + end + + name + end + + def merge_attr_rw(existing_rw, new_rw) + rw = +'' + rw << 'R' if existing_rw.include?('R') || new_rw.include?('R') + rw << 'W' if existing_rw.include?('W') || new_rw.include?('W') + rw + end + + def merge_documentation(object, comment, type_signature_lines) + if comment + object.comment = if object.comment.empty? + comment + else + "#{object.comment}\n---\n#{comment}" + end + end + + object.type_signature_lines ||= type_signature_lines + end + + def parse_attr_decl(decl, context) + rw = case decl + when ::RBS::AST::Members::AttrReader + 'R' + when ::RBS::AST::Members::AttrWriter + 'W' + when ::RBS::AST::Members::AttrAccessor + 'RW' + end + + comment = rdoc_comment_for(decl, context) + type_signature_lines = [decl.type.to_s] + if attribute = context.find_attribute(decl.name.to_s, decl.kind == :singleton) + merge_documentation attribute, comment, type_signature_lines + attribute.rw = merge_attr_rw attribute.rw, rw + return + end + + attribute = RDoc::Attr.new( + decl.name.to_s, + rw, + comment, + singleton: decl.kind == :singleton + ) + record_object_location attribute, decl.location + attribute.type_signature_lines = type_signature_lines + context.add_attribute attribute + attribute.visibility = decl.visibility if decl.visibility + end + + def parse_class_decl(decl, context, namespace) + name = context == @top_level && namespace ? namespace + decl.name : decl.name + superclass = decl.super_class&.name&.to_s || '::Object' + klass = context.add_class RDoc::NormalClass, name.to_s, superclass + record_object_location klass, decl.location + klass.add_comment rdoc_comment_for(decl, @top_level), @top_level if decl.comment + + nested_namespace = namespace ? namespace + decl.name : decl.name + decl.members.each { |member| parse_decl member, klass, nested_namespace } + end + + def parse_constant_decl(decl, context) + constant = RDoc::Constant.new decl.name.to_s, decl.type.to_s, + rdoc_comment_for(decl, context) + record_object_location constant, decl.location + context.add_constant constant + end + + def parse_decl(decl, context, namespace = nil) + case decl + when ::RBS::AST::Declarations::Class + parse_class_decl decl, context, namespace + when ::RBS::AST::Declarations::Module, ::RBS::AST::Declarations::Interface + parse_module_decl decl, context, namespace + else + parse_member_decl decl, context, namespace + end + end + + def parse_extend_decl(decl, context, namespace) + extend_decl = RDoc::Extend.new local_module_name(decl.name, namespace), + rdoc_comment_for(decl, context) + record_object_location extend_decl, decl.location + context.add_extend extend_decl + end + + def parse_include_decl(decl, context, namespace) + include_decl = RDoc::Include.new local_module_name(decl.name, namespace), + rdoc_comment_for(decl, context) + record_object_location include_decl, decl.location + context.add_include include_decl + end + + def parse_member_decl(decl, context, namespace) + case decl + when ::RBS::AST::Declarations::Constant + parse_constant_decl decl, context + when ::RBS::AST::Members::MethodDefinition + parse_method_decl decl, context + when ::RBS::AST::Members::Alias + parse_method_alias_decl decl, context + when ::RBS::AST::Members::AttrReader, + ::RBS::AST::Members::AttrWriter, + ::RBS::AST::Members::AttrAccessor + parse_attr_decl decl, context + when ::RBS::AST::Members::Include + parse_include_decl decl, context, namespace + when ::RBS::AST::Members::Extend + parse_extend_decl decl, context, namespace + when ::RBS::AST::Members::Private, + ::RBS::AST::Members::Public + # TODO: Track standalone RBS visibility members. + nil + end + end + + def parse_method_alias_decl(decl, context) + alias_def = RDoc::Alias.new( + decl.old_name.to_s, + decl.new_name.to_s, + rdoc_comment_for(decl, context), + singleton: decl.kind == :singleton + ) + record_object_location alias_def, decl.location + context.add_alias alias_def + end + + def parse_method_decl(decl, context) + comment = rdoc_comment_for(decl, context) + type_signature_lines = decl.overloads.map { |overload| overload.method_type.to_s } + + if method = context.find_method(decl.name.to_s, decl.singleton?) + merge_documentation method, comment, type_signature_lines + return + end + + method = RDoc::AnyMethod.new decl.name.to_s, singleton: decl.singleton? + record_object_location method, decl.location + method.type_signature_lines = type_signature_lines + + if loc = decl.location + method.start_collecting_tokens :ruby + method.add_token line_no: loc.start_line, char_no: 1, text: loc.source + end + + method.comment = comment if decl.comment + context.add_method method + method.visibility = decl.visibility if decl.visibility + end + + def parse_module_decl(decl, context, namespace) + name = context == @top_level && namespace ? namespace + decl.name : decl.name + mod = context.add_module RDoc::NormalModule, name.to_s + record_object_location mod, decl.location + mod.add_comment rdoc_comment_for(decl, @top_level), @top_level if decl.comment + + nested_namespace = namespace ? namespace + decl.name : decl.name + decl.members.each { |member| parse_decl member, mod, nested_namespace } + end +end diff --git a/lib/rdoc/rdoc.rb b/lib/rdoc/rdoc.rb index cbbae72e8a..8089de0afe 100644 --- a/lib/rdoc/rdoc.rb +++ b/lib/rdoc/rdoc.rb @@ -430,7 +430,7 @@ def parse_files(files) def remove_unparseable(files) files.reject do |file, *| - file =~ /\.(?:class|eps|erb|rbs|scpt\.txt|svg|ttf|yml)\z/i or + file =~ /\.(?:class|eps|erb|scpt\.txt|svg|ttf|yml)\z/i or (file =~ /tags\z/i and /\A(\f\n[^,]+,\d+$|!_TAG_)/.match?(File.binread(file, 100))) end @@ -470,11 +470,11 @@ def document(options) @store.load_cache parse_files @options.files - record_rbs_signature_mtimes + record_auto_discovered_rbs_signature_mtimes @options.default_title = "RDoc Documentation" - load_rbs_signatures + load_auto_discovered_rbs_signatures @store.complete @options.visibility start_server @@ -489,20 +489,20 @@ def document(options) @store.load_cache - rbs_signatures_changed = rbs_signatures_changed? - # When only sig/*.rbs changed, no Ruby file would be reparsed under - # normal mtime checks. The store cache holds class metadata but not - # live RDoc::Context objects, so the generator would have nothing to - # iterate. Force a full reparse so updated signatures show up in - # the rendered output. - @last_modified.clear if rbs_signatures_changed + auto_discovered_rbs_signatures_changed = auto_discovered_rbs_signatures_changed? + # When only auto-discovered RBS signatures changed, no Ruby file would be + # reparsed under normal mtime checks. The store cache holds class metadata + # but not live RDoc::Context objects, so the generator would have nothing + # to iterate. Force a full reparse so updated signatures show up in the + # rendered output. + @last_modified.clear if auto_discovered_rbs_signatures_changed file_info = parse_files @options.files - record_rbs_signature_mtimes + record_auto_discovered_rbs_signature_mtimes @options.default_title = "RDoc Documentation" - load_rbs_signatures + load_auto_discovered_rbs_signatures @store.complete @options.visibility @@ -512,7 +512,7 @@ def document(options) puts puts @stats.report - elsif file_info.empty? && !rbs_signatures_changed then + elsif file_info.empty? && !auto_discovered_rbs_signatures_changed then $stderr.puts "\nNo newer files." unless @options.quiet else gen_klass = @options.generator @@ -555,10 +555,10 @@ def generate end ## - # Loads RBS type signatures from the project's sig/ directory and - # RBS stdlib, then merges them into the store's code objects. + # Loads RBS type signatures from the project's +sig+ directory and RBS + # stdlib, then merges them into the store's code objects. - def load_rbs_signatures + def load_auto_discovered_rbs_signatures sig_dirs = [] sig_dir = File.join(@options.root.to_s, 'sig') sig_dirs << sig_dir if File.directory?(sig_dir) @@ -573,18 +573,19 @@ def load_rbs_signatures end ## - # Returns all RBS signature files for the project. + # Returns RBS files that RDoc auto-discovers for signature loading. - def rbs_signature_files + def auto_discovered_rbs_signature_files Dir[File.join(@options.root.to_s, 'sig', '**', '*.rbs')].sort end ## - # Returns true if any RBS signature file has changed since the last run. + # Returns true if any auto-discovered RBS signature file has changed since + # the last run. - def rbs_signatures_changed? - current = rbs_signature_mtimes - previous = @last_modified.select { |file, _| rbs_signature_file?(file) } + def auto_discovered_rbs_signatures_changed? + current = auto_discovered_rbs_signature_mtimes + previous = @last_modified.select { |file, _| auto_discovered_rbs_signature_file?(file) } return true unless (previous.keys - current.keys).empty? @@ -595,27 +596,39 @@ def rbs_signatures_changed? end ## - # Records RBS signature file mtimes so normal generation freshness checks - # and the live server watcher can see signature-only edits. + # Records auto-discovered RBS signature file mtimes so normal generation + # freshness checks and the live server watcher can see signature-only edits. - def record_rbs_signature_mtimes - @last_modified.reject! { |file, _| rbs_signature_file?(file) } - @last_modified.merge! rbs_signature_mtimes + def record_auto_discovered_rbs_signature_mtimes + @last_modified.reject! { |file, _| auto_discovered_rbs_signature_file?(file) } + @last_modified.merge! auto_discovered_rbs_signature_mtimes end ## # Files watched by the live preview server. def watch_files - (@last_modified.keys + rbs_signature_files).uniq + (@last_modified.keys + auto_discovered_rbs_signature_files).uniq end - def rbs_signature_file?(file) # :nodoc: - File.extname(file) == '.rbs' + ## + # Returns true for project RBS files that are auto-discovered for signature + # loading. RDoc parses any selected .rbs file as documentation input, but + # only +sig/**/*.rbs+ files are loaded through RBS::EnvironmentLoader for + # type signature merging and live-reload bookkeeping. + + def auto_discovered_rbs_signature_file?(file) # :nodoc: + return false unless File.extname(file) == '.rbs' + + root = Pathname(@options.root.to_s).expand_path + relative_path = Pathname(file).expand_path.relative_path_from root + relative_path.each_filename.first == 'sig' + rescue ArgumentError + false end - def rbs_signature_mtimes # :nodoc: - rbs_signature_files.each_with_object({}) do |file, mtimes| + def auto_discovered_rbs_signature_mtimes # :nodoc: + auto_discovered_rbs_signature_files.each_with_object({}) do |file, mtimes| mtime = RDoc.safe_mtime(file) mtimes[file] = mtime if mtime end @@ -641,14 +654,33 @@ def remove_siginfo_handler trap 'INFO', handler end + ## + # Returns true when +extension+ is the RBS gem's RDoc discovery hook. + # Released RBS gems install their plugin through this hook, so skip it to + # avoid replacing the built-in parser during discovery. + + def self.rbs_discovery_extension?(extension) # :nodoc: + extension = File.expand_path(extension) + + Gem::Specification.find_all_by_name('rbs').any? do |spec| + File.expand_path('lib/rdoc/discover.rb', spec.full_gem_path) == extension + end + end + end +# Load built-in parser registrations before RubyGems discovery so the RBS gem's +# plugin hook cannot replace RDoc::Parser::RBS. +require_relative 'parser' + begin require 'rubygems' rdoc_extensions = Gem.find_latest_files 'rdoc/discover' rdoc_extensions.each do |extension| + next if RDoc::RDoc.rbs_discovery_extension?(extension) + begin load extension rescue => e diff --git a/lib/rdoc/server.rb b/lib/rdoc/server.rb index fffa875cc6..66441c3ffd 100644 --- a/lib/rdoc/server.rb +++ b/lib/rdoc/server.rb @@ -321,16 +321,12 @@ def file_mtimes_for(files) def check_for_changes changed = [] removed = [] - changed_rbs = [] - removed_rbs = [] + rbs_changed = false @file_mtimes.each do |file, old_mtime| unless File.exist?(file) - if @rdoc.rbs_signature_file?(file) - removed_rbs << file - else - removed << file - end + rbs_changed = true if @rdoc.auto_discovered_rbs_signature_file?(file) + removed << file next end @@ -338,11 +334,8 @@ def check_for_changes next unless current_mtime next unless old_mtime.nil? || current_mtime > old_mtime - if @rdoc.rbs_signature_file?(file) - changed_rbs << file - else - changed << file - end + rbs_changed = true if @rdoc.auto_discovered_rbs_signature_file?(file) + changed << file end file_list = @rdoc.normalized_file_list( @@ -353,22 +346,22 @@ def check_for_changes file_list.each_key do |file| unless @file_mtimes.key?(file) @file_mtimes[file] = nil # will be updated after parse + rbs_changed = true if @rdoc.auto_discovered_rbs_signature_file?(file) changed << file end end - @rdoc.rbs_signature_files.each do |file| + @rdoc.auto_discovered_rbs_signature_files.each do |file| unless @file_mtimes.key?(file) @file_mtimes[file] = nil - changed_rbs << file + rbs_changed = true + changed << file end end - return false if changed.empty? && removed.empty? && changed_rbs.empty? && removed_rbs.empty? - - removed_rbs.each { |file| @file_mtimes.delete(file) } + return false if changed.empty? && removed.empty? && !rbs_changed - reparse_and_refresh(changed, removed, rbs_changed: !changed_rbs.empty? || !removed_rbs.empty?) + reparse_and_refresh(changed, removed, rbs_changed: rbs_changed) true end @@ -410,9 +403,9 @@ def reparse_and_refresh(changed_files, removed_files, rbs_changed: false) if rbs_changed duration_ms = measure do - @rdoc.load_rbs_signatures - @rdoc.record_rbs_signature_mtimes - @rdoc.rbs_signature_files.each do |file| + @rdoc.load_auto_discovered_rbs_signatures + @rdoc.record_auto_discovered_rbs_signature_mtimes + @rdoc.auto_discovered_rbs_signature_files.each do |file| @file_mtimes[file] = RDoc.safe_mtime(file) end end diff --git a/test/rdoc/parser/rbs_test.rb b/test/rdoc/parser/rbs_test.rb new file mode 100644 index 0000000000..30f4668da6 --- /dev/null +++ b/test/rdoc/parser/rbs_test.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require_relative '../helper' +require 'rdoc/parser' +require 'rdoc/generator/markup' + +class RDocParserRBSTest < RDoc::TestCase + def setup + super + + @filename = 'sample.rbs' + @top_level = @store.add_file @filename + + @options = RDoc::Options.new + @options.quiet = true + @stats = RDoc::Stats.new @store, 0 + end + + def test_can_parse + assert_equal RDoc::Parser::RBS, RDoc::Parser.can_parse_by_name(@filename) + end + + def test_scan_adds_rbs_declarations_with_locations_and_signatures + util_parser(<<~RBS).scan + # Sample class + class Sample + VERSION: String + + # Greets by name. + def greet: (String) -> Integer + | (Symbol) -> String + + # Display name. + attr_reader name: String + + alias salutation greet + end + RBS + + sample = @store.find_class_named 'Sample' + assert_equal 'Sample class', sample.comment.text.strip + + version = sample.constants.first + assert_equal 'VERSION', version.name + assert_equal 'String', version.value + + greet = sample.find_method 'greet', false + assert_equal ['(String) -> Integer', '(Symbol) -> String'], greet.type_signature_lines + assert_equal 'Greets by name.', greet.comment.text.strip + + markup_code = greet.markup_code + assert_equal 1, markup_code.scan('# File sample.rbs, line 6').size + refute_match(/line\(s\)/, markup_code) + + name = sample.find_attribute 'name', false + assert_equal ['String'], name.type_signature_lines + + salutation = sample.find_method 'salutation', false + assert_equal greet, salutation.is_alias_for + assert_equal [@top_level], [version, greet, name, salutation].map(&:file).uniq + end + + def test_scan_qualifies_nested_mixins + util_parser(<<~RBS).scan + module Sample + module Enumerable + end + + module Nested + module Enumerable + end + + module Extension + end + + class Collection + include Enumerable + extend Extension + end + end + end + RBS + + collection = @store.find_class_named 'Sample::Nested::Collection' + assert_equal ['Sample::Nested::Enumerable'], collection.includes.map(&:name) + assert_equal ['Sample::Nested::Extension'], collection.extends.map(&:name) + end + + def test_scan_preserves_inline_visibility_modifiers + util_parser(<<~RBS).scan + class Sample + private attr_reader name: String + private def greet: () -> String + end + RBS + + sample = @store.find_class_named 'Sample' + name = sample.find_attribute 'name', false + greet = sample.find_method 'greet', false + + assert_equal :private, name.visibility + assert_equal :private, greet.visibility + end + + def test_scan_extends_existing_method_documentation + ruby_top_level = @store.add_file 'sample.rb' + sample = ruby_top_level.add_class RDoc::NormalClass, 'Sample' + sample.add_comment 'Ruby class docs.', ruby_top_level + + greet = RDoc::AnyMethod.new 'greet' + greet.comment = 'Ruby method docs.' + sample.add_method greet + + util_parser(<<~RBS).scan + # RBS class docs. + class Sample + # RBS method docs. + def greet: () -> String + end + RBS + + assert_equal "Ruby class docs.\n---\nRBS class docs.", sample.comment.to_s.strip + assert_equal "Ruby method docs.\n---\nRBS method docs.", greet.comment.to_s.strip + assert_equal ['() -> String'], greet.type_signature_lines + end + + def test_scan_extends_existing_attribute_documentation + ruby_top_level = @store.add_file 'sample.rb' + sample = ruby_top_level.add_class RDoc::NormalClass, 'Sample' + + name = RDoc::Attr.new 'name', 'R', 'Ruby attribute docs.' + sample.add_attribute name + + util_parser(<<~RBS).scan + class Sample + # RBS attribute docs. + attr_reader name: String + end + RBS + + assert_equal "Ruby attribute docs.\n---\nRBS attribute docs.", name.comment.to_s.strip + assert_equal ['String'], name.type_signature_lines + end + + def util_parser(content) + RDoc::Parser::RBS.new @top_level, content, @options, @stats + end +end diff --git a/test/rdoc/rdoc_rdoc_test.rb b/test/rdoc/rdoc_rdoc_test.rb index 3b60804b36..d99705e0c0 100644 --- a/test/rdoc/rdoc_rdoc_test.rb +++ b/test/rdoc/rdoc_rdoc_test.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true require_relative 'helper' +require 'rbconfig' class RDocRDocTest < RDoc::TestCase class RegenerationTrackingGenerator @@ -110,7 +111,160 @@ def greet: () -> String end end - def test_load_rbs_signatures_clears_stale_signatures_on_failure + def test_document_generates_from_explicit_rbs_file + temp_dir do |dir| + source = File.join dir, 'sample.rbs' + output_dir = File.join dir, 'doc' + + File.write source, <<~RBS + class Sample + def greet: () -> String + end + RBS + + options = RDoc::Options.new + options.files = [source] + options.root = Pathname dir + options.op_dir = output_dir + options.force_update = true + options.quiet = true + options.generator = RegenerationTrackingGenerator + RegenerationTrackingGenerator.generated_store = nil + + capture_output do + RDoc::RDoc.new.document options + end + + store = RegenerationTrackingGenerator.generated_store + refute_nil store + + sample = store.find_class_or_module 'Sample' + + greet = sample.find_method 'greet', false + assert_equal ['() -> String'], greet.type_signature_lines + end + end + + def test_auto_discovered_rbs_signature_file + temp_dir do |dir| + @options.root = Pathname dir + + assert @rdoc.auto_discovered_rbs_signature_file?(File.join(dir, 'sig', 'sample.rbs')) + refute @rdoc.auto_discovered_rbs_signature_file?(File.join(dir, 'types', 'sample.rbs')) + refute @rdoc.auto_discovered_rbs_signature_file?(File.join(dir, 'sig', 'sample.rb')) + end + end + + def test_parse_files_generates_docs_from_rbs_signatures + temp_dir do |dir| + sig_dir = File.join dir, 'sig' + FileUtils.mkdir_p sig_dir + + File.write 'sample.rb', <<~RUBY + class Sample + end + RUBY + + File.write File.join(sig_dir, 'sample.rbs'), <<~RBS + # Signature-only class docs. + class SignatureOnly + end + RBS + + @options.root = Pathname(dir) + @options.quiet = true + @rdoc.store = RDoc::Store.new(@options) + + @rdoc.parse_files [] + + assert @rdoc.store.find_class_named('Sample') + + signature_only = @rdoc.store.find_class_named('SignatureOnly') + refute_nil signature_only + assert_equal 'Signature-only class docs.', signature_only.comment.text.strip + end + end + + def test_builtin_rbs_parser_skips_released_rbs_discovery + temp_dir do |dir| + discover = File.join dir, 'lib', 'rdoc', 'discover.rb' + result = File.join dir, 'discover-result' + FileUtils.mkdir_p File.dirname(discover) + + File.write discover, <<~'RUBY' + File.write ENV.fetch('RDOC_DISCOVER_RESULT'), 'loaded released RBS discovery' + class RDoc::Parser::RBS < RDoc::Parser + parse_files_matching(/\.rbs$/) + + def scan + raise 'released RBS discovery was not skipped' + end + end + RUBY + + script = <<~'RUBY' + FakeSpec = Data.define(:full_gem_path) + + module Gem + class << self + def find_latest_files(path) + path == 'rdoc/discover' ? [ENV.fetch('RDOC_FAKE_DISCOVER')] : [] + end + end + end + + class Gem::Specification + class << self + alias rdoc_original_find_all_by_name find_all_by_name + + def find_all_by_name(name, *_requirements) + return rdoc_original_find_all_by_name(name, *_requirements) unless name == 'rbs' + + [FakeSpec.new(File.dirname(File.dirname(File.dirname(ENV.fetch('RDOC_FAKE_DISCOVER')))))] + end + end + end + + require 'rdoc/rdoc' + + result = ENV.fetch('RDOC_DISCOVER_RESULT') + abort File.read(result) if File.exist?(result) && !File.empty?(result) + + source = File.join File.dirname(result), 'sample.rbs' + File.write source, "class Sample\n def greet: () -> String\nend\n" + + options = RDoc::Options.new + store = RDoc::Store.new options + top_level = store.add_file source + parser = RDoc::Parser.for( + top_level, + File.read(source), + options, + RDoc::Stats.new(store, 0) + ) + parser.scan + + sample = store.find_class_named 'Sample' + greet = sample.find_method 'greet', false + + File.write result, greet.type_signature_lines.join("\n") + RUBY + + lib = File.expand_path('../../lib', __dir__) + env = { + 'RDOC_FAKE_DISCOVER' => discover, + 'RDOC_DISCOVER_RESULT' => result, + } + command = [RbConfig.ruby, "-I#{lib}", '-e', script] + + output = IO.popen(env, command, err: [:child, :out], &:read) + assert $?.success?, output + + assert_equal '() -> String', File.read(result) + end + end + + def test_load_auto_discovered_rbs_signatures_clears_stale_signatures_on_failure temp_dir do |dir| sig_dir = File.join dir, 'sig' sig = File.join sig_dir, 'example.rbs' @@ -131,13 +285,13 @@ def greet: () -> String method = RDoc::AnyMethod.new 'greet' example.add_method method - @rdoc.load_rbs_signatures + @rdoc.load_auto_discovered_rbs_signatures assert_equal ['() -> String'], @rdoc.store.rbs_signature_for(method) File.write sig, "class Example\n def greet: ( -> " @options.verbosity = 2 _out, err = capture_output do - @rdoc.load_rbs_signatures + @rdoc.load_auto_discovered_rbs_signatures end assert_includes err, 'Failed to load RBS type signatures' diff --git a/test/rdoc/rdoc_server_test.rb b/test/rdoc/rdoc_server_test.rb index 3414d33c5d..09dd626a4f 100644 --- a/test/rdoc/rdoc_server_test.rb +++ b/test/rdoc/rdoc_server_test.rb @@ -78,7 +78,7 @@ def test_route_returns_404_for_missing_page assert_equal 'text/html', content_type end - def test_check_for_changes_reloads_rbs_signatures + def test_check_for_changes_parses_and_reloads_rbs_signatures @server.instance_variable_set(:@file_mtimes, @rdoc.last_modified.keys.to_h { |file| [file, File.mtime(file)] }) @@ -87,6 +87,7 @@ def test_check_for_changes_reloads_rbs_signatures FileUtils.mkdir_p sig_dir File.write File.join(sig_dir, 'example.rbs'), <<~RBS class Example + # RBS method docs. def greet: () -> String end RBS @@ -99,6 +100,30 @@ def greet: () -> String example = @rdoc.store.find_class_or_module 'Example' greet = example.find_method 'greet', false + assert_equal "RBS method docs.", greet.comment.to_s.strip + assert_equal ['() -> String'], greet.type_signature_lines assert_equal ['() -> String'], @rdoc.store.rbs_signature_for(greet) end + + def test_check_for_changes_parses_rbs_sources + @server.instance_variable_set(:@file_mtimes, @rdoc.last_modified.keys.to_h { |file| + [file, File.mtime(file)] + }) + + File.write File.join(@dir, 'sample.rbs'), <<~RBS + class Sample + def greet: () -> String + end + RBS + + _out, err = capture_output do + assert @server.send(:check_for_changes) + end + + assert_not_include err, 'Error parsing' + + sample = @rdoc.store.find_class_or_module 'Sample' + greet = sample.find_method 'greet', false + assert_equal ['() -> String'], greet.type_signature_lines + end end From 6438b5f77fc390a99973a2f835a5722f6551001e Mon Sep 17 00:00:00 2001 From: st0012 Date: Sat, 13 Jun 2026 22:18:57 +0100 Subject: [PATCH 02/10] Clarify RBS live reload state --- lib/rdoc/rdoc.rb | 5 ++++- lib/rdoc/server.rb | 18 +++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/lib/rdoc/rdoc.rb b/lib/rdoc/rdoc.rb index 8089de0afe..7099d1b3b5 100644 --- a/lib/rdoc/rdoc.rb +++ b/lib/rdoc/rdoc.rb @@ -613,7 +613,7 @@ def watch_files ## # Returns true for project RBS files that are auto-discovered for signature - # loading. RDoc parses any selected .rbs file as documentation input, but + # loading. RDoc parses any selected .rbs file as documentation input, but # only +sig/**/*.rbs+ files are loaded through RBS::EnvironmentLoader for # type signature merging and live-reload bookkeeping. @@ -627,6 +627,9 @@ def auto_discovered_rbs_signature_file?(file) # :nodoc: false end + ## + # Returns mtimes for auto-discovered RBS signature files. + def auto_discovered_rbs_signature_mtimes # :nodoc: auto_discovered_rbs_signature_files.each_with_object({}) do |file, mtimes| mtime = RDoc.safe_mtime(file) diff --git a/lib/rdoc/server.rb b/lib/rdoc/server.rb index 66441c3ffd..1671be7b8c 100644 --- a/lib/rdoc/server.rb +++ b/lib/rdoc/server.rb @@ -321,11 +321,11 @@ def file_mtimes_for(files) def check_for_changes changed = [] removed = [] - rbs_changed = false + reload_rbs_signatures = false @file_mtimes.each do |file, old_mtime| unless File.exist?(file) - rbs_changed = true if @rdoc.auto_discovered_rbs_signature_file?(file) + reload_rbs_signatures = true if @rdoc.auto_discovered_rbs_signature_file?(file) removed << file next end @@ -334,7 +334,7 @@ def check_for_changes next unless current_mtime next unless old_mtime.nil? || current_mtime > old_mtime - rbs_changed = true if @rdoc.auto_discovered_rbs_signature_file?(file) + reload_rbs_signatures = true if @rdoc.auto_discovered_rbs_signature_file?(file) changed << file end @@ -346,7 +346,7 @@ def check_for_changes file_list.each_key do |file| unless @file_mtimes.key?(file) @file_mtimes[file] = nil # will be updated after parse - rbs_changed = true if @rdoc.auto_discovered_rbs_signature_file?(file) + reload_rbs_signatures = true if @rdoc.auto_discovered_rbs_signature_file?(file) changed << file end end @@ -354,14 +354,14 @@ def check_for_changes @rdoc.auto_discovered_rbs_signature_files.each do |file| unless @file_mtimes.key?(file) @file_mtimes[file] = nil - rbs_changed = true + reload_rbs_signatures = true changed << file end end - return false if changed.empty? && removed.empty? && !rbs_changed + return false if changed.empty? && removed.empty? && !reload_rbs_signatures - reparse_and_refresh(changed, removed, rbs_changed: rbs_changed) + reparse_and_refresh(changed, removed, reload_rbs_signatures: reload_rbs_signatures) true end @@ -369,7 +369,7 @@ def check_for_changes # Re-parses changed files, removes deleted files from the store, # refreshes the generator, and invalidates caches. - def reparse_and_refresh(changed_files, removed_files, rbs_changed: false) + def reparse_and_refresh(changed_files, removed_files, reload_rbs_signatures: false) @mutex.synchronize do unless removed_files.empty? $stderr.puts "Removed: #{removed_files.join(', ')}" @@ -401,7 +401,7 @@ def reparse_and_refresh(changed_files, removed_files, rbs_changed: false) $stderr.puts "Re-parsed #{changed_file_names.join(', ')} (#{duration_ms}ms)" end - if rbs_changed + if reload_rbs_signatures duration_ms = measure do @rdoc.load_auto_discovered_rbs_signatures @rdoc.record_auto_discovered_rbs_signature_mtimes From 802138b657b93baf5bb95b82f2ab1edef67ad628 Mon Sep 17 00:00:00 2001 From: st0012 Date: Sat, 13 Jun 2026 22:20:12 +0100 Subject: [PATCH 03/10] Consolidate live reload file change state --- lib/rdoc/server.rb | 76 +++++++++++++++++++++++++++++++++------------- 1 file changed, 55 insertions(+), 21 deletions(-) diff --git a/lib/rdoc/server.rb b/lib/rdoc/server.rb index 1671be7b8c..6a67a32e1a 100644 --- a/lib/rdoc/server.rb +++ b/lib/rdoc/server.rb @@ -54,6 +54,46 @@ def self.live_reload_script(last_change_time) 500 => 'Internal Server Error', }.freeze + class FileChanges # :nodoc: + attr_reader :changed_files, :removed_files + + def initialize(rdoc) + @rdoc = rdoc + @changed_files = [] + @removed_files = [] + @reload_rbs_signatures = false + end + + def empty? + changed_files.empty? && removed_files.empty? && !reload_rbs_signatures? + end + + def record_changed(file) + reload_rbs_signatures_if_needed file + changed_files << file + end + + def record_auto_discovered_rbs_signature(file) + @reload_rbs_signatures = true + changed_files << file + end + + def record_removed(file) + reload_rbs_signatures_if_needed file + removed_files << file + end + + def reload_rbs_signatures? + @reload_rbs_signatures + end + + private + + def reload_rbs_signatures_if_needed(file) + @reload_rbs_signatures = true if @rdoc.auto_discovered_rbs_signature_file?(file) + end + end + ## # Creates a new server. # @@ -319,14 +359,11 @@ def file_mtimes_for(files) # changes were found and processed. def check_for_changes - changed = [] - removed = [] - reload_rbs_signatures = false + changes = FileChanges.new @rdoc @file_mtimes.each do |file, old_mtime| unless File.exist?(file) - reload_rbs_signatures = true if @rdoc.auto_discovered_rbs_signature_file?(file) - removed << file + changes.record_removed file next end @@ -334,8 +371,7 @@ def check_for_changes next unless current_mtime next unless old_mtime.nil? || current_mtime > old_mtime - reload_rbs_signatures = true if @rdoc.auto_discovered_rbs_signature_file?(file) - changed << file + changes.record_changed file end file_list = @rdoc.normalized_file_list( @@ -346,22 +382,20 @@ def check_for_changes file_list.each_key do |file| unless @file_mtimes.key?(file) @file_mtimes[file] = nil # will be updated after parse - reload_rbs_signatures = true if @rdoc.auto_discovered_rbs_signature_file?(file) - changed << file + changes.record_changed file end end @rdoc.auto_discovered_rbs_signature_files.each do |file| unless @file_mtimes.key?(file) @file_mtimes[file] = nil - reload_rbs_signatures = true - changed << file + changes.record_auto_discovered_rbs_signature file end end - return false if changed.empty? && removed.empty? && !reload_rbs_signatures + return false if changes.empty? - reparse_and_refresh(changed, removed, reload_rbs_signatures: reload_rbs_signatures) + reparse_and_refresh changes true end @@ -369,11 +403,11 @@ def check_for_changes # Re-parses changed files, removes deleted files from the store, # refreshes the generator, and invalidates caches. - def reparse_and_refresh(changed_files, removed_files, reload_rbs_signatures: false) + def reparse_and_refresh(changes) @mutex.synchronize do - unless removed_files.empty? - $stderr.puts "Removed: #{removed_files.join(', ')}" - removed_files.each do |f| + unless changes.removed_files.empty? + $stderr.puts "Removed: #{changes.removed_files.join(', ')}" + changes.removed_files.each do |f| @file_mtimes.delete(f) relative = @rdoc.relative_path_for(f) @store.clear_file_contributions(relative) @@ -381,10 +415,10 @@ def reparse_and_refresh(changed_files, removed_files, reload_rbs_signatures: fal end end - unless changed_files.empty? + unless changes.changed_files.empty? changed_file_names = [] duration_ms = measure do - changed_files.each do |f| + changes.changed_files.each do |f| relative = @rdoc.relative_path_for(f) changed_file_names << relative begin @@ -401,7 +435,7 @@ def reparse_and_refresh(changed_files, removed_files, reload_rbs_signatures: fal $stderr.puts "Re-parsed #{changed_file_names.join(', ')} (#{duration_ms}ms)" end - if reload_rbs_signatures + if changes.reload_rbs_signatures? duration_ms = measure do @rdoc.load_auto_discovered_rbs_signatures @rdoc.record_auto_discovered_rbs_signature_mtimes @@ -413,7 +447,7 @@ def reparse_and_refresh(changed_files, removed_files, reload_rbs_signatures: fal end @store.complete(@options.visibility) - @store.invalidate_type_name_lookup unless changed_files.empty? && removed_files.empty? + @store.invalidate_type_name_lookup unless changes.changed_files.empty? && changes.removed_files.empty? @generator.refresh_store_data @page_cache.clear From 4a74a388efec676f5b5338e6aff67689a33b9a88 Mon Sep 17 00:00:00 2001 From: st0012 Date: Sat, 13 Jun 2026 22:28:02 +0100 Subject: [PATCH 04/10] Simplify live reload change handling --- lib/rdoc/server.rb | 152 +++++++++++++++++++++++---------------------- 1 file changed, 78 insertions(+), 74 deletions(-) diff --git a/lib/rdoc/server.rb b/lib/rdoc/server.rb index 6a67a32e1a..4d098285d4 100644 --- a/lib/rdoc/server.rb +++ b/lib/rdoc/server.rb @@ -65,7 +65,7 @@ def initialize(rdoc) end def empty? - changed_files.empty? && removed_files.empty? && !reload_rbs_signatures? + !source_files_changed? && !reload_rbs_signatures? end def record_changed(file) @@ -73,11 +73,6 @@ def record_changed(file) changed_files << file end - def record_auto_discovered_rbs_signature(file) - @reload_rbs_signatures = true - changed_files << file - end - def record_removed(file) reload_rbs_signatures_if_needed file removed_files << file @@ -87,6 +82,10 @@ def reload_rbs_signatures? @reload_rbs_signatures end + def source_files_changed? + !changed_files.empty? || !removed_files.empty? + end + private def reload_rbs_signatures_if_needed(file) @@ -360,37 +359,17 @@ def file_mtimes_for(files) def check_for_changes changes = FileChanges.new @rdoc + current_files = current_watch_files - @file_mtimes.each do |file, old_mtime| - unless File.exist?(file) - changes.record_removed file - next - end - - current_mtime = RDoc.safe_mtime(file) - next unless current_mtime - next unless old_mtime.nil? || current_mtime > old_mtime - - changes.record_changed file + @file_mtimes.each_key do |file| + changes.record_removed file unless current_files.include? file end - file_list = @rdoc.normalized_file_list( - @options.files.empty? ? [@options.root.to_s] : @options.files, - true, @options.exclude - ) - file_list = @rdoc.remove_unparseable(file_list) - file_list.each_key do |file| - unless @file_mtimes.key?(file) - @file_mtimes[file] = nil # will be updated after parse - changes.record_changed file - end - end + current_files.each do |file| + next unless file_changed? file - @rdoc.auto_discovered_rbs_signature_files.each do |file| - unless @file_mtimes.key?(file) - @file_mtimes[file] = nil - changes.record_auto_discovered_rbs_signature file - end + @file_mtimes[file] = nil unless @file_mtimes.key? file + changes.record_changed file end return false if changes.empty? @@ -405,54 +384,79 @@ def check_for_changes def reparse_and_refresh(changes) @mutex.synchronize do - unless changes.removed_files.empty? - $stderr.puts "Removed: #{changes.removed_files.join(', ')}" - changes.removed_files.each do |f| - @file_mtimes.delete(f) - relative = @rdoc.relative_path_for(f) - @store.clear_file_contributions(relative) - @store.remove_file(relative) - end - end + remove_files changes.removed_files + reparse_files changes.changed_files + reload_rbs_signatures if changes.reload_rbs_signatures? + @store.complete(@options.visibility) + @store.invalidate_type_name_lookup if changes.source_files_changed? - unless changes.changed_files.empty? - changed_file_names = [] - duration_ms = measure do - changes.changed_files.each do |f| - relative = @rdoc.relative_path_for(f) - changed_file_names << relative - begin - @store.clear_file_contributions(relative, keep_position: true) - @rdoc.parse_file(f) - @file_mtimes[f] = RDoc.safe_mtime(f) - rescue => e - $stderr.puts "Error parsing #{f}: #{e.message}" - end - end - - @store.cleanup_stale_contributions - end - $stderr.puts "Re-parsed #{changed_file_names.join(', ')} (#{duration_ms}ms)" + @generator.refresh_store_data + @page_cache.clear + @last_change_time = Time.now.to_f + end + end + + def current_watch_files + file_list = @rdoc.normalized_file_list( + @options.files.empty? ? [@options.root.to_s] : @options.files, + true, @options.exclude + ) + @rdoc.remove_unparseable(file_list).keys | @rdoc.auto_discovered_rbs_signature_files + end + + def file_changed?(file) + return true unless @file_mtimes.key? file + + old_mtime = @file_mtimes[file] + return true unless old_mtime + + current_mtime = RDoc.safe_mtime(file) + current_mtime && current_mtime > old_mtime + end + + def remove_files(files) + return if files.empty? + + $stderr.puts "Removed: #{files.join(', ')}" + files.each do |f| + @file_mtimes.delete(f) + relative = @rdoc.relative_path_for(f) + @store.clear_file_contributions(relative) + @store.remove_file(relative) + end + end + + def reload_rbs_signatures + duration_ms = measure do + @rdoc.load_auto_discovered_rbs_signatures + @rdoc.record_auto_discovered_rbs_signature_mtimes + @rdoc.auto_discovered_rbs_signature_files.each do |file| + @file_mtimes[file] = RDoc.safe_mtime(file) end + end + $stderr.puts "Reloaded RBS signatures (#{duration_ms}ms)" + end + + def reparse_files(files) + return if files.empty? - if changes.reload_rbs_signatures? - duration_ms = measure do - @rdoc.load_auto_discovered_rbs_signatures - @rdoc.record_auto_discovered_rbs_signature_mtimes - @rdoc.auto_discovered_rbs_signature_files.each do |file| - @file_mtimes[file] = RDoc.safe_mtime(file) - end + changed_file_names = [] + duration_ms = measure do + files.each do |f| + relative = @rdoc.relative_path_for(f) + changed_file_names << relative + begin + @store.clear_file_contributions(relative, keep_position: true) + @rdoc.parse_file(f) + @file_mtimes[f] = RDoc.safe_mtime(f) + rescue => e + $stderr.puts "Error parsing #{f}: #{e.message}" end - $stderr.puts "Reloaded RBS signatures (#{duration_ms}ms)" end - @store.complete(@options.visibility) - @store.invalidate_type_name_lookup unless changes.changed_files.empty? && changes.removed_files.empty? - - @generator.refresh_store_data - @page_cache.clear - @last_change_time = Time.now.to_f + @store.cleanup_stale_contributions end + $stderr.puts "Re-parsed #{changed_file_names.join(', ')} (#{duration_ms}ms)" end end From 4c855bed5b4530d5d6563028f43cfe8a332ea4ca Mon Sep 17 00:00:00 2001 From: st0012 Date: Sun, 14 Jun 2026 11:17:16 +0100 Subject: [PATCH 05/10] Document current RBS parser limitations --- test/rdoc/parser/rbs_test.rb | 86 ++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/test/rdoc/parser/rbs_test.rb b/test/rdoc/parser/rbs_test.rb index 30f4668da6..78d66b6b05 100644 --- a/test/rdoc/parser/rbs_test.rb +++ b/test/rdoc/parser/rbs_test.rb @@ -60,6 +60,27 @@ def greet: (String) -> Integer assert_equal [@top_level], [version, greet, name, salutation].map(&:file).uniq end + def test_scan_omits_class_and_module_alias_declarations + util_parser(<<~RBS).scan + class OldClass + end + + class NewClass = OldClass + + module OldModule + end + + module NewModule = OldModule + RBS + + assert @store.find_class_named('OldClass') + assert @store.find_module_named('OldModule') + + # TODO: RBS class and module aliases should be added to the store. + assert_nil @store.find_class_named('NewClass') + assert_nil @store.find_module_named('NewModule') + end + def test_scan_qualifies_nested_mixins util_parser(<<~RBS).scan module Sample @@ -102,6 +123,25 @@ class Sample assert_equal :private, greet.visibility end + def test_scan_standalone_visibility_sections_leave_members_public + util_parser(<<~RBS).scan + class Sample + private + + attr_reader token: String + def authenticate: () -> String + end + RBS + + sample = @store.find_class_named 'Sample' + token = sample.find_attribute 'token', false + authenticate = sample.find_method 'authenticate', false + + # TODO: Standalone RBS visibility sections should apply to later members. + assert_equal :public, token.visibility + assert_equal :public, authenticate.visibility + end + def test_scan_extends_existing_method_documentation ruby_top_level = @store.add_file 'sample.rb' sample = ruby_top_level.add_class RDoc::NormalClass, 'Sample' @@ -142,6 +182,52 @@ class Sample assert_equal ['String'], name.type_signature_lines end + def test_scan_reparsing_keeps_existing_rbs_method_overlay + ruby_top_level = @store.add_file 'sample.rb' + sample = ruby_top_level.add_class RDoc::NormalClass, 'Sample' + + greet = RDoc::AnyMethod.new 'greet' + greet.comment = 'Ruby method docs.' + sample.add_method greet + + util_parser(<<~RBS).scan + class Sample + # RBS v1 docs. + def greet: () -> String + end + RBS + + @store.clear_file_contributions @filename, keep_position: true + + util_parser(<<~RBS).scan + class Sample + # RBS v2 docs. + def greet: () -> Integer + end + RBS + + # TODO: Incremental RBS reparsing should replace the previous RBS overlay. + assert_equal "Ruby method docs.\n---\nRBS v1 docs.\n---\nRBS v2 docs.", + greet.comment.to_s.strip + assert_equal ['() -> String'], greet.type_signature_lines + end + + def test_scan_self_question_method_definitions_add_singleton_method_only + util_parser(<<~RBS).scan + class Greeter + def self?.shout: (String text) -> String + end + RBS + + greeter = @store.find_class_named 'Greeter' + shout = greeter.find_method 'shout', true + + assert_equal ['(String text) -> String'], shout.type_signature_lines + + # TODO: RBS self? methods should also create a private instance method. + assert_nil greeter.find_method('shout', false) + end + def util_parser(content) RDoc::Parser::RBS.new @top_level, content, @options, @stats end From 0b096ce950878c65103f7a1ee540be4357dc0860 Mon Sep 17 00:00:00 2001 From: st0012 Date: Sun, 14 Jun 2026 12:35:58 +0100 Subject: [PATCH 06/10] Map RBS constructors to new --- lib/rdoc/parser/rbs.rb | 25 ++++++++++++++++++++++--- test/rdoc/parser/rbs_test.rb | 31 +++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/lib/rdoc/parser/rbs.rb b/lib/rdoc/parser/rbs.rb index 95b5a92a89..09253a3e27 100644 --- a/lib/rdoc/parser/rbs.rb +++ b/lib/rdoc/parser/rbs.rb @@ -78,6 +78,22 @@ def merge_documentation(object, comment, type_signature_lines) object.type_signature_lines ||= type_signature_lines end + def rdoc_method_name(decl) + rbs_constructor_decl?(decl) ? 'new' : decl.name.to_s + end + + def rdoc_method_singleton?(decl) + rbs_constructor_decl?(decl) || decl.singleton? + end + + def rdoc_method_visibility(decl) + rbs_constructor_decl?(decl) ? :public : decl.visibility + end + + def rbs_constructor_decl?(decl) + decl.kind == :instance && decl.name == :initialize + end + def parse_attr_decl(decl, context) rw = case decl when ::RBS::AST::Members::AttrReader @@ -188,13 +204,16 @@ def parse_method_alias_decl(decl, context) def parse_method_decl(decl, context) comment = rdoc_comment_for(decl, context) type_signature_lines = decl.overloads.map { |overload| overload.method_type.to_s } + method_name = rdoc_method_name(decl) + singleton = rdoc_method_singleton?(decl) + visibility = rdoc_method_visibility(decl) - if method = context.find_method(decl.name.to_s, decl.singleton?) + if method = context.find_method(method_name, singleton) merge_documentation method, comment, type_signature_lines return end - method = RDoc::AnyMethod.new decl.name.to_s, singleton: decl.singleton? + method = RDoc::AnyMethod.new method_name, singleton: singleton record_object_location method, decl.location method.type_signature_lines = type_signature_lines @@ -205,7 +224,7 @@ def parse_method_decl(decl, context) method.comment = comment if decl.comment context.add_method method - method.visibility = decl.visibility if decl.visibility + method.visibility = visibility if visibility end def parse_module_decl(decl, context, namespace) diff --git a/test/rdoc/parser/rbs_test.rb b/test/rdoc/parser/rbs_test.rb index 78d66b6b05..0702dd1e8a 100644 --- a/test/rdoc/parser/rbs_test.rb +++ b/test/rdoc/parser/rbs_test.rb @@ -228,6 +228,37 @@ def self?.shout: (String text) -> String assert_nil greeter.find_method('shout', false) end + def test_scan_maps_initialize_to_singleton_new + util_parser(<<~RBS).scan + class Sample + # Build a sample. + def initialize: (String name) -> void + end + RBS + + sample = @store.find_class_named 'Sample' + constructor = sample.find_method 'new', true + + assert_not_nil constructor + assert_equal ['(String name) -> void'], constructor.type_signature_lines + assert_equal 'Build a sample.', constructor.comment.text.strip + assert_equal :public, constructor.visibility + assert_nil sample.find_method('initialize', false) + end + + def test_scan_maps_private_initialize_to_public_singleton_new + util_parser(<<~RBS).scan + class Sample + private def initialize: () -> void + end + RBS + + sample = @store.find_class_named 'Sample' + constructor = sample.find_method 'new', true + + assert_equal :public, constructor.visibility + end + def util_parser(content) RDoc::Parser::RBS.new @top_level, content, @options, @stats end From ea51f183d3f7cdc6bf64e4f207780592c5e9fdd9 Mon Sep 17 00:00:00 2001 From: st0012 Date: Sun, 14 Jun 2026 13:00:52 +0100 Subject: [PATCH 07/10] Document remaining RBS parser follow-ups --- lib/rdoc/parser/rbs.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/rdoc/parser/rbs.rb b/lib/rdoc/parser/rbs.rb index 09253a3e27..13022a5675 100644 --- a/lib/rdoc/parser/rbs.rb +++ b/lib/rdoc/parser/rbs.rb @@ -38,6 +38,8 @@ def rdoc_comment_for(decl, context) rbs_comment = decl.comment if decl.respond_to?(:comment) return unless rbs_comment + # TODO: Run RBS comments through RDoc's directive preprocessor so + # directives like :nodoc: affect the documented object. comment = RDoc::Comment.new rbs_comment.string, context comment.format = 'markdown' comment @@ -75,6 +77,8 @@ def merge_documentation(object, comment, type_signature_lines) end end + # TODO: Track RBS-owned documentation overlays so incremental reparsing can + # replace stale comments and signatures from the previous RBS parse. object.type_signature_lines ||= type_signature_lines end @@ -83,6 +87,8 @@ def rdoc_method_name(decl) end def rdoc_method_singleton?(decl) + # TODO: RBS `self?` methods are :singleton_instance and should add both a + # singleton method and a private instance method. rbs_constructor_decl?(decl) || decl.singleton? end @@ -148,6 +154,10 @@ def parse_decl(decl, context, namespace = nil) parse_class_decl decl, context, namespace when ::RBS::AST::Declarations::Module, ::RBS::AST::Declarations::Interface parse_module_decl decl, context, namespace + when ::RBS::AST::Declarations::ClassAlias, + ::RBS::AST::Declarations::ModuleAlias + # TODO: Add RBS class and module aliases to the RDoc store. + nil else parse_member_decl decl, context, namespace end From 08f6a0b1fc0d45c4cae298b843ca74b8b7386170 Mon Sep 17 00:00:00 2001 From: st0012 Date: Sun, 14 Jun 2026 16:24:26 +0100 Subject: [PATCH 08/10] Refine RBS namespace parsing --- lib/rdoc/code_object/context.rb | 39 ++++++++++++++++++++++++------ lib/rdoc/parser/rbs.rb | 42 +++++++++++++++++++++++---------- test/rdoc/parser/rbs_test.rb | 37 +++++++++++++++++++++++++++++ test/rdoc/rdoc_context_test.rb | 32 +++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 20 deletions(-) diff --git a/lib/rdoc/code_object/context.rb b/lib/rdoc/code_object/context.rb index a3a48490ff..398769d1b1 100644 --- a/lib/rdoc/code_object/context.rb +++ b/lib/rdoc/code_object/context.rb @@ -313,13 +313,7 @@ def add_class(class_type, given_name, superclass = '::Object') @store.modules_hash[given_name] return enclosing if enclosing # not found: create the parent(s) - names = ename.split('::') - enclosing = self - names.each do |n| - enclosing = enclosing.classes_hash[n] || - enclosing.modules_hash[n] || - enclosing.add_module(RDoc::NormalModule, n) - end + enclosing = find_or_create_module_path ename end else name = full_name @@ -500,11 +494,42 @@ def add_method(method) method end + ## + # Returns the owner context and local name for +constant_path+, creating + # missing namespace modules. A leading +::+ resolves from the top-level. + + def find_or_create_constant_owner_name(constant_path) # :nodoc: + constant_path = constant_path.to_s + owner = constant_path.start_with?('::') ? top_level : self + constant_path = constant_path.delete_prefix('::') + + owner_path, separator, name = constant_path.rpartition('::') + owner = owner.find_or_create_module_path owner_path unless separator.empty? + + [owner, name] + end + + ## + # Finds or creates the module path under this context. + + def find_or_create_module_path(path) # :nodoc: + path.to_s.split('::').inject(self) do |owner, name| + owner.classes_hash[name] || + owner.modules_hash[name] || + owner.add_module(RDoc::NormalModule, name) + end + end + ## # Adds a module named +name+. If RDoc already knows +name+ is a class then # that class is returned instead. See also #add_class. def add_module(class_type, name) + if name.to_s.include?('::') + owner, name = find_or_create_constant_owner_name name + return owner.add_module class_type, name unless owner == self + end + mod = @classes[name] || @modules[name] return mod if mod diff --git a/lib/rdoc/parser/rbs.rb b/lib/rdoc/parser/rbs.rb index 13022a5675..12f4f228ae 100644 --- a/lib/rdoc/parser/rbs.rb +++ b/lib/rdoc/parser/rbs.rb @@ -82,6 +82,19 @@ def merge_documentation(object, comment, type_signature_lines) object.type_signature_lines ||= type_signature_lines end + def merge_attribute_methods(context, name, rw, singleton, comment, type_signature_lines) + method_names = [] + method_names << name if rw.include?('R') + method_names << "#{name}=" if rw.include?('W') + + methods = method_names.map { |method_name| context.find_method(method_name, singleton) } + methods.compact.each do |method| + merge_documentation method, comment, type_signature_lines + end + + methods.all? + end + def rdoc_method_name(decl) rbs_constructor_decl?(decl) ? 'new' : decl.name.to_s end @@ -112,17 +125,23 @@ def parse_attr_decl(decl, context) comment = rdoc_comment_for(decl, context) type_signature_lines = [decl.type.to_s] - if attribute = context.find_attribute(decl.name.to_s, decl.kind == :singleton) + name = decl.name.to_s + singleton = decl.kind == :singleton + if attribute = context.find_attribute(name, singleton) merge_documentation attribute, comment, type_signature_lines attribute.rw = merge_attr_rw attribute.rw, rw return end + if merge_attribute_methods(context, name, rw, singleton, comment, type_signature_lines) + return + end + attribute = RDoc::Attr.new( - decl.name.to_s, + name, rw, comment, - singleton: decl.kind == :singleton + singleton: singleton ) record_object_location attribute, decl.location attribute.type_signature_lines = type_signature_lines @@ -130,15 +149,14 @@ def parse_attr_decl(decl, context) attribute.visibility = decl.visibility if decl.visibility end - def parse_class_decl(decl, context, namespace) - name = context == @top_level && namespace ? namespace + decl.name : decl.name + def parse_class_decl(decl, context, _namespace) + owner, name = context.find_or_create_constant_owner_name decl.name superclass = decl.super_class&.name&.to_s || '::Object' - klass = context.add_class RDoc::NormalClass, name.to_s, superclass + klass = owner.add_class RDoc::NormalClass, name, superclass record_object_location klass, decl.location klass.add_comment rdoc_comment_for(decl, @top_level), @top_level if decl.comment - nested_namespace = namespace ? namespace + decl.name : decl.name - decl.members.each { |member| parse_decl member, klass, nested_namespace } + decl.members.each { |member| parse_decl member, klass, klass.full_name } end def parse_constant_decl(decl, context) @@ -237,13 +255,11 @@ def parse_method_decl(decl, context) method.visibility = visibility if visibility end - def parse_module_decl(decl, context, namespace) - name = context == @top_level && namespace ? namespace + decl.name : decl.name - mod = context.add_module RDoc::NormalModule, name.to_s + def parse_module_decl(decl, context, _namespace) + mod = context.add_module RDoc::NormalModule, decl.name.to_s record_object_location mod, decl.location mod.add_comment rdoc_comment_for(decl, @top_level), @top_level if decl.comment - nested_namespace = namespace ? namespace + decl.name : decl.name - decl.members.each { |member| parse_decl member, mod, nested_namespace } + decl.members.each { |member| parse_decl member, mod, mod.full_name } end end diff --git a/test/rdoc/parser/rbs_test.rb b/test/rdoc/parser/rbs_test.rb index 0702dd1e8a..25edb78097 100644 --- a/test/rdoc/parser/rbs_test.rb +++ b/test/rdoc/parser/rbs_test.rb @@ -81,6 +81,24 @@ module NewModule = OldModule assert_nil @store.find_module_named('NewModule') end + def test_scan_splits_qualified_class_and_module_names + util_parser(<<~RBS).scan + module Foo::Bar + end + + class Foo::Baz + end + RBS + + foo = @store.find_module_named 'Foo' + bar = @store.find_module_named 'Foo::Bar' + baz = @store.find_class_named 'Foo::Baz' + + assert_not_nil foo + assert_same bar, foo.modules_hash['Bar'] + assert_same baz, foo.classes_hash['Baz'] + end + def test_scan_qualifies_nested_mixins util_parser(<<~RBS).scan module Sample @@ -182,6 +200,25 @@ class Sample assert_equal ['String'], name.type_signature_lines end + def test_scan_merges_attribute_signature_into_existing_method + ruby_top_level = @store.add_file 'sample.rb' + sample = ruby_top_level.add_class RDoc::NormalClass, 'Sample' + + name = RDoc::AnyMethod.new 'name' + name.comment = 'Ruby method docs.' + sample.add_method name + + util_parser(<<~RBS).scan + class Sample + # RBS attribute docs. + attr_reader name: String + end + RBS + + assert_equal "Ruby method docs.\n---\nRBS attribute docs.", name.comment.to_s.strip + assert_equal ['String'], name.type_signature_lines + end + def test_scan_reparsing_keeps_existing_rbs_method_overlay ruby_top_level = @store.add_file 'sample.rb' sample = ruby_top_level.add_class RDoc::NormalClass, 'Sample' diff --git a/test/rdoc/rdoc_context_test.rb b/test/rdoc/rdoc_context_test.rb index 1419ce68a3..4135c0d0cd 100644 --- a/test/rdoc/rdoc_context_test.rb +++ b/test/rdoc/rdoc_context_test.rb @@ -275,6 +275,22 @@ def test_add_module assert_includes @c1.modules.map { |m| m.full_name }, 'C1::Mod' end + def test_add_module_qualified_name + mod = @c1.add_module RDoc::NormalModule, 'Relative::Nested' + + assert_equal 'Nested', mod.name + assert_equal 'C1::Relative::Nested', mod.full_name + assert_same mod, @c1.modules_hash['Relative'].modules_hash['Nested'] + end + + def test_add_module_qualified_name_absolute + mod = @c1.add_module RDoc::NormalModule, '::Absolute::Nested' + + assert_equal 'Nested', mod.name + assert_equal 'Absolute::Nested', mod.full_name + assert_same mod, @top_level.modules_hash['Absolute'].modules_hash['Nested'] + end + def test_add_module_alias tl = @store.add_file 'file.rb' @@ -421,6 +437,22 @@ def test_child_name assert_equal 'C1::C1', @c1.child_name('C1') end + def test_find_or_create_constant_owner_name + owner, name = @c1.find_or_create_constant_owner_name 'Relative::Nested' + + assert_equal 'Nested', name + assert_equal 'C1::Relative', owner.full_name + assert_same owner, @c1.modules_hash['Relative'] + end + + def test_find_or_create_constant_owner_name_absolute + owner, name = @c1.find_or_create_constant_owner_name '::Absolute::Nested' + + assert_equal 'Nested', name + assert_equal 'Absolute', owner.full_name + assert_same owner, @top_level.modules_hash['Absolute'] + end + def test_classes assert_equal %w[C2::C3], @c2.classes.map { |k| k.full_name } assert_equal %w[C3::H1 C3::H2], @c3.classes.map { |k| k.full_name }.sort From 21e9e354515f93506ca9f74f7da11deb6282fe4a Mon Sep 17 00:00:00 2001 From: st0012 Date: Sun, 14 Jun 2026 18:12:25 +0100 Subject: [PATCH 09/10] Address RBS parser review feedback --- lib/rdoc/parser/rbs.rb | 13 ++++++++++++- lib/rdoc/server.rb | 4 +++- test/rdoc/parser/rbs_test.rb | 22 ++++++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/lib/rdoc/parser/rbs.rb b/lib/rdoc/parser/rbs.rb index 12f4f228ae..55b6d87237 100644 --- a/lib/rdoc/parser/rbs.rb +++ b/lib/rdoc/parser/rbs.rb @@ -73,7 +73,7 @@ def merge_documentation(object, comment, type_signature_lines) object.comment = if object.comment.empty? comment else - "#{object.comment}\n---\n#{comment}" + merge_comments object, comment end end @@ -82,6 +82,17 @@ def merge_documentation(object, comment, type_signature_lines) object.type_signature_lines ||= type_signature_lines end + def merge_comments(object, comment) + document = RDoc::Markup::Document.new + document.concat object.parse(object.comment).parts + document << RDoc::Markup::Rule.new(1) + document.concat comment.parse.parts + + merged_comment = RDoc::Comment.new "#{object.comment}\n---\n#{comment}", comment.location + merged_comment.document = document + merged_comment + end + def merge_attribute_methods(context, name, rw, singleton, comment, type_signature_lines) method_names = [] method_names << name if rw.include?('R') diff --git a/lib/rdoc/server.rb b/lib/rdoc/server.rb index 4d098285d4..2f884d7978 100644 --- a/lib/rdoc/server.rb +++ b/lib/rdoc/server.rb @@ -3,6 +3,7 @@ require 'socket' require 'json' require 'erb' +require 'set' require 'uri' ## @@ -360,9 +361,10 @@ def file_mtimes_for(files) def check_for_changes changes = FileChanges.new @rdoc current_files = current_watch_files + current_file_set = current_files.to_set @file_mtimes.each_key do |file| - changes.record_removed file unless current_files.include? file + changes.record_removed file unless current_file_set.include? file end current_files.each do |file| diff --git a/test/rdoc/parser/rbs_test.rb b/test/rdoc/parser/rbs_test.rb index 25edb78097..96549be79b 100644 --- a/test/rdoc/parser/rbs_test.rb +++ b/test/rdoc/parser/rbs_test.rb @@ -3,6 +3,7 @@ require_relative '../helper' require 'rdoc/parser' require 'rdoc/generator/markup' +require 'rdoc/markup/to_html' class RDocParserRBSTest < RDoc::TestCase def setup @@ -182,6 +183,27 @@ def greet: () -> String assert_equal ['() -> String'], greet.type_signature_lines end + def test_scan_preserves_rbs_markdown_when_extending_method_documentation + ruby_top_level = @store.add_file 'sample.rb' + sample = ruby_top_level.add_class RDoc::NormalClass, 'Sample' + + greet = RDoc::AnyMethod.new 'greet' + greet.comment = 'Ruby method docs.' + sample.add_method greet + + util_parser(<<~RBS).scan + class Sample + # [RBS method docs](https://example.com/rbs-docs). + def greet: () -> String + end + RBS + + html = RDoc::Markup::ToHtml.new.convert greet.parse(greet.comment) + + assert_include html, '

Ruby method docs.

' + assert_include html, 'RBS method docs' + end + def test_scan_extends_existing_attribute_documentation ruby_top_level = @store.add_file 'sample.rb' sample = ruby_top_level.add_class RDoc::NormalClass, 'Sample' From 13804b7a0ea37423faac4dcd06793ae96acdbeba Mon Sep 17 00:00:00 2001 From: st0012 Date: Sun, 14 Jun 2026 22:20:04 +0100 Subject: [PATCH 10/10] Document native RBS parser support --- AGENTS.md | 15 +++++++++++---- README.md | 4 +++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 45b111ca51..0e5d13ad7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project Overview -**RDoc** is Ruby's default documentation generation tool that produces HTML and command-line documentation for Ruby projects. It parses Ruby source code, C extensions, and markup files to generate documentation. +**RDoc** is Ruby's default documentation generation tool that produces HTML and command-line documentation for Ruby projects. It parses Ruby source code, C extensions, RBS signature files, and markup files to generate documentation. - **Repository:** https://github.com/ruby/rdoc - **Homepage:** https://ruby.github.io/rdoc @@ -177,9 +177,10 @@ lib/rdoc/ ├── rdoc.rb # Main entry point (RDoc::RDoc class) ├── version.rb # Version constant ├── task.rb # Rake task integration -├── parser/ # Source code parsers (Ruby, C, Markdown, RD) +├── parser/ # Source code parsers (Ruby, C, RBS, Markdown, RD) │ ├── ruby.rb # Prism-based Ruby parser │ ├── c.rb # C extension parser +│ ├── rbs.rb # RBS signature parser │ └── ... ├── server.rb # Live-reloading preview server (rdoc --server) ├── generator/ # Documentation generators @@ -235,10 +236,16 @@ exe/ ### Parsers and Generators -- **Parsers:** Prism-based Ruby (`RDoc::Parser::Ruby`), C, Markdown, RD +- **Parsers:** Prism-based Ruby (`RDoc::Parser::Ruby`), C, RBS (`RDoc::Parser::RBS`), Markdown, RD - **Generators:** HTML/Aliki (default), HTML/Darkfish (deprecated), RI, POT (gettext), JSON, Markup -Parser tests live in the `RDocParserRubyTest` class (`test/rdoc/parser/ruby_test.rb`). +Parser tests live under `test/rdoc/parser/`, including `RDocParserRubyTest` (`test/rdoc/parser/ruby_test.rb`) and `RDocParserRBSTest` (`test/rdoc/parser/rbs_test.rb`). + +### RBS Documentation Input and Signature Merging + +Selected `.rbs` files are first-class documentation input through `RDoc::Parser::RBS`. RBS declarations can create documentation for classes, modules, methods, attributes, and constants, or extend objects already documented from Ruby source. + +RBS files under the project's `sig/` directory are also auto-discovered by `RDoc::RDoc` for type signature merging and live preview bookkeeping. Keep this distinction clear: `.rbs` parsing builds documentation objects, while `sig/**/*.rbs` auto-discovery feeds the existing RBS type-signature merge path. ### Code Object Model and Constant Aliases diff --git a/README.md b/README.md index 3f3e4519a1..4a835e50f6 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,9 @@ rdoc --main README.md You'll find information on the various formatting tricks you can use in comment blocks in the documentation this generates. -RDoc uses file extensions to determine how to process each file. File names ending `.rb` and `.rbw` are assumed to be Ruby source. Files ending `.c` are parsed as C files. All other files are assumed to contain just Markup-style markup (with or without leading `#` comment markers). If directory names are passed to RDoc, they are scanned recursively for C and Ruby source files only. +RDoc uses file extensions to determine how to process each file. File names ending `.rb` and `.rbw` are assumed to be Ruby source. Files ending `.c` are parsed as C files. Files ending `.rbs` are parsed as RBS signature files. All other files are assumed to contain just Markup-style markup (with or without leading `#` comment markers). If directory names are passed to RDoc, they are scanned recursively for C, Ruby, and RBS source files. + +RBS files can document classes, modules, methods, attributes, and constants. When RBS declarations match objects already documented from Ruby source, their comments and type signatures extend the existing documentation. To generate documentation using `rake` see [RDoc::Task](https://ruby.github.io/rdoc/RDoc/Task.html).