# frozen_string_literal: true module JSI # JSI::Util contains public utilities module Util autoload :Private, 'jsi/util/private' include Private extend self autoload :Arraylike, 'jsi/util/typelike' autoload :Hashlike, 'jsi/util/typelike' # yields the content of the given param `object`. for objects which have a #jsi_modified_copy # method of their own (JSI::Base, JSI::MetaschemaNode) that method is invoked with the given # block. otherwise the given object itself is yielded. # # the given block must result in a modified copy of its block parameter # (not destructively modifying the yielded content). # # @yield [Object] the content of the given object. the block should result # in a (nondestructively) modified copy of this. # @return [object.class] modified copy of the given object def modified_copy(object, &block) if object.respond_to?(:jsi_modified_copy) object.jsi_modified_copy(&block) else yield(object) end end # A structure like the given `object`, recursively coerced to JSON-compatible types. # # - Structures of Hash, Array, and basic types of String/number/boolean/nil are returned as-is. # - If the object responds to `#as_json`, that method is used, passing any given options. # - If the object supports [implicit conversion](https://docs.ruby-lang.org/en/master/implicit_conversion_rdoc.html) # with `#to_hash`, `#to_ary`, `#to_str`, or `#to_int`, that is used. # - Set becomes Array; Symbol becomes String. # - Types with no known coersion to JSON-compatible raise TypeError. # # @param object [Object] # @return [Array, Hash, String, Integer, Float, Boolean, NilClass] a JSON-compatible structure like the given `object` # @raise [TypeError] If the object cannot be coerced to a JSON-compatible structure def as_json(object, options = {}) type_err = proc { raise(TypeError, "cannot express object as json: #{object.pretty_inspect.chomp}") } if object.respond_to?(:as_json) options.empty? ? object.as_json : object.as_json(**options) # TODO remove eventually (keyword argument compatibility) elsif object.is_a?(Addressable::URI) object.to_s elsif object.respond_to?(:to_hash) && (object_to_hash = object.to_hash).is_a?(Hash) result = {} object_to_hash.each_pair do |k, v| ks = k.is_a?(String) ? k : k.is_a?(Symbol) ? k.to_s : k.respond_to?(:to_str) && (kstr = k.to_str).is_a?(String) ? kstr : raise(TypeError, "json object (hash) cannot be keyed with: #{k.pretty_inspect.chomp}") result[ks] = as_json(v, **options) end result elsif object.respond_to?(:to_ary) && (object_to_ary = object.to_ary).is_a?(Array) object_to_ary.map { |e| as_json(e, **options) } elsif [String, Integer, TrueClass, FalseClass, NilClass].any? { |c| object.is_a?(c) } object elsif object.is_a?(Float) type_err.call unless object.finite? object elsif object.is_a?(Symbol) object.to_s elsif object.is_a?(Set) as_json(object.to_a, **options) elsif object.respond_to?(:to_str) && (object_to_str = object.to_str).is_a?(String) object_to_str elsif object.respond_to?(:to_int) && (object_to_int = object.to_int).is_a?(Integer) object_to_int else type_err.call end end # A JSON encoded string of the given object. # # - If the object has a `#to_json` method that isn't defined by the stdlib `json` gem, # that method is used, passing any given options. # - Otherwise, JSON is generated using {as_json} to coerce to compatible types. # @return [String] def to_json(object, options = {}) if USE_TO_JSON_METHOD[object.class] options.empty? ? object.to_json : object.to_json(**options) # TODO remove eventually (keyword argument compatibility) else JSON.generate(as_json(object, **options)) end end # a hash copied from the given hashlike, in which any symbol keys are # converted to strings. behavior on collisions is undefined (but in the # future could take a block like # ActiveSupport::HashWithIndifferentAccess#update) # # at the moment it is undefined whether the returned hash is the same # instance as the `hash` param. if `hash` is already a hash which contains # no symbol keys, this method MAY return that same instance. use #dup on # the return if you need to ensure it is not the same instance as the # argument instance. # # @param hashlike [#to_hash] the hash from which to convert symbol keys to strings # @return [same class as the param `hash`, or Hash if the former cannot be done] a # hash(-like) instance containing no symbol keys def stringify_symbol_keys(hashlike) unless hashlike.respond_to?(:to_hash) raise(ArgumentError, "expected argument to be a hash; got #{hashlike.class.inspect}: #{hashlike.pretty_inspect.chomp}") end JSI::Util.modified_copy(hashlike) do |hash| out = {} hash.each do |k, v| out[k.is_a?(Symbol) ? k.to_s : k] = v end out end end def deep_stringify_symbol_keys(object) if object.respond_to?(:to_hash) && !object.is_a?(Addressable::URI) JSI::Util.modified_copy(object) do |hash| out = {} (hash.respond_to?(:each) ? hash : hash.to_hash).each do |k, v| out[k.is_a?(Symbol) ? k.to_s.freeze : deep_stringify_symbol_keys(k)] = deep_stringify_symbol_keys(v) end out end elsif object.respond_to?(:to_ary) JSI::Util.modified_copy(object) do |ary| (ary.respond_to?(:each) ? ary : ary.to_ary).map do |e| deep_stringify_symbol_keys(e) end end else object end end # returns an object which is equal to the param object, and is recursively frozen. # the given object is not modified. def deep_to_frozen(object, not_implemented: nil) dtf = proc { |o| deep_to_frozen(o, not_implemented: not_implemented) } if object.instance_of?(Hash) out = {} identical = object.frozen? object.each do |k, v| fk = dtf[k] fv = dtf[v] identical &&= fk.__id__ == k.__id__ identical &&= fv.__id__ == v.__id__ out[fk] = fv end if !object.default.nil? out.default = dtf[object.default] identical &&= out.default.__id__ == object.default.__id__ end if object.default_proc raise(ArgumentError, "cannot make immutable copy of a Hash with default_proc") end if identical object else out.freeze end elsif object.instance_of?(Array) identical = object.frozen? out = Array.new(object.size) object.each_with_index do |e, i| fe = dtf[e] identical &&= fe.__id__ == e.__id__ out[i] = fe end if identical object else out.freeze end elsif object.instance_of?(String) if object.frozen? object else object.dup.freeze end elsif CLASSES_ALWAYS_FROZEN.any? { |c| object.is_a?(c) } # note: `is_a?`, not `instance_of?`, here because instance_of?(Integer) is false until Fixnum/Bignum is gone. this is fine here; there is no concern of subclasses of CLASSES_ALWAYS_FROZEN duping/freezing differently (as with e.g. ActiveSupport::HashWithIndifferentAccess) object else if not_implemented not_implemented.call(object) else raise(NotImplementedError, [ "deep_to_frozen not implemented for class: #{object.class}", "object: #{object.pretty_inspect.chomp}", ].join("\n")) end end end # ensures the given param becomes a frozen Set of Modules. # returns the param if it is already that, otherwise initializes and freezes such a Set. # # @param modules [Set, Enumerable] the object to ensure becomes a frozen Set of Modules # @return [Set] frozen Set containing the given modules # @raise [ArgumentError] when the modules param is not an Enumerable # @raise [Schema::NotASchemaError] when the modules param contains objects which are not Schemas def ensure_module_set(modules) if modules.is_a?(Set) && modules.frozen? set = modules elsif modules.is_a?(Enumerable) set = Set.new(modules).freeze else raise(TypeError, "not given an Enumerable of Modules") end not_modules = set.reject { |s| s.is_a?(Module) } if !not_modules.empty? raise(TypeError, [ "ensure_module_set given non-Module objects:", *not_modules.map { |ns| ns.pretty_inspect.chomp }, ].join("\n")) end set end end end