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..0f0dc0b1a4 100644 --- a/lib/rdoc/code_object/method_attr.rb +++ b/lib/rdoc/code_object/method_attr.rb @@ -58,6 +58,18 @@ class RDoc::MethodAttr < RDoc::CodeObject attr_accessor :call_seq + ## + # RBS type signature from inline annotations or loaded .rbs files + + attr_accessor :type_signature + + ## + # Returns the type signature split into individual lines. + + def type_signature_lines + @type_signature&.split("\n") + end + ## # The call_seq or the param_seq with method name, if there is no call_seq. @@ -86,6 +98,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/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..5cd7e71b32 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 %> + <%= type_signature_html(attrib, klass.path) %> + <%- end %>
@@ -150,6 +153,10 @@
<%- end %> + + <%- if method.type_signature %> +
<%= 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 8341a4ff37..6c4f474428 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); @@ -1335,6 +1349,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); @@ -1444,6 +1501,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/markdown.kpeg b/lib/rdoc/markdown.kpeg index d95a88a823..8d24cc097f 100644 --- a/lib/rdoc/markdown.kpeg +++ b/lib/rdoc/markdown.kpeg @@ -1249,7 +1249,7 @@ RawNoteBlock = @StartList:a # Markdown extensions added by RDoc follow CodeFence = &{ github? } - Ticks3 (@Sp StrChunk:format)? Spnl < ( + Ticks3 (@Sp StrChunk:format)? @Sp @Newline? < ( ( !"`" Nonspacechar )+ | !Ticks3 /`+/ | Spacechar | diff --git a/lib/rdoc/markdown.rb b/lib/rdoc/markdown.rb index e4d0ae9ff6..45954fd1cc 100644 --- a/lib/rdoc/markdown.rb +++ b/lib/rdoc/markdown.rb @@ -15570,7 +15570,7 @@ def _RawNoteBlock return _tmp end - # CodeFence = &{ github? } Ticks3 (@Sp StrChunk:format)? Spnl < ((!"`" Nonspacechar)+ | !Ticks3 /`+/ | Spacechar | @Newline)+ > Ticks3 @Sp @Newline* { verbatim = RDoc::Markup::Verbatim.new text verbatim.format = format.intern if format.instance_of?(String) verbatim } + # CodeFence = &{ github? } Ticks3 (@Sp StrChunk:format)? @Sp @Newline? < ((!"`" Nonspacechar)+ | !Ticks3 /`+/ | Spacechar | @Newline)+ > Ticks3 @Sp @Newline* { verbatim = RDoc::Markup::Verbatim.new text verbatim.format = format.intern if format.instance_of?(String) verbatim } def _CodeFence _save = self.pos @@ -15612,31 +15612,41 @@ def _CodeFence self.pos = _save break end - _tmp = apply(:_Spnl) + _tmp = _Sp() unless _tmp self.pos = _save break end - _text_start = self.pos _save4 = self.pos - + _tmp = _Newline() + unless _tmp + _tmp = true + self.pos = _save4 + end + unless _tmp + self.pos = _save + break + end + _text_start = self.pos _save5 = self.pos - while true # choice - _save6 = self.pos + _save6 = self.pos + while true # choice _save7 = self.pos + + _save8 = self.pos while true # sequence - _save8 = self.pos + _save9 = self.pos _tmp = match_string("`") _tmp = _tmp ? nil : true - self.pos = _save8 + self.pos = _save9 unless _tmp - self.pos = _save7 + self.pos = _save8 break end _tmp = apply(:_Nonspacechar) unless _tmp - self.pos = _save7 + self.pos = _save8 end break end # end sequence @@ -15644,19 +15654,19 @@ def _CodeFence if _tmp while true - _save9 = self.pos + _save10 = self.pos while true # sequence - _save10 = self.pos + _save11 = self.pos _tmp = match_string("`") _tmp = _tmp ? nil : true - self.pos = _save10 + self.pos = _save11 unless _tmp - self.pos = _save9 + self.pos = _save10 break end _tmp = apply(:_Nonspacechar) unless _tmp - self.pos = _save9 + self.pos = _save10 end break end # end sequence @@ -15665,59 +15675,59 @@ def _CodeFence end _tmp = true else - self.pos = _save6 + self.pos = _save7 end break if _tmp - self.pos = _save5 + self.pos = _save6 - _save11 = self.pos + _save12 = self.pos while true # sequence - _save12 = self.pos + _save13 = self.pos _tmp = apply(:_Ticks3) _tmp = _tmp ? nil : true - self.pos = _save12 + self.pos = _save13 unless _tmp - self.pos = _save11 + self.pos = _save12 break end _tmp = scan(/\G(?-mix:`+)/) unless _tmp - self.pos = _save11 + self.pos = _save12 end break end # end sequence break if _tmp - self.pos = _save5 + self.pos = _save6 _tmp = apply(:_Spacechar) break if _tmp - self.pos = _save5 + self.pos = _save6 _tmp = _Newline() break if _tmp - self.pos = _save5 + self.pos = _save6 break end # end choice if _tmp while true - _save13 = self.pos + _save14 = self.pos while true # choice - _save14 = self.pos - _save15 = self.pos + + _save16 = self.pos while true # sequence - _save16 = self.pos + _save17 = self.pos _tmp = match_string("`") _tmp = _tmp ? nil : true - self.pos = _save16 + self.pos = _save17 unless _tmp - self.pos = _save15 + self.pos = _save16 break end _tmp = apply(:_Nonspacechar) unless _tmp - self.pos = _save15 + self.pos = _save16 end break end # end sequence @@ -15725,19 +15735,19 @@ def _CodeFence if _tmp while true - _save17 = self.pos + _save18 = self.pos while true # sequence - _save18 = self.pos + _save19 = self.pos _tmp = match_string("`") _tmp = _tmp ? nil : true - self.pos = _save18 + self.pos = _save19 unless _tmp - self.pos = _save17 + self.pos = _save18 break end _tmp = apply(:_Nonspacechar) unless _tmp - self.pos = _save17 + self.pos = _save18 end break end # end sequence @@ -15746,36 +15756,36 @@ def _CodeFence end _tmp = true else - self.pos = _save14 + self.pos = _save15 end break if _tmp - self.pos = _save13 + self.pos = _save14 - _save19 = self.pos + _save20 = self.pos while true # sequence - _save20 = self.pos + _save21 = self.pos _tmp = apply(:_Ticks3) _tmp = _tmp ? nil : true - self.pos = _save20 + self.pos = _save21 unless _tmp - self.pos = _save19 + self.pos = _save20 break end _tmp = scan(/\G(?-mix:`+)/) unless _tmp - self.pos = _save19 + self.pos = _save20 end break end # end sequence break if _tmp - self.pos = _save13 + self.pos = _save14 _tmp = apply(:_Spacechar) break if _tmp - self.pos = _save13 + self.pos = _save14 _tmp = _Newline() break if _tmp - self.pos = _save13 + self.pos = _save14 break end # end choice @@ -15783,7 +15793,7 @@ def _CodeFence end _tmp = true else - self.pos = _save4 + self.pos = _save5 end if _tmp text = get_text(_text_start) @@ -16677,7 +16687,7 @@ def _DefinitionListDefinition Rules[:_InlineNote] = rule_info("InlineNote", "&{ notes? } \"^[\" @StartList:a (!\"]\" Inline:l { a << l })+ \"]\" { ref = [:inline, @note_order.length] @footnotes[ref] = paragraph a note_for ref }") Rules[:_Notes] = rule_info("Notes", "(Note | SkipBlock)*") Rules[:_RawNoteBlock] = rule_info("RawNoteBlock", "@StartList:a (!@BlankLine !RawNoteReference OptionallyIndentedLine:l { a << l })+ < @BlankLine* > { a << text } { a }") - Rules[:_CodeFence] = rule_info("CodeFence", "&{ github? } Ticks3 (@Sp StrChunk:format)? Spnl < ((!\"`\" Nonspacechar)+ | !Ticks3 /`+/ | Spacechar | @Newline)+ > Ticks3 @Sp @Newline* { verbatim = RDoc::Markup::Verbatim.new text verbatim.format = format.intern if format.instance_of?(String) verbatim }") + Rules[:_CodeFence] = rule_info("CodeFence", "&{ github? } Ticks3 (@Sp StrChunk:format)? @Sp @Newline? < ((!\"`\" Nonspacechar)+ | !Ticks3 /`+/ | Spacechar | @Newline)+ > Ticks3 @Sp @Newline* { verbatim = RDoc::Markup::Verbatim.new text verbatim.format = format.intern if format.instance_of?(String) verbatim }") Rules[:_Table] = rule_info("Table", "&{ github? } TableHead:header TableLine:line TableRow+:body { table = RDoc::Markup::Table.new(header, line, body) parse_table_cells(table) }") Rules[:_TableHead] = rule_info("TableHead", "TableItem2+:items \"|\"? @Newline { items }") Rules[:_TableRow] = rule_info("TableRow", "((TableItem:item1 TableItem2+:items { [item1, *items] }):row | TableItem2+:row) \"|\"? @Newline { row }") diff --git a/lib/rdoc/parser/prism_ruby.rb b/lib/rdoc/parser/prism_ruby.rb index e8dedd5c18..5e8efb1e11 100644 --- a/lib/rdoc/parser/prism_ruby.rb +++ b/lib/rdoc/parser/prism_ruby.rb @@ -133,6 +133,9 @@ class RDoc::Parser::PrismRuby < RDoc::Parser parse_files_matching(/\.rbw?$/) unless ENV['RDOC_USE_RIPPER_PARSER'] + # 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 @@ -461,10 +464,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 = 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] 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) @@ -594,7 +601,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 +609,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 +648,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 +663,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 +689,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| @@ -836,6 +846,35 @@ 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 the type signature string, or nil if none found. + + 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? + + text.replace(doc_lines.join) + type_sig = sig_lines.map { |l| l.sub(RBS_SIG_LINE, '').chomp }.join("\n") + validate_type_signature(type_sig, start_line + doc_lines.size) + type_sig + end + + def validate_type_signature(sig, line_no) + sig.split("\n").each_with_index do |line, i| + method_error = RDoc::RbsHelper.validate_method_type(line) + next unless method_error + type_error = RDoc::RbsHelper.validate_type(line) + next unless type_error + @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..3072ab99e1 --- /dev/null +++ b/lib/rdoc/rbs_helper.rb @@ -0,0 +1,173 @@ +# 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 + + ## + # 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 + + ## + # 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 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 = ::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..723163399f 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 @@ -492,6 +493,17 @@ def document(options) @store.complete @options.visibility + # Load RBS type signatures from 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::RbsHelper.load_signatures(*sig_dirs) + @store.merge_rbs_signatures(signatures) unless signatures.empty? + rescue RBS::ParsingError, Errno::ENOENT, LoadError => 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..9b8c36e742 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 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/store.rb b/lib/rdoc/store.rb index 379d2f2246..adcc26081e 100644 --- a/lib/rdoc/store.rb +++ b/lib/rdoc/store.rb @@ -343,6 +343,61 @@ 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 + next if unqualified_name == cm.full_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) + 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 + ## # All TopLevels known to RDoc @@ -443,6 +498,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/parser/prism_ruby_test.rb b/test/rdoc/parser/prism_ruby_test.rb index f018123f7b..dca28c2bd8 100644 --- a/test/rdoc/parser/prism_ruby_test.rb +++ b/test/rdoc/parser/prism_ruby_test.rb @@ -2374,6 +2374,160 @@ def m2; end assert_equal "foo1\nbar1", m1.call_seq.chomp assert_equal "ARGF.readlines(a)\nARGF.readlines(b)\nARGF.readlines(c)\nARGF.readlines(d)", m2.call_seq.chomp end + + # Type signature tests — Prism parser only (Ripper doesn't extract #: annotations) + + def test_method_type_signature + omit 'Prism parser only' if accept_legacy_bug? + 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 + 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_helper_test.rb b/test/rdoc/rbs_helper_test.rb new file mode 100644 index 0000000000..fb011ccabf --- /dev/null +++ b/test/rdoc/rbs_helper_test.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require_relative 'helper' +require 'rdoc/rbs_helper' +require 'rdoc/markup/formatter' + +class RDocRbsHelperTest < RDoc::TestCase + def test_validate_method_type_valid + assert_nil RDoc::RbsHelper.validate_method_type('(String) -> void') + assert_nil RDoc::RbsHelper.validate_method_type('(Integer, ?String) -> bool') + assert_nil RDoc::RbsHelper.validate_method_type('() -> Array[String]') + end + + def test_validate_method_type_invalid + error = RDoc::RbsHelper.validate_method_type('(String ->') + assert_kind_of String, error + end + + def test_validate_type_valid + assert_nil RDoc::RbsHelper.validate_type('String') + assert_nil RDoc::RbsHelper.validate_type('Array[Integer]') + assert_nil RDoc::RbsHelper.validate_type('String?') + end + + def test_validate_type_invalid + error = RDoc::RbsHelper.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::RbsHelper.load_signatures(dir) + assert_equal '(String name) -> void', sigs['Greeter#greet'] + assert_equal 'String', 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 +end diff --git a/test/rdoc/rdoc_markdown_test.rb b/test/rdoc/rdoc_markdown_test.rb index fc96b2985c..5ffed64f78 100644 --- a/test/rdoc/rdoc_markdown_test.rb +++ b/test/rdoc/rdoc_markdown_test.rb @@ -1242,6 +1242,21 @@ def test_code_fence_with_unintended_array assert_equal expected, doc end + def test_code_fence_preserves_text_indentation + doc = parse <<~MD + ```ruby + foo() + bar() + ``` + MD + + expected = doc( + verb(" foo()\n bar()\n").tap { |v| v.format = :ruby } + ) + + assert_equal expected, doc + end + def test_gfm_table doc = parse <<~MD | | |compare-ruby|built-ruby| diff --git a/test/rdoc/rdoc_store_test.rb b/test/rdoc/rdoc_store_test.rb index d9bb0bbbdb..44d3c15d8b 100644 --- a/test/rdoc/rdoc_store_test.rb +++ b/test/rdoc/rdoc_store_test.rb @@ -1311,4 +1311,83 @@ 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 + assert_equal 'String', a.type_signature + end + + def test_merge_rbs_signatures_singleton_method + @s.merge_rbs_signatures( + 'Object::cmethod' => '() -> String' + ) + + assert_equal '() -> String', @cmeth.type_signature + end + + def test_type_name_lookup_full_names + @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'] + end + + def test_type_name_lookup_unqualified_names + @s.complete :public + + lookup = @s.type_name_lookup + assert_equal @nest_klass.path, lookup['SubClass'] + assert_equal @mod.path, lookup['Mod'] + 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_cached + @s.complete :public + + lookup1 = @s.type_name_lookup + lookup2 = @s.type_name_lookup + assert_same lookup1, lookup2 + end + + def test_merge_rbs_signatures_does_not_overwrite_inline_annotations + m = RDoc::AnyMethod.new(nil, 'greet') + m.params = '(name)' + m.type_signature = '(String) -> void' + @klass.add_method m + + @s.merge_rbs_signatures( + 'Object#greet' => '(String name, ?Integer count) -> void' + ) + + 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