ConsoleSourceConsoleFormatSafe

class Safe

A safe format for converting objects to strings.

Handles issues like circular references and encoding errors.

Definitions

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

Create a new safe format.

Signature

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.

Implementation

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

def dump(object)

Dump the given object to a string.

Signature

parameter object Object

The object to dump.

returns String

The dumped object.

Implementation

def dump(object)
	@format.dump(object, @limit)
rescue SystemStackError, StandardError => error
	@format.dump(safe_dump(object, error))
end

def filter_backtrace(error)

Filter the backtrace to remove duplicate frames and reduce verbosity.

Signature

parameter error Exception

The exception to filter.

returns Array(String)

The filtered backtrace.

Implementation

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 safe_dump(object, error)

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.

Signature

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.

Implementation

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 replacement_for(object)

Replace the given object with a safe truncated representation.

Signature

parameter object Object

The object to replace.

returns String

The replacement string.

Implementation

def replacement_for(object)
	case object
	when Array
		"[...]"
	when Hash
		"{...}"
	else
		"..."
	end
end

def default_objects

Create a new hash with identity comparison.

Implementation

def default_objects
	Hash.new.compare_by_identity
end

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

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.

Signature

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.

Implementation

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