From 4add4c01266bfe0a5ad35f5a9eb80baa9c02718d Mon Sep 17 00:00:00 2001 From: tompng Date: Sun, 2 Aug 2026 18:51:53 +0900 Subject: [PATCH] Read line-by-line without the line editor when stdin or stdout is not a tty Key-by-key line editing is a closed feedback loop of input and display; when either side is not a terminal the loop is broken, and an editor the user cannot see is worse than useless (invisible history recall or completion submits unseen content, and raw mode disables even the terminal driver's own echo). Instead, read with gets and echo the prompt and the input back to the output as a plain, escape-sequence-free transcript. This makes piped usage behave like GNU Readline for printable input: `echo input | ruby -rreadline -e '...'` now produces "> input" with no escape sequences. Behavior details: - The line editor is used only when both input and output are ttys (libedit does the same). For interactive editing with stdout redirected, point the render target at a tty with `Reline.output = $stderr`, as bash does with rl_outstream (ANSI only; the Windows gate is bound to the console's stdout). - Editing keys in piped input are no longer interpreted (libedit-style raw reads). GNU Readline interprets them, but GNU and libedit already disagree here, so exact readline-ext compatibility is not a well-defined target; control characters in input are the uncommon case. They are echoed in caret notation (^A) so they cannot corrupt the output. - The prompt and the input are echoed even though frontends with local echo will then show the input twice; GNU Readline echoes in exactly the same situations, so this introduces no new doubling, and it keeps redirected output a self-contained transcript. The prompt line is closed with a newline even on EOF. - Multiline reads honor confirm_multiline_termination and prompt_proc, so irb transcripts keep their per-line prompts and indentation. - Reline::Dumb is unchanged: TERM=dumb and test-mode usage keep the line editor path (IO#both_tty? defaults to true). Fixes https://github.com/ruby/reline/issues/886 Co-Authored-By: Claude Fable 5 --- lib/reline.rb | 52 ++++++++++++++++ lib/reline/io.rb | 5 ++ lib/reline/io/ansi.rb | 2 +- lib/reline/io/dumb.rb | 1 + lib/reline/io/windows.rb | 5 ++ test/reline/test_reline.rb | 66 ++++++++++++++------- test/reline/yamatanooroti/test_rendering.rb | 34 +++++++++-- 7 files changed, 139 insertions(+), 26 deletions(-) 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")