Skip to content

Commit

Permalink
Stream pager
Browse files Browse the repository at this point in the history
  • Loading branch information
tompng committed Nov 26, 2024
1 parent 84366b8 commit f165fe3
Show file tree
Hide file tree
Showing 4 changed files with 167 additions and 67 deletions.
114 changes: 85 additions & 29 deletions lib/irb.rb
Original file line number Diff line number Diff line change
Expand Up @@ -919,6 +919,61 @@ def irb_abort(irb, exception = Abort) # :nodoc:
end
end

class OutputFormatter
def initialize(output, format_pre, format_post, omit_proc:, newline_before_multiline_output:)
@format_pre = format_pre
@format_post = format_post
@output = output
@buffer = +''
@omit_proc = omit_proc
@passthrough = false
@newline_before_multiline_output = newline_before_multiline_output
end

def puts(text)
write(text + "\n")
end

def write(text)
if @passthrough
@output.write text
return
end

if text.include?("\n")
@buffer << text
multiline = text.count("\n") != text.bytesize
else
multiline = @buffer.end_with?("\n")
@buffer << text
end

if multiline
buf = "#{@format_pre}#{"\n" if @newline_before_multiline_output}#{@buffer}"
if @omit_proc
@output.write @omit_proc.call(buf)
raise IRB::Abort
else
@output.write buf
@passthrough = true
end
end
end

def flush
if @passthrough
@output.write @format_post
return
end

buf = "#{@format_pre}#{@buffer.chomp}#{@format_post}"
@output.write(@omit_proc ? @omit_proc.call(buf) : buf)
end

alias print write
alias << write
end

class Irb
# Note: instance and index assignment expressions could also be written like:
# "foo.bar=(1)" and "foo.[]=(1, bar)", when expressed that way, the former be
Expand Down Expand Up @@ -1369,40 +1424,41 @@ def signal_status(status)
end

def output_value(omit = false) # :nodoc:
str = @context.inspect_last_value
multiline_p = str.include?("\n")
return_format_pre, return_format_post = @context.return_format.split('%s', 2)
return return_format_pre unless return_format_post

if omit
winwidth = @context.io.winsize.last
if multiline_p
first_line = str.split("\n").first
result = @context.newline_before_multiline_output? ? (@context.return_format % first_line) : first_line
output_width = Reline::Unicode.calculate_width(result, true)
diff_size = output_width - Reline::Unicode.calculate_width(first_line, true)
if diff_size.positive? and output_width > winwidth
lines, _ = Reline::Unicode.split_by_width(first_line, winwidth - diff_size - 3)
str = "%s..." % lines.first
str += "\e[0m" if Color.colorable?
multiline_p = false
else
str = str.gsub(/(\A.*?\n).*/m, "\\1...")
str += "\e[0m" if Color.colorable?
end
else
output_width = Reline::Unicode.calculate_width(@context.return_format % str, true)
diff_size = output_width - Reline::Unicode.calculate_width(str, true)
if diff_size.positive? and output_width > winwidth
lines, _ = Reline::Unicode.split_by_width(str, winwidth - diff_size - 3)
str = "%s..." % lines.first
str += "\e[0m" if Color.colorable?
omit_proc = ->(str) do
return str if str.empty?

lines = str.lines
winwidth = @context.io.winsize.last
truncate_line_index = @context.newline_before_multiline_output? && lines.size >= 2 ? 1 : 0
line = lines[truncate_line_index]
if Reline::Unicode.calculate_width(line, true) > winwidth
truncated_line = Reline::Unicode.split_by_width(line, winwidth - 3).first.first
line = "%s..." % truncated_line
line += "\e[0m" if Color.colorable?
lines[truncate_line_index] = line + "\n"
lines = lines.take(truncate_line_index + 1)
elsif lines.size > truncate_line_index + 1
line = "#{Color.colorable? ? "\e[0m" : ''}...\n"
lines = lines.take(truncate_line_index + 1) + [line]
end
lines.join
end
end

if multiline_p && @context.newline_before_multiline_output?
str = "\n" + str
Pager.page(retain_content: true) do |io|
formatter = OutputFormatter.new(
io,
return_format_pre,
return_format_post,
omit_proc: omit_proc,
newline_before_multiline_output: @context.newline_before_multiline_output?
)
@context.inspect_last_value(formatter)
formatter.flush
end

Pager.page_content(format(@context.return_format, str), retain_content: true)
end

# Outputs the local variables to this current session, including #signal_status
Expand Down
4 changes: 2 additions & 2 deletions lib/irb/context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -673,8 +673,8 @@ def colorize_input(input, complete:)
end
end

def inspect_last_value # :nodoc:
@inspect_method.inspect_value(@last_value)
def inspect_last_value(output = nil) # :nodoc:
@inspect_method.inspect_value(@last_value, output)
end

NOPRINTING_IVARS = ["@last_value"] # :nodoc:
Expand Down
9 changes: 5 additions & 4 deletions lib/irb/inspector.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,9 @@ def init
end

# Proc to call when the input is evaluated and output in irb.
def inspect_value(v)
@inspect.call(v)
def inspect_value(v, output)
@inspect.arity == 2 ? @inspect.call(v, output) : output.write(@inspect.call(v))
rescue IRB::Pager::Abort
rescue => e
puts "An error occurred when inspecting the object: #{e.inspect}"

Expand All @@ -113,8 +114,8 @@ def inspect_value(v)
Inspector.def_inspector([:p, :inspect]){|v|
Color.colorize_code(v.inspect, colorable: Color.colorable? && Color.inspect_colorable?(v))
}
Inspector.def_inspector([true, :pp, :pretty_inspect], proc{require_relative "color_printer"}){|v|
IRB::ColorPrinter.pp(v, +'').chomp
Inspector.def_inspector([true, :pp, :pretty_inspect], proc{require_relative "color_printer"}){|v, output|
IRB::ColorPrinter.pp(v, output)
}
Inspector.def_inspector([:yaml, :YAML], proc{require "yaml"}){|v|
begin
Expand Down
107 changes: 75 additions & 32 deletions lib/irb/pager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,47 +4,64 @@ module IRB
# The implementation of this class is borrowed from RDoc's lib/rdoc/ri/driver.rb.
# Please do NOT use this class directly outside of IRB.
class Pager
class Abort < StandardError
end
PAGE_COMMANDS = [ENV['RI_PAGER'], ENV['PAGER'], 'less', 'more'].compact.uniq

class << self
def page_content(content, **options)
if content_exceeds_screen_height?(content)
page(**options) do |io|
io.puts content
end
class IO
def initialize(**options)
@options = options
@buffer = +''
@io = should_page? ? nil : $stdout
end

def puts(text)
write(text + "\n")
end

def write(text)
if @io
@io.write(text)
else
$stdout.puts content
prev_bytesize = @buffer.bytesize
@buffer << text
if @buffer.bytesize / 1024 != prev_bytesize / 1024
prepare_pager if content_exceeds_screen_height?(@buffer)
end
end
rescue Errno::EPIPE
raise Pager::Abort
end
alias print write
alias << write

def page(retain_content: false)
if should_page? && pager = setup_pager(retain_content: retain_content)
begin
pid = pager.pid
yield pager
ensure
pager.close
def prepare_pager
@pager_io = setup_pager(**@options)
@pager_pid = @pager_io&.pid
@io = @pager_io || $stdout
@io.write @buffer
end

def close
unless @io
if content_exceeds_screen_height?(@buffer)
prepare_pager
else
$stdout.write @buffer
end
else
yield $stdout
end
# When user presses Ctrl-C, IRB would raise `IRB::Abort`
# But since Pager is implemented by running paging commands like `less` in another process with `IO.popen`,
# the `IRB::Abort` exception only interrupts IRB's execution but doesn't affect the pager
# So to properly terminate the pager with Ctrl-C, we need to catch `IRB::Abort` and kill the pager process
rescue IRB::Abort
@pager_io&.close
end

def cleanup
begin
begin
Process.kill("TERM", pid) if pid
rescue Errno::EINVAL
# SIGTERM not supported (windows)
Process.kill("KILL", pid)
end
rescue Errno::ESRCH
# Pager process already terminated
Process.kill("TERM", @pager_pid) if @pager_pid
rescue Errno::EINVAL
# SIGTERM not supported (windows)
Process.kill("KILL", @pager_pid)
end
nil
rescue Errno::EPIPE
rescue Errno::ESRCH
# Pager process already terminated
end

private
Expand Down Expand Up @@ -81,7 +98,7 @@ def setup_pager(retain_content:)
end

begin
io = IO.popen(cmd, 'w')
io = ::IO.popen(cmd, 'w')
rescue
next
end
Expand All @@ -96,5 +113,31 @@ def setup_pager(retain_content:)
nil
end
end

class << self
def page_content(content, **options)
page(**options) do |io|
io.puts content
end
end

def page(retain_content: false)
io = Pager::IO.new(retain_content: retain_content)
begin
yield io
ensure
io.close
end
# When user presses Ctrl-C, IRB would raise `IRB::Abort`
# But since Pager is implemented by running paging commands like `less` in another process with `IO.popen`,
# the `IRB::Abort` exception only interrupts IRB's execution but doesn't affect the pager
# So to properly terminate the pager with Ctrl-C, we need to catch `IRB::Abort` and kill the pager process
rescue Pager::Abort
rescue IRB::Abort
io.cleanup
nil
rescue Errno::EPIPE
end
end
end
end

0 comments on commit f165fe3

Please sign in to comment.