lib/cli/kit/error_handler.rb in cli-kit-4.0.0 vs lib/cli/kit/error_handler.rb in cli-kit-5.0.0
- old
+ new
@@ -1,121 +1,182 @@
+# typed: true
+
require 'cli/kit'
require 'English'
module CLI
module Kit
class ErrorHandler
- def initialize(log_file:, exception_reporter:, tool_name: nil)
+ extend T::Sig
+
+ ExceptionReporterOrProc = T.type_alias do
+ T.any(T.class_of(ExceptionReporter), T.proc.returns(T.class_of(ExceptionReporter)))
+ end
+
+ sig { params(override_exception_handler: T.proc.params(arg0: Exception).returns(Integer)).void }
+ attr_writer :override_exception_handler
+
+ sig do
+ params(
+ log_file: T.nilable(String),
+ exception_reporter: ExceptionReporterOrProc,
+ tool_name: T.nilable(String),
+ dev_mode: T::Boolean,
+ ).void
+ end
+ def initialize(log_file: nil, exception_reporter: NullExceptionReporter, tool_name: nil, dev_mode: false)
@log_file = log_file
- @exception_reporter_or_proc = exception_reporter || NullExceptionReporter
+ @exception_reporter_or_proc = exception_reporter
@tool_name = tool_name
+ @dev_mode = dev_mode
end
- module NullExceptionReporter
- def self.report(_exception, _logs)
- nil
+ class ExceptionReporter
+ extend T::Sig
+ extend T::Helpers
+ abstract!
+
+ class << self
+ extend T::Sig
+
+ sig { abstract.params(exception: T.nilable(Exception), logs: T.nilable(String)).void }
+ def report(exception, logs = nil); end
end
end
+ class NullExceptionReporter < ExceptionReporter
+ extend T::Sig
+
+ class << self
+ extend T::Sig
+
+ sig { override.params(_exception: T.nilable(Exception), _logs: T.nilable(String)).void }
+ def report(_exception, _logs = nil)
+ nil
+ end
+ end
+ end
+
+ sig { params(block: T.proc.void).returns(Integer) }
def call(&block)
- install!
- handle_abort(&block)
+ # @at_exit_exception is set if handle_abort decides to submit an error.
+ # $ERROR_INFO is set if we terminate because of a signal.
+ at_exit { report_exception(@at_exit_exception || $ERROR_INFO) }
+ triage_all_exceptions(&block)
end
- def handle_exception(error)
+ sig { params(error: T.nilable(Exception)).void }
+ def report_exception(error)
if (notify_with = exception_for_submission(error))
- logs = begin
- File.read(@log_file)
- rescue => e
- "(#{e.class}: #{e.message})"
+ logs = nil
+ if @log_file
+ logs = begin
+ File.read(@log_file)
+ rescue => e
+ "(#{e.class}: #{e.message})"
+ end
end
exception_reporter.report(notify_with, logs)
end
end
- # maybe we can get rid of this.
- attr_writer :exception
+ SIGNALS_THAT_ARENT_BUGS = [
+ 'SIGTERM', 'SIGHUP', 'SIGINT',
+ ].freeze
private
+ # Run the program, handling any errors that occur.
+ #
+ # Errors are printed to stderr unless they're #silent?, and are reported
+ # to bugsnag (by setting @at_exit_exeption for our at_exit handler) if
+ # they're #bug?
+ #
+ # Returns an exit status for the program.
+ sig { params(block: T.proc.void).returns(Integer) }
+ def triage_all_exceptions(&block)
+ begin
+ block.call
+ CLI::Kit::EXIT_SUCCESS
+ rescue Interrupt => e # Ctrl-C
+ # transform message, prevent bugsnag
+ exc = e.exception('Interrupt')
+ CLI::Kit.raise(exc, bug: false)
+ rescue Errno::ENOSPC => e
+ # transform message, prevent bugsnag
+ message = if @tool_name
+ "Your disk is full - {{command:#{@tool_name}}} requires free space to operate"
+ else
+ 'Your disk is full - free space is required to operate'
+ end
+ exc = e.exception(message)
+ CLI::Kit.raise(exc, bug: false)
+ end
+ # If SystemExit was raised, e.g. `exit()`, then
+ # return whatever status is attached to the exception
+ # object. The special exit statuses have already been
+ # handled below.
+ rescue SystemExit => e
+ e.status
+ rescue Exception => e # rubocop:disable Lint/RescueException
+ @at_exit_exception = e if e.bug?
+
+ if (eh = @override_exception_handler)
+ return eh.call(e)
+ end
+
+ raise(e) if @dev_mode && e.bug?
+
+ stderr_puts(e.message) unless e.silent?
+ e.bug? ? CLI::Kit::EXIT_BUG : CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG
+ end
+
+ sig { params(error: T.nilable(Exception)).returns(T.nilable(Exception)) }
def exception_for_submission(error)
+ # happens on normal non-error termination
+ return(nil) if error.nil?
+
+ return(nil) unless error.bug?
+
case error
- when nil # normal, non-error termination
- nil
- when Interrupt # ctrl-c
- nil
- when CLI::Kit::Abort, CLI::Kit::AbortSilent # Not a bug
- nil
when SignalException
- skip = ['SIGTERM', 'SIGHUP', 'SIGINT']
- skip.include?(error.message) ? nil : error
+ SIGNALS_THAT_ARENT_BUGS.include?(error.message) ? nil : error
when SystemExit # "exit N" called
case error.status
when CLI::Kit::EXIT_SUCCESS # submit nothing if it was `exit 0`
nil
when CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG
- # if it was `exit 30`, translate the exit code to 1, and submit nothing.
- # 30 is used to signal normal failures that are not indicative of bugs.
- # However, users should see it presented as 1.
+ # if it was `exit 30`, translate the exit code to 1, and submit
+ # nothing. 30 is used to signal normal failures that are not
+ # indicative of bugs. However, users should see it presented as 1.
exit(1)
else
- # A weird termination status happened. `error.exception "message"` will maintain backtrace
- # but allow us to set a message
- error.exception("abnormal termination status: #{error.status}")
+ # don't treat this as an exception, simply reraise.
+ # this is indicative of `exit` being called with a
+ # non-zero number, and the requested exit status
+ # needs to be maintained.
+ exit(error.status)
end
else
error
end
end
- def install!
- at_exit { handle_exception(@exception || $ERROR_INFO) }
- end
-
- def handle_abort
- yield
- CLI::Kit::EXIT_SUCCESS
- rescue CLI::Kit::GenericAbort => e
- is_bug = e.is_a?(CLI::Kit::Bug) || e.is_a?(CLI::Kit::BugSilent)
- is_silent = e.is_a?(CLI::Kit::AbortSilent) || e.is_a?(CLI::Kit::BugSilent)
-
- print_error_message(e) unless is_silent
- (@exception = e) if is_bug
-
- CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG
- rescue Interrupt
- stderr_puts_message('Interrupt')
- CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG
- rescue Errno::ENOSPC
- message = if @tool_name
- "Your disk is full - {{command:#{@tool_name}}} requires free space to operate"
- else
- 'Your disk is full - free space is required to operate'
- end
- stderr_puts_message(message)
- CLI::Kit::EXIT_FAILURE_BUT_NOT_BUG
- end
-
- def stderr_puts_message(message)
- $stderr.puts(format_error_message(message))
- rescue Errno::EPIPE
+ sig { params(message: String).void }
+ def stderr_puts(message)
+ $stderr.puts(CLI::UI.fmt("{{red:#{message}}}"))
+ rescue Errno::EPIPE, Errno::EIO
nil
end
+ sig { returns(T.class_of(ExceptionReporter)) }
def exception_reporter
- if @exception_reporter_or_proc.respond_to?(:report)
- @exception_reporter_or_proc
- else
+ case @exception_reporter_or_proc
+ when Proc
@exception_reporter_or_proc.call
+ else
+ @exception_reporter_or_proc
end
- end
-
- def format_error_message(msg)
- CLI::UI.fmt("{{red:#{msg}}}")
- end
-
- def print_error_message(e)
- $stderr.puts(format_error_message(e.message))
end
end
end
end