class Console::Format::Safe

Handles issues like circular references and encoding errors.
A safe format for converting objects to strings.

def default_objects

Create a new hash with identity comparison.
def default_objects
	Hash.new.compare_by_identity
end

def dump(object)

@returns [String] The dumped object.
@parameter object [Object] The object to dump.

Dump the given object to a string.
def dump(object)
	@format.dump(object, @limit)
rescue SystemStackError, StandardError => error
	@format.dump(safe_dump(object, error))
end

def filter_backtrace(error)

@returns [Array(String)] The filtered backtrace.
@parameter error [Exception] The exception to filter.

Filter the backtrace to remove duplicate frames and reduce verbosity.
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

def initialize(format: ::JSON, limit: 8, encoding: ::Encoding::UTF_8)

@parameter encoding [Encoding] The encoding to use for strings.
@parameter limit [Integer] The maximum depth to recurse into objects.
@parameter format [JSON] The format to use for serialization.

Create a new safe format.
def initialize(format: ::JSON, limit: 8, encoding: ::Encoding::UTF_8)
	@format = format
	@limit = limit
	@encoding = encoding
end

def replacement_for(object)

@returns [String] The replacement string.
@parameter object [Object] The object to replace.

Replace the given object with a safe truncated representation.
def replacement_for(object)
	case object
	when Array
		"[...]"
	when Hash
		"{...}"
	else
		"..."
	end
end

def safe_dump(object, error)

@returns [Hash] The dumped (truncated) object including error details.
@parameter error [Exception] The error that occurred while dumping the object.
@parameter object [Object] The object to dump.

This is a slow path so we try to avoid it.

Dump the given object to a string, replacing it with a safe representation if there is an error.
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

def safe_dump_recurse(object, limit = @limit, objects = default_objects)

@returns [Object] The dumped object as a primitive representation.
@parameter objects [Hash] The objects that have already been visited.
@parameter limit [Integer] The maximum depth to recurse into objects.
@parameter object [Object] The object to dump.

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`.
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