lib/console/format/safe.rb



# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2023-2024, by Samuel Williams.

require "json"

module Console
	module Format
		# This class is used to safely dump objects.
		# It will attempt to dump the object using the given format, but if it fails, it will generate a safe version of the object.
		class Safe
			def initialize(format: ::JSON, limit: 8, encoding: ::Encoding::UTF_8)
				@format = format
				@limit = limit
				@encoding = encoding
			end
			
			def dump(object)
				@format.dump(object, @limit)
			rescue SystemStackError, StandardError => error
				@format.dump(safe_dump(object, error))
			end
			
			private
			
			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)
				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)
				case object
				when Array
					"[...]"
				when Hash
					"{...}"
				else
					"..."
				end
			end
			
			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`.
			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