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). 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.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..55b6d87237 --- /dev/null +++ b/lib/rdoc/parser/rbs.rb @@ -0,0 +1,276 @@ +# 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 + + # 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 + 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 + merge_comments object, comment + 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 + + 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') + 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 + + 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 + + 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 + '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] + 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( + name, + rw, + comment, + singleton: 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) + owner, name = context.find_or_create_constant_owner_name decl.name + superclass = decl.super_class&.name&.to_s || '::Object' + 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 + + decl.members.each { |member| parse_decl member, klass, klass.full_name } + 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 + 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 + 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 } + method_name = rdoc_method_name(decl) + singleton = rdoc_method_singleton?(decl) + visibility = rdoc_method_visibility(decl) + + if method = context.find_method(method_name, singleton) + merge_documentation method, comment, type_signature_lines + return + end + + method = RDoc::AnyMethod.new method_name, singleton: 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 = visibility if visibility + end + + 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 + + decl.members.each { |member| parse_decl member, mod, mod.full_name } + end +end diff --git a/lib/rdoc/rdoc.rb b/lib/rdoc/rdoc.rb index cbbae72e8a..7099d1b3b5 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,42 @@ 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| + ## + # 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) mtimes[file] = mtime if mtime end @@ -641,14 +657,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..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' ## @@ -54,6 +55,45 @@ 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? + !source_files_changed? && !reload_rbs_signatures? + end + + def record_changed(file) + reload_rbs_signatures_if_needed file + 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 + + def source_files_changed? + !changed_files.empty? || !removed_files.empty? + 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,56 +359,24 @@ def file_mtimes_for(files) # changes were found and processed. def check_for_changes - changed = [] - removed = [] - changed_rbs = [] - removed_rbs = [] - - @file_mtimes.each do |file, old_mtime| - unless File.exist?(file) - if @rdoc.rbs_signature_file?(file) - removed_rbs << file - else - removed << file - end - next - end - - current_mtime = RDoc.safe_mtime(file) - next unless current_mtime - next unless old_mtime.nil? || current_mtime > old_mtime + changes = FileChanges.new @rdoc + current_files = current_watch_files + current_file_set = current_files.to_set - if @rdoc.rbs_signature_file?(file) - changed_rbs << file - else - changed << file - end + @file_mtimes.each_key do |file| + changes.record_removed file unless current_file_set.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 - changed << file - end - end + current_files.each do |file| + next unless file_changed? file - @rdoc.rbs_signature_files.each do |file| - unless @file_mtimes.key?(file) - @file_mtimes[file] = nil - changed_rbs << file - end + @file_mtimes[file] = nil unless @file_mtimes.key? file + changes.record_changed file end - return false if changed.empty? && removed.empty? && changed_rbs.empty? && removed_rbs.empty? + return false if changes.empty? - removed_rbs.each { |file| @file_mtimes.delete(file) } - - reparse_and_refresh(changed, removed, rbs_changed: !changed_rbs.empty? || !removed_rbs.empty?) + reparse_and_refresh changes true end @@ -376,56 +384,81 @@ 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(changes) @mutex.synchronize do - unless removed_files.empty? - $stderr.puts "Removed: #{removed_files.join(', ')}" - 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 changed_files.empty? - changed_file_names = [] - duration_ms = measure do - 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 rbs_changed - duration_ms = measure do - @rdoc.load_rbs_signatures - @rdoc.record_rbs_signature_mtimes - @rdoc.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 changed_files.empty? && 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 diff --git a/test/rdoc/parser/rbs_test.rb b/test/rdoc/parser/rbs_test.rb new file mode 100644 index 0000000000..96549be79b --- /dev/null +++ b/test/rdoc/parser/rbs_test.rb @@ -0,0 +1,324 @@ +# frozen_string_literal: true + +require_relative '../helper' +require 'rdoc/parser' +require 'rdoc/generator/markup' +require 'rdoc/markup/to_html' + +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_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_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 + 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_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' + 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_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' + + 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 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' + + 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 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 +end 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 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