lib/console/format/safe.rb
# frozen_string_literal: true # Released under the MIT License. # Copyright, 2023-2025, by Samuel Williams. require "json" module Console # @namespace module Format # A safe format for converting objects to strings. # # Handles issues like circular references and encoding errors. class Safe # Create a new safe format. # # @parameter format [JSON] The format to use for serialization. # @parameter limit [Integer] The maximum depth to recurse into objects. # @parameter encoding [Encoding] The encoding to use for strings. def initialize(format: ::JSON, limit: 8, encoding: ::Encoding::UTF_8) @format = format @limit = limit @encoding = encoding end # Dump the given object to a string. # # @parameter object [Object] The object to dump. # @returns [String] The dumped object. def dump(object) @format.dump(object, @limit) rescue SystemStackError, StandardError => error @format.dump(safe_dump(object, error)) end private # Filter the backtrace to remove duplicate frames and reduce verbosity. # # @parameter error [Exception] The exception to filter. # @returns [Array(String)] The filtered backtrace. def filter_backtrace(error) frames = error.backtrace filtered = {} filtered_count = nil skipped = nil frames = frames.filter_map do |frame| if filtered[frame] if filtered_count == nil filtered_count = 1 skipped = frame.dup else filtered_count += 1 nil end else if skipped if filtered_count > 1 skipped.replace("[... #{filtered_count} frames skipped ...]") end filtered_count = nil skipped = nil end filtered[frame] = true frame end end if skipped && filtered_count > 1 skipped.replace("[... #{filtered_count} frames skipped ...]") end return frames end # Dump the given object to a string, replacing it with a safe representation if there is an error. # # This is a slow path so we try to avoid it. # # @parameter object [Object] The object to dump. # @parameter error [Exception] The error that occurred while dumping the object. # @returns [Hash] The dumped (truncated) object including error details. def safe_dump(object, error) object = safe_dump_recurse(object) object[:truncated] = true object[:error] = { class: safe_dump_recurse(error.class.name), message: safe_dump_recurse(error.message), backtrace: safe_dump_recurse(filter_backtrace(error)), } return object end # Replace the given object with a safe truncated representation. # # @parameter object [Object] The object to replace. # @returns [String] The replacement string. def replacement_for(object) case object when Array "[...]" when Hash "{...}" else "..." end end # Create a new hash with identity comparison. def default_objects Hash.new.compare_by_identity end # This will recursively generate a safe version of the object. Nested hashes and arrays will be transformed recursively. Strings will be encoded with the given encoding. Primitive values will be returned as-is. Other values will be converted using `as_json` if available, otherwise `to_s`. # # @parameter object [Object] The object to dump. # @parameter limit [Integer] The maximum depth to recurse into objects. # @parameter objects [Hash] The objects that have already been visited. # @returns [Object] The dumped object as a primitive representation. def safe_dump_recurse(object, limit = @limit, objects = default_objects) if limit <= 0 || objects[object] return replacement_for(object) end case object when Hash objects[object] = true object.to_h do |key, value| [ String(key).encode(@encoding, invalid: :replace, undef: :replace), safe_dump_recurse(value, limit - 1, objects) ] end when Array objects[object] = true object.map do |value| safe_dump_recurse(value, limit - 1, objects) end when String object.encode(@encoding, invalid: :replace, undef: :replace) when Numeric, TrueClass, FalseClass, NilClass object else objects[object] = true # We could do something like this but the chance `as_json` will blow up. # We'd need to be extremely careful about it. # if object.respond_to?(:as_json) # safe_dump_recurse(object.as_json, limit - 1, objects) # else safe_dump_recurse(object.to_s, limit - 1, objects) end end end end end