diff --git a/lib/error_highlight/formatter.rb b/lib/error_highlight/formatter.rb index 20ca78d..0656a88 100644 --- a/lib/error_highlight/formatter.rb +++ b/lib/error_highlight/formatter.rb @@ -1,15 +1,66 @@ module ErrorHighlight class DefaultFormatter + MIN_SNIPPET_WIDTH = 20 + def self.message_for(spot) # currently only a one-line code snippet is supported - if spot[:first_lineno] == spot[:last_lineno] - indent = spot[:snippet][0...spot[:first_column]].gsub(/[^\t]/, " ") - marker = indent + "^" * (spot[:last_column] - spot[:first_column]) + return "" unless spot[:first_lineno] == spot[:last_lineno] + + snippet = spot[:snippet] + first_column = spot[:first_column] + last_column = spot[:last_column] + ellipsis = "..." + + # truncate snippet to fit in the viewport + if snippet_max_width && snippet.size > snippet_max_width + available_width = snippet_max_width - ellipsis.size + center = first_column - snippet_max_width / 2 + + visible_start = last_column < available_width ? 0 : [center, 0].max + visible_end = visible_start + snippet_max_width + visible_start = snippet.size - snippet_max_width if visible_end > snippet.size + + prefix = visible_start.positive? ? ellipsis : "" + suffix = visible_end < snippet.size ? ellipsis : "" - "\n\n#{ spot[:snippet] }#{ marker }" - else - "" + snippet = prefix + snippet[(visible_start + prefix.size)...(visible_end - suffix.size)] + suffix + snippet << "\n" unless snippet.end_with?("\n") + + first_column -= visible_start + last_column = [last_column - visible_start, snippet.size - 1].min end + + indent = snippet[0...first_column].gsub(/[^\t]/, " ") + marker = indent + "^" * (last_column - first_column) + + "\n\n#{ snippet }#{ marker }" + end + + def self.snippet_max_width + return if Ractor.current[:__error_highlight_max_snippet_width__] == :disabled + + Ractor.current[:__error_highlight_max_snippet_width__] ||= terminal_width + end + + def self.snippet_max_width=(width) + return Ractor.current[:__error_highlight_max_snippet_width__] = :disabled if width.nil? + + width = width.to_i + + if width < MIN_SNIPPET_WIDTH + warn "'snippet_max_width' adjusted to minimum value of #{MIN_SNIPPET_WIDTH}." + width = MIN_SNIPPET_WIDTH + end + + Ractor.current[:__error_highlight_max_snippet_width__] = width + end + + def self.terminal_width + # lazy load io/console, so it's not loaded when snippet_max_width is set + require "io/console" + STDERR.winsize[1] if STDERR.tty? + rescue LoadError, NoMethodError, SystemCallError + # do not truncate when window size is not available end end diff --git a/test/test_error_highlight.rb b/test/test_error_highlight.rb index f0da5b5..be36fca 100644 --- a/test/test_error_highlight.rb +++ b/test/test_error_highlight.rb @@ -5,6 +5,8 @@ require "tempfile" class ErrorHighlightTest < Test::Unit::TestCase + ErrorHighlight::DefaultFormatter.snippet_max_width = 80 + class DummyFormatter def self.message_for(corrections) "" @@ -1285,6 +1287,136 @@ def test_no_final_newline end end + def test_errors_on_small_terminal_window_at_the_end + assert_error_message(NoMethodError, <<~END) do +undefined method `time' for #{ ONE_RECV_MESSAGE } + +...0000000000000000000000000000000000000000000000000000000000000000 + 1.time {} + ^^^^^ + END + + 100000000000000000000000000000000000000000000000000000000000000000000000000000 + 1.time {} + end + end + + def test_errors_on_small_terminal_window_at_the_beginning + assert_error_message(NoMethodError, <<~END) do +undefined method `time' for #{ ONE_RECV_MESSAGE } + + 1.time { 10000000000000000000000000000000000000000000000000000000000000... + ^^^^^ + END + + 1.time { 100000000000000000000000000000000000000000000000000000000000000000000000000000 } + + end + end + + def test_errors_on_small_terminal_window_at_the_middle_near_beginning + assert_error_message(NoMethodError, <<~END) do +undefined method `time' for #{ ONE_RECV_MESSAGE } + + 100000000000000000000000000000000000000 + 1.time { 1000000000000000000000... + ^^^^^ + END + + 100000000000000000000000000000000000000 + 1.time { 100000000000000000000000000000000000000 } + end + end + + def test_errors_on_small_terminal_window_at_the_middle + assert_error_message(NoMethodError, <<~END) do +undefined method `time' for #{ ONE_RECV_MESSAGE } + +...000000000000000000000000000000000 + 1.time { 10000000000000000000000000000... + ^^^^^ + END + + 10000000000000000000000000000000000000000000000000000000000000000000000 + 1.time { 1000000000000000000000000000000 } + end + end + + def test_errors_on_extremely_small_terminal_window + custom_max_width = 30 + original_max_width = ErrorHighlight::DefaultFormatter.snippet_max_width + + ErrorHighlight::DefaultFormatter.snippet_max_width = custom_max_width + + assert_error_message(NoMethodError, <<~END) do +undefined method `time' for #{ ONE_RECV_MESSAGE } + +...00000000 + 1.time { 1000... + ^^^^^ + END + + 100000000000000 + 1.time { 100000000000000 } + end + ensure + ErrorHighlight::DefaultFormatter.snippet_max_width = original_max_width + end + + def test_errors_on_terminal_window_smaller_than_min_width + custom_max_width = 5 + original_max_width = ErrorHighlight::DefaultFormatter.snippet_max_width + + ErrorHighlight::DefaultFormatter.snippet_max_width = custom_max_width + + assert_error_message(NoMethodError, <<~END) do +undefined method `time' for #{ ONE_RECV_MESSAGE } + +...000 + 1.time {... + ^^^^^ + END + + 100000000000000 + 1.time { 100000000000000 } + end + ensure + ErrorHighlight::DefaultFormatter.snippet_max_width = original_max_width + end + + def test_errors_on_terminal_window_when_truncation_is_disabled + custom_max_width = nil + original_max_width = ErrorHighlight::DefaultFormatter.snippet_max_width + + ErrorHighlight::DefaultFormatter.snippet_max_width = custom_max_width + + assert_error_message(NoMethodError, <<~END) do +undefined method `time' for #{ ONE_RECV_MESSAGE } + + 10000000000000000000000000000000000000000000000000000000000000000000000 + 1.time { 1000000000000000000000000000000 } + ^^^^^ + END + + 10000000000000000000000000000000000000000000000000000000000000000000000 + 1.time { 1000000000000000000000000000000 } + end + ensure + ErrorHighlight::DefaultFormatter.snippet_max_width = original_max_width + end + + def test_errors_on_small_terminal_window_when_larger_than_viewport + assert_error_message(NoMethodError, <<~END) do +undefined method `timessssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss!' for #{ ONE_RECV_MESSAGE } + + 1.timesssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss... + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + END + + 1.timessssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss! + end + end + + def test_errors_on_small_terminal_window_when_exact_size_of_viewport + assert_error_message(NoMethodError, <<~END) do +undefined method `timessssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss!' for #{ ONE_RECV_MESSAGE } + + 1.timessssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss!... + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + END + + 1.timessssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss! * 1000 + end + end + def test_simulate_funcallv_from_embedded_ruby assert_error_message(NoMethodError, <<~END) do undefined method `foo' for #{ NIL_RECV_MESSAGE }