diff --git a/lib/reline.rb b/lib/reline.rb index 53adf1ad4d..74bb68b6b9 100644 --- a/lib/reline.rb +++ b/lib/reline.rb @@ -253,6 +253,14 @@ def readmultiline(_prompt = '', _add_history = false, prompt: _prompt, add_histo raise ArgumentError.new('#readmultiline needs block to confirm multiline termination') end + unless io_gate.both_tty? + whole_buffer = read_noninteractively(prompt, true, &confirm_multiline_termination) + if add_history and whole_buffer and whole_buffer.chomp("\n").size > 0 + Reline::HISTORY << whole_buffer + end + return whole_buffer + end + io_gate.with_raw_input do inner_readline(prompt, add_history, true, rprompt: rprompt, &confirm_multiline_termination) end @@ -275,6 +283,14 @@ def readmultiline(_prompt = '', _add_history = false, prompt: _prompt, add_histo def readline(_prompt = '', _add_history = false, prompt: _prompt, add_history: _add_history, rprompt: nil) @mutex.synchronize do + unless io_gate.both_tty? + line = read_noninteractively(prompt, false) + if add_history and line and line.chomp("\n").size > 0 + Reline::HISTORY << line.chomp("\n") + end + return line + end + io_gate.with_raw_input do inner_readline(prompt, add_history, false, rprompt: rprompt) end @@ -290,6 +306,42 @@ def readline(_prompt = '', _add_history = false, prompt: _prompt, add_history: _ end end + # Line-by-line read used when input or output is not a tty. Editing keys + # in the input are not interpreted, and the output contains no escape + # sequences. Like GNU Readline, the prompt and the input are echoed to + # the output so that it forms a self-contained transcript. + # https://github.com/ruby/reline/issues/886 + private def read_noninteractively(prompt, multiline, &confirm_multiline_termination) + input = io_gate.input + lines = [] + loop do + output.write(noninteractive_prompt(prompt, multiline, lines)) + output.flush + line = input.gets + unless line + # Close the prompt line even on EOF + output.write("\n") + break + end + chomped = line.chomp + # Control characters in the input are echoed in caret notation so + # that they cannot corrupt the output + output.write("#{Reline::Unicode.escape_for_print(chomped)}\n") + lines << chomped + break unless line.end_with?("\n") # EOF without a trailing newline + break unless multiline + break if confirm_multiline_termination.call(lines.join("\n") + "\n") + end + output.flush + lines.empty? ? nil : lines.join("\n") + end + + private def noninteractive_prompt(prompt, multiline, lines) + return prompt unless multiline and prompt_proc + prompt_list = prompt_proc.call(lines + ['']) + prompt_list[lines.size] || prompt_list[0] || prompt + end + private def inner_readline(prompt, add_history, multiline, rprompt: nil, &confirm_multiline_termination) if ENV['RELINE_STDERR_TTY'] if io_gate.win? diff --git a/lib/reline/io.rb b/lib/reline/io.rb index 5ea894c2dd..bfb04c83a8 100644 --- a/lib/reline/io.rb +++ b/lib/reline/io.rb @@ -34,6 +34,11 @@ def dumb? false end + # Whether input and output are both connected to a terminal. + def both_tty? + true + end + def win? false end diff --git a/lib/reline/io/ansi.rb b/lib/reline/io/ansi.rb index f2e9ecf56a..1526973247 100644 --- a/lib/reline/io/ansi.rb +++ b/lib/reline/io/ansi.rb @@ -17,7 +17,7 @@ class Reline::ANSI < Reline::IO 'H' => [:ed_move_to_beg, {}], } - attr_writer :input, :output + attr_accessor :input, :output def initialize @input = STDIN diff --git a/lib/reline/io/dumb.rb b/lib/reline/io/dumb.rb index 0c04c755d2..55306b0012 100644 --- a/lib/reline/io/dumb.rb +++ b/lib/reline/io/dumb.rb @@ -3,6 +3,7 @@ class Reline::Dumb < Reline::IO RESET_COLOR = '' # Do not send color reset sequence + attr_reader :input attr_writer :output def initialize(encoding: nil) diff --git a/lib/reline/io/windows.rb b/lib/reline/io/windows.rb index e7be59d714..b5ecd20821 100644 --- a/lib/reline/io/windows.rb +++ b/lib/reline/io/windows.rb @@ -5,6 +5,7 @@ class Reline::Windows < Reline::IO const_set(name, console.const_get(name)) end + attr_reader :input attr_writer :output def initialize @@ -93,6 +94,10 @@ def set_default_key_bindings(config) false end + def both_tty? + @input.tty? && @console_output.tty? + end + def msys_tty? @input.tty?(:msys, :cygwin) end diff --git a/test/reline/test_reline.rb b/test/reline/test_reline.rb index 49c736eb4a..1239b7bef0 100644 --- a/test/reline/test_reline.rb +++ b/test/reline/test_reline.rb @@ -428,28 +428,48 @@ def test_readline_returns_nil_on_piped_stdin_eof assert_include(out, { result: nil }.inspect) end - def test_read_eof_returns_input - pend if win? - lib = File.expand_path("../../lib", __dir__) - code = "p result: Reline.readline" - out = IO.popen([Reline.test_rubybin, "-I#{lib}", "-rreline", "-e", code], "r+") do |io| - io.write "a\C-a" - io.close_write - io.read - end - assert_include(out, { result: 'a' }.inspect) + def test_readline_with_piped_stdin_emits_plain_transcript + out = readline_from_piped_stdin("input\n") + assert_include(out, ">input\n") + assert_not_include(out, "\e") end - def test_read_eof_returns_nil_if_empty - pend if win? - lib = File.expand_path("../../lib", __dir__) - code = "p result: Reline.readline" - out = IO.popen([Reline.test_rubybin, "-I#{lib}", "-rreline", "-e", code], "r+") do |io| - io.write "a\C-h" - io.close_write - io.read - end - assert_include(out, { result: nil }.inspect) + def test_readline_with_piped_stdin_closes_prompt_line_on_eof + out = readline_from_piped_stdin("") + assert_include(out, ">\n") + end + + def test_readline_does_not_interpret_editing_keys_in_piped_stdin + out = readline_from_piped_stdin("a\C-ab\n") + assert_include(out, ">a^Ab\n") + assert_include(out, { result: "a\C-ab" }.inspect) + end + + def test_readline_adds_piped_stdin_to_history + code = <<~'RUBY' + require 'timeout' + Timeout.timeout(3) { Reline.readline('>', true) } + p history: Reline::HISTORY.to_a + RUBY + out = run_ruby_with_piped_stdin(code, "input\n") + assert_include(out, { history: ['input'] }.inspect) + end + + def test_readmultiline_with_piped_stdin + code = <<~'RUBY' + require 'timeout' + Reline.prompt_proc = proc { |lines| lines.map.with_index { |_, i| "#{i}>" } } + p result: Timeout.timeout(3) { Reline.readmultiline('fallback>') { |code| code.include?('end') } } + RUBY + out = run_ruby_with_piped_stdin(code, "a\nend\n") + assert_include(out, "0>a\n") + assert_include(out, "1>end\n") + assert_include(out, { result: "a\nend" }.inspect) + end + + def test_read_eof_returns_partial_line + out = readline_from_piped_stdin("a") + assert_include(out, { result: 'a' }.inspect) end def test_require_reline_should_not_trigger_winsize @@ -471,7 +491,6 @@ def win? end def readline_from_piped_stdin(stdin) - lib = File.expand_path("../../lib", __dir__) code = <<~'RUBY' require 'timeout' begin @@ -481,6 +500,11 @@ def readline_from_piped_stdin(stdin) end RUBY + run_ruby_with_piped_stdin(code, stdin) + end + + def run_ruby_with_piped_stdin(code, stdin) + lib = File.expand_path("../../lib", __dir__) IO.popen([Reline.test_rubybin, "-I#{lib}", "-rreline", "-e", code], "r+") do |io| io.write stdin io.close_write diff --git a/test/reline/yamatanooroti/test_rendering.rb b/test/reline/yamatanooroti/test_rendering.rb index 545e0385e1..4a66baeee7 100644 --- a/test/reline/yamatanooroti/test_rendering.rb +++ b/test/reline/yamatanooroti/test_rendering.rb @@ -965,8 +965,8 @@ def test_nontty cmd = %Q{ruby -e 'puts(%Q{ello\C-ah\C-e})' | ruby -I#{@pwd}/lib -rreline -e 'p Reline.readline(%{> })' | ruby -e 'print STDIN.read'} start_terminal(40, 50, ['bash', '-c', cmd]) assert_screen(<<~'EOC') - > hello - "hello" + > ello^Ah^E + "ello\u0001h\u0005" EOC close end @@ -976,8 +976,8 @@ def test_eof_with_newline cmd = %Q{ruby -e 'print(%Q{abc def \\e\\r})' | ruby -I#{@pwd}/lib -rreline -e 'p Reline.readline(%{> })'} start_terminal(40, 50, ['bash', '-c', cmd]) assert_screen(<<~'EOC') - > abc def - "abc def " + > abc def ^[ + "abc def \e" EOC close end @@ -993,6 +993,32 @@ def test_eof_without_newline close end + def test_nontty_multiline_eof + omit if Reline.core.io_gate.win? + cmd = %Q{ruby -e 'puts(%{hello});print(%{world})' | ruby -I#{@pwd}/lib -rreline -e 'p Reline.readmultiline(%{> }){false}'} + start_terminal(40, 50, ['bash', '-c', cmd]) + assert_screen(<<~'EOC') + > hello + > world + "hello\nworld" + EOC + close + end + + def test_nontty_multiline + omit if Reline.core.io_gate.win? + cmd = %Q{ruby -e 'puts("def f", "42", "end", "hello")' | ruby -I#{@pwd}/lib -rreline -e 'p Reline.readmultiline(%{> }){|input| input.match?(/end/)}; p gets'} + start_terminal(40, 50, ['bash', '-c', cmd]) + assert_screen(<<~'EOC') + > def f + > 42 + > end + "def f\n42\nend" + "hello\n" + EOC + close + end + def test_em_set_mark_and_em_exchange_mark start_terminal(10, 50, %W{ruby -I#{@pwd}/lib #{@pwd}/test/reline/yamatanooroti/multiline_repl}, startup_message: 'Multiline REPL.') write("aaa bbb ccc ddd\eb\eb\e\x20\eb\C-x\C-xX\C-x\C-xY")