From c4041d39079524656a894666296bec15dc253f0d Mon Sep 17 00:00:00 2001 From: nick evans Date: Tue, 10 Feb 2026 09:04:23 -0500 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A5=85=F0=9F=92=84=20Add=20color=20hi?= =?UTF-8?q?ghlights=20to=20parse=20error=20details?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding colors makes it much faster/easier for me to read and understand detailed debug messages. Of all the exception classes in net-imap, this one benefits the most from some extra formatting, IMO. When `ENV["NO_COLOR"]` is not empty, highlights are monochromatic by default (only uses bold and underline). --- lib/net/imap/errors.rb | 15 ++++++++++++++- test/net/imap/test_errors.rb | 36 ++++++++++++++++++++++++++++-------- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/lib/net/imap/errors.rb b/lib/net/imap/errors.rb index 3a3ee5a9..532508a9 100644 --- a/lib/net/imap/errors.rb +++ b/lib/net/imap/errors.rb @@ -67,6 +67,14 @@ class ResponseParseError < Error ).freeze private_constant :ESC_NO_COLOR + # ANSI highlights, with color + ESC_COLORS = Hash.new(&default_highlight).update( + reset: "\e[m", + val: "\e[36;40m", # cyan on black (to ensure contrast) + alt: "\e[1;33;40m", # bold; yellow on black + ).freeze + private_constant :ESC_COLORS + # Net::IMAP::ResponseParser, unless a custom parser produced the error. attr_reader :parser_class @@ -119,13 +127,18 @@ def initialize(message = "unspecified parse error", # # When +highlight+ is not explicitly set, highlights may be enabled # automatically, based on +TERM+ and +FORCE_COLOR+ environment variables. + # + # By default, +highlight+ uses colors from the basic ANSI palette. When + # +highlight_no_color+ is true or the +NO_COLOR+ environment variable is + # not empty, only monochromatic highlights are used: bold, underline, etc. def detailed_message(parser_state: Net::IMAP.debug, parser_backtrace: false, highlight: default_highlight_from_env, + highlight_no_color: (ENV["NO_COLOR"] || "") != "", **) return super unless parser_state || parser_backtrace msg = super.dup - esc = highlight ? ESC_NO_COLOR : ESC_NO_HL + esc = !highlight ? ESC_NO_HL : highlight_no_color ? ESC_NO_COLOR : ESC_COLORS hl = ->str { str % esc } val = ->str, val { val.nil? ? "nil" : str % esc % val } if parser_state && (string || pos || lex_state || token) diff --git a/test/net/imap/test_errors.rb b/test/net/imap/test_errors.rb index 83be0659..122d0ff8 100644 --- a/test/net/imap/test_errors.rb +++ b/test/net/imap/test_errors.rb @@ -11,6 +11,8 @@ def self.SGR(*attr) = CSI attr.join(?;), ?m RESET = SGR "" # could also use 0 BOLD = SGR 1 BOLD_UNDERLINE = SGR 1, 4 + BOLD_YELLOW = SGR 1, 33, 40 + CYAN = SGR 36, 40 setup do @term_env_vars = ENV["TERM"], ENV["NO_COLOR"], ENV["FORCE_COLOR"] @@ -90,26 +92,41 @@ def self.SGR(*attr) = CSI attr.join(?;), ?m lex_state : #{BOLD}:EXPR_BEG#{RESET} token : #{BOLD}:QUOTED#{RESET} => #{BOLD}"Microsoft.Exchange.Error: foo"#{RESET} MSG + expected_color_hl = <<~MSG.strip + #{BOLD}#{msg} (#{BOLD_UNDERLINE}#{name}#{RESET}#{BOLD})#{RESET} + processed : #{CYAN}"tag OK [Error=\\"Microsoft.Exchange.Error: foo\\""#{RESET} + remaining : #{BOLD_YELLOW}"] done\\r\\n"#{RESET} + pos : #{CYAN}45#{RESET} + lex_state : #{CYAN}:EXPR_BEG#{RESET} + token : #{CYAN}:QUOTED#{RESET} => #{CYAN}"Microsoft.Exchange.Error: foo"#{RESET} + MSG ENV["TERM"], ENV["NO_COLOR"], ENV["FORCE_COLOR"] = nil, nil, "0" assert_equal(expected_no_hl, err.detailed_message) - assert_equal(expected_no_color, err.detailed_message(highlight: true)) + assert_equal(expected_color_hl, err.detailed_message(highlight: true)) + assert_equal(expected_no_color, err.detailed_message(highlight: true, + highlight_no_color: true)) ENV["TERM"], ENV["NO_COLOR"], ENV["FORCE_COLOR"] = "dumb", "1", nil assert_equal(expected_no_hl, err.detailed_message) assert_equal(expected_no_color, err.detailed_message(highlight: true)) + assert_equal(expected_color_hl, err.detailed_message(highlight: true, + highlight_no_color: false)) ENV["TERM"], ENV["NO_COLOR"], ENV["FORCE_COLOR"] = "xterm", nil, nil - assert_equal(expected_no_color, err.detailed_message) + assert_equal(expected_color_hl, err.detailed_message) assert_equal(expected_no_hl, err.detailed_message(highlight: false)) + assert_equal(expected_no_color, err.detailed_message(highlight_no_color: true)) ENV["TERM"], ENV["NO_COLOR"], ENV["FORCE_COLOR"] = "dumb", nil, "1" - assert_equal(expected_no_color, err.detailed_message) + assert_equal(expected_color_hl, err.detailed_message) assert_equal(expected_no_hl, err.detailed_message(highlight: false)) + assert_equal(expected_no_color, err.detailed_message(highlight_no_color: true)) ENV["TERM"], ENV["NO_COLOR"], ENV["FORCE_COLOR"] = "unknown", "1", "1" assert_equal(expected_no_color, err.detailed_message) assert_equal(expected_no_hl, err.detailed_message(highlight: false)) + assert_equal(expected_color_hl, err.detailed_message(highlight_no_color: false)) # reset to nil ENV["TERM"], ENV["NO_COLOR"], ENV["FORCE_COLOR"] = nil, nil, nil @@ -135,10 +152,10 @@ def self.SGR(*attr) = CSI attr.join(?;), ?m MSG assert_equal(<<~MSG.strip, err.detailed_message(highlight: true, parser_state: true)) #{BOLD}#{msg} (#{BOLD_UNDERLINE}#{name}#{RESET}#{BOLD})#{RESET} - processed : #{BOLD}"tag OK [Error=\\"Microsoft.Exchange.Error: foo\\""#{RESET} - remaining : #{BOLD_UNDERLINE}"] done\\r\\n"#{RESET} - pos : #{BOLD}45#{RESET} - lex_state : #{BOLD}:EXPR_BEG#{RESET} + processed : #{CYAN}"tag OK [Error=\\"Microsoft.Exchange.Error: foo\\""#{RESET} + remaining : #{BOLD_YELLOW}"] done\\r\\n"#{RESET} + pos : #{CYAN}45#{RESET} + lex_state : #{CYAN}:EXPR_BEG#{RESET} token : nil MSG @@ -147,9 +164,12 @@ def self.SGR(*attr) = CSI attr.join(?;), ?m parser = Net::IMAP::ResponseParser.new error = parser.parse("* 123 FETCH (UNKNOWN ...)\r\n") rescue $! no_hl = error.detailed_message(parser_backtrace: true) - no_color = error.detailed_message(parser_backtrace: true, highlight: true) + color_hl = error.detailed_message(parser_backtrace: true, highlight: true) + no_color = error.detailed_message(parser_backtrace: true, highlight: true, + highlight_no_color: true) assert_include no_hl, "caller[ 1]: %-30s (" % "msg_att" assert_include no_color, "caller[ 1]: #{BOLD}%-30s#{RESET} (" % "msg_att" + assert_include color_hl, "caller[ 1]: #{CYAN}%-30s#{RESET} (" % "msg_att" end test "ResponseTooLargeError" do From 2288106c760b5b62ca69f8e13f0ba7fce83c472b Mon Sep 17 00:00:00 2001 From: nick evans Date: Tue, 10 Feb 2026 14:41:37 -0500 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=A5=85=F0=9F=92=84=E2=99=BB=EF=B8=8F?= =?UTF-8?q?=20Reset=20highlight=20with=20"closing=20tags"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iterating on the prior highlighting approach, this a tidy solution to the problem of only resetting what needs to be reset. Specifically, I'd like to add color highlighting, while keeping the NO_COLOR highlighting, and I don't want to add a bunch of redundant reset sequences. This is still annoyingly complex (and adding ractor support makes it much worse), but I think it still comes out as simpler than the other approaches I looked at. --- lib/net/imap/errors.rb | 28 +++++++++++++++++++++------- test/net/imap/test_errors.rb | 9 +++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/lib/net/imap/errors.rb b/lib/net/imap/errors.rb index 532508a9..de65c15d 100644 --- a/lib/net/imap/errors.rb +++ b/lib/net/imap/errors.rb @@ -59,8 +59,22 @@ class ResponseParseError < Error ESC_NO_HL = Hash.new("").freeze private_constant :ESC_NO_HL + # Translates hash[:"/foo"] to hash[:reset] when hash.key?(:foo), else "" + # + # TODO: DRY this up with Config::AttrTypeCoercion.safe + if defined?(::Ractor.shareable_proc) + default_highlight = Ractor.shareable_proc {|hash, key| + %r{\A/(.+)} =~ key && hash.key?($1.to_sym) ? hash[:reset] : "" + } + else + default_highlight = nil.instance_eval { Proc.new {|hash, key| + %r{\A/(.+)} =~ key && hash.key?($1.to_sym) ? hash[:reset] : "" + } } + ::Ractor.make_shareable(default_highlight) if defined?(::Ractor) + end + # ANSI highlights, but no colors - ESC_NO_COLOR = Hash.new("").update( + ESC_NO_COLOR = Hash.new(&default_highlight).update( reset: "\e[m", val: "\e[1m", # bold alt: "\e[1;4m", # bold and underlined @@ -142,12 +156,12 @@ def detailed_message(parser_state: Net::IMAP.debug, hl = ->str { str % esc } val = ->str, val { val.nil? ? "nil" : str % esc % val } if parser_state && (string || pos || lex_state || token) - msg << "\n processed : " << val["%{val}%%p%{reset}", processed_string] - msg << "\n remaining : " << val["%{alt}%%p%{reset}", remaining_string] - msg << "\n pos : " << val["%{val}%%p%{reset}", pos] - msg << "\n lex_state : " << val["%{val}%%p%{reset}", lex_state] + msg << "\n processed : " << val["%{val}%%p%{/val}", processed_string] + msg << "\n remaining : " << val["%{alt}%%p%{/alt}", remaining_string] + msg << "\n pos : " << val["%{val}%%p%{/val}", pos] + msg << "\n lex_state : " << val["%{val}%%p%{/val}", lex_state] msg << "\n token : " << val[ - "%{val}%%p%{reset} => %{val}%%p%{reset}", token&.to_h + "%{val}%%p%{/val} => %{val}%%p%{/val}", token&.to_h ] end if parser_backtrace @@ -161,7 +175,7 @@ def detailed_message(parser_state: Net::IMAP.debug, end msg << "\n %s: %s (%s:%d)" % [ "caller[%2d]" % idx, - hl["%{val}%%-30s%{reset}"] % loc.base_label, + hl["%{val}%%-30s%{/val}"] % loc.base_label, File.basename(loc.path, ".rb"), loc.lineno ] end diff --git a/test/net/imap/test_errors.rb b/test/net/imap/test_errors.rb index 122d0ff8..75981245 100644 --- a/test/net/imap/test_errors.rb +++ b/test/net/imap/test_errors.rb @@ -172,6 +172,15 @@ def self.SGR(*attr) = CSI attr.join(?;), ?m assert_include color_hl, "caller[ 1]: #{CYAN}%-30s#{RESET} (" % "msg_att" end + if defined?(::Ractor) + %i[ESC_NO_HL ESC_NO_COLOR ESC_COLORS].each do |name| + test "ResponseParseError::#{name} is Ractor shareable" do + value = Net::IMAP::ResponseParseError.const_get(name) + assert Ractor.shareable? value + end + end + end + test "ResponseTooLargeError" do err = Net::IMAP::ResponseTooLargeError.new assert_nil err.bytes_read From 8ed89494d50daf0fc11728abcf2a6a625c6391f1 Mon Sep 17 00:00:00 2001 From: nick evans Date: Tue, 10 Feb 2026 16:24:41 -0500 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A5=85=F0=9F=92=84=20Add=20more=20hig?= =?UTF-8?q?hlight=20types=20to=20parse=20error=20details?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds more distinct highlighting types, for color highlighting. Monochrome highlighting simply ignores types it hasn't defined. --- lib/net/imap/errors.rb | 25 ++++++++++++++++--------- test/net/imap/test_errors.rb | 28 +++++++++++++++++----------- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/lib/net/imap/errors.rb b/lib/net/imap/errors.rb index de65c15d..595f5fda 100644 --- a/lib/net/imap/errors.rb +++ b/lib/net/imap/errors.rb @@ -78,14 +78,21 @@ class ResponseParseError < Error reset: "\e[m", val: "\e[1m", # bold alt: "\e[1;4m", # bold and underlined + sym: "\e[1m", # bold + label: "\e[1m", # bold ).freeze private_constant :ESC_NO_COLOR # ANSI highlights, with color ESC_COLORS = Hash.new(&default_highlight).update( reset: "\e[m", + key: "\e[95m", # bright magenta + idx: "\e[34m", # blue val: "\e[36;40m", # cyan on black (to ensure contrast) alt: "\e[1;33;40m", # bold; yellow on black + sym: "\e[33;40m", # yellow on black + label: "\e[1m", # bold + nil: "\e[35m", # magenta ).freeze private_constant :ESC_COLORS @@ -154,14 +161,14 @@ def detailed_message(parser_state: Net::IMAP.debug, msg = super.dup esc = !highlight ? ESC_NO_HL : highlight_no_color ? ESC_NO_COLOR : ESC_COLORS hl = ->str { str % esc } - val = ->str, val { val.nil? ? "nil" : str % esc % val } + val = ->str, val { hl[val.nil? ? "%{nil}%%p%{/nil}" : str] % val } if parser_state && (string || pos || lex_state || token) - msg << "\n processed : " << val["%{val}%%p%{/val}", processed_string] - msg << "\n remaining : " << val["%{alt}%%p%{/alt}", remaining_string] - msg << "\n pos : " << val["%{val}%%p%{/val}", pos] - msg << "\n lex_state : " << val["%{val}%%p%{/val}", lex_state] - msg << "\n token : " << val[ - "%{val}%%p%{/val} => %{val}%%p%{/val}", token&.to_h + msg << hl["\n %{key}processed %{/key}: "] << val["%{val}%%p%{/val}", processed_string] + msg << hl["\n %{key}remaining %{/key}: "] << val["%{alt}%%p%{/alt}", remaining_string] + msg << hl["\n %{key}pos %{/key}: "] << val["%{val}%%p%{/val}", pos] + msg << hl["\n %{key}lex_state %{/key}: "] << val["%{sym}%%p%{/sym}", lex_state] + msg << hl["\n %{key}token %{/key}: "] << val[ + "%{sym}%%p%{/sym} => %{val}%%p%{/val}", token&.to_h ] end if parser_backtrace @@ -174,8 +181,8 @@ def detailed_message(parser_state: Net::IMAP.debug, next unless loc.path&.include?("net/imap/response_parser") end msg << "\n %s: %s (%s:%d)" % [ - "caller[%2d]" % idx, - hl["%{val}%%-30s%{/val}"] % loc.base_label, + hl["%{key}caller[%{/key}%{idx}%%2d%{/idx}%{key}]%{/key}"] % idx, + hl["%{label}%%-30s%{/label}"] % loc.base_label, File.basename(loc.path, ".rb"), loc.lineno ] end diff --git a/test/net/imap/test_errors.rb b/test/net/imap/test_errors.rb index 75981245..29a179c8 100644 --- a/test/net/imap/test_errors.rb +++ b/test/net/imap/test_errors.rb @@ -12,7 +12,11 @@ def self.SGR(*attr) = CSI attr.join(?;), ?m BOLD = SGR 1 BOLD_UNDERLINE = SGR 1, 4 BOLD_YELLOW = SGR 1, 33, 40 + YELLOW = SGR 33, 40 + BLUE = SGR 34 CYAN = SGR 36, 40 + MAGENTA_DARK = SGR 35 + MAGENTA = SGR 95 setup do @term_env_vars = ENV["TERM"], ENV["NO_COLOR"], ENV["FORCE_COLOR"] @@ -94,11 +98,11 @@ def self.SGR(*attr) = CSI attr.join(?;), ?m MSG expected_color_hl = <<~MSG.strip #{BOLD}#{msg} (#{BOLD_UNDERLINE}#{name}#{RESET}#{BOLD})#{RESET} - processed : #{CYAN}"tag OK [Error=\\"Microsoft.Exchange.Error: foo\\""#{RESET} - remaining : #{BOLD_YELLOW}"] done\\r\\n"#{RESET} - pos : #{CYAN}45#{RESET} - lex_state : #{CYAN}:EXPR_BEG#{RESET} - token : #{CYAN}:QUOTED#{RESET} => #{CYAN}"Microsoft.Exchange.Error: foo"#{RESET} + #{MAGENTA}processed #{RESET}: #{CYAN}"tag OK [Error=\\"Microsoft.Exchange.Error: foo\\""#{RESET} + #{MAGENTA}remaining #{RESET}: #{BOLD_YELLOW}"] done\\r\\n"#{RESET} + #{MAGENTA}pos #{RESET}: #{CYAN }45#{RESET} + #{MAGENTA}lex_state #{RESET}: #{YELLOW}:EXPR_BEG#{RESET} + #{MAGENTA}token #{RESET}: #{YELLOW}:QUOTED#{RESET} => #{CYAN}"Microsoft.Exchange.Error: foo"#{RESET} MSG ENV["TERM"], ENV["NO_COLOR"], ENV["FORCE_COLOR"] = nil, nil, "0" @@ -152,11 +156,11 @@ def self.SGR(*attr) = CSI attr.join(?;), ?m MSG assert_equal(<<~MSG.strip, err.detailed_message(highlight: true, parser_state: true)) #{BOLD}#{msg} (#{BOLD_UNDERLINE}#{name}#{RESET}#{BOLD})#{RESET} - processed : #{CYAN}"tag OK [Error=\\"Microsoft.Exchange.Error: foo\\""#{RESET} - remaining : #{BOLD_YELLOW}"] done\\r\\n"#{RESET} - pos : #{CYAN}45#{RESET} - lex_state : #{CYAN}:EXPR_BEG#{RESET} - token : nil + #{MAGENTA}processed #{RESET}: #{CYAN}"tag OK [Error=\\"Microsoft.Exchange.Error: foo\\""#{RESET} + #{MAGENTA}remaining #{RESET}: #{BOLD_YELLOW}"] done\\r\\n"#{RESET} + #{MAGENTA}pos #{RESET}: #{CYAN }45#{RESET} + #{MAGENTA}lex_state #{RESET}: #{YELLOW}:EXPR_BEG#{RESET} + #{MAGENTA}token #{RESET}: #{MAGENTA_DARK}nil#{RESET} MSG # with parser_backtrace @@ -169,7 +173,9 @@ def self.SGR(*attr) = CSI attr.join(?;), ?m highlight_no_color: true) assert_include no_hl, "caller[ 1]: %-30s (" % "msg_att" assert_include no_color, "caller[ 1]: #{BOLD}%-30s#{RESET} (" % "msg_att" - assert_include color_hl, "caller[ 1]: #{CYAN}%-30s#{RESET} (" % "msg_att" + assert_include color_hl, + "#{MAGENTA}caller[#{RESET}#{BLUE} 1#{RESET}#{MAGENTA}]#{RESET}: " \ + "#{BOLD}%-30s#{RESET} (" % "msg_att" end if defined?(::Ractor)