# frozen_string_literal: true module JSI # a representation to work with JSON Pointer, as described by RFC 6901 https://tools.ietf.org/html/rfc6901 # # a pointer is a sequence of tokens pointing to a node in a document. class Ptr class Error < StandardError end # raised when attempting to parse a JSON Pointer string with invalid syntax class PointerSyntaxError < Error end # raised when a pointer refers to a path in a document that could not be resolved class ResolutionError < Error end POS_INT_RE = /\A[1-9]\d*\z/ private_constant :POS_INT_RE # instantiates a pointer or returns the given pointer # @param ary_ptr [#to_ary, JSI::Ptr] an array of tokens, or a pointer # @return [JSI::Ptr] def self.ary_ptr(ary_ptr) if ary_ptr.is_a?(Ptr) ary_ptr else new(ary_ptr) end end # instantiates a pointer from the given tokens. # # JSI::Ptr[] # # instantiates a root pointer. # # JSI::Ptr['a', 'b'] # JSI::Ptr['a']['b'] # # are both ways to instantiate a pointer with tokens ['a', 'b']. the latter example chains the # class .[] method with the instance #[] method. # # @param tokens any number of tokens # @return [JSI::Ptr] def self.[](*tokens) tokens.empty? ? EMPTY : new(tokens.freeze) end # parse a URI-escaped fragment and instantiate as a JSI::Ptr # # JSI::Ptr.from_fragment('/foo/bar') # => JSI::Ptr["foo", "bar"] # # with URI escaping: # # JSI::Ptr.from_fragment('/foo%20bar') # => JSI::Ptr["foo bar"] # # Note: A fragment does not include a leading '#'. The string "#/foo" is a URI containing the # fragment "/foo", which should be parsed by `Addressable::URI` before passing to this method, e.g.: # # JSI::Ptr.from_fragment(Addressable::URI.parse("#/foo").fragment) # => JSI::Ptr["foo"] # # @param fragment [String] a fragment containing a pointer # @return [JSI::Ptr] # @raise [JSI::Ptr::PointerSyntaxError] when the fragment does not contain a pointer with # valid pointer syntax def self.from_fragment(fragment) from_pointer(Addressable::URI.unescape(fragment)) end # parse a pointer string and instantiate as a JSI::Ptr # # JSI::Ptr.from_pointer('/foo') # => JSI::Ptr["foo"] # # JSI::Ptr.from_pointer('/foo~0bar/baz~1qux') # => JSI::Ptr["foo~bar", "baz/qux"] # # @param pointer_string [String] a pointer string # @return [JSI::Ptr] # @raise [JSI::Ptr::PointerSyntaxError] when the pointer_string does not have valid pointer syntax def self.from_pointer(pointer_string) pointer_string = pointer_string.to_str if pointer_string[0] == ?/ tokens = pointer_string.split('/', -1).map! do |piece| piece.gsub!('~1', '/') piece.gsub!('~0', '~') piece.freeze end tokens.shift new(tokens.freeze) elsif pointer_string.empty? EMPTY else raise(PointerSyntaxError, "Invalid pointer syntax in #{pointer_string.inspect}: pointer must begin with /") end end # initializes a JSI::Ptr from the given tokens. # # @param tokens [Array<Object>] def initialize(tokens) unless tokens.respond_to?(:to_ary) raise(TypeError, "tokens must be an array. got: #{tokens.inspect}") end @tokens = Util.deep_to_frozen(tokens.to_ary, not_implemented: proc { |o| o }) end attr_reader :tokens # takes a root json document and evaluates this pointer through the document, returning the value # pointed to by this pointer. # # @param document [#to_ary, #to_hash] the document against which we will evaluate this pointer # @param a arguments are passed to each invocation of `#[]` # @return [Object] the content of the document pointed to by this pointer # @raise [JSI::Ptr::ResolutionError] the document does not contain the path this pointer references def evaluate(document, *a, **kw) res = tokens.inject(document) do |value, token| _, child = node_subscript_token_child(value, token, *a, **kw) child end res end # the pointer string representation of this pointer # @return [String] def pointer tokens.map { |t| '/' + t.to_s.gsub('~', '~0').gsub('/', '~1') }.join('').freeze end # the fragment string representation of this pointer # @return [String] def fragment Addressable::URI.escape(pointer).freeze end # a URI consisting of a fragment containing this pointer's fragment string representation # @return [Addressable::URI] def uri Addressable::URI.new(fragment: fragment).freeze end # whether this pointer is empty, i.e. it has no tokens # @return [Boolean] def empty? tokens.empty? end # whether this is a root pointer, indicated by an empty array of tokens # @return [Boolean] alias_method :root?, :empty? # pointer to the parent of where this pointer points # @return [JSI::Ptr] # @raise [JSI::Ptr::Error] if this pointer has no parent (points to the root) def parent if root? raise(Ptr::Error, "cannot access parent of root pointer: #{pretty_inspect.chomp}") end tokens.size == 1 ? EMPTY : Ptr.new(tokens[0...-1].freeze) end # whether this pointer contains the other_ptr - that is, whether this pointer is an ancestor # of `other_ptr`, a descendent pointer. `contains?` is inclusive; a pointer does contain itself. # @return [Boolean] def contains?(other_ptr) tokens == other_ptr.tokens[0...tokens.size] end # part of this pointer relative to the given ancestor_ptr # @return [JSI::Ptr] # @raise [JSI::Ptr::Error] if the given ancestor_ptr is not an ancestor of this pointer def relative_to(ancestor_ptr) unless ancestor_ptr.contains?(self) raise(Error, "ancestor_ptr #{ancestor_ptr.inspect} is not ancestor of #{inspect}") end ancestor_ptr.tokens.size == tokens.size ? EMPTY : Ptr.new(tokens[ancestor_ptr.tokens.size..-1].freeze) end # a pointer with the tokens of this one plus the given `ptr`'s. # @param ptr [JSI::Ptr, #to_ary] # @return [JSI::Ptr] def +(ptr) if ptr.is_a?(Ptr) ptr_tokens = ptr.tokens elsif ptr.respond_to?(:to_ary) ptr_tokens = ptr else raise(TypeError, "ptr must be a #{Ptr} or Array of tokens; got: #{ptr.inspect}") end ptr_tokens.empty? ? self : Ptr.new((tokens + ptr_tokens).freeze) end # a pointer consisting of the first `n` of our tokens # @param n [Integer] # @return [JSI::Ptr] # @raise [ArgumentError] if n is not between 0 and the size of our tokens def take(n) unless n.is_a?(Integer) && n >= 0 && n <= tokens.size raise(ArgumentError, "n not in range (0..#{tokens.size}): #{n.inspect}") end n == tokens.size ? self : Ptr.new(tokens.take(n).freeze) end # appends the given token to this pointer's tokens and returns the result # # @param token [Object] # @return [JSI::Ptr] pointer to a child node of this pointer with the given token def [](token) Ptr.new(tokens.dup.push(token).freeze) end # takes a document and a block. the block is yielded the content of the given document at this # pointer's location. the block must result a modified copy of that content (and MUST NOT modify # the object it is given). this modified copy of that content is incorporated into a modified copy # of the given document, which is then returned. the structure and contents of the document outside # the path pointed to by this pointer is not modified. # # @param document [Object] the document to apply this pointer to # @yield [Object] the content this pointer applies to in the given document # the block must result in the new content which will be placed in the modified document copy. # @return [Object] a copy of the given document, with the content this pointer applies to # replaced by the result of the block def modified_document_copy(document, &block) # we need to preserve the rest of the document, but modify the content at our path. # # this is actually a bit tricky. we can't modify the original document, obviously. # we could do a deep copy, but that's expensive. instead, we make a copy of each array # or hash in the path above the node we point to. this node's content is modified by the # caller, and that is recursively merged up to the document root. if empty? Util.modified_copy(document, &block) else car = tokens[0] cdr = Ptr.new(tokens[1..-1].freeze) token, document_child = node_subscript_token_child(document, car) modified_document_child = cdr.modified_document_copy(document_child, &block) if modified_document_child.object_id == document_child.object_id document else modified_document = document.respond_to?(:[]=) ? document.dup : document.respond_to?(:to_hash) ? document.to_hash.dup : document.respond_to?(:to_ary) ? document.to_ary.dup : raise(Bug) # not possible; node_subscript_token_child would have raised modified_document[token] = modified_document_child modified_document end end end # a string representation of this pointer # @return [String] def inspect -"#{self.class.name}[#{tokens.map(&:inspect).join(", ")}]" end alias_method :to_s, :inspect # see {Util::Private::FingerprintHash} # @api private def jsi_fingerprint {class: Ptr, tokens: tokens} end include Util::FingerprintHash::Immutable EMPTY = new(Util::EMPTY_ARY) private def node_subscript_token_child(value, token, *a, **kw) if value.respond_to?(:to_ary) if token.is_a?(String) && (token == '0' || token =~ POS_INT_RE) token = token.to_i elsif token == '-' # per rfc6901, - refers "to the (nonexistent) member after the last array element" and is # expected to raise an error condition. raise(ResolutionError, "Invalid resolution: #{token.inspect} refers to a nonexistent element in array #{value.inspect}") end size = (value.respond_to?(:size) ? value : value.to_ary).size unless token.is_a?(Integer) && token >= 0 && token < size raise(ResolutionError, "Invalid resolution: #{token.inspect} is not a valid array index of #{value.inspect}") end ary = (value.respond_to?(:[]) ? value : value.to_ary) if kw.empty? # TODO remove eventually (keyword argument compatibility) child = ary[token, *a] else child = ary[token, *a, **kw] end elsif value.respond_to?(:to_hash) unless (value.respond_to?(:key?) ? value : value.to_hash).key?(token) raise(ResolutionError, "Invalid resolution: #{token.inspect} is not a valid key of #{value.inspect}") end hsh = (value.respond_to?(:[]) ? value : value.to_hash) if kw.empty? # TODO remove eventually (keyword argument compatibility) child = hsh[token, *a] else child = hsh[token, *a, **kw] end else raise(ResolutionError, "Invalid resolution: #{token.inspect} cannot be resolved in #{value.inspect}") end [token, child] end end end