diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e4d1c295e2..33eaafb49c 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 test: needs: ruby-versions @@ -26,14 +26,6 @@ jobs: ruby: ${{ fromJson(needs.ruby-versions.outputs.versions) }} os: [ubuntu-latest, macos-latest, windows-latest] exclude: - - os: windows-latest - 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 @@ -68,7 +60,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..26b42c07cb 100644 --- a/lib/rdoc/code_object/any_method.rb +++ b/lib/rdoc/code_object/any_method.rb @@ -14,7 +14,7 @@ class RDoc::AnyMethod < RDoc::MethodAttr # RDoc 4.1 # Added is_alias_for - MARSHAL_VERSION = 3 # :nodoc: + MARSHAL_VERSION = 4 # :nodoc: ## # Don't rename \#initialize to \::new @@ -166,6 +166,7 @@ def marshal_dump @parent.class, @section.title, is_alias_for, + @type_signature, ] end @@ -204,6 +205,7 @@ def marshal_load(array) @parent_title = array[13] @section_title = array[14] @is_alias_for = array[15] + @type_signature = array[16] 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..3895121854 100644 --- a/lib/rdoc/code_object/attr.rb +++ b/lib/rdoc/code_object/attr.rb @@ -11,7 +11,7 @@ class RDoc::Attr < RDoc::MethodAttr # Added parent name and class # Added section title - MARSHAL_VERSION = 3 # :nodoc: + MARSHAL_VERSION = 4 # :nodoc: ## # Is the attribute readable ('R'), writable ('W') or both ('RW')? @@ -108,7 +108,8 @@ def marshal_dump @file.relative_name, @parent.full_name, @parent.class, - @section.title + @section.title, + @type_signature, ] end @@ -140,6 +141,7 @@ def marshal_load(array) @parent_name = array[8] @parent_class = array[9] @section_title = array[10] + @type_signature = array[11] @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..f968543bdd 100644 --- a/lib/rdoc/code_object/method_attr.rb +++ b/lib/rdoc/code_object/method_attr.rb @@ -58,6 +58,11 @@ class RDoc::MethodAttr < RDoc::CodeObject attr_accessor :call_seq + ## + # RBS type signature from inline #: annotations + + attr_accessor :type_signature + ## # The call_seq or the param_seq with method name, if there is no call_seq. @@ -86,6 +91,7 @@ def initialize(text, name, singleton: false) @block_params = nil @call_seq = nil @params = nil + @type_signature = nil end ## diff --git a/lib/rdoc/generator/markup.rb b/lib/rdoc/generator/markup.rb index dc4556c019..aa5857413d 100644 --- a/lib/rdoc/generator/markup.rb +++ b/lib/rdoc/generator/markup.rb @@ -141,6 +141,26 @@ def markup_code src end + ## + # Returns the type signature as HTML with type names linked to their + # documentation pages. Delegates to RDoc::RbsSupport for RBS-aware parsing. + # + # Falls back to escaped HTML without links when the store or parent + # path is unavailable (e.g. in ri mode). + + def type_signature_html + return unless @type_signature + + store = @store || parent&.store + from_path = parent&.path + lookup = store&.type_name_lookup + + RDoc::RbsSupport.signature_to_html(@type_signature, lookup: lookup) do |name, target_path| + href = RDoc::Markup::ToHtml.gen_relative_url(from_path, target_path) + "#{ERB::Util.html_escape(name)}" + end + end + end class RDoc::ClassModule diff --git a/lib/rdoc/generator/template/aliki/class.rhtml b/lib/rdoc/generator/template/aliki/class.rhtml index ba1238b9e9..eed247f072 100644 --- a/lib/rdoc/generator/template/aliki/class.rhtml +++ b/lib/rdoc/generator/template/aliki/class.rhtml @@ -91,8 +91,9 @@
<%= h attrib.name %> - [<%= attrib.rw %>] - + [<%= attrib.rw %>]<%- if attrib.type_signature %> + <%= attrib.type_signature_html %> + <%- end %>
@@ -150,6 +151,10 @@
<%- end %> + + <%- if method.type_signature %> +
<%= method.type_signature_html %>
+ <%- 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 0ebfa344bf..bcc333300c 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); @@ -1334,6 +1348,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); @@ -1440,6 +1497,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); } 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/prism_ruby.rb b/lib/rdoc/parser/prism_ruby.rb index e8dedd5c18..9a853e8748 100644 --- a/lib/rdoc/parser/prism_ruby.rb +++ b/lib/rdoc/parser/prism_ruby.rb @@ -461,7 +461,37 @@ 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 = extract_type_signature!(text) + result = parse_comment_text_to_directives(text, start_line) + return unless result + comment, directives = result + [comment, directives, type_signature] + end + + # Extracts RBS type signature lines (#: ...) from raw comment text. + # Mutates the input text to remove the extracted lines. + # Returns the type signature string, or nil if none found. + private def extract_type_signature!(text) + return nil unless text.include?('#:') + + lines = text.lines + sig_lines, doc_lines = lines.partition { |l| l.match?(/\A#:\s/) } + return nil if sig_lines.empty? + + text.replace(doc_lines.join) + type_sig = sig_lines.map { |l| l.sub(/\A#:\s?/, '').chomp }.join("\n") + validate_type_signature(type_sig) + type_sig + end + + private def validate_type_signature(sig) + sig.split("\n").each do |line| + method_error = RDoc::RbsSupport.validate_method_type(line) + next unless method_error + type_error = RDoc::RbsSupport.validate_type(line) + next unless type_error + @options.warn "Invalid RBS type signature: #{line.inspect}" + end end # Parses comment text and retuns a pair of RDoc::Comment and directives @@ -594,7 +624,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 = consecutive_comment(line_no) handle_code_object_directives(@container, directives) if directives return unless @container.document_children @@ -602,6 +632,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 = type_signature record_location(a) handle_modifier_directive(a, line_no) @container.add_attribute(a) if should_document?(a) @@ -640,7 +671,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 = consecutive_comment(start_line) handle_code_object_directives(@container, directives) if directives internal_add_method( @@ -655,11 +686,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: type_signature ) 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: nil) # :nodoc: meth = RDoc::AnyMethod.new(nil, method_name, singleton: singleton) meth.comment = comment handle_code_object_directives(meth, directives) if directives @@ -680,6 +712,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 = type_signature record_location(meth) meth.start_collecting_tokens(:ruby) tokens.each do |token| diff --git a/lib/rdoc/rbs_support.rb b/lib/rdoc/rbs_support.rb new file mode 100644 index 0000000000..2999349d6f --- /dev/null +++ b/lib/rdoc/rbs_support.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require 'erb' +require 'rbs' + +## +# 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 RbsSupport + class << self + + ## + # Validates an RBS method type signature string. + # Returns nil if valid, or an error message string if invalid. + + def validate_method_type(sig) + RBS::Parser.parse_method_type(sig, require_eof: true) + nil + rescue RBS::ParsingError => e + e.message + end + + ## + # Validates an RBS type signature string. + # Returns nil if valid, or an error message string if invalid. + + def validate_type(sig) + RBS::Parser.parse_type(sig, require_eof: true) + nil + rescue RBS::ParsingError => e + e.message + 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 + key = member.singleton? ? "#{class_name}::#{member.name}" : "#{class_name}##{member.name}" + sigs = member.overloads.map { |o| o.method_type.to_s } + signatures[key] = sigs.join("\n") + when RBS::AST::Members::AttrReader, RBS::AST::Members::AttrWriter, RBS::AST::Members::AttrAccessor + key = "#{class_name}.#{member.name}" + signatures[key] = member.type.to_s + end + end + end + end + + signatures + end + + ## + # Merges loaded RBS signatures into the store's code objects. + # Inline #: annotations take priority and are not overwritten. + + def merge_into_store(store, signatures) + store.all_classes_and_modules.each do |cm| + cm.method_list.each do |method| + next if method.type_signature + + key = method.singleton ? "#{cm.full_name}::#{method.name}" : "#{cm.full_name}##{method.name}" + if (sig = signatures[key]) + method.type_signature = sig + end + end + + cm.attributes.each do |attr| + next if attr.type_signature + + if (sig = signatures["#{cm.full_name}.#{attr.name}"]) + attr.type_signature = sig + end + end + end + end + + ## + # Converts a type signature string to HTML with type names linked + # to their documentation pages. Uses the RBS parser to extract type + # name locations precisely. + # + # +lookup+ is a Hash mapping type names to their doc paths. + # +&resolve_link+ is a block that receives (name, target_path) and + # returns an HTML string for the link. This decouples RbsSupport + # from the generator's URL resolution. + # + # Returns escaped HTML with +->+ replaced by +→+. + + def signature_to_html(signature, lookup: nil, &resolve_link) + signature.split("\n").map { |line| + html = link_type_names_in_line(line, lookup, &resolve_link) + html.gsub('->', '→') + }.join("\n") + end + + private + + ## + # Links type names in a single signature line using the RBS parser. + # Falls back to plain HTML escaping if no lookup or block is given. + + def link_type_names_in_line(line, lookup, &resolve_link) + escaped = ERB::Util.html_escape(line) + return escaped unless lookup && resolve_link + + locs = collect_type_name_locations(line) + return escaped if locs.empty? + + result = escaped.dup + + # Replace type names with links, working backwards to preserve positions + locs.sort_by { |l| -l[:start] }.each do |loc| + name = loc[:name] + next unless lookup[name] + + # Map original string positions to escaped string positions. + # HTML escaping (e.g. -> becomes ->) shifts positions, so we + # re-escape the prefix to find the correct offset in the result. + 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 + + link = resolve_link.call(name, lookup[name]) + result[start_in_escaped...end_in_escaped] = link + 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, stop: name_loc.end_pos } + end + type.args.each { |a| collect_from_type(a, locs) } + when RBS::Types::Union, RBS::Types::Intersection + type.types.each { |t| collect_from_type(t, locs) } + when RBS::Types::Optional + collect_from_type(type.type, locs) + when RBS::Types::Tuple + type.types.each { |t| collect_from_type(t, 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..f1d097752d 100644 --- a/lib/rdoc/rdoc.rb +++ b/lib/rdoc/rdoc.rb @@ -5,6 +5,7 @@ require 'fileutils' require 'pathname' require 'time' +require_relative 'rbs_support' ## # This is the driver for generating RDoc output. It handles file parsing and @@ -492,6 +493,17 @@ def document(options) @store.complete @options.visibility + # Load RBS type signatures from core types and sig/ directory + begin + sig_dirs = [] + sig_dir = File.join(@options.root.to_s, 'sig') + sig_dirs << sig_dir if File.directory?(sig_dir) + signatures = RDoc::RbsSupport.load_signatures(*sig_dirs) + RDoc::RbsSupport.merge_into_store(@store, signatures) unless signatures.empty? + rescue => e + @options.warn "Failed to load RBS type signatures: #{e.message}" + end + @stats.coverage_level = @options.coverage_report if @options.coverage_report then diff --git a/lib/rdoc/ri/driver.rb b/lib/rdoc/ri/driver.rb index 014c5be4fb..670be70783 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 render_method_superclass out, method if method.is_alias_for al = method.is_alias_for @@ -1452,6 +1453,14 @@ def render_method_comment(out, method, alias_for = nil)# :nodoc: end end + def render_method_type_signature(out, type_signature) # :nodoc: + return unless type_signature + + sigs = type_signature.split("\n").map { |s| s + "\n" } + out << RDoc::Markup::Verbatim.new(*sigs) + out << RDoc::Markup::BlankLine.new + end + def render_method_superclass(out, method) # :nodoc: return unless method.respond_to?(:superclass_method) and method.superclass_method diff --git a/lib/rdoc/store.rb b/lib/rdoc/store.rb index bcba7ddabb..cb373095de 100644 --- a/lib/rdoc/store.rb +++ b/lib/rdoc/store.rb @@ -343,6 +343,22 @@ 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 full names (Foo::Bar) and + # short names (Bar). Cached after first call. + + def type_name_lookup + @type_name_lookup ||= begin + lookup = {} + all_classes_and_modules.each do |cm| + lookup[cm.full_name] = cm.path + lookup[cm.name] = cm.path unless lookup.key?(cm.name) + end + lookup + end + end + ## # All TopLevels known to RDoc @@ -443,6 +459,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..eb97257d5a 100644 --- a/test/rdoc/code_object/any_method_test.rb +++ b/test/rdoc/code_object/any_method_test.rb @@ -242,6 +242,27 @@ 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.block_params = 'some_block' + m.call_seq = 'call_seq' + m.comment = 'this is a comment' + m.params = 'param' + m.type_signature = '(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 + 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..a226ffa5cd 100644 --- a/test/rdoc/code_object/attr_test.rb +++ b/test/rdoc/code_object/attr_test.rb @@ -74,6 +74,23 @@ 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 = '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 + end + def test_marshal_dump_singleton tl = @store.add_file 'file.rb' diff --git a/test/rdoc/code_object/method_attr_test.rb b/test/rdoc/code_object/method_attr_test.rb index bffcb799d3..ac3ab2d092 100644 --- a/test/rdoc/code_object/method_attr_test.rb +++ b/test/rdoc/code_object/method_attr_test.rb @@ -207,4 +207,53 @@ def test_to_s assert_equal 'RDoc::AnyMethod: C1::m', @c1__m.to_s end + def test_type_signature_html_links_known_types + @c1_m.type_signature = "(C2) -> C1" + + result = @c1_m.type_signature_html + assert_includes result, 'C2' + assert_includes result, '>C1' + assert_includes result, '→' + end + + def test_type_signature_html_leaves_unknown_types_plain + @c1_m.type_signature = "(UnknownType) -> void" + + result = @c1_m.type_signature_html + refute_includes result, 'C2::C3' + end + + def test_type_signature_html_nil_without_signature + assert_nil @c1_m.type_signature_html + end + + def test_type_signature_html_multiline + @c1_m.type_signature = "(C1) -> C2\n(C2) -> C1" + + result = @c1_m.type_signature_html + assert_includes result, '>C1' + assert_includes result, '>C2' + assert_includes result, "\n" + end + + def test_type_signature_html_escapes_html + @c1_m.type_signature = "(Array[String]) -> void" + + result = @c1_m.type_signature_html + refute_includes result, ' 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 + assert_equal 'A greeting method', greet.comment.text.strip + end + + def test_attribute_type_signature + omit 'Prism parser only' if accept_legacy_bug? + 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 + assert_equal 'name', attrs[1].name + assert_equal 'String', attrs[1].type_signature + end + + def test_method_type_signature_multiple_overloads + omit 'Prism parser only' if accept_legacy_bug? + 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\n(Integer) -> String", convert.type_signature + 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 + assert_equal 'A plain method', plain.comment.text.strip + end + + def test_method_type_signature_only + omit 'Prism parser only' if accept_legacy_bug? + util_parser <<~RUBY + class Foo + #: (Integer) -> void + def bar(x); end + end + RUBY + + klass = @store.find_class_named 'Foo' + bar = klass.method_list.first + assert_equal '(Integer) -> void', bar.type_signature + end + + def test_method_type_signature_with_blank_line_separation + omit 'Prism parser only' if accept_legacy_bug? + 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 + assert_includes bar.comment.text, 'Documentation here' + end + + def test_method_type_signature_with_override_comment + omit 'Prism parser only' if accept_legacy_bug? + util_parser <<~RUBY + class Foo + # @override + #: (untyped) -> void + def accept(visitor); end + end + RUBY + + klass = @store.find_class_named 'Foo' + accept = klass.method_list.first + assert_equal '(untyped) -> void', accept.type_signature + assert_includes accept.comment.text, '@override' + end + + def test_type_signature_real_file + content = File.read(File.expand_path('../../../lib/rdoc/markup/heading.rb', __dir__)) + top_level = @store.add_file 'lib/rdoc/markup/heading.rb' + parser = RDoc::Parser::PrismRuby.new(top_level, content, @options, @stats) + parser.scan + + heading_class = @store.find_class_named('RDoc::Markup::Heading') + assert heading_class, 'RDoc::Markup::Heading should be found' + + label_method = heading_class.method_list.find { |m| m.name == 'label' } + assert label_method, 'label method should be found' + assert_equal '(RDoc::Context?) -> String', label_method.type_signature + + aref_method = heading_class.method_list.find { |m| m.name == 'aref' } + assert aref_method, 'aref method should be found' + assert_equal '() -> String', aref_method.type_signature + + accept_method = heading_class.method_list.find { |m| m.name == 'accept' } + assert accept_method, 'accept method should be found' + assert_equal '(untyped) -> void', accept_method.type_signature + end + + def test_type_signature_invalid_still_stored + omit 'Prism parser only' if accept_legacy_bug? + 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 + end end class RDocParserPrismRubyTest < RDoc::TestCase diff --git a/test/rdoc/rbs_support_test.rb b/test/rdoc/rbs_support_test.rb new file mode 100644 index 0000000000..b5545dd933 --- /dev/null +++ b/test/rdoc/rbs_support_test.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require_relative 'helper' +require 'rdoc/rbs_support' + +class RDocRbsSupportTest < RDoc::TestCase + def test_validate_method_type_valid + assert_nil RDoc::RbsSupport.validate_method_type('(String) -> void') + assert_nil RDoc::RbsSupport.validate_method_type('(Integer, ?String) -> bool') + assert_nil RDoc::RbsSupport.validate_method_type('() -> Array[String]') + end + + def test_validate_method_type_invalid + error = RDoc::RbsSupport.validate_method_type('(String ->') + assert_kind_of String, error + end + + def test_validate_type_valid + assert_nil RDoc::RbsSupport.validate_type('String') + assert_nil RDoc::RbsSupport.validate_type('Array[Integer]') + assert_nil RDoc::RbsSupport.validate_type('String?') + end + + def test_validate_type_invalid + error = RDoc::RbsSupport.validate_type('String[') + assert_kind_of String, error + 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::RbsSupport.load_signatures(dir) + assert_equal '(String name) -> void', sigs['Greeter#greet'] + assert_equal 'String', sigs['Greeter.language'] + end + end + + def test_merge_into_store + top_level = @store.add_file 'test.rb' + cm = top_level.add_class RDoc::NormalClass, 'Greeter' + + m = RDoc::AnyMethod.new(nil, 'greet') + m.params = '(name)' + cm.add_method m + + a = RDoc::Attr.new(nil, 'language', 'R', '') + cm.add_attribute a + + signatures = { + 'Greeter#greet' => '(String name) -> void', + 'Greeter.language' => 'String' + } + + RDoc::RbsSupport.merge_into_store(@store, signatures) + + assert_equal '(String name) -> void', m.type_signature + assert_equal 'String', a.type_signature + end + + def test_merge_does_not_overwrite_inline_annotations + top_level = @store.add_file 'test.rb' + cm = top_level.add_class RDoc::NormalClass, 'Greeter' + + m = RDoc::AnyMethod.new(nil, 'greet') + m.params = '(name)' + m.type_signature = '(String) -> void' + cm.add_method m + + signatures = { + 'Greeter#greet' => '(String name, ?Integer count) -> void' + } + + RDoc::RbsSupport.merge_into_store(@store, signatures) + + assert_equal '(String) -> void', m.type_signature + end +end diff --git a/test/rdoc/ri/driver_test.rb b/test/rdoc/ri/driver_test.rb index 2d1a2ce741..ec6a1c8475 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 = '(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