diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0c3730a157..f1d9c57b17 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,9 +14,9 @@ jobs: ruby-versions: uses: ruby/actions/.github/workflows/ruby_versions.yml@master with: - # 2.7 breaks `test_parse_statements_nodoc_identifier_alias_method` - min_version: 3.0 + min_version: 3.2 versions: '["mswin"]' + engine: cruby-truffleruby test: needs: ruby-versions @@ -30,10 +30,6 @@ jobs: ruby: truffleruby - os: windows-latest ruby: truffleruby-head - - os: windows-latest - ruby: jruby - - os: windows-latest - ruby: jruby-head - os: macos-latest ruby: mswin - os: ubuntu-latest @@ -63,7 +59,7 @@ jobs: strategy: fail-fast: false matrix: - prism_version: ['1.0.0', '1.3.0', '1.7.0', 'head'] + prism_version: ['1.6.0', '1.7.0', 'head'] runs-on: ubuntu-latest env: RUBYOPT: --enable-frozen_string_literal diff --git a/Gemfile b/Gemfile index 317623101b..4b2be8f5d5 100644 --- a/Gemfile +++ b/Gemfile @@ -18,7 +18,5 @@ elsif ENV['PRISM_VERSION'] end platforms :ruby do - if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.2') - gem 'mini_racer' # For testing the searcher.js file - end + gem 'mini_racer' # For testing the searcher.js file end diff --git a/lib/rdoc/code_object/any_method.rb b/lib/rdoc/code_object/any_method.rb index f56110ea11..4b30eb3fb8 100644 --- a/lib/rdoc/code_object/any_method.rb +++ b/lib/rdoc/code_object/any_method.rb @@ -13,8 +13,10 @@ class RDoc::AnyMethod < RDoc::MethodAttr # 3:: # RDoc 4.1 # Added is_alias_for + # 4:: + # Added type_signature_lines (serialized as joined string) - MARSHAL_VERSION = 3 # :nodoc: + MARSHAL_VERSION = 4 # :nodoc: ## # Don't rename \#initialize to \::new @@ -60,6 +62,7 @@ def add_alias(an_alias, context = nil) method.visibility = self.visibility method.comment = an_alias.comment method.is_alias_for = self + method.type_signature_lines = self.type_signature_lines @aliases << method context.add_method method if context method @@ -166,6 +169,7 @@ def marshal_dump @parent.class, @section.title, is_alias_for, + @type_signature_lines&.join("\n"), ] end @@ -204,6 +208,7 @@ def marshal_load(array) @parent_title = array[13] @section_title = array[14] @is_alias_for = array[15] + @type_signature_lines = array[16]&.split("\n") array[8].each do |new_name, document| add_alias RDoc::Alias.new(nil, @name, new_name, RDoc::Comment.from_document(document), singleton: @singleton) diff --git a/lib/rdoc/code_object/attr.rb b/lib/rdoc/code_object/attr.rb index bfc981f7e8..92abd2a7e3 100644 --- a/lib/rdoc/code_object/attr.rb +++ b/lib/rdoc/code_object/attr.rb @@ -10,8 +10,10 @@ class RDoc::Attr < RDoc::MethodAttr # RDoc 4 # Added parent name and class # Added section title + # 4:: + # Added type_signature_lines (serialized as joined string) - MARSHAL_VERSION = 3 # :nodoc: + MARSHAL_VERSION = 4 # :nodoc: ## # Is the attribute readable ('R'), writable ('W') or both ('RW')? @@ -48,6 +50,7 @@ def add_alias(an_alias, context) new_attr.record_location an_alias.file new_attr.visibility = self.visibility new_attr.is_alias_for = self + new_attr.type_signature_lines = self.type_signature_lines @aliases << new_attr context.add_attribute new_attr new_attr @@ -108,7 +111,8 @@ def marshal_dump @file.relative_name, @parent.full_name, @parent.class, - @section.title + @section.title, + @type_signature_lines&.join("\n"), ] end @@ -140,6 +144,7 @@ def marshal_load(array) @parent_name = array[8] @parent_class = array[9] @section_title = array[10] + @type_signature_lines = array[11]&.split("\n") @file = RDoc::TopLevel.new array[7] if version > 1 diff --git a/lib/rdoc/code_object/method_attr.rb b/lib/rdoc/code_object/method_attr.rb index 3169640982..95a03c22c9 100644 --- a/lib/rdoc/code_object/method_attr.rb +++ b/lib/rdoc/code_object/method_attr.rb @@ -58,6 +58,12 @@ class RDoc::MethodAttr < RDoc::CodeObject attr_accessor :call_seq + ## + # RBS type signature lines from inline annotations or loaded .rbs files. + # Each entry is one overload or type expression. + + attr_accessor :type_signature_lines + ## # The call_seq or the param_seq with method name, if there is no call_seq. @@ -86,6 +92,7 @@ def initialize(text, name, singleton: false) @block_params = nil @call_seq = nil @params = nil + @type_signature_lines = nil end ## diff --git a/lib/rdoc/generator/aliki.rb b/lib/rdoc/generator/aliki.rb index d8314196f9..650961c228 100644 --- a/lib/rdoc/generator/aliki.rb +++ b/lib/rdoc/generator/aliki.rb @@ -117,6 +117,21 @@ def write_search_index File.write search_index_path, "var search_data = #{JSON.generate(data)};" end + ## + # Returns the type signature of +method_attr+ as HTML with linked type names. + # Returns nil if no type signature is present. + + def type_signature_html(method_attr, from_path) + lines = method_attr.type_signature_lines + return unless lines + + RDoc::RbsHelper.signature_to_html( + lines, + lookup: @store.type_name_lookup, + from_path: from_path + ) + end + ## # Resolves a URL for use in templates. Absolute URLs are returned unchanged. # Relative URLs are prefixed with rel_prefix to ensure they resolve correctly from any page. diff --git a/lib/rdoc/generator/template/aliki/class.rhtml b/lib/rdoc/generator/template/aliki/class.rhtml index ba1238b9e9..bed46838de 100644 --- a/lib/rdoc/generator/template/aliki/class.rhtml +++ b/lib/rdoc/generator/template/aliki/class.rhtml @@ -93,6 +93,9 @@ <%= h attrib.name %> [<%= attrib.rw %>] + <%- if attrib.type_signature_lines %> + <%= type_signature_html(attrib, klass.path) %> + <%- end %>
@@ -150,6 +153,10 @@
<%- end %> + + <%- if method.type_signature_lines %> +
<%= type_signature_html(method, klass.path) %>
+ <%- end %> <%- if method.token_stream %> diff --git a/lib/rdoc/generator/template/aliki/css/rdoc.css b/lib/rdoc/generator/template/aliki/css/rdoc.css index e3e0aec650..25eabb3c0b 100644 --- a/lib/rdoc/generator/template/aliki/css/rdoc.css +++ b/lib/rdoc/generator/template/aliki/css/rdoc.css @@ -1075,6 +1075,20 @@ main h6 a:hover { font-style: italic; } +/* RBS Type Signature Links — linked types get subtle underline */ +a.rbs-type { + color: inherit; + text-decoration: underline; + text-decoration-color: var(--color-border-default); + text-underline-offset: 0.2em; + transition: text-decoration-color var(--transition-fast), color var(--transition-fast); +} + +a.rbs-type:hover { + color: var(--color-link-hover); + text-decoration-color: var(--color-link-hover); +} + /* Emphasis */ em { text-decoration-color: var(--color-emphasis-decoration); @@ -1336,6 +1350,49 @@ main .method-heading .method-args { font-weight: var(--font-weight-normal); } +/* Type signatures — overloads stack as a code block under the method name */ +pre.method-type-signature { + position: relative; + margin: var(--space-2) 0 0; + padding: var(--space-2) 0 0; + background: transparent; + border: none; + border-radius: 0; + overflow: visible; + font-family: var(--font-code); + font-size: var(--font-size-sm); + color: var(--color-text-tertiary); + line-height: var(--line-height-tight); + white-space: pre-wrap; + overflow-wrap: break-word; +} + +pre.method-type-signature::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + border-top: 1px dotted var(--color-border-default); +} + +pre.method-type-signature code { + font-family: inherit; + font-size: inherit; + color: inherit; + background: transparent; + padding: 0; +} + +/* Attribute type sigs render inline after the [RW] badge */ +main .method-heading > .method-type-signature { + display: inline; + margin-left: var(--space-2); + font-family: var(--font-code); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + main .method-controls { position: absolute; top: var(--space-3); @@ -1445,6 +1502,10 @@ main .attribute-access-type { font-size: var(--font-size-base); } + pre.method-type-signature { + font-size: var(--font-size-xs); + } + main .method-header { padding: var(--space-2); padding-right: var(--space-2); diff --git a/lib/rdoc/generator/template/aliki/js/aliki.js b/lib/rdoc/generator/template/aliki/js/aliki.js index 7883132b00..4dcfa826fc 100644 --- a/lib/rdoc/generator/template/aliki/js/aliki.js +++ b/lib/rdoc/generator/template/aliki/js/aliki.js @@ -435,8 +435,8 @@ function wrapCodeBlocksWithCopyButton() { // not directly in rhtml templates // - Modifying the formatter would require extending RDoc's core internals - // Find all pre elements that are not already wrapped - const preElements = document.querySelectorAll('main pre:not(.code-block-wrapper pre)'); + // Target code examples and source code; skip type signature blocks + const preElements = document.querySelectorAll('main pre:not(.method-type-signature)'); preElements.forEach((pre) => { // Skip if already wrapped diff --git a/lib/rdoc/parser/ruby.rb b/lib/rdoc/parser/ruby.rb index 4adaa8ad9e..486155e7c4 100644 --- a/lib/rdoc/parser/ruby.rb +++ b/lib/rdoc/parser/ruby.rb @@ -2,6 +2,7 @@ require 'prism' require_relative 'ripper_state_lex' +require_relative '../rbs_helper' # Parse and collect document from Ruby source code. @@ -128,6 +129,9 @@ class RDoc::Parser::Ruby < RDoc::Parser parse_files_matching(/\.rbw?$/) + # Matches an RBS inline type annotation line: #: followed by whitespace + RBS_SIG_LINE = /\A#:\s/ # :nodoc: + attr_accessor :visibility attr_reader :container, :singleton, :in_proc_block @@ -456,10 +460,14 @@ def skip_comments_until(line_no_until) def consecutive_comment(line_no) return unless @unprocessed_comments.first&.first == line_no _line_no, start_line, text = @unprocessed_comments.shift - parse_comment_text_to_directives(text, start_line) + type_signature_lines = extract_type_signature!(text, start_line) + result = parse_comment_text_to_directives(text, start_line) + return unless result + comment, directives = result + [comment, directives, type_signature_lines] end - # Parses comment text and retuns a pair of RDoc::Comment and directives + # Parses comment text and returns a pair of RDoc::Comment and directives def parse_comment_text_to_directives(comment_text, start_line) # :nodoc: comment_text, directives = @preprocess.parse_comment(comment_text, start_line, :ruby) @@ -589,7 +597,7 @@ def add_alias_method(old_name, new_name, line_no) # Handles `attr :a, :b`, `attr_reader :a, :b`, `attr_writer :a, :b` and `attr_accessor :a, :b` def add_attributes(names, rw, line_no) - comment, directives = consecutive_comment(line_no) + comment, directives, type_signature_lines = consecutive_comment(line_no) handle_code_object_directives(@container, directives) if directives return unless @container.document_children @@ -597,6 +605,7 @@ def add_attributes(names, rw, line_no) a = RDoc::Attr.new(nil, symbol.to_s, rw, comment, singleton: @singleton) a.store = @store a.line = line_no + a.type_signature_lines = type_signature_lines record_location(a) handle_modifier_directive(a, line_no) @container.add_attribute(a) if should_document?(a) @@ -635,7 +644,7 @@ def add_extends(names, line_no) # :nodoc: def add_method(method_name, receiver_name:, receiver_fallback_type:, visibility:, singleton:, params:, calls_super:, block_params:, tokens:, start_line:, args_end_line:, end_line:) receiver = receiver_name ? find_or_create_module_path(receiver_name, receiver_fallback_type) : @container - comment, directives = consecutive_comment(start_line) + comment, directives, type_signature_lines = consecutive_comment(start_line) handle_code_object_directives(@container, directives) if directives internal_add_method( @@ -650,11 +659,12 @@ def add_method(method_name, receiver_name:, receiver_fallback_type:, visibility: params: params, calls_super: calls_super, block_params: block_params, - tokens: tokens + tokens: tokens, + type_signature_lines: type_signature_lines ) end - private def internal_add_method(method_name, container, comment:, dont_rename_initialize: false, directives:, modifier_comment_lines: nil, line_no:, visibility:, singleton:, params:, calls_super:, block_params:, tokens:) # :nodoc: + private def internal_add_method(method_name, container, comment:, dont_rename_initialize: false, directives:, modifier_comment_lines: nil, line_no:, visibility:, singleton:, params:, calls_super:, block_params:, tokens:, type_signature_lines: nil) # :nodoc: meth = RDoc::AnyMethod.new(nil, method_name, singleton: singleton) meth.comment = comment handle_code_object_directives(meth, directives) if directives @@ -675,6 +685,7 @@ def add_method(method_name, receiver_name:, receiver_fallback_type:, visibility: meth.params ||= params || '()' meth.calls_super = calls_super meth.block_params ||= block_params if block_params + meth.type_signature_lines = type_signature_lines record_location(meth) meth.start_collecting_tokens(:ruby) tokens.each do |token| @@ -833,6 +844,37 @@ def add_module_or_class(module_name, start_line, end_line, is_class: false, supe mod end + private + + # Extracts RBS type signature lines (#: ...) from raw comment text. + # Mutates the input text to remove the extracted lines. + # Returns an array of extracted type signature lines, or nil if none are + # found. The array may contain multiple lines for overloaded signatures. + + def extract_type_signature!(text, start_line) + return nil unless text.include?('#:') + + lines = text.lines + sig_lines, doc_lines = lines.partition { |l| l.match?(RBS_SIG_LINE) } + return nil if sig_lines.empty? + + first_sig_line = start_line + lines.index(sig_lines.first) + text.replace(doc_lines.join) + type_signature_lines = sig_lines.map { |l| l.sub(RBS_SIG_LINE, '').strip }.reject(&:empty?) + return nil if type_signature_lines.empty? + + warn_invalid_type_signature(type_signature_lines, first_sig_line) + type_signature_lines + end + + def warn_invalid_type_signature(type_signature_lines, line_no) + type_signature_lines.each_with_index do |line, i| + next if RDoc::RbsHelper.valid_method_type?(line) + next if RDoc::RbsHelper.valid_type?(line) + @options.warn "#{@top_level.relative_name}:#{line_no + i}: invalid RBS type signature: #{line.inspect}" + end + end + class RDocVisitor < Prism::Visitor # :nodoc: def initialize(scanner, top_level, store) @scanner = scanner diff --git a/lib/rdoc/rbs_helper.rb b/lib/rdoc/rbs_helper.rb new file mode 100644 index 0000000000..5750663eee --- /dev/null +++ b/lib/rdoc/rbs_helper.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +require 'erb' +require 'pathname' +require 'rbs' +require 'rdoc/markup/formatter' + +## +# RBS type signature support. +# Loads type information from .rbs files, validates inline annotations, +# and converts type signatures to HTML with linked type names. + +module RDoc + module RbsHelper + class << self + + ## + # Returns true if +sig+ is a valid RBS method type signature. + + def valid_method_type?(sig) + RBS::Parser.parse_method_type(sig, require_eof: true) + true + rescue RBS::ParsingError + false + end + + ## + # Returns true if +sig+ is a valid RBS type signature. + + def valid_type?(sig) + RBS::Parser.parse_type(sig, require_eof: true) + true + rescue RBS::ParsingError + false + end + + ## + # Loads RBS signatures from the given directories. + # Returns a Hash mapping "ClassName#method_name" => ["type sig string", ...]. + + def load_signatures(*dirs) + loader = RBS::EnvironmentLoader.new + dirs.each { |dir| loader.add(path: Pathname(dir)) } + + env = RBS::Environment.new + loader.load(env: env) + + signatures = {} + + env.class_decls.each do |type_name, entry| + class_name = type_name.to_s.delete_prefix('::') + + entry.each_decl do |decl| + decl.members.each do |member| + case member + when RBS::AST::Members::MethodDefinition + sigs = member.overloads.map { |o| o.method_type.to_s } + method_keys_for(class_name, member).each do |key| + signatures[key] ||= sigs + end + when RBS::AST::Members::AttrReader, RBS::AST::Members::AttrWriter, RBS::AST::Members::AttrAccessor + key = member.kind == :singleton ? "#{class_name}.#{member.name}" : "#{class_name}##{member.name}" + signatures[key] ||= [member.type.to_s] + end + end + end + end + + signatures + end + + ## + # Converts type signature lines to HTML with type names linked to + # their documentation pages. Uses the RBS parser to extract type + # name locations precisely. + # + # +lines+ is an Array of signature line strings. + # +lookup+ is a Hash mapping type names to their doc paths. + # +from_path+ is the current page path for generating relative URLs. + # + # Returns escaped HTML with +->+ replaced by +→+. + + def signature_to_html(lines, lookup:, from_path:) + lines.map { |line| + link_type_names_in_line(line, lookup, from_path).gsub('->', '→') + }.join("\n") + end + + private + + # `def self?.foo: ...` produces a member whose kind is :singleton_instance — + # it defines both Class.foo (singleton) and a private Class#foo (instance), + # so we need to register the signature under both keys. + def method_keys_for(class_name, member) + case member.kind + when :singleton + ["#{class_name}.#{member.name}"] + when :singleton_instance + ["#{class_name}.#{member.name}", "#{class_name}##{member.name}"] + else + ["#{class_name}##{member.name}"] + end + end + + def link_type_names_in_line(line, lookup, from_path) + escaped = ERB::Util.html_escape(line) + + locs = collect_type_name_locations(line) + return escaped if locs.empty? + + result = escaped.dup + + # Replace type names with links, working backwards to preserve positions. + # HTML escaping (e.g. -> becomes ->) shifts positions, so we + # re-escape the prefix to find the correct offset in the result. + locs.sort_by { |l| -l[:start] }.each do |loc| + name = loc[:name] + next unless (target_path = lookup[name]) + + prefix = ERB::Util.html_escape(line[0...loc[:start]]) + escaped_name = ERB::Util.html_escape(name) + start_in_escaped = prefix.length + end_in_escaped = start_in_escaped + escaped_name.length + + href = ERB::Util.html_escape(::RDoc::Markup::Formatter.gen_relative_url(from_path, target_path)) + result[start_in_escaped...end_in_escaped] = + "#{escaped_name}" + end + + result + end + + ## + # Extracts type name locations from a signature line using the RBS parser. + + def collect_type_name_locations(line) + locs = [] + + begin + mt = RBS::Parser.parse_method_type(line, require_eof: true) + rescue RBS::ParsingError + begin + type = RBS::Parser.parse_type(line, require_eof: true) + collect_from_type(type, locs) + return locs + rescue RBS::ParsingError + return locs + end + end + + mt.type.each_param { |p| collect_from_type(p.type, locs) } + if mt.block + mt.block.type.each_param { |p| collect_from_type(p.type, locs) } + collect_from_type(mt.block.type.return_type, locs) + end + collect_from_type(mt.type.return_type, locs) + + locs + end + + ## + # Recursively collects type name locations from an RBS type AST node. + + def collect_from_type(type, locs) + case type + when RBS::Types::ClassInstance + name = type.name.to_s.delete_prefix('::') + if type.location + name_loc = type.location[:name] || type.location + locs << { name: name, start: name_loc.end_pos - name.length } + end + type.args.each { |a| collect_from_type(a, locs) } + when RBS::Types::Union, RBS::Types::Intersection, RBS::Types::Tuple + type.types.each { |t| collect_from_type(t, locs) } + when RBS::Types::Optional + collect_from_type(type.type, locs) + when RBS::Types::Record + type.all_fields.each_value { |t| collect_from_type(t, locs) } + when RBS::Types::Proc + type.type.each_param { |p| collect_from_type(p.type, locs) } + collect_from_type(type.type.return_type, locs) + end + end + end + end +end diff --git a/lib/rdoc/rdoc.rb b/lib/rdoc/rdoc.rb index 195bd21421..b2f8b4ce09 100644 --- a/lib/rdoc/rdoc.rb +++ b/lib/rdoc/rdoc.rb @@ -5,6 +5,7 @@ require 'fileutils' require 'pathname' require 'time' +require_relative 'rbs_helper' ## # This is the driver for generating RDoc output. It handles file parsing and @@ -429,7 +430,7 @@ def parse_files(files) def remove_unparseable(files) files.reject do |file, *| - file =~ /\.(?:class|eps|erb|scpt\.txt|svg|ttf|yml)$/i or + file =~ /\.(?:class|eps|erb|rbs|scpt\.txt|svg|ttf|yml)$/i or (file =~ /tags$/i and /\A(\f\n[^,]+,\d+$|!_TAG_)/.match?(File.binread(file, 100))) end @@ -469,10 +470,12 @@ def document(options) @store.load_cache parse_files @options.files + record_rbs_signature_mtimes @options.default_title = "RDoc Documentation" @store.complete @options.visibility + load_rbs_signatures start_server exit @@ -486,19 +489,28 @@ def document(options) @store.load_cache + rbs_signatures_changed = rbs_signatures_changed? + # When only sig/*.rbs changed, no Ruby file would be reparsed and the + # cached HTML pipeline keeps no in-memory class data across runs, so + # force a full reparse to give merge_rbs_signatures a populated store. + @last_modified.clear if rbs_signatures_changed + file_info = parse_files @options.files + record_rbs_signature_mtimes @options.default_title = "RDoc Documentation" @store.complete @options.visibility + load_rbs_signatures + @stats.coverage_level = @options.coverage_report if @options.coverage_report then puts puts @stats.report.accept RDoc::Markup::ToRdoc.new - elsif file_info.empty? then + elsif file_info.empty? && !rbs_signatures_changed then $stderr.puts "\nNo newer files." unless @options.quiet else gen_klass = @options.generator @@ -540,6 +552,73 @@ def generate end end + ## + # 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 + sig_dirs = [] + sig_dir = File.join(@options.root.to_s, 'sig') + sig_dirs << sig_dir if File.directory?(sig_dir) + signatures = RDoc::RbsHelper.load_signatures(*sig_dirs) + @store.merge_rbs_signatures(signatures) + rescue RBS::BaseError, Errno::ENOENT, LoadError => e + # In server mode, a previous successful load may have populated the store; + # drop those signatures so a now-broken sig file doesn't keep showing + # stale types alongside the warning. + @store.clear_rbs_signatures + @options.warn "Failed to load RBS type signatures: #{e.message}" + end + + ## + # Returns all RBS signature files for the project. + + def 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. + + def rbs_signatures_changed? + current = rbs_signature_mtimes + previous = @last_modified.select { |file, _| File.extname(file) == '.rbs' } + + return true unless (previous.keys - current.keys).empty? + + current.any? do |file, mtime| + last_modified = @last_modified[file] + last_modified.nil? || mtime.to_i > last_modified.to_i + end + end + + ## + # Records 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, _| File.extname(file) == '.rbs' } + @last_modified.merge! rbs_signature_mtimes + end + + ## + # Files watched by the live preview server. + + def watch_files + (@last_modified.keys + rbs_signature_files).uniq + end + + def rbs_signature_file?(file) # :nodoc: + File.extname(file) == '.rbs' + end + + def rbs_signature_mtimes # :nodoc: + rbs_signature_files.each_with_object({}) do |file, mtimes| + mtime = File.mtime(file) rescue nil + mtimes[file] = mtime if mtime + end + end + ## # Starts a live-reloading HTTP server for previewing documentation. # Called from #document when --server is given. diff --git a/lib/rdoc/ri/driver.rb b/lib/rdoc/ri/driver.rb index 014c5be4fb..f67710550b 100644 --- a/lib/rdoc/ri/driver.rb +++ b/lib/rdoc/ri/driver.rb @@ -1415,6 +1415,7 @@ def render_method(out, store, method, name) # :nodoc: out << RDoc::Markup::Rule.new(1) render_method_arguments out, method.arglists + render_method_type_signature out, method.type_signature_lines if method.type_signature_lines render_method_superclass out, method if method.is_alias_for al = method.is_alias_for @@ -1452,6 +1453,10 @@ def render_method_comment(out, method, alias_for = nil)# :nodoc: end end + def render_method_type_signature(out, lines) # :nodoc: + out << RDoc::Markup::Verbatim.new(*lines.map { |s| s + "\n" }) + end + def render_method_superclass(out, method) # :nodoc: return unless method.respond_to?(:superclass_method) and method.superclass_method diff --git a/lib/rdoc/server.rb b/lib/rdoc/server.rb index 9dbb342908..52c9130352 100644 --- a/lib/rdoc/server.rb +++ b/lib/rdoc/server.rb @@ -84,7 +84,7 @@ def start @tcp_server = TCPServer.new('127.0.0.1', @port) @running = true - @watcher_thread = start_watcher(@rdoc.last_modified.keys) + @watcher_thread = start_watcher(@rdoc.watch_files) url = "http://localhost:#{@port}" $stderr.puts "\nServing documentation at: \e]8;;#{url}\e\\#{url}\e]8;;\e\\" @@ -294,9 +294,7 @@ def inject_live_reload(html, last_change_time) # re-parsing when changes are detected. def start_watcher(source_files) - @file_mtimes = source_files.each_with_object({}) do |f, h| - h[f] = File.mtime(f) rescue nil - end + @file_mtimes = file_mtimes_for(source_files) Thread.new do while @running @@ -310,6 +308,12 @@ def start_watcher(source_files) end end + def file_mtimes_for(files) + files.each_with_object({}) do |f, h| + h[f] = File.mtime(f) rescue nil + end + end + ## # Checks for modified, new, and deleted files. Returns true if any # changes were found and processed. @@ -317,16 +321,28 @@ def start_watcher(source_files) def check_for_changes changed = [] removed = [] + changed_rbs = [] + removed_rbs = [] @file_mtimes.each do |file, old_mtime| unless File.exist?(file) - removed << file + if @rdoc.rbs_signature_file?(file) + removed_rbs << file + else + removed << file + end next end current_mtime = File.mtime(file) rescue nil next unless current_mtime - changed << file if old_mtime.nil? || current_mtime > old_mtime + next unless old_mtime.nil? || current_mtime > old_mtime + + if @rdoc.rbs_signature_file?(file) + changed_rbs << file + else + changed << file + end end file_list = @rdoc.normalized_file_list( @@ -341,9 +357,18 @@ def check_for_changes end end - return false if changed.empty? && removed.empty? + @rdoc.rbs_signature_files.each do |file| + unless @file_mtimes.key?(file) + @file_mtimes[file] = nil + changed_rbs << file + end + end + + return false if changed.empty? && removed.empty? && changed_rbs.empty? && removed_rbs.empty? + + removed_rbs.each { |file| @file_mtimes.delete(file) } - reparse_and_refresh(changed, removed) + reparse_and_refresh(changed, removed, rbs_changed: !changed_rbs.empty? || !removed_rbs.empty?) true end @@ -351,7 +376,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) + def reparse_and_refresh(changed_files, removed_files, rbs_changed: false) @mutex.synchronize do unless removed_files.empty? $stderr.puts "Removed: #{removed_files.join(', ')}" @@ -385,6 +410,17 @@ def reparse_and_refresh(changed_files, removed_files) @store.complete(@options.visibility) + if rbs_changed || !changed_files.empty? + duration_ms = measure do + @rdoc.load_rbs_signatures + @rdoc.record_rbs_signature_mtimes + @rdoc.rbs_signature_files.each do |file| + @file_mtimes[file] = File.mtime(file) rescue nil + end + end + $stderr.puts "Reloaded RBS signatures (#{duration_ms}ms)" if rbs_changed + end + @generator.refresh_store_data @page_cache.clear @last_change_time = Time.now.to_f diff --git a/lib/rdoc/store.rb b/lib/rdoc/store.rb index 379d2f2246..52dbd7c241 100644 --- a/lib/rdoc/store.rb +++ b/lib/rdoc/store.rb @@ -343,6 +343,93 @@ def all_classes_and_modules @classes_hash.values + @modules_hash.values end + ## + # Returns a hash mapping class/module names to their paths, for use + # by type signature linking. Maps both qualified names (Foo::Bar) and + # unambiguous unqualified names (Bar). Ambiguous unqualified names + # (where multiple classes share the same name) are excluded to avoid + # wrong links. Cached after first call. + + def type_name_lookup + @type_name_lookup ||= begin + lookup = {} + unqualified_names = {} + ambiguous_names = {} + all_classes_and_modules.each do |cm| + lookup[cm.full_name] = cm.path + unqualified_name = cm.name + + if ambiguous_names[unqualified_name] + # already known ambiguous, skip + elsif unqualified_names.key?(unqualified_name) + unqualified_names.delete(unqualified_name) + ambiguous_names[unqualified_name] = true + else + unqualified_names[unqualified_name] = cm.path + end + end + lookup.merge!(unqualified_names) + end + end + + ## + # Merges RBS type signatures into code objects. + # Inline #: annotations take priority and are not overwritten. + + def merge_rbs_signatures(signatures) + clear_rbs_signatures + + all_classes_and_modules.each do |cm| + cm.method_list.each do |method| + next if method.type_signature_lines + + sig = signatures[rbs_key(cm, method)] + + # RBS keys constructors as #initialize, but RDoc renames them to .new + if !sig && method.name == 'new' && method.singleton + sig = signatures["#{cm.full_name}#initialize"] + end + + assign_rbs_signature method, sig if sig + end + + cm.attributes.each do |attr| + next if attr.type_signature_lines + + if (sig = signatures[rbs_key(cm, attr)]) + assign_rbs_signature attr, sig + end + end + end + end + + def assign_rbs_signature(method_attr, signature) # :nodoc: + @rbs_signature_method_attrs ||= [] + + method_attr.type_signature_lines = signature + @rbs_signature_method_attrs << method_attr + + method_attr.aliases.each do |aliased| + next if aliased.type_signature_lines + aliased.type_signature_lines = signature + @rbs_signature_method_attrs << aliased + end + end + + def clear_rbs_signatures # :nodoc: + return unless @rbs_signature_method_attrs + + @rbs_signature_method_attrs.each do |method_attr| + method_attr.type_signature_lines = nil + end + + @rbs_signature_method_attrs.clear + end + + def rbs_key(cm, method_attr) # :nodoc: + method_attr.singleton ? "#{cm.full_name}.#{method_attr.name}" : "#{cm.full_name}##{method_attr.name}" + end + ## # All TopLevels known to RDoc @@ -443,6 +530,7 @@ def clean_cache_collection(collection) # :nodoc: # See also RDoc::Context#remove_from_documentation? def complete(min_visibility) + @type_name_lookup = nil fix_basic_object_inheritance # cache included modules before they are removed from the documentation diff --git a/rdoc.gemspec b/rdoc.gemspec index 1afe52f7b6..294255d7a1 100644 --- a/rdoc.gemspec +++ b/rdoc.gemspec @@ -63,11 +63,12 @@ RDoc includes the +rdoc+ and +ri+ tools for generating and displaying documentat s.rdoc_options = ["--main", "README.md"] s.extra_rdoc_files += s.files.grep(%r[\A[^\/]+\.(?:rdoc|md)\z]) - s.required_ruby_version = Gem::Requirement.new(">= 2.7.0") + s.required_ruby_version = Gem::Requirement.new(">= 3.2.0") s.required_rubygems_version = Gem::Requirement.new(">= 2.2") s.add_dependency 'psych', '>= 4.0.0' s.add_dependency 'erb' s.add_dependency 'tsort' - s.add_dependency 'prism', '>= 1.0.0' + s.add_dependency 'prism', '>= 1.6.0' + s.add_dependency 'rbs', '>= 4.0.0' end diff --git a/test/rdoc/code_object/any_method_test.rb b/test/rdoc/code_object/any_method_test.rb index 43dc679d95..0af0db63ea 100644 --- a/test/rdoc/code_object/any_method_test.rb +++ b/test/rdoc/code_object/any_method_test.rb @@ -242,6 +242,40 @@ def test_marshal_dump assert_equal section, loaded.section end + def test_marshal_dump_with_type_signature + @store.path = Dir.tmpdir + top_level = @store.add_file 'file.rb' + + m = RDoc::AnyMethod.new nil, 'method' + m.type_signature_lines = ['(String) -> Integer'] + m.record_location top_level + + cm = top_level.add_class RDoc::ClassModule, 'Klass' + cm.add_method m + + loaded = Marshal.load Marshal.dump m + loaded.store = @store + + assert_equal ['(String) -> Integer'], loaded.type_signature_lines + end + + def test_add_alias_copies_type_signature + @store.path = Dir.tmpdir + top_level = @store.add_file 'file.rb' + cm = top_level.add_class RDoc::ClassModule, 'Klass' + + m = RDoc::AnyMethod.new nil, 'original' + m.type_signature_lines = ['(String) -> void'] + m.record_location top_level + cm.add_method m + + a = RDoc::Alias.new nil, 'original', 'aliased', '' + a.record_location top_level + + aliased = m.add_alias a, cm + assert_equal ['(String) -> void'], aliased.type_signature_lines + end + def test_marshal_load_aliased_method aliased_method = Marshal.load Marshal.dump(@c2_a) diff --git a/test/rdoc/code_object/attr_test.rb b/test/rdoc/code_object/attr_test.rb index 3588743694..744282f951 100644 --- a/test/rdoc/code_object/attr_test.rb +++ b/test/rdoc/code_object/attr_test.rb @@ -74,6 +74,40 @@ def test_marshal_dump assert_equal section, loaded.section end + def test_marshal_dump_with_type_signature + @store.path = Dir.tmpdir + top_level = @store.add_file 'file.rb' + + a = RDoc::Attr.new nil, 'name', 'R', 'a comment' + a.type_signature_lines = ['String'] + a.record_location top_level + + cm = top_level.add_class RDoc::ClassModule, 'Klass' + cm.add_attribute a + + loaded = Marshal.load Marshal.dump a + loaded.store = @store + + assert_equal ['String'], loaded.type_signature_lines + end + + def test_add_alias_copies_type_signature + @store.path = Dir.tmpdir + top_level = @store.add_file 'file.rb' + cm = top_level.add_class RDoc::ClassModule, 'Klass' + + a = RDoc::Attr.new nil, 'name', 'R', '' + a.type_signature_lines = ['String'] + a.record_location top_level + cm.add_attribute a + + al = RDoc::Alias.new nil, 'name', 'label', '' + al.record_location top_level + + aliased = a.add_alias al, cm + assert_equal ['String'], aliased.type_signature_lines + end + def test_marshal_dump_singleton tl = @store.add_file 'file.rb' diff --git a/test/rdoc/parser/ruby_test.rb b/test/rdoc/parser/ruby_test.rb index 9ec4722b5a..c2484afdab 100644 --- a/test/rdoc/parser/ruby_test.rb +++ b/test/rdoc/parser/ruby_test.rb @@ -2529,6 +2529,101 @@ def m2; end assert_equal "ARGF.readlines(a)\nARGF.readlines(b)\nARGF.readlines(c)\nARGF.readlines(d)", m2.call_seq.chomp end + def test_method_type_signature + util_parser <<~RUBY + class Foo + # A greeting method + #: (String, Integer) -> void + def greet(name, count); end + end + RUBY + + klass = @store.find_class_named 'Foo' + greet = klass.method_list.first + assert_equal 'greet', greet.name + assert_equal ['(String, Integer) -> void'], greet.type_signature_lines + assert_equal 'A greeting method', greet.comment.text.strip + end + + def test_attribute_type_signature + util_parser <<~RUBY + class Foo + #: String + attr_reader :name + + #: Integer + attr_accessor :count + end + RUBY + + klass = @store.find_class_named 'Foo' + attrs = klass.attributes.sort_by(&:name) + assert_equal 'count', attrs[0].name + assert_equal ['Integer'], attrs[0].type_signature_lines + assert_equal 'name', attrs[1].name + assert_equal ['String'], attrs[1].type_signature_lines + end + + def test_method_type_signature_multiple_overloads + util_parser <<~RUBY + class Foo + # Convert a value + #: (String) -> Integer + #: (Integer) -> String + def convert(value); end + end + RUBY + + klass = @store.find_class_named 'Foo' + convert = klass.method_list.first + assert_equal ['(String) -> Integer', '(Integer) -> String'], convert.type_signature_lines + assert_equal 'Convert a value', convert.comment.text.strip + end + + def test_method_without_type_signature + util_parser <<~RUBY + class Foo + # A plain method + def plain; end + end + RUBY + + klass = @store.find_class_named 'Foo' + plain = klass.method_list.first + assert_nil plain.type_signature_lines + assert_equal 'A plain method', plain.comment.text.strip + end + + def test_method_type_signature_with_blank_line_separation + util_parser <<~RUBY + class Foo + # Documentation here + # + #: (String) -> void + def bar(x); end + end + RUBY + + klass = @store.find_class_named 'Foo' + bar = klass.method_list.first + assert_equal ['(String) -> void'], bar.type_signature_lines + assert_equal "Documentation here", bar.comment.text + end + + def test_type_signature_invalid_still_stored + util_parser <<~RUBY + class Foo + #: (String -> + def bar(x); end + end + RUBY + + klass = @store.find_class_named 'Foo' + bar = klass.method_list.first + # Invalid sigs are still stored (don't block display) + assert_equal ['(String ->'], bar.type_signature_lines + end + def util_parser(content) @parser = RDoc::Parser::Ruby.new @top_level, content, @options, @stats @parser.scan diff --git a/test/rdoc/rbs_helper_test.rb b/test/rdoc/rbs_helper_test.rb new file mode 100644 index 0000000000..38cd5ad5c5 --- /dev/null +++ b/test/rdoc/rbs_helper_test.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require_relative 'helper' +require 'rdoc/rbs_helper' +require 'rdoc/markup/formatter' + +class RDocRbsHelperTest < RDoc::TestCase + def test_valid_method_type + assert RDoc::RbsHelper.valid_method_type?('(String) -> void') + assert RDoc::RbsHelper.valid_method_type?('(Integer, ?String) -> bool') + assert RDoc::RbsHelper.valid_method_type?('() -> Array[String]') + end + + def test_invalid_method_type + refute RDoc::RbsHelper.valid_method_type?('(String ->') + end + + def test_valid_type + assert RDoc::RbsHelper.valid_type?('String') + assert RDoc::RbsHelper.valid_type?('Array[Integer]') + assert RDoc::RbsHelper.valid_type?('String?') + end + + def test_invalid_type + refute RDoc::RbsHelper.valid_type?('String[') + end + + def test_load_signatures_from_directory + Dir.mktmpdir do |dir| + File.write(File.join(dir, 'test.rbs'), <<~RBS) + class Greeter + def greet: (String name) -> void + attr_reader language: String + end + RBS + + sigs = RDoc::RbsHelper.load_signatures(dir) + assert_equal ['(String name) -> void'], sigs['Greeter#greet'] + assert_equal ['String'], sigs['Greeter#language'] + end + end + + def test_load_signatures_registers_module_function_methods_under_both_keys + Dir.mktmpdir do |dir| + File.write(File.join(dir, 'test.rbs'), <<~RBS) + class Greeter + def self?.shout: (String text) -> String + end + RBS + + sigs = RDoc::RbsHelper.load_signatures(dir) + assert_equal ['(String text) -> String'], sigs['Greeter.shout'] + assert_equal ['(String text) -> String'], sigs['Greeter#shout'] + end + end + + def test_load_signatures_keeps_instance_and_singleton_attributes_separate + Dir.mktmpdir do |dir| + File.write(File.join(dir, 'test.rbs'), <<~RBS) + class Greeter + attr_reader language: String + attr_reader self.language: Integer + end + RBS + + sigs = RDoc::RbsHelper.load_signatures(dir) + assert_equal ['String'], sigs['Greeter#language'] + assert_equal ['Integer'], sigs['Greeter.language'] + end + end + + def test_signature_to_html_links_known_types + lookup = { 'String' => 'String.html', 'Integer' => 'Integer.html' } + result = RDoc::RbsHelper.signature_to_html(["(String) -> Integer"], lookup: lookup, from_path: 'Test.html') + + assert_includes result, 'String' + assert_includes result, 'Integer' + assert_includes result, '→' + end + + def test_signature_to_html_leaves_unknown_types_plain + result = RDoc::RbsHelper.signature_to_html(["(UnknownType) -> void"], lookup: {}, from_path: 'Test.html') + + refute_includes result, ' 'Foo/Bar.html' } + result = RDoc::RbsHelper.signature_to_html(["(::Foo::Bar) -> void"], lookup: lookup, from_path: 'Test.html') + + assert_includes result, 'Foo::Bar' + end + + def test_signature_to_html_multiline + lookup = { 'String' => 'String.html', 'Integer' => 'Integer.html' } + result = RDoc::RbsHelper.signature_to_html(["(String) -> Integer", "(Integer) -> String"], lookup: lookup, from_path: 'Test.html') + + assert_includes result, 'String' + assert_includes result, 'Integer' + assert_includes result, "\n" + end + + def test_signature_to_html_union_type + lookup = { 'String' => 'String.html', 'Integer' => 'Integer.html' } + result = RDoc::RbsHelper.signature_to_html(["(String | Integer) -> void"], lookup: lookup, from_path: 'Test.html') + + assert_includes result, 'String' + assert_includes result, 'Integer' + end + + def test_signature_to_html_optional_type + lookup = { 'String' => 'String.html' } + result = RDoc::RbsHelper.signature_to_html(["String?"], lookup: lookup, from_path: 'Test.html') + + assert_includes result, 'String' + end + + def test_signature_to_html_tuple_type + lookup = { 'String' => 'String.html', 'Integer' => 'Integer.html' } + result = RDoc::RbsHelper.signature_to_html(["[String, Integer]"], lookup: lookup, from_path: 'Test.html') + + assert_includes result, 'String' + assert_includes result, 'Integer' + end + + def test_signature_to_html_intersection_type + lookup = { 'String' => 'String.html', 'Comparable' => 'Comparable.html' } + result = RDoc::RbsHelper.signature_to_html(["(String & Comparable) -> void"], lookup: lookup, from_path: 'Test.html') + + assert_includes result, 'String' + assert_includes result, 'Comparable' + end + + def test_signature_to_html_proc_type + lookup = { 'String' => 'String.html', 'Integer' => 'Integer.html' } + result = RDoc::RbsHelper.signature_to_html(["^(String) -> Integer"], lookup: lookup, from_path: 'Test.html') + + assert_includes result, 'String' + assert_includes result, 'Integer' + end + + def test_signature_to_html_links_block_return_type + lookup = { 'String' => 'String.html', 'Integer' => 'Integer.html' } + result = RDoc::RbsHelper.signature_to_html(["() { (String) -> Integer } -> void"], lookup: lookup, from_path: 'Test.html') + + assert_includes result, 'String' + assert_includes result, 'Integer' + end + + def test_signature_to_html_unparseable_signature + result = RDoc::RbsHelper.signature_to_html(["(String ->"], lookup: { 'String' => 'String.html' }, from_path: 'Test.html') + + # Unparseable sigs are returned as escaped HTML with no links + refute_includes result, ' String + end + RBS + + source_mtime = Time.at 1000 + old_sig_mtime = Time.at 1000 + new_sig_mtime = Time.at 2000 + FileUtils.touch source, mtime: source_mtime + FileUtils.touch sig, mtime: new_sig_mtime + + File.open @rdoc.output_flag_file(output_dir), 'w' do |io| + io.puts Time.at(1500).rfc2822 + io.puts "#{source}\t#{source_mtime.rfc2822}" + io.puts "#{sig}\t#{old_sig_mtime.rfc2822}" + end + + options = RDoc::Options.new + options.files = [source] + options.root = Pathname dir + options.op_dir = output_dir + options.force_update = false + 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 + + example = store.find_class_or_module 'Example' + greet = example.find_method 'greet', false + assert_equal ['() -> String'], greet.type_signature_lines + end + end + + def test_load_rbs_signatures_clears_stale_signatures_on_failure + temp_dir do |dir| + sig_dir = File.join dir, 'sig' + sig = File.join sig_dir, 'example.rbs' + FileUtils.mkdir_p sig_dir + + File.write sig, <<~RBS + class Example + def greet: () -> String + end + RBS + + @options.root = Pathname(dir) + @options.op_dir = dir + @rdoc.store = RDoc::Store.new(@options) + + top_level = @rdoc.store.add_file 'example.rb' + example = top_level.add_class RDoc::NormalClass, 'Example' + method = RDoc::AnyMethod.new nil, 'greet' + example.add_method method + + @rdoc.load_rbs_signatures + assert_equal ['() -> String'], method.type_signature_lines + + File.write sig, "class Example\n def greet: ( -> " + @options.verbosity = 2 + _out, err = capture_output do + @rdoc.load_rbs_signatures + end + + assert_includes err, 'Failed to load RBS type signatures' + assert_nil method.type_signature_lines + end + end + def test_document_with_dry_run # functional test options = RDoc::Options.new options.files = [File.expand_path('../xref_data.rb', __FILE__)] diff --git a/test/rdoc/rdoc_server_test.rb b/test/rdoc/rdoc_server_test.rb index bc141cabe7..66457f9ee8 100644 --- a/test/rdoc/rdoc_server_test.rb +++ b/test/rdoc/rdoc_server_test.rb @@ -10,7 +10,13 @@ def setup File.write File.join(@dir, "PAGE.md"), "# A Page\n\nSome content.\n" File.write File.join(@dir, "NOTES.rdoc"), "= Notes\n\nSome notes.\n" - File.write File.join(@dir, "example.rb"), "# A class\nclass Example; end\n" + File.write File.join(@dir, "example.rb"), <<~RUBY + # A class + class Example + def greet + end + end + RUBY @options.files = [@dir] @options.op_dir = File.join(@dir, "_site") @@ -71,4 +77,28 @@ def test_route_returns_404_for_missing_page assert_equal 404, status assert_equal 'text/html', content_type end + + def test_check_for_changes_reloads_rbs_signatures + @server.instance_variable_set(:@file_mtimes, @rdoc.last_modified.keys.to_h { |file| + [file, File.mtime(file)] + }) + + sig_dir = File.join @dir, 'sig' + FileUtils.mkdir_p sig_dir + File.write File.join(sig_dir, 'example.rbs'), <<~RBS + class Example + def greet: () -> String + end + RBS + + _out, err = capture_output do + assert @server.send(:check_for_changes) + end + + assert_not_include err, 'Error parsing' + + example = @rdoc.store.find_class_or_module 'Example' + greet = example.find_method 'greet', false + assert_equal ['() -> String'], greet.type_signature_lines + end end diff --git a/test/rdoc/rdoc_store_test.rb b/test/rdoc/rdoc_store_test.rb index 2de0db4935..0fc144974c 100644 --- a/test/rdoc/rdoc_store_test.rb +++ b/test/rdoc/rdoc_store_test.rb @@ -1289,4 +1289,173 @@ def test_cleanup_stale_contributions_removes_empty_class assert_not_include @s.classes_hash, 'GoneClass' end + def test_merge_rbs_signatures + m = RDoc::AnyMethod.new(nil, 'greet') + m.params = '(name)' + @klass.add_method m + + a = RDoc::Attr.new(nil, 'language', 'R', '') + @klass.add_attribute a + + @s.merge_rbs_signatures( + 'Object#greet' => ['(String name) -> void'], + 'Object#language' => ['String'] + ) + + assert_equal ['(String name) -> void'], m.type_signature_lines + assert_equal ['String'], a.type_signature_lines + end + + def test_merge_rbs_signatures_singleton_method + @s.merge_rbs_signatures( + 'Object.cmethod' => ['() -> String'] + ) + + assert_equal ['() -> String'], @cmeth.type_signature_lines + end + + def test_merge_rbs_signatures_constructor + ctor = RDoc::AnyMethod.new nil, 'new', singleton: true + ctor.record_location @top_level + @klass.add_method ctor + + @s.merge_rbs_signatures( + 'Object#initialize' => ['(String name) -> void'] + ) + + assert_equal ['(String name) -> void'], ctor.type_signature_lines + end + + def test_merge_rbs_signatures_replaces_previous_rbs_signature + @s.merge_rbs_signatures( + 'Object#method' => ['() -> String'] + ) + + @s.merge_rbs_signatures( + 'Object#method' => ['() -> Integer'] + ) + + assert_equal ['() -> Integer'], @meth.type_signature_lines + end + + def test_merge_rbs_signatures_propagates_to_method_alias + original = RDoc::AnyMethod.new nil, 'original' + original.record_location @top_level + @klass.add_method original + + alias_def = RDoc::Alias.new nil, 'original', 'aliased', '' + alias_def.record_location @top_level + aliased = original.add_alias alias_def, @klass + + @s.merge_rbs_signatures( + 'Object#original' => ['() -> String'] + ) + + assert_equal ['() -> String'], original.type_signature_lines + assert_equal ['() -> String'], aliased.type_signature_lines + end + + def test_merge_rbs_signatures_propagates_to_attribute_alias + original = RDoc::Attr.new nil, 'language', 'R', '' + original.record_location @top_level + @klass.add_attribute original + + alias_def = RDoc::Alias.new nil, 'language', 'locale', '' + alias_def.record_location @top_level + aliased = original.add_alias alias_def, @klass + + @s.merge_rbs_signatures( + 'Object#language' => ['String'] + ) + + assert_equal ['String'], original.type_signature_lines + assert_equal ['String'], aliased.type_signature_lines + end + + def test_merge_rbs_signatures_keeps_instance_and_singleton_attributes_separate + instance_attr = RDoc::Attr.new nil, 'language', 'R', '' + instance_attr.record_location @top_level + @klass.add_attribute instance_attr + + singleton_attr = RDoc::Attr.new nil, 'language', 'R', '', singleton: true + singleton_attr.record_location @top_level + @klass.add_attribute singleton_attr + + @s.merge_rbs_signatures( + 'Object#language' => ['String'], + 'Object.language' => ['Integer'] + ) + + assert_equal ['String'], instance_attr.type_signature_lines + assert_equal ['Integer'], singleton_attr.type_signature_lines + end + + def test_type_name_lookup + @s.complete :public + + lookup = @s.type_name_lookup + assert_equal @klass.path, lookup['Object'] + assert_equal @nest_klass.path, lookup['Object::SubClass'] + assert_equal @mod.path, lookup['Mod'] + assert_equal @nest_klass.path, lookup['SubClass'] + end + + def test_type_name_lookup_ambiguous_unqualified_name_excluded + file = @s.add_file 'other.rb' + other_klass = file.add_class RDoc::NormalClass, 'Other::SubClass' + other_klass.record_location file + @s.complete :public + + lookup = @s.type_name_lookup + + # Both qualified names are present + assert_equal @nest_klass.path, lookup['Object::SubClass'] + assert_equal other_klass.path, lookup['Other::SubClass'] + + # Ambiguous unqualified name is excluded to avoid wrong links + refute lookup.key?('SubClass') + end + + def test_type_name_lookup_top_level_class_wins_over_nested_namesake + top_level_string = @top_level.add_class RDoc::NormalClass, 'String' + top_level_string.record_location @top_level + + nested_string = @top_level.add_class RDoc::NormalClass, 'Gem::Elements::String' + nested_string.record_location @top_level + + @s.complete :public + + lookup = @s.type_name_lookup + + # Top-level class retains its own path; nested namesake does not + # hijack the unqualified name. + assert_equal top_level_string.path, lookup['String'] + assert_equal nested_string.path, lookup['Gem::Elements::String'] + end + + + def test_merge_rbs_signatures_does_not_overwrite_inline_annotations + m = RDoc::AnyMethod.new(nil, 'greet') + m.params = '(name)' + m.type_signature_lines = ['(String) -> void'] + @klass.add_method m + + @s.merge_rbs_signatures( + 'Object#greet' => ['(String name, ?Integer count) -> void'] + ) + + assert_equal ['(String) -> void'], m.type_signature_lines + end + + def test_merge_rbs_signatures_unmatched_key + @s.merge_rbs_signatures( + 'Object#nonexistent' => ['(String) -> void'] + ) + + # No method matches — nothing should blow up, no sigs assigned + @klass.method_list.each do |m| + assert_nil m.type_signature_lines + end + end + end diff --git a/test/rdoc/ri/driver_test.rb b/test/rdoc/ri/driver_test.rb index 2d1a2ce741..915f2a87c8 100644 --- a/test/rdoc/ri/driver_test.rb +++ b/test/rdoc/ri/driver_test.rb @@ -737,6 +737,20 @@ def test_display_method assert_match %r%blah.6%, out end + def test_display_method_with_type_signature + util_store + + @blah.type_signature_lines = ['(Integer) -> String'] + @store1.save + + out, = capture_output do + @driver.display_method 'Foo::Bar#blah' + end + + assert_match %r%Foo::Bar#blah%, out + assert_match %r%\(Integer\) -> String%, out + end + def test_display_method_attribute util_store