Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 136 additions & 2 deletions lib/rdoc/ri/driver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ def self.process_args(argv)

Class::method | Class#method | Class.method | method

$global_variable | PREDEFINED_CONSTANT

gem_name: | gem_name:README | gem_name:History

ruby: | ruby:NEWS | ruby:globals
Expand Down Expand Up @@ -152,9 +154,12 @@ def self.process_args(argv)
#{opt.program_name} zip
#{opt.program_name} rdoc:README
#{opt.program_name} ruby:comments
#{opt.program_name} ARGF
#{opt.program_name} '$<'
#{opt.program_name} '$LOAD_PATH'

Note that shell quoting or escaping may be required for method names
containing punctuation:
containing punctuation or for global variables:

#{opt.program_name} 'Array.[]'
#{opt.program_name} compact\\!
Expand Down Expand Up @@ -843,13 +848,136 @@ def display_method(name)
display out
end

##
# Pre-defined global constants that can be looked up from globals.rdoc

PREDEFINED_GLOBAL_CONSTANTS = %w[
STDIN STDOUT STDERR ARGV ARGF DATA TOPLEVEL_BINDING
].freeze

##
# Prefixes for pre-defined global constants

PREDEFINED_GLOBAL_CONSTANT_PREFIXES = %w[RUBY_].freeze

##
# Returns true if +name+ is a pre-defined global constant like STDIN, STDOUT,
# RUBY_VERSION, etc.

def predefined_global_constant?(name)
PREDEFINED_GLOBAL_CONSTANTS.include?(name) ||
PREDEFINED_GLOBAL_CONSTANT_PREFIXES.any? { |prefix| name.start_with?(prefix) }
end

##
# Outputs formatted RI data for the global variable or pre-defined constant
# +name+. Looks up the documentation in the globals.rdoc page from the system
# store.

def display_global(name)
store = @stores.find { |s| s.type == :system }

raise NotFoundError, name unless store

begin
page = store.load_page('globals.rdoc')
rescue RDoc::Store::MissingFileError
raise NotFoundError, name
end

document = page.comment.parse
section = extract_global_section(document, name)

raise NotFoundError, name unless section

display section

true
end

##
# Extracts the section for global +name+ from +document+.
# Returns an RDoc::Markup::Document containing just that section,
# or nil if not found.
#
# The globals.rdoc document has a hierarchical structure with headings:
# = Pre-Defined Global Variables (level 1)
# == Streams (level 2)
# === $< (ARGF or $stdin) (level 3)
# paragraph content...
# === $> (Default Output) (level 3)
# paragraph content...
#
# This method finds the heading matching +name+ and collects all content
# until the next heading at the same or higher level.

def extract_global_section(document, name)
result = RDoc::Markup::Document.new
in_section = false # true once we find the matching heading
section_level = nil # heading level of the matched section (e.g., 3 for ===)

document.parts.each do |part|
if RDoc::Markup::Heading === part
if heading_matches_global?(part, name)
# Found our target heading - start capturing content
in_section = true
section_level = part.level
result << part
elsif in_section && part.level <= section_level
# Hit next section at same or higher level - stop capturing
break
elsif in_section
# Sub-heading within our section - include it
result << part
end
elsif in_section
# Non-heading content (paragraphs, code blocks, etc.) - include it
result << part
end
end

result.empty? ? nil : result
end

##
# Returns true if +heading+ matches the global +name+.
# Handles formats like "$< (ARGF or $stdin)", "<tt>$<</tt> (ARGF...)", or just "STDOUT".

def heading_matches_global?(heading, name)
text = heading.text

# Direct match: "STDOUT" or "$<"
return true if text == name
return true if text.start_with?("#{name} ") || text.start_with?("#{name}\t")

# Match with <tt> wrapper: "<tt>$<</tt> (description)"
tt_wrapped = "<tt>#{name}</tt>"
return true if text.start_with?(tt_wrapped)
return true if text.start_with?("#{tt_wrapped} ")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this doesn't evaluate to true because we already have return true if text.start_with?(tt_wrapped)


false
end

##
# Outputs formatted RI data for the class or method +name+.
#
# Returns true if +name+ was found, false if it was not an alternative could
# be guessed, raises an error if +name+ couldn't be guessed.

def display_name(name)
# Handle global variables immediately (classes can't start with $)
return display_global(name) if name.start_with?('$')

# Try predefined constants BEFORE class lookup to avoid case-insensitive
# filesystem matching (e.g., DATA matching Data class on macOS)
if predefined_global_constant?(name)
begin
return display_global(name)
rescue NotFoundError
# Fall through to class lookup
end
end

if name =~ /\w:(\w|$)/ then
display_page name
return true
Expand All @@ -862,7 +990,7 @@ def display_name(name)
true
rescue NotFoundError
matches = list_methods_matching name if name =~ /::|#|\./
matches = classes.keys.grep(/^#{Regexp.escape name}/) if matches.empty?
matches = classes.keys.grep(/^#{Regexp.escape name}/) if matches.nil? || matches.empty?

raise if matches.empty?

Expand Down Expand Up @@ -983,6 +1111,12 @@ def expand_class(klass)
# #expand_class.

def expand_name(name)
# Global variables don't need expansion
return name if name.start_with?('$')

# Predefined global constants don't need expansion
return name if predefined_global_constant?(name)

klass, selector, method = parse_name name

return [selector, method].join if klass.empty?
Expand Down
181 changes: 181 additions & 0 deletions test/rdoc/ri/driver_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -951,6 +951,187 @@ def test_display_page_list
assert_match %r%OTHER\.rdoc%, out
end

def test_display_global_variable
util_store

# Create a globals page in the store
globals = @store1.add_file 'globals.rdoc'
globals.parser = RDoc::Parser::Simple
globals.comment = RDoc::Comment.from_document(doc(
head(1, 'Pre-Defined Global Variables'),
head(2, 'Streams'),
head(3, '$< (ARGF or $stdin)'),
para('Points to stream ARGF if not empty, else to stream $stdin; read-only.'),
head(3, '$> (Default Standard Output)'),
para('An output stream, initially $stdout.')
))
@store1.save_page globals
@store1.type = :system

out, = capture_output do
@driver.display_global '$<'
end

assert_match %r%\$< \(ARGF or \$stdin\)%, out
assert_match %r%Points to stream ARGF%, out
refute_match %r%\$>%, out
end

def test_display_global_constant
util_store

# Create a globals page in the store
globals = @store1.add_file 'globals.rdoc'
globals.parser = RDoc::Parser::Simple
globals.comment = RDoc::Comment.from_document(doc(
head(1, 'Pre-Defined Global Constants'),
head(2, 'Streams'),
head(3, 'STDIN'),
para('The standard input stream.'),
head(3, 'STDOUT'),
para('The standard output stream.')
))
@store1.save_page globals
@store1.type = :system

out, = capture_output do
@driver.display_global 'STDOUT'
end

assert_match %r%STDOUT%, out
assert_match %r%standard output stream%, out
refute_match %r%STDIN%, out
end

def test_display_global_not_found
util_store

# Create a globals page in the store (without the requested global)
globals = @store1.add_file 'globals.rdoc'
globals.parser = RDoc::Parser::Simple
globals.comment = RDoc::Comment.from_document(doc(
head(1, 'Pre-Defined Global Variables'),
head(3, '$<'),
para('Some doc')
))
@store1.save_page globals
@store1.type = :system

assert_raise RDoc::RI::Driver::NotFoundError do
@driver.display_global '$NONEXISTENT'
end
end

def test_display_global_no_system_store
util_store
# Store type is :home by default, not :system

assert_raise RDoc::RI::Driver::NotFoundError do
@driver.display_global '$<'
end
end

def test_display_name_global_variable
util_store

# Create a globals page in the store
globals = @store1.add_file 'globals.rdoc'
globals.parser = RDoc::Parser::Simple
globals.comment = RDoc::Comment.from_document(doc(
head(1, 'Pre-Defined Global Variables'),
head(3, '$< (ARGF or $stdin)'),
para('Points to stream ARGF.')
))
@store1.save_page globals
@store1.type = :system

out, = capture_output do
@driver.display_name '$<'
end

assert_match %r%\$<%, out
assert_match %r%ARGF%, out
end

def test_display_name_predefined_constant
util_store

# Create a globals page in the store
globals = @store1.add_file 'globals.rdoc'
globals.parser = RDoc::Parser::Simple
globals.comment = RDoc::Comment.from_document(doc(
head(1, 'Pre-Defined Global Constants'),
head(3, 'ARGV'),
para('An array of the given command-line arguments.')
))
@store1.save_page globals
@store1.type = :system

out, = capture_output do
@driver.display_name 'ARGV'
end

assert_match %r%ARGV%, out
assert_match %r%command-line arguments%, out
end

def test_display_name_predefined_constant_over_class
util_store

# Create a Data class that could conflict with DATA constant
# (on case-insensitive filesystems, DATA could match Data)
@cData = @top_level.add_class RDoc::NormalClass, 'Data'
@cData.add_comment 'Data class for value objects', @top_level
@cData.record_location @top_level
@store1.save_class @cData

# Create a globals page with DATA constant
globals = @store1.add_file 'globals.rdoc'
globals.parser = RDoc::Parser::Simple
globals.comment = RDoc::Comment.from_document(doc(
head(1, 'Pre-Defined Global Constants'),
head(3, 'DATA'),
para('File object for lines after __END__.')
))
@store1.save_page globals
@store1.type = :system

# DATA (all caps) should show the predefined constant, not the Data class
out, = capture_output do
@driver.display_name 'DATA'
end

assert_match %r%DATA%, out
assert_match %r%__END__%, out
refute_match %r%value objects%, out

# Data (capitalized) should show the class, not the constant
out, = capture_output do
@driver.display_name 'Data'
end

assert_match %r%Data%, out
assert_match %r%value objects%, out
refute_match %r%__END__%, out
end

def test_predefined_global_constant?
assert @driver.predefined_global_constant?('STDIN')
assert @driver.predefined_global_constant?('STDOUT')
assert @driver.predefined_global_constant?('STDERR')
assert @driver.predefined_global_constant?('ARGV')
assert @driver.predefined_global_constant?('ARGF')
assert @driver.predefined_global_constant?('DATA')
assert @driver.predefined_global_constant?('TOPLEVEL_BINDING')
assert @driver.predefined_global_constant?('RUBY_VERSION')
assert @driver.predefined_global_constant?('RUBY_PLATFORM')

refute @driver.predefined_global_constant?('ENV') # ENV is a class, not a simple constant
refute @driver.predefined_global_constant?('MyClass')
refute @driver.predefined_global_constant?('Foo')
refute @driver.predefined_global_constant?('$<')
end

def test_expand_class
util_store

Expand Down
Loading