diff --git a/lib/net/imap/errors.rb b/lib/net/imap/errors.rb index bab4bbcf..cfa21fbe 100644 --- a/lib/net/imap/errors.rb +++ b/lib/net/imap/errors.rb @@ -51,7 +51,99 @@ def response_size_msg end # Error raised when a response from the server is non-parsable. + # + # NOTE: Parser attributes are provided for debugging and inspection only. + # Their names and semantics may change incompatibly in any release. class ResponseParseError < Error + # Net::IMAP::ResponseParser, unless a custom parser produced the error. + attr_reader :parser_class + + # The full raw response string which was being parsed. + attr_reader :string + + # The parser's byte position in #string when the error was raised. + # + # _NOTE:_ This attribute is provided for debugging and inspection only. + # Its name and semantics may change incompatibly in any release. + attr_reader :pos + + # The parser's lex state + # + # _NOTE:_ This attribute is provided for debugging and inspection only. + # Its name and semantics may change incompatibly in any release. + attr_reader :lex_state + + # The last lexed token + # + # May be +nil+ when the parser has accepted the last token and peeked at + # the next byte without generating a token. + # + # _NOTE:_ This attribute is provided for debugging and inspection only. + # Its name and semantics may change incompatibly in any release. + attr_reader :token + + def initialize(message = "unspecified parse error", + parser_class: Net::IMAP::ResponseParser, + parser_state: nil, + string: parser_state&.at(0), # see ParserUtils#parser_state + lex_state: parser_state&.at(1), # see ParserUtils#parser_state + pos: parser_state&.at(2), # see ParserUtils#parser_state + token: parser_state&.at(3)) # see ParserUtils#parser_state + @parser_class = parser_class + @string = string + @pos = pos + @lex_state = lex_state + @token = token + super(message) + end + + # When +parser_state+ is true, debug info about the parser state is + # included. Defaults to the value of Net::IMAP.debug. + # + # When +parser_backtrace+ is true, a simplified backtrace is included, + # containing only frames which belong to methods in parser_class. Most + # parser method names are based on rules in the IMAP grammar. + def detailed_message(parser_state: Net::IMAP.debug, + parser_backtrace: false, + **) + return super unless parser_state || parser_backtrace + msg = super.dup + if parser_state && (string || pos || lex_state || token) + msg << "\n processed : %p" % processed_string + msg << "\n remaining : %p" % remaining_string + msg << "\n pos : %p" % pos + msg << "\n lex_state : %p" % lex_state + msg << "\n token : " + if token + msg << "%p => %p" % [token.symbol, token.value] + else + msg << "nil" + end + end + if parser_backtrace + backtrace_locations&.each_with_index do |loc, idx| + next if loc.base_label.include? "parse_error" + break if loc.base_label == "parse" + next unless loc.label.include?(parser_class.name) + msg << "\n caller[%2d]: %-30s (%s:%d)" % [ + idx, + loc.base_label, + File.basename(loc.path, ".rb"), + loc.lineno + ] + end + end + msg + rescue => error + msg ||= super.dup + msg << "\n BUG in %s#%s: %s" % [self.class, __method__, + error.detailed_message] + msg + end + + def processed_string = string && pos && string[...pos] + def remaining_string = string && pos && string[pos..] + end # Superclass of all errors used to encapsulate "fail" responses diff --git a/lib/net/imap/response_parser/parser_utils.rb b/lib/net/imap/response_parser/parser_utils.rb index 85fd2b8d..214ed78c 100644 --- a/lib/net/imap/response_parser/parser_utils.rb +++ b/lib/net/imap/response_parser/parser_utils.rb @@ -216,29 +216,22 @@ def shift_token end def parse_error(fmt, *args) - msg = format(fmt, *args) + raise exception format(fmt, *args) + rescue ResponseParseError => error if config.debug? - local_path = File.dirname(__dir__) - tok = @token ? "%s: %p" % [@token.symbol, @token.value] : "nil" - warn "%s %s: %s" % [self.class, __method__, msg] - warn " tokenized : %s" % [@str[...@pos].dump] - warn " remaining : %s" % [@str[@pos..].dump] - warn " @lex_state: %s" % [@lex_state] - warn " @pos : %d" % [@pos] - warn " @token : %s" % [tok] - caller_locations(1..20).each_with_index do |cloc, idx| - next unless cloc.path&.start_with?(local_path) - warn " caller[%2d]: %-30s (%s:%d)" % [ - idx, - cloc.base_label, - File.basename(cloc.path, ".rb"), - cloc.lineno - ] - end + warn error.detailed_message(parser_state: true, + parser_backtrace: true) end - raise ResponseParseError, msg + raise end + def exception(message) = ResponseParseError.new( + message, parser_state:, parser_class: self.class + ) + + def current_state = [@lex_state, @pos, @token] + def parser_state = [@str, *current_state] + end end end diff --git a/test/net/imap/test_errors.rb b/test/net/imap/test_errors.rb index 6d4d9fb8..61370af5 100644 --- a/test/net/imap/test_errors.rb +++ b/test/net/imap/test_errors.rb @@ -4,6 +4,91 @@ require "test/unit" class IMAPErrorsTest < Net::IMAP::TestCase + Token = Net::IMAP::ResponseParser::Token + CSI = "\e[" + def self.CSI(*args) = CSI + args.join + def self.SGR(*attr) = CSI attr.join(?;), ?m + RESET = SGR "" # could also use 0 + BOLD = SGR 1 + BOLD_UNDERLINE = SGR 1, 4 + + test "ResponseParseError" do + # The first examples don't add parser state, so this makes no difference. + # It affects the last example, which has parser state. + Net::IMAP.debug = true + + name = Net::IMAP::ResponseParseError.name + + msg = "unspecified parse error" + err = Net::IMAP::ResponseParseError.new + assert_equal msg, err.message + assert_equal Net::IMAP::ResponseParser, err.parser_class + assert_nil err.string + assert_nil err.pos + assert_nil err.token + assert_nil err.lex_state + assert_equal("#{msg} (#{name})", err.detailed_message(parser_state: false)) + assert_equal("#{msg} (#{name})", err.detailed_message) + assert_equal( + "#{BOLD}#{msg} (#{BOLD_UNDERLINE}#{name}#{RESET}#{BOLD})#{RESET}", + err.detailed_message(highlight: true) + ) + + msg = "unexpected ATOM (expected NSTRING)" + err = Net::IMAP::ResponseParseError.new(msg) + assert_equal msg, err.message + assert_nil err.string + assert_nil err.pos + assert_nil err.token + assert_nil err.lex_state + assert_equal("#{msg} (#{name})", err.detailed_message(parser_state: false)) + assert_equal("#{msg} (#{name})", err.detailed_message) + assert_equal( + "#{BOLD}#{msg} (#{BOLD_UNDERLINE}#{name}#{RESET}#{BOLD})#{RESET}", + err.detailed_message(highlight: true) + ) + + msg = "unexpected QUOTED (expected \"]\")" + string = "tag OK [Error=\"Microsoft.Exchange.Error: foo\"] done\r\n" + token = Net::IMAP::ResponseParser::Token[:QUOTED, string[15, 29]] + parser_state = [string, :EXPR_BEG, 45, token] + err = Net::IMAP::ResponseParseError.new(msg, string:, parser_state:) + assert_equal msg, err.message + assert_equal string, err.string + assert_equal 45, err.pos + assert_same token, err.token + assert_equal :EXPR_BEG, err.lex_state + assert_equal msg, err.message + assert_equal("#{msg} (#{name})", err.detailed_message(parser_state: false)) + assert_equal( + "#{BOLD}#{msg} (#{BOLD_UNDERLINE}#{name}#{RESET}#{BOLD})#{RESET}", + err.detailed_message(highlight: true, parser_state: false) + ) + assert_equal(<<~MSG.strip, err.detailed_message) + #{msg} (#{name}) + processed : "tag OK [Error=\\"Microsoft.Exchange.Error: foo\\"" + remaining : "] done\\r\\n" + pos : 45 + lex_state : :EXPR_BEG + token : :QUOTED => "Microsoft.Exchange.Error: foo" + MSG + assert_equal(<<~MSG.strip, err.detailed_message(highlight: true)) + #{BOLD}#{msg} (#{BOLD_UNDERLINE}#{name}#{RESET}#{BOLD})#{RESET} + processed : "tag OK [Error=\\"Microsoft.Exchange.Error: foo\\"" + remaining : "] done\\r\\n" + pos : 45 + lex_state : :EXPR_BEG + token : :QUOTED => "Microsoft.Exchange.Error: foo" + MSG + + # `parser_state` defaults to `Net::IMAP.debug`: + Net::IMAP.debug = false + assert_equal("#{msg} (#{name})", err.detailed_message) + assert_equal( + "#{BOLD}#{msg} (#{BOLD_UNDERLINE}#{name}#{RESET}#{BOLD})#{RESET}", + err.detailed_message(highlight: true) + ) + end test "ResponseTooLargeError" do err = Net::IMAP::ResponseTooLargeError.new