text:
# frozen_string_literal: true

require 'set'

module JSON
  module LD
    ##
    # Expand module, used as part of API
    module Expand
      include Utils

      # The following constant is used to reduce object allocations
      CONTAINER_INDEX_ID_TYPE = Set['@index', '@id', '@type'].freeze
      KEY_ID = %w[@id].freeze
      KEYS_VALUE_LANGUAGE_TYPE_INDEX_DIRECTION = %w[@value @language @type @index @direction @annotation].freeze
      KEYS_SET_LIST_INDEX = %w[@set @list @index].freeze
      KEYS_INCLUDED_TYPE_REVERSE = %w[@included @type @reverse].freeze

      ##
      # Expand an Array or Object given an active context and performing local context expansion.
      #
      # @param [Array, Hash] input
      # @param [String] active_property
      # @param [Context] context
      # @param [Boolean] framing (false)
      #   Special rules for expanding a frame
      # @param [Boolean] from_map
      #   Expanding from a map, which could be an `@type` map, so don't clear out context term definitions
      #
      # @return [Array<Hash{String => Object}>]
      def expand(input, active_property, context,
                 framing: false, from_map: false, log_depth: nil)
        # log_debug("expand", depth: log_depth.to_i) {"input: #{input.inspect}, active_property: #{active_property.inspect}, context: #{context.inspect}"}
        framing = false if active_property == '@default'
        if active_property
          expanded_active_property = context.expand_iri(active_property, vocab: true, as_string: true,
            base: @options[:base])
        end

        # Use a term-specific context, if defined, based on the non-type-scoped context.
        if active_property && context.term_definitions[active_property]
          property_scoped_context = context.term_definitions[active_property].context
        end
        # log_debug("expand", depth: log_depth.to_i) {"property_scoped_context: #{property_scoped_context.inspect}"} unless property_scoped_context.nil?

        case input
        when Array
          # If element is an array,
          is_list = context.container(active_property).include?('@list')
          input.each_with_object([]) do |v, memo|
            # Initialize expanded item to the result of using this algorithm recursively, passing active context, active property, and item as element.
            v = expand(v, active_property, context,
              framing: framing,
              from_map: from_map,
              log_depth: log_depth.to_i + 1)

            # If the active property is @list or its container mapping is set to @list and v is an array, change it to a list object
            if is_list && v.is_a?(Array)
              # Make sure that no member of v contains an annotation object
              if v.any? { |n| n.is_a?(Hash) && n.key?('@annotation') }
                raise JsonLdError::InvalidAnnotation,
                  "A list element must not contain @annotation."
              end
              v = { "@list" => v }
            end

            case v
            when nil then nil
            when Array then memo.concat(v)
            else            memo << v
            end
          end

        when Hash
          if context.previous_context
            expanded_key_map = input.keys.inject({}) do |memo, key|
              memo.merge(key => context.expand_iri(key, vocab: true, as_string: true, base: @options[:base]))
            end
            # Revert any previously type-scoped term definitions, unless this is from a map, a value object or a subject reference
            revert_context = !from_map &&
                             !expanded_key_map.value?('@value') &&
                             expanded_key_map.values != ['@id']

            # If there's a previous context, the context was type-scoped
            # log_debug("expand", depth: log_depth.to_i) {"previous_context: #{context.previous_context.inspect}"} if revert_context
            context = context.previous_context if revert_context
          end

          # Apply property-scoped context after reverting term-scoped context
          unless property_scoped_context.nil?
            context = context.parse(property_scoped_context, base: @options[:base], override_protected: true)
          end
          # log_debug("expand", depth: log_depth.to_i) {"after property_scoped_context: #{context.inspect}"} unless property_scoped_context.nil?

          # If element contains the key @context, set active context to the result of the Context Processing algorithm, passing active context and the value of the @context key as local context.
          if input.key?('@context')
            context = context.parse(input['@context'], base: @options[:base])
            # log_debug("expand", depth: log_depth.to_i) {"context: #{context.inspect}"}
          end

          # Set the type-scoped context to the context on input, for use later
          type_scoped_context = context

          output_object = {}

          # See if keys mapping to @type have terms with a local context
          type_key = nil
          (input.keys - %w[@context]).sort
            .select { |k| context.expand_iri(k, vocab: true, base: @options[:base]) == '@type' }
            .each do |tk|
            type_key ||= tk # Side effect saves the first found key mapping to @type
            Array(input[tk]).sort.each do |term|
              if type_scoped_context.term_definitions[term]
                term_context = type_scoped_context.term_definitions[term].context
              end
              unless term_context.nil?
                # log_debug("expand", depth: log_depth.to_i) {"term_context[#{term}]: #{term_context.inspect}"}
                context = context.parse(term_context, base: @options[:base], propagate: false)
              end
            end
          end

          # Process each key and value in element. Ignores @nesting content
          expand_object(input, active_property, context, output_object,
            expanded_active_property: expanded_active_property,
            framing: framing,
            type_key: type_key,
            type_scoped_context: type_scoped_context,
            log_depth: log_depth.to_i + 1)

          # log_debug("output object", depth: log_depth.to_i) {output_object.inspect}

          # If result contains the key @value:
          if value?(output_object)
            keys = output_object.keys
            unless (keys - KEYS_VALUE_LANGUAGE_TYPE_INDEX_DIRECTION).empty?
              # The result must not contain any keys other than @direction, @value, @language, @type, and @index. It must not contain both the @language key and the @type key. Otherwise, an invalid value object error has been detected and processing is aborted.
              raise JsonLdError::InvalidValueObject,
                "value object has unknown keys: #{output_object.inspect}"
            end

            if keys.include?('@type') && !(keys & %w[@language @direction]).empty?
              # @type is inconsistent with either @language or @direction
              raise JsonLdError::InvalidValueObject,
                "value object must not include @type with either @language or @direction: #{output_object.inspect}"
            end

            if output_object.key?('@language') && Array(output_object['@language']).empty?
              output_object.delete('@language')
            end
            type_is_json = output_object['@type'] == '@json'
            output_object.delete('@type') if output_object.key?('@type') && Array(output_object['@type']).empty?

            # If the value of result's @value key is null, then set result to null and @type is not @json.
            ary = Array(output_object['@value'])
            return nil if ary.empty? && !type_is_json

            if output_object['@type'] == '@json' && context.processingMode('json-ld-1.1')
              # Any value of @value is okay if @type: @json
            elsif !ary.all? { |v| v.is_a?(String) || (v.is_a?(Hash) && v.empty?) } && output_object.key?('@language')
              # Otherwise, if the value of result's @value member is not a string and result contains the key @language, an invalid language-tagged value error has been detected (only strings can be language-tagged) and processing is aborted.
              raise JsonLdError::InvalidLanguageTaggedValue,
                "when @language is used, @value must be a string: #{output_object.inspect}"
            elsif output_object['@type'] &&
                  (!Array(output_object['@type']).all? do |t|
                     (t.is_a?(String) && RDF::URI(t).valid? && !t.start_with?('_:')) ||
                     (t.is_a?(Hash) && t.empty?)
                   end ||
                   (!framing && !output_object['@type'].is_a?(String)))
              # Otherwise, if the result has a @type member and its value is not an IRI, an invalid typed value error has been detected and processing is aborted.
              raise JsonLdError::InvalidTypedValue,
                "value of @type must be an IRI or '@json': #{output_object.inspect}"
            elsif !framing && !output_object.fetch('@type', '').is_a?(String) &&
                  RDF::URI(t).valid? && !t.start_with?('_:')
              # Otherwise, if the result has a @type member and its value is not an IRI, an invalid typed value error has been detected and processing is aborted.
              raise JsonLdError::InvalidTypedValue,
                "value of @type must be an IRI or '@json': #{output_object.inspect}"
            end
          elsif !output_object.fetch('@type', []).is_a?(Array)
            # Otherwise, if result contains the key @type and its associated value is not an array, set it to an array containing only the associated value.
            output_object['@type'] = [output_object['@type']]
          elsif output_object.key?('@set') || output_object.key?('@list')
            # Otherwise, if result contains the key @set or @list:
            # The result must contain at most one other key and that key must be @index. Otherwise, an invalid set or list object error has been detected and processing is aborted.
            unless (output_object.keys - KEYS_SET_LIST_INDEX).empty?
              raise JsonLdError::InvalidSetOrListObject,
                "@set or @list may only contain @index: #{output_object.keys.inspect}"
            end

            # If result contains the key @set, then set result to the key's associated value.
            return output_object['@set'] if output_object.key?('@set')
          elsif output_object['@annotation']
            # Otherwise, if result contains the key @annotation,
            # the array value must all be node objects without an @id property, otherwise, an invalid annotation error has been detected and processing is aborted.
            unless output_object['@annotation'].all? { |o| node?(o) && !o.key?('@id') }
              raise JsonLdError::InvalidAnnotation,
                "@annotation must reference node objects without @id."
            end

            # Additionally, the property must not be used if there is no active property, or the expanded active property is @graph.
            if %w[@graph @included].include?(expanded_active_property || '@graph')
              raise JsonLdError::InvalidAnnotation,
                "@annotation must not be used on a top-level object."
            end

          end

          # If result contains only the key @language, set result to null.
          return nil if output_object.length == 1 && output_object.key?('@language')

          # If active property is null or @graph, drop free-floating values as follows:
          if (expanded_active_property || '@graph') == '@graph' &&
             (output_object.key?('@value') || output_object.key?('@list') ||
             ((output_object.keys - KEY_ID).empty? && !framing))
            # log_debug(" =>", depth: log_depth.to_i) { "empty top-level: " + output_object.inspect}
            return nil
          end

          # Re-order result keys if ordering
          if @options[:ordered]
            output_object.keys.sort.each_with_object({}) { |kk, memo| memo[kk] = output_object[kk] }
          else
            output_object
          end
        else
          # Otherwise, unless the value is a number, expand the value according to the Value Expansion rules, passing active property.
          return nil if input.nil? || active_property.nil? || expanded_active_property == '@graph'

          # Apply property-scoped context
          unless property_scoped_context.nil?
            context = context.parse(property_scoped_context,
              base: @options[:base],
              override_protected: true)
          end
          # log_debug("expand", depth: log_depth.to_i) {"property_scoped_context: #{context.inspect}"} unless property_scoped_context.nil?

          context.expand_value(active_property, input, base: @options[:base])
        end

        # log_debug(depth: log_depth.to_i) {" => #{result.inspect}"}
      end

      private

      # Expand each key and value of element adding them to result
      def expand_object(input, active_property, context, output_object,
                        expanded_active_property:,
                        framing:,
                        type_key:,
                        type_scoped_context:,
                        log_depth: nil)
        nests = []

        input_type = Array(input[type_key]).last
        input_type = context.expand_iri(input_type, vocab: true, as_string: true, base: @options[:base]) if input_type

        # Then, proceed and process each property and value in element as follows:
        keys = @options[:ordered] ? input.keys.sort : input.keys
        keys.each do |key|
          # For each key and value in element, ordered lexicographically by key:
          value = input[key]
          expanded_property = context.expand_iri(key, vocab: true, base: @options[:base])

          # If expanded property is null or it neither contains a colon (:) nor it is a keyword, drop key by continuing to the next key.
          next if expanded_property.is_a?(RDF::URI) && expanded_property.relative?

          expanded_property = expanded_property.to_s if expanded_property.is_a?(RDF::Resource)

          warn "[DEPRECATION] Blank Node properties deprecated in JSON-LD 1.1." if
            @options[:validate] &&
            expanded_property.to_s.start_with?("_:") &&
            context.processingMode('json-ld-1.1')

          # log_debug("expand property", depth: log_depth.to_i) {"ap: #{active_property.inspect}, expanded: #{expanded_property.inspect}, value: #{value.inspect}"}

          if expanded_property.nil?
            # log_debug(" => ", depth: log_depth.to_i) {"skip nil property"}
            next
          end

          if KEYWORDS.include?(expanded_property)
            # If active property equals @reverse, an invalid reverse property map error has been detected and processing is aborted.
            if expanded_active_property == '@reverse'
              raise JsonLdError::InvalidReversePropertyMap,
                "@reverse not appropriate at this point"
            end

            # If result has already an expanded property member (other than @type), an colliding keywords error has been detected and processing is aborted.
            if output_object.key?(expanded_property) && !KEYS_INCLUDED_TYPE_REVERSE.include?(expanded_property)
              raise JsonLdError::CollidingKeywords,
                "#{expanded_property} already exists in result"
            end

            expanded_value = case expanded_property
            when '@id'
              # If expanded active property is `@annotation`, an invalid annotation error has been found and processing is aborted.
              if expanded_active_property == '@annotation' && @options[:rdfstar]
                raise JsonLdError::InvalidAnnotation,
                  "an annotation must not contain a property expanding to @id"
              end

              # If expanded property is @id and value is not a string, an invalid @id value error has been detected and processing is aborted
              e_id = case value
              when String
                context.expand_iri(value, as_string: true, base: @options[:base], documentRelative: true)
              when Array
                # Framing allows an array of IRIs, and always puts values in an array
                unless framing
                  raise JsonLdError::InvalidIdValue,
                    "value of @id must be a string unless framing: #{value.inspect}"
                end
                context.expand_iri(value, as_string: true, base: @options[:base], documentRelative: true)
                value.map do |v|
                  unless v.is_a?(String)
                    raise JsonLdError::InvalidTypeValue,
                      "@id value must be a string or array of strings for framing: #{v.inspect}"
                  end
                  context.expand_iri(v, as_string: true, base: @options[:base], documentRelative: true)
                end
              when Hash
                if framing
                  unless value.empty?
                    raise JsonLdError::InvalidTypeValue,
                      "value of @id must be a an empty object for framing: #{value.inspect}"
                  end
                  [{}]
                elsif @options[:rdfstar]
                  # Result must have just a single statement
                  rei_node = expand(value, nil, context, log_depth: log_depth.to_i + 1)

                  # Node must not contain @reverse
                  if rei_node&.key?('@reverse')
                    raise JsonLdError::InvalidEmbeddedNode,
                      "Embedded node with @reverse"
                  end
                  statements = to_enum(:item_to_rdf, rei_node)
                  unless statements.count == 1
                    raise JsonLdError::InvalidEmbeddedNode,
                      "Embedded node with #{statements.size} statements"
                  end
                  rei_node
                else
                  unless framing
                    raise JsonLdError::InvalidIdValue,
                      "value of @id must be a string unless framing: #{value.inspect}"
                  end
                end
              else
                raise JsonLdError::InvalidIdValue,
                  "value of @id must be a string or hash if framing: #{value.inspect}"
              end

              # Use array form if framing
              if framing
                as_array(e_id)
              else
                e_id
              end
            when '@included'
              # Included blocks are treated as an array of separate object nodes sharing the same referencing active_property. For 1.0, it is skipped as are other unknown keywords
              next if context.processingMode('json-ld-1.0')

              included_result = as_array(expand(value, active_property, context,
                framing: framing,
                log_depth: log_depth.to_i + 1))

              # Expanded values must be node objects
              unless included_result.all? do |e|
                       node?(e)
                     end
                raise JsonLdError::InvalidIncludedValue,
                  "values of @included must expand to node objects"
              end

              # As other properties may alias to @included, add this to any other previously expanded values
              Array(output_object['@included']) + included_result
            when '@type'
              # If expanded property is @type and value is neither a string nor an array of strings, an invalid type value error has been detected and processing is aborted. Otherwise, set expanded value to the result of using the IRI Expansion algorithm, passing active context, true for vocab, and true for document relative to expand the value or each of its items.
              # log_debug("@type", depth: log_depth.to_i) {"value: #{value.inspect}"}
              e_type = case value
              when Array
                value.map do |v|
                  unless v.is_a?(String)
                    raise JsonLdError::InvalidTypeValue,
                      "@type value must be a string or array of strings: #{v.inspect}"
                  end
                  type_scoped_context.expand_iri(v,
                    as_string: true,
                    base: @options[:base],
                    documentRelative: true,
                    vocab: true)
                end
              when String
                type_scoped_context.expand_iri(value,
                  as_string: true,
                  base: @options[:base],
                  documentRelative: true,
                  vocab: true)
              when Hash
                if !framing
                  raise JsonLdError::InvalidTypeValue,
                    "@type value must be a string or array of strings: #{value.inspect}"
                elsif value.keys.length == 1 &&
                      type_scoped_context.expand_iri(value.keys.first, vocab: true, base: @options[:base]) == '@default'
                  # Expand values of @default, which must be a string, or array of strings expanding to IRIs
                  [{ '@default' => Array(value['@default']).map do |v|
                    unless v.is_a?(String)
                      raise JsonLdError::InvalidTypeValue,
                        "@type default value must be a string or array of strings: #{v.inspect}"
                    end
                    type_scoped_context.expand_iri(v,
                      as_string: true,
                      base: @options[:base],
                      documentRelative: true,
                      vocab: true)
                  end }]
                elsif !value.empty?
                  raise JsonLdError::InvalidTypeValue,
                    "@type value must be a an empty object for framing: #{value.inspect}"
                else
                  [{}]
                end
              else
                raise JsonLdError::InvalidTypeValue,
                  "@type value must be a string or array of strings: #{value.inspect}"
              end

              e_type = Array(output_object['@type']) + Array(e_type)
              # Use array form if framing
              framing || e_type.length > 1 ? e_type : e_type.first
            when '@graph'
              # If expanded property is @graph, set expanded value to the result of using this algorithm recursively passing active context, @graph for active property, and value for element.
              value = expand(value, '@graph', context,
                framing: framing,
                log_depth: log_depth.to_i + 1)
              as_array(value)
            when '@value'
              # If expanded property is @value and input contains @type: json, accept any value.
              # If expanded property is @value and value is not a scalar or null, an invalid value object value error has been detected and processing is aborted. (In 1.1, @value can have any JSON value of @type is @json or the property coerces to @json).
              # Otherwise, set expanded value to value. If expanded value is null, set the @value member of result to null and continue with the next key from element. Null values need to be preserved in this case as the meaning of an @type member depends on the existence of an @value member.
              # If framing, always use array form, unless null
              if input_type == '@json' && context.processingMode('json-ld-1.1')
                value
              else
                case value
                when String, TrueClass, FalseClass, Numeric then (framing ? [value] : value)
                when nil
                  output_object['@value'] = nil
                  next
                when Array
                  unless framing
                    raise JsonLdError::InvalidValueObjectValue,
                      "@value value may not be an array unless framing: #{value.inspect}"
                  end
                  value
                when Hash
                  unless value.empty? && framing
                    raise JsonLdError::InvalidValueObjectValue,
                      "@value value must be a an empty object for framing: #{value.inspect}"
                  end
                  [value]
                else
                  raise JsonLdError::InvalidValueObjectValue,
                    "Value of #{expanded_property} must be a scalar or null: #{value.inspect}"
                end
              end
            when '@language'
              # If expanded property is @language and value is not a string, an invalid language-tagged string error has been detected and processing is aborted. Otherwise, set expanded value to lowercased value.
              # If framing, always use array form, unless null
              case value
              when String
                unless /^[a-zA-Z]{1,8}(-[a-zA-Z0-9]{1,8})*$/.match?(value)
                  warn "@language must be valid BCP47: #{value.inspect}"
                end
                if @options[:lowercaseLanguage]
                  (framing ? [value.downcase] : value.downcase)
                else
                  (framing ? [value] : value)
                end
              when Array
                unless framing
                  raise JsonLdError::InvalidLanguageTaggedString,
                    "@language value may not be an array unless framing: #{value.inspect}"
                end
                value.each do |v|
                  unless /^[a-zA-Z]{1,8}(-[a-zA-Z0-9]{1,8})*$/.match?(v)
                    warn "@language must be valid BCP47: #{v.inspect}"
                  end
                end
                @options[:lowercaseLanguage] ? value.map(&:downcase) : value
              when Hash
                unless value.empty? && framing
                  raise JsonLdError::InvalidLanguageTaggedString,
                    "@language value must be a an empty object for framing: #{value.inspect}"
                end
                [value]
              else
                raise JsonLdError::InvalidLanguageTaggedString,
                  "Value of #{expanded_property} must be a string: #{value.inspect}"
              end
            when '@direction'
              # If expanded property is @direction and value is not either 'ltr' or 'rtl', an invalid base direction error has been detected and processing is aborted. Otherwise, set expanded value to value.
              # If framing, always use array form, unless null
              case value
              when 'ltr', 'rtl' then (framing ? [value] : value)
              when Array
                unless framing
                  raise JsonLdError::InvalidBaseDirection,
                    "@direction value may not be an array unless framing: #{value.inspect}"
                end
                unless value.all? do |v|
                         %w[
                           ltr rtl
                         ].include?(v) || (v.is_a?(Hash) && v.empty?)
                       end
                  raise JsonLdError::InvalidBaseDirection,
                    "@direction must be one of 'ltr', 'rtl', or an array of those if framing #{value.inspect}"
                end
                value
              when Hash
                unless value.empty? && framing
                  raise JsonLdError::InvalidBaseDirection,
                    "@direction value must be a an empty object for framing: #{value.inspect}"
                end
                [value]
              else
                raise JsonLdError::InvalidBaseDirection,
                  "Value of #{expanded_property} must be one of 'ltr' or 'rtl': #{value.inspect}"
              end
            when '@index'
              # If expanded property is @index and value is not a string, an invalid @index value error has been detected and processing is aborted. Otherwise, set expanded value to value.
              unless value.is_a?(String)
                raise JsonLdError::InvalidIndexValue,
                  "Value of @index is not a string: #{value.inspect}"
              end
              value
            when '@list'
              # If expanded property is @graph:

              # If active property is null or @graph, continue with the next key from element to remove the free-floating list.
              next if (expanded_active_property || '@graph') == '@graph'

              # Otherwise, initialize expanded value to the result of using this algorithm recursively passing active context, active property, and value for element.
              value = expand(value, active_property, context,
                framing: framing,
                log_depth: log_depth.to_i + 1)

              # Spec FIXME: need to be sure that result is an array
              value = as_array(value)

              # Make sure that no member of value contains an annotation object
              if value.any? { |n| n.is_a?(Hash) && n.key?('@annotation') }
                raise JsonLdError::InvalidAnnotation,
                  "A list element must not contain @annotation."
              end

              value
            when '@set'
              # If expanded property is @set, set expanded value to the result of using this algorithm recursively, passing active context, active property, and value for element.
              expand(value, active_property, context,
                framing: framing,
                log_depth: log_depth.to_i + 1)
            when '@reverse'
              # If expanded property is @reverse and value is not a JSON object, an invalid @reverse value error has been detected and processing is aborted.
              unless value.is_a?(Hash)
                raise JsonLdError::InvalidReverseValue,
                  "@reverse value must be an object: #{value.inspect}"
              end

              # Otherwise
              # Initialize expanded value to the result of using this algorithm recursively, passing active context, @reverse as active property, and value as element.
              value = expand(value, '@reverse', context,
                framing: framing,
                log_depth: log_depth.to_i + 1)

              # If expanded value contains an @reverse member, i.e., properties that are reversed twice, execute for each of its property and item the following steps:
              if value.key?('@reverse')
                # log_debug("@reverse", depth: log_depth.to_i) {"double reverse: #{value.inspect}"}
                value['@reverse'].each do |property, item|
                  # If result does not have a property member, create one and set its value to an empty array.
                  # Append item to the value of the property member of result.
                  (output_object[property] ||= []).concat([item].flatten.compact)
                end
              end

              # If expanded value contains members other than @reverse:
              if !value.key?('@reverse') || value.length > 1
                # If result does not have an @reverse member, create one and set its value to an empty JSON object.
                reverse_map = output_object['@reverse'] ||= {}
                value.each do |property, items|
                  next if property == '@reverse'

                  items.each do |item|
                    if value?(item) || list?(item)
                      raise JsonLdError::InvalidReversePropertyValue,
                        item.inspect
                    end
                    merge_value(reverse_map, property, item)
                  end
                end
              end

              # Continue with the next key from element
              next
            when '@default', '@embed', '@explicit', '@omitDefault', '@preserve', '@requireAll'
              next unless framing

              # Framing keywords
              [expand(value, expanded_property, context,
                framing: framing,
                log_depth: log_depth.to_i + 1)].flatten
            when '@nest'
              # Add key to nests
              nests << key
              # Continue with the next key from element
              next
            when '@annotation'
              # Skip unless rdfstar option is set
              next unless @options[:rdfstar]

              as_array(expand(value, '@annotation', context,
                framing: framing,
                log_depth: log_depth.to_i + 1))
            else
              # Skip unknown keyword
              next
            end

            # Unless expanded value is null, set the expanded property member of result to expanded value.
            # log_debug("expand #{expanded_property}", depth: log_depth.to_i) { expanded_value.inspect}
            unless expanded_value.nil? && expanded_property == '@value' && input_type != '@json'
              output_object[expanded_property] =
                expanded_value
            end
            next
          end

          container = context.container(key)
          expanded_value = if context.coerce(key) == '@json'
            # In JSON-LD 1.1, values can be native JSON
            { "@value" => value, "@type" => "@json" }
          elsif container.include?('@language') && value.is_a?(Hash)
            # Otherwise, if key's container mapping in active context is @language and value is a JSON object then value is expanded from a language map as follows:

            # Set multilingual array to an empty array.
            ary = []

            # For each key-value pair language-language value in value, ordered lexicographically by language
            keys = @options[:ordered] ? value.keys.sort : value.keys
            keys.each do |k|
              expanded_k = context.expand_iri(k, vocab: true, as_string: true, base: @options[:base])

              if k !~ /^[a-zA-Z]{1,8}(-[a-zA-Z0-9]{1,8})*$/ && expanded_k != '@none'
                warn "@language must be valid BCP47: #{k.inspect}"
              end

              [value[k]].flatten.each do |item|
                # item must be a string, otherwise an invalid language map value error has been detected and processing is aborted.
                unless item.nil? || item.is_a?(String)
                  raise JsonLdError::InvalidLanguageMapValue,
                    "Expected #{item.inspect} to be a string"
                end

                # Append a JSON object to expanded value that consists of two key-value pairs: (@value-item) and (@language-lowercased language).
                v = { '@value' => item }
                v['@language'] = (@options[:lowercaseLanguage] ? k.downcase : k) unless expanded_k == '@none'
                v['@direction'] = context.direction(key) if context.direction(key)
                ary << v if item
              end
            end

            ary
          elsif container.intersect?(CONTAINER_INDEX_ID_TYPE) && value.is_a?(Hash)
            # Otherwise, if key's container mapping in active context contains @index, @id, @type and value is a JSON object then value is expanded from an index map as follows:

            # Set ary to an empty array.
            ary = []
            index_key = context.term_definitions[key].index || '@index'

            # While processing index keys, if container includes @type, clear type-scoped term definitions
            container_context = if container.include?('@type') && context.previous_context
              context.previous_context
            elsif container.include?('@id') && context.term_definitions[key]
              id_context = context.term_definitions[key].context if context.term_definitions[key]
              if id_context.nil?
                context
              else
                # log_debug("expand", depth: log_depth.to_i) {"id_context: #{id_context.inspect}"}
                context.parse(id_context, base: @options[:base], propagate: false)
              end
            else
              context
            end

            # For each key-value in the object:
            keys = @options[:ordered] ? value.keys.sort : value.keys
            keys.each do |k|
              # If container mapping in the active context includes @type, and k is a term in the active context having a local context, use that context when expanding values
              if container.include?('@type') && container_context.term_definitions[k]
                map_context = container_context.term_definitions[k].context
              end
              unless map_context.nil?
                # log_debug("expand", depth: log_depth.to_i) {"map_context: #{map_context.inspect}"}
                map_context = container_context.parse(map_context, base: @options[:base],
                  propagate: false)
              end
              map_context ||= container_context

              expanded_k = container_context.expand_iri(k, vocab: true, as_string: true, base: @options[:base])

              # Initialize index value to the result of using this algorithm recursively, passing active context, key as active property, and index value as element.
              index_value = expand([value[k]].flatten, key, map_context,
                framing: framing,
                from_map: true,
                log_depth: log_depth.to_i + 1)
              index_value.each do |item|
                if container.include?('@index')
                  # Indexed graph by graph name
                  item = { '@graph' => as_array(item) } if !graph?(item) && container.include?('@graph')
                  if index_key == '@index'
                    item['@index'] ||= k unless expanded_k == '@none'
                  elsif value?(item)
                    raise JsonLdError::InvalidValueObject, "Attempt to add illegal key to value object: #{index_key}"
                  else
                    # Expand key based on term
                    expanded_k = if k == '@none'
                      '@none'
                    else
                      container_context.expand_value(index_key, k,
                        base: @options[:base])
                    end
                    index_property = container_context.expand_iri(index_key, vocab: true, as_string: true,
                      base: @options[:base])
                    item[index_property] = [expanded_k].concat(Array(item[index_property])) unless expanded_k == '@none'
                  end
                elsif container.include?('@id')
                  # Indexed graph by graph name
                  item = { '@graph' => as_array(item) } if !graph?(item) && container.include?('@graph')
                  # Expand k document relative
                  unless expanded_k == '@none'
                    expanded_k = container_context.expand_iri(k, as_string: true, base: @options[:base],
                      documentRelative: true)
                  end
                  item['@id'] ||= expanded_k unless expanded_k == '@none'
                elsif container.include?('@type')
                  item['@type'] = [expanded_k].concat(Array(item['@type'])) unless expanded_k == '@none'
                end

                # Append item to expanded value.
                ary << item
              end
            end
            ary
          else
            # Otherwise, initialize expanded value to the result of using this algorithm recursively, passing active context, key for active property, and value for element.
            expand(value, key, context,
              framing: framing,
              log_depth: log_depth.to_i + 1)
          end

          # If expanded value is null, ignore key by continuing to the next key from element.
          if expanded_value.nil?
            # log_debug(" => skip nil value", depth: log_depth.to_i)
            next
          end

          # log_debug(depth: log_depth.to_i) {" => #{expanded_value.inspect}"}

          # If the container mapping associated to key in active context is @list and expanded value is not already a list object, convert expanded value to a list object by first setting it to an array containing only expanded value if it is not already an array, and then by setting it to a JSON object containing the key-value pair @list-expanded value.
          if container.first == '@list' && container.length == 1 && !list?(expanded_value)
            # log_debug(" => ", depth: log_depth.to_i) { "convert #{expanded_value.inspect} to list"}
            expanded_value = { '@list' => as_array(expanded_value) }
          end
          # log_debug(depth: log_depth.to_i) {" => #{expanded_value.inspect}"}

          # convert expanded value to @graph if container specifies it
          if container.first == '@graph' && container.length == 1
            # log_debug(" => ", depth: log_depth.to_i) { "convert #{expanded_value.inspect} to list"}
            expanded_value = as_array(expanded_value).map do |v|
              { '@graph' => as_array(v) }
            end
          end

          # Otherwise, if the term definition associated to key indicates that it is a reverse property
          # Spec FIXME: this is not an otherwise.
          if (td = context.term_definitions[key]) && td.reverse_property
            # If result has no @reverse member, create one and initialize its value to an empty JSON object.
            reverse_map = output_object['@reverse'] ||= {}
            [expanded_value].flatten.each do |item|
              # If item is a value object or list object, an invalid reverse property value has been detected and processing is aborted.
              if value?(item) || list?(item)
                raise JsonLdError::InvalidReversePropertyValue,
                  item.inspect
              end

              # If reverse map has no expanded property member, create one and initialize its value to an empty array.
              # Append item to the value of the expanded property member of reverse map.
              merge_value(reverse_map, expanded_property, item)
            end
          else
            # Otherwise, if key is not a reverse property:
            # If result does not have an expanded property member, create one and initialize its value to an empty array.
            (output_object[expanded_property] ||= []).tap do |memo|
              # expanded_value is either Array[Hash] or Hash; in both case append to memo without flatten
              if expanded_value.is_a?(Array)
                memo.concat(expanded_value)
              else # Hash
                memo << expanded_value
              end
            end
          end
        end

        # For each key in nests, recusively expand content
        nests.each do |key|
          nest_context = context.term_definitions[key].context if context.term_definitions[key]
          nest_context = if nest_context.nil?
            context
          else
            # log_debug("expand", depth: log_depth.to_i) {"nest_context: #{nest_context.inspect}"}
            context.parse(nest_context, base: @options[:base],
              override_protected: true)
          end
          nested_values = as_array(input[key])
          nested_values.each do |nv|
            raise JsonLdError::InvalidNestValue, nv.inspect unless
              nv.is_a?(Hash) && nv.keys.none? do |k|
                nest_context.expand_iri(k, vocab: true, base: @options[:base]) == '@value'
              end

            expand_object(nv, active_property, nest_context, output_object,
              framing: framing,
              expanded_active_property: expanded_active_property,
              type_key: type_key,
              type_scoped_context: type_scoped_context,
              log_depth: log_depth.to_i + 1)
          end
        end
      end
    end
  end
end

repo_name: ruby-rdf/json-ld

repo_language: HTML

mime_type: text/plain


text:
# frozen_string_literal: true

module JSON
  module LD
    module Utils
      ##
      # Is value a node? A value is a node if
      # * it is a Hash
      # * it is not a @value, @set or @list
      # * it has more than 1 key or any key is not @id
      # @param [Object] value
      # @return [Boolean]
      def node?(value)
        value.is_a?(Hash) &&
          !(value.key?('@value') || value.key?('@list') || value.key?('@set')) &&
          (value.length > 1 || !value.key?('@id'))
      end

      ##
      # Is value a node reference?
      # @param [Object] value
      # @return [Boolean]
      def node_reference?(value)
        value.is_a?(Hash) && value.length == 1 && value.key?('@id')
      end

      ##
      # Is value a node or a node reference reference?
      # @param [Object] value
      # @return [Boolean]
      def node_or_ref?(value)
        value.is_a?(Hash) &&
          !(value.key?('@value') || value.key?('@list') || value.key?('@set'))
      end

      ##
      # Is value a blank node? Value is a blank node
      #
      # @param [Object] value
      # @return [Boolean]
      def blank_node?(value)
        case value
        when nil    then true
        when String then value.start_with?('_:')
        else
          (node?(value) || node_reference?(value)) && value.fetch('@id', '_:').start_with?('_:')
        end
      end

      ##
      # Is value an expaned @graph?
      #
      # Note: A value is a graph if all of these hold true:
      # 1. It is an object.
      # 2. It has an `@graph` key.
      # 3. It may have '@context', '@id' or '@index'
      #
      # @param [Object] value
      # @return [Boolean]
      def graph?(value)
        value.is_a?(Hash) && (value.keys - UTIL_GRAPH_KEYS) == ['@graph']
      end

      ##
      # Is value a simple graph (lacking @id)?
      #
      # @param [Object] value
      # @return [Boolean]
      def simple_graph?(value)
        graph?(value) && !value.key?('@id')
      end

      ##
      # Is value an expaned @list?
      #
      # @param [Object] value
      # @return [Boolean]
      def list?(value)
        value.is_a?(Hash) && value.key?('@list')
      end

      ##
      # Is value annotated?
      #
      # @param [Object] value
      # @return [Boolean]
      def index?(value)
        value.is_a?(Hash) && value.key?('@index')
      end

      ##
      # Is value literal?
      #
      # @param [Object] value
      # @return [Boolean]
      def value?(value)
        value.is_a?(Hash) && value.key?('@value')
      end

      ##
      # Represent an id as an IRI or Blank Node
      # @param [String] id
      # @param [RDF::URI] base (nil)
      # @return [RDF::Resource]
      def as_resource(id, base = nil)
        @nodes ||= {} # Re-use BNodes
        if id.start_with?('_:')
          (@nodes[id] ||= RDF::Node.new(namer.get_sym(id)))
        elsif base
          base.join(id)
        else
          RDF::URI(id)
        end
      end

      ##
      # Represent as an array
      # @param [Object] object
      # @return [Array<Object>]
      def as_array(object)
        object.is_a?(Array) ? object : [object]
      end

      ##
      # Compares two JSON-LD values for equality. Two JSON-LD values will be
      # considered equal if:
      #
      # 1. They are both primitives of the same type and value.
      # 2. They are both @values with the same @value, @type, @language,
      #   and @index, OR
      # 3. They both have @ids that are the same.
      #
      # @param [Object] v1 the first value.
      # @param [Object] v2 the second value.
      #
      # @return [Boolean] v1 and v2 are considered equal
      def compare_values(v1, v2)
        if node_or_ref?(v1) && node_or_ref?(v2)
          v1['@id'] && v1['@id'] == v2['@id']
        elsif value?(v1) && value?(v2)
          v1['@value'] == v2['@value'] &&
            v1['@type'] == v2['@type'] &&
            v1['@language'] == v2['@language'] &&
            v1['@index'] == v2['@index']
        else
          v1 == v2
        end
      end

      # Adds a value to a subject. If the value is an array, all values in the
      # array will be added.
      #
      # @param [Hash] subject the hash to add the value to.
      # @param [String] property the property that relates the value to the subject.
      # @param [Object] value the value to add.
      # @param [Boolean] property_is_array (false)
      #   true if the property is always an array, false if not.
      # @param [Boolean] value_is_array (false)
      #   true if the value to be added should be preserved as an array (lists)
      # @param [Boolean] allow_duplicate (true)
      #   true to allow duplicates, false not to (uses
      #     a simple shallow comparison of subject ID or value).
      def add_value(subject, property, value, property_is_array: false, value_is_array: false, allow_duplicate: true)
        if value_is_array
          subject[property] = value
        elsif value.is_a?(Array)
          subject[property] = [] if value.empty? && property_is_array
          value.each do |v|
            add_value(subject, property, v,
              property_is_array: property_is_array, allow_duplicate: allow_duplicate)
          end
        elsif subject[property]
          # check if subject already has value if duplicates not allowed
          _has_value = !allow_duplicate && has_value?(subject, property, value)

          # make property an array if value not present or always an array
          if !subject[property].is_a?(Array) && (!_has_value || property_is_array)
            subject[property] =
              [subject[property]]
          end
          subject[property] << value unless _has_value
        else
          subject[property] = property_is_array ? [value] : value
        end
      end

      # Returns True if the given subject has the given property.
      #
      # @param subject the subject to check.
      # @param property the property to look for.
      #
      # @return [Boolean] true if the subject has the given property, false if not.
      def property?(subject, property)
        return false unless (value = subject[property])

        !value.is_a?(Array) || !value.empty?
      end

      # Determines if the given value is a property of the given subject.
      #
      # @param [Hash] subject the subject to check.
      # @param [String] property the property to check.
      # @param [Object] value the value to check.
      #
      # @return [Boolean] true if the value exists, false if not.
      def has_value?(subject, property, value)
        if property?(subject, property)
          val = subject[property]
          is_list = list?(val)
          if val.is_a?(Array) || is_list
            val = val['@list'] if is_list
            val.any? { |v| compare_values(value, v) }
          elsif !val.is_a?(Array)
            compare_values(value, val)
          else
            false
          end
        else
          false
        end
      end

      private

      UTIL_GRAPH_KEYS = %w[@context @id @index].freeze

      # Merge the last value into an array based for the specified key if hash is not null and value is not already in that array
      def merge_value(hash, key, value)
        return unless hash

        values = hash[key] ||= []
        if key == '@list'
          values << value
        elsif list?(value)
          values << value
        elsif !values.include?(value)
          values << value
        end
      end
    end

    ##
    # Utility class for mapping old blank node identifiers, or unnamed blank
    # nodes to new identifiers
    class BlankNodeMapper < Hash
      ##
      # Just return a Blank Node based on `old`. Manufactures
      # a node if `old` is nil or empty
      # @param [String] old ("")
      # @return [String]
      def get_sym(old = "")
        old = RDF::Node.new.to_s if old.to_s.empty?
        old.to_s.sub(/_:/, '')
      end

      ##
      # Get a new mapped name for `old`
      #
      # @param [String] old ("")
      # @return [String]
      def get_name(old = "")
        "_:" + get_sym(old)
      end
    end

    class BlankNodeUniqer < BlankNodeMapper
      ##
      # Use the uniquely generated bnodes, rather than a sequence
      # @param [String] old ("")
      # @return [String]
      def get_sym(old = "")
        old = old.to_s.sub(/_:/, '')
        if old && key?(old)
          self[old]
        elsif !old.empty?
          self[old] = RDF::Node.new.to_unique_base[2..]
        else
          RDF::Node.new.to_unique_base[2..]
        end
      end
    end

    class BlankNodeNamer < BlankNodeMapper
      # @param [String] prefix
      def initialize(prefix)
        @prefix = prefix.to_s
        @num = 0
        super
      end

      ##
      # Get a new symbol mapped from `old`
      # @param [String] old ("")
      # @return [String]
      def get_sym(old = "")
        old = old.to_s.sub(/_:/, '')
        if !old.empty? && key?(old)
          self[old]
        elsif !old.empty?
          @num += 1
          # puts "allocate #{@prefix + (@num - 1).to_s} to #{old.inspect}"
          self[old] = @prefix + (@num - 1).to_s
        else
          # Not referenced, just return a new unique value
          @num += 1
          # puts "allocate #{@prefix + (@num - 1).to_s} to #{old.inspect}"
          @prefix + (@num - 1).to_s
        end
      end
    end
  end
end


text:
# frozen_string_literal: true

module JSON
  module LD
    ##
    # JSON-LD format specification.
    #
    # @example Obtaining an JSON-LD format class
    #     RDF::Format.for(:jsonld)           #=> JSON::LD::Format
    #     RDF::Format.for("etc/foaf.jsonld")
    #     RDF::Format.for(:file_name         => "etc/foaf.jsonld")
    #     RDF::Format.for(file_extension: "jsonld")
    #     RDF::Format.for(:content_type   => "application/ld+json")
    #
    # @example Obtaining serialization format MIME types
    #     RDF::Format.content_types      #=> {"application/ld+json" => [JSON::LD::Format],
    #                                         "application/x-ld+json" => [JSON::LD::Format]}
    #
    # @example Obtaining serialization format file extension mappings
    #     RDF::Format.file_extensions    #=> {:jsonld => [JSON::LD::Format] }
    #
    # @see https://www.w3.org/TR/json-ld11/
    # @see https://w3c.github.io/json-ld-api/tests/
    class Format < RDF::Format
      content_type     'application/ld+json',
        extension: :jsonld,
        alias: 'application/x-ld+json',
        uri: 'http://www.w3.org/ns/formats/JSON-LD'
      content_encoding 'utf-8'

      reader { JSON::LD::Reader }
      writer { JSON::LD::Writer }

      ##
      # Sample detection to see if it matches JSON-LD
      #
      # Use a text sample to detect the format of an input file. Sub-classes implement
      # a matcher sufficient to detect probably format matches, including disambiguating
      # between other similar formats.
      #
      # @param [String] sample Beginning several bytes (~ 1K) of input.
      # @return [Boolean]
      def self.detect(sample)
        !!sample.match(/\{\s*"@(id|context|type)"/m) &&
          # Exclude CSVW metadata
          !sample.include?("http://www.w3.org/ns/csvw")
      end

      # Specify how to execute CLI commands for each supported format.
      # Derived formats (e.g., YAML-LD) define their own entrypoints.
      LD_FORMATS = {
        jsonld: {
          expand: lambda { |input, **options|
            JSON::LD::API.expand(input,
              serializer: JSON::LD::API.method(:serializer),
                                 **options)
          },
          compact: lambda { |input, **options|
            JSON::LD::API.compact(input,
              options[:context],
              serializer: JSON::LD::API.method(:serializer),
                                  **options)
          },
          flatten: lambda { |input, **options|
            JSON::LD::API.flatten(input,
              options[:context],
              serializer: JSON::LD::API.method(:serializer),
                                  **options)
          },
          frame: lambda { |input, **options|
            JSON::LD::API.frame(input,
              options[:frame],
              serializer: JSON::LD::API.method(:serializer),
                                  **options)
          }
        }
      }

      # Execute the body of a CLI command, generic for each different API method based on definitions on {LD_FORMATS}.
      #
      # Expands the input, or transforms from an RDF format based on the `:format` option, and then executes the appropriate command based on `:output_format` and does appropriate output serialization.
      # @private
      def self.cli_exec(command, files, output: $stdin, **options)
        output.set_encoding(Encoding::UTF_8) if output.respond_to?(:set_encoding) && RUBY_PLATFORM == "java"
        options[:base] ||= options[:base_uri]

        # Parse using input format, serialize using output format
        in_fmt = LD_FORMATS[options.fetch(:format, :jsonld)]
        out_fmt = LD_FORMATS[options.fetch(:output_format, :jsonld)]

        if in_fmt
          # Input is a JSON-LD based source (or derived)
          if files.empty?
            # If files are empty, either use options[:evaluate] or STDIN
            input = options[:evaluate] ? StringIO.new(options[:evaluate]) : $stdin
            input.set_encoding(options.fetch(:encoding, Encoding::UTF_8))
            expanded = in_fmt[:expand].call(input, serializer: nil, **options)
            output.puts out_fmt[command].call(expanded, expanded: true, **options)
          else
            files.each do |file|
              expanded = in_fmt[:expand].call(file, serializer: nil, **options)
              output.puts out_fmt[command].call(expanded, expanded: true, **options)
            end
          end
        else
          # Turn RDF into JSON-LD first
          RDF::CLI.parse(files, **options) do |reader|
            JSON::LD::API.fromRdf(reader, serializer: nil, **options) do |expanded|
              output.puts out_fmt[command].call(expanded, expanded: true, **options)
            end
          end
        end
      end

      ##
      # Hash of CLI commands appropriate for this format:
      #
      # * `expand` => {JSON::LD::API.expand}
      # * `compact` => {JSON::LD::API.compact}
      # * `flatten` => {JSON::LD::API.flatten}
      # * `frame` => {JSON::LD::API.frame}
      #
      # @return [Hash{Symbol => Hash}]
      def self.cli_commands
        {
          expand: {
            description: "Expand JSON-LD or parsed RDF",
            parse: false,
            help: "expand [--context <context-file>] files ...",
            filter: { output_format: LD_FORMATS.keys },  # Only shows output format set
            lambda: lambda do |files, **options|
              options = options.merge(expandContext: options.delete(:context)) if options.key?(:context)
              cli_exec(:expand, files, **options)
            end,
            option_use: { context: :removed }
          },
          compact: {
            description: "Compact JSON-LD or parsed RDF",
            parse: false,
            filter: { output_format: LD_FORMATS.keys },  # Only shows output format set
            help: "compact --context <context-file> files ...",
            lambda: lambda do |files, **options|
              raise ArgumentError, "Compacting requires a context" unless options[:context]

              cli_exec(:compact, files, **options)
            end,
            options: [
              RDF::CLI::Option.new(
                symbol: :context,
                datatype: RDF::URI,
                control: :url2,
                use: :required,
                on: ["--context CONTEXT"],
                description: "Context to use when compacting."
              ) { |arg| RDF::URI(arg).absolute? ? RDF::URI(arg) : StringIO.new(File.read(arg)) }
            ]
          },
          flatten: {
            description: "Flatten JSON-LD or parsed RDF",
            parse: false,
            help: "flatten [--context <context-file>] files ...",
            filter: { output_format: LD_FORMATS.keys },  # Only shows output format set
            lambda: lambda do |files, **options|
              cli_exec(:compact, files, **options)
            end,
            options: [
              RDF::CLI::Option.new(
                symbol: :context,
                datatype: RDF::URI,
                control: :url2,
                use: :required,
                on: ["--context CONTEXT"],
                description: "Context to use when compacting."
              ) { |arg| RDF::URI(arg) },
              RDF::CLI::Option.new(
                symbol: :createAnnotations,
                datatype: TrueClass,
                default: false,
                control: :checkbox,
                on: ["--[no-]create-annotations"],
                description: "Unfold embedded nodes which can be represented using `@annotation`."
              )
            ]
          },
          frame: {
            description: "Frame JSON-LD or parsed RDF",
            parse: false,
            help: "frame --frame <frame-file>  files ...",
            filter: { output_format: LD_FORMATS.keys },  # Only shows output format set
            lambda: lambda do |files, **options|
              raise ArgumentError, "Framing requires a frame" unless options[:frame]

              cli_exec(:compact, files, **options)
            end,
            option_use: { context: :removed },
            options: [
              RDF::CLI::Option.new(
                symbol: :frame,
                datatype: RDF::URI,
                control: :url2,
                use: :required,
                on: ["--frame FRAME"],
                description: "Frame to use when serializing."
              ) { |arg| RDF::URI(arg).absolute? ? RDF::URI(arg) : StringIO.new(File.read(arg)) }
            ]
          }
        }
      end

      ##
      # Override normal symbol generation
      def self.to_sym
        :jsonld
      end

      ##
      # Override normal format name
      def self.name
        "JSON-LD"
      end
    end
  end
end


text:
# frozen_string_literal: true

require 'set'

module JSON
  module LD
    module Frame
      include Utils

      ##
      # Frame input. Input is expected in expanded form, but frame is in compacted form.
      #
      # @param [Hash{Symbol => Object}] state
      #   Current framing state
      # @param [Array<String>] subjects
      #   The subjects to filter
      # @param [Hash{String => Object}] frame
      # @param [String] property (nil)
      #   The parent property.
      # @param [Hash{String => Object}] parent (nil)
      #   Parent subject or top-level array
      # @param [Boolean] ordered (true)
      #   Ensure output objects have keys ordered properly
      # @param [Hash{Symbol => Object}] options ({})
      # @raise [JSON::LD::InvalidFrame]
      def frame(state, subjects, frame, parent: nil, property: nil, ordered: false, **options)
        # Validate the frame
        validate_frame(frame)
        frame = frame.first if frame.is_a?(Array)

        # Get values for embedOn and explicitOn
        flags = {
          embed: get_frame_flag(frame, options, :embed),
          explicit: get_frame_flag(frame, options, :explicit),
          requireAll: get_frame_flag(frame, options, :requireAll)
        }

        # Get link for current graph
        link = state[:link][state[:graph]] ||= {}

        # Create a set of matched subjects by filtering subjects by checking the map of flattened subjects against frame
        # This gives us a hash of objects indexed by @id
        matches = filter_subjects(state, subjects, frame, flags)

        # For each id and node from the set of matched subjects ordered by id
        matches.keys.opt_sort(ordered: ordered).each do |id|
          subject = matches[id]

          # NOTE: In order to treat each top-level match as a compartmentalized result, clear the unique embedded subjects map when the property is nil, which only occurs at the top-level.
          if property.nil?
            state[:uniqueEmbeds] = { state[:graph] => {} }
          else
            state[:uniqueEmbeds][state[:graph]] ||= {}
          end

          if flags[:embed] == '@link' && link.key?(id)
            # add existing linked subject
            add_frame_output(parent, property, link[id])
            next
          end

          output = { '@id' => id }
          link[id] = output

          if %w[@first @last].include?(flags[:embed]) && context.processingMode('json-ld-1.1')
            if @options[:validate]
              raise JSON::LD::JsonLdError::InvalidEmbedValue,
                "#{flags[:embed]} is not a valid value of @embed in 1.1 mode"
            end

            warn "[DEPRECATION] #{flags[:embed]}  is not a valid value of @embed in 1.1 mode.\n"
          end

          if !state[:embedded] && state[:uniqueEmbeds][state[:graph]].key?(id)
            # Skip adding this node object to the top-level, as it was included in another node object
            next
          elsif state[:embedded] &&
                (flags[:embed] == '@never' || creates_circular_reference(subject, state[:graph], state[:subjectStack]))
            # if embed is @never or if a circular reference would be created by an embed, the subject cannot be embedded, just add the reference; note that a circular reference won't occur when the embed flag is `@link` as the above check will short-circuit before reaching this point
            add_frame_output(parent, property, output)
            next
          elsif state[:embedded] &&
                %w[@first @once].include?(flags[:embed]) &&
                state[:uniqueEmbeds][state[:graph]].key?(id)

            # if only the first match should be embedded
            # Embed unless already embedded
            add_frame_output(parent, property, output)
            next
          elsif flags[:embed] == '@last'
            # if only the last match should be embedded
            # remove any existing embed
            remove_embed(state, id) if state[:uniqueEmbeds][state[:graph]].include?(id)
          end

          state[:uniqueEmbeds][state[:graph]][id] = {
            parent: parent,
            property: property
          }

          # push matching subject onto stack to enable circular embed checks
          state[:subjectStack] << { subject: subject, graph: state[:graph] }

          # Subject is also the name of a graph
          if state[:graphMap].key?(id)
            # check frame's "@graph" to see what to do next
            # 1. if it doesn't exist and state.graph === "@merged", don't recurse
            # 2. if it doesn't exist and state.graph !== "@merged", recurse
            # 3. if "@merged" then don't recurse
            # 4. if "@default" then don't recurse
            # 5. recurse
            recurse = false
            subframe = nil
            if frame.key?('@graph')
              subframe = frame['@graph'].first
              recurse = !['@merged', '@default'].include?(id)
              subframe = {} unless subframe.is_a?(Hash)
            else
              recurse = (state[:graph] != '@merged')
              subframe = {}
            end

            if recurse
              frame(state.merge(graph: id, embedded: false), state[:graphMap][id].keys, [subframe], parent: output,
                property: '@graph', **options)
            end
          end

          # If frame has `@included`, recurse over its sub-frame
          if frame['@included']
            frame(state.merge(embedded: false), subjects, frame['@included'], parent: output, property: '@included',
  **options)
          end

          # iterate over subject properties in order
          subject.keys.opt_sort(ordered: ordered).each do |prop|
            objects = subject[prop]

            # copy keywords to output
            if prop.start_with?('@')
              output[prop] = objects.dup
              next
            end

            # explicit is on and property isn't in frame, skip processing
            next if flags[:explicit] && !frame.key?(prop)

            # add objects
            objects.each do |o|
              subframe = Array(frame[prop]).first || create_implicit_frame(flags)

              if list?(o)
                subframe = frame[prop].first['@list'] if Array(frame[prop]).first.is_a?(Hash)
                subframe ||= create_implicit_frame(flags)
                # add empty list
                list = { '@list' => [] }
                add_frame_output(output, prop, list)

                src = o['@list']
                src.each do |oo|
                  if node_reference?(oo)
                    frame(state.merge(embedded: true), [oo['@id']], subframe, parent: list, property: '@list',
**options)
                  else
                    add_frame_output(list, '@list', oo.dup)
                  end
                end
              elsif node_reference?(o)
                # recurse into subject reference
                frame(state.merge(embedded: true), [o['@id']], subframe, parent: output, property: prop, **options)
              elsif value_match?(subframe, o)
                # Include values if they match
                add_frame_output(output, prop, o.dup)
              end
            end
          end

          # handle defaults in order
          frame.keys.opt_sort(ordered: ordered).each do |prop|
            if prop == '@type' && frame[prop].first.is_a?(Hash) && frame[prop].first.keys == %w[@default]
              # Treat this as a default
            elsif prop.start_with?('@')
              next
            end

            # if omit default is off, then include default values for properties that appear in the next frame but are not in the matching subject
            n = frame[prop].first || {}
            omit_default_on = get_frame_flag(n, options, :omitDefault)
            if !omit_default_on && !output[prop]
              preserve = as_array(n.fetch('@default', '@null').dup)
              output[prop] = [{ '@preserve' => preserve }]
            end
          end

          # If frame has @reverse, embed identified nodes having this subject as a value of the associated property.
          frame.fetch('@reverse', {}).each do |reverse_prop, subframe|
            state[:subjects].each do |r_id, node|
              next unless Array(node[reverse_prop]).any? { |v| v['@id'] == id }

              # Node has property referencing this subject
              # recurse into  reference
              (output['@reverse'] ||= {})[reverse_prop] ||= []
              frame(state.merge(embedded: true), [r_id], subframe, parent: output['@reverse'][reverse_prop],
                property: property, **options)
            end
          end

          # add output to parent
          add_frame_output(parent, property, output)

          # pop matching subject from circular ref-checking stack
          state[:subjectStack].pop
        end
        # end
      end

      ##
      # Recursively find and count blankNode identifiers.
      # @return [Hash{String => Integer}]
      def count_blank_node_identifiers(input)
        {}.tap do |results|
          count_blank_node_identifiers_internal(input, results)
        end
      end

      def count_blank_node_identifiers_internal(input, results)
        case input
        when Array
          input.each { |o| count_blank_node_identifiers_internal(o, results) }
        when Hash
          input.each do |_k, v|
            count_blank_node_identifiers_internal(v, results)
          end
        when String
          if input.start_with?('_:')
            results[input] ||= 0
            results[input] += 1
          end
        end
      end

      ##
      # Prune BNode identifiers recursively
      #
      # @param [Array, Hash] input
      # @param [Array<String>] bnodes_to_clear
      # @return [Array, Hash]
      def prune_bnodes(input, bnodes_to_clear)
        case input
        when Array
          # If, after replacement, an array contains only the value null remove the value, leaving an empty array.
          input.map { |o| prune_bnodes(o, bnodes_to_clear) }.compact
        when Hash
          output = {}
          input.each do |key, value|
            if context.expand_iri(key) == '@id' && bnodes_to_clear.include?(value)
              # Don't add this to output, as it is pruned as being superfluous
            else
              output[key] = prune_bnodes(value, bnodes_to_clear)
            end
          end
          output
        else
          input
        end
      end

      ##
      # Replace @preserve keys with the values, also replace @null with null.
      #
      # @param [Array, Hash] input
      # @return [Array, Hash]
      def cleanup_preserve(input)
        case input
        when Array
          input.map! { |o| cleanup_preserve(o) }
        when Hash
          if input.key?('@preserve')
            # Replace with the content of `@preserve`
            cleanup_preserve(input['@preserve'].first)
          else
            input.transform_values do |v|
              cleanup_preserve(v)
            end
          end
        else
          input
        end
      end

      ##
      # Replace `@null` with `null`, removing it from arrays.
      #
      # @param [Array, Hash] input
      # @return [Array, Hash]
      def cleanup_null(input)
        case input
        when Array
          # If, after replacement, an array contains only the value null remove the value, leaving an empty array.
          input.map! { |o| cleanup_null(o) }.compact
        when Hash
          input.transform_values do |v|
            cleanup_null(v)
          end
        when '@null'
          # If the value from the key-pair is @null, replace the value with null
          nil
        else
          input
        end
      end

      private

      ##
      # Returns a map of all of the subjects that match a parsed frame.
      #
      # @param [Hash{Symbol => Object}] state
      #   Current framing state
      # @param [Array<String>] subjects
      #   The subjects to filter
      # @param [Hash{String => Object}] frame
      # @param [Hash{Symbol => String}] flags the frame flags.
      #
      # @return all of the matched subjects.
      def filter_subjects(state, subjects, frame, flags)
        subjects.each_with_object({}) do |id, memo|
          subject = state[:graphMap][state[:graph]][id]
          memo[id] = subject if filter_subject(subject, frame, state, flags)
        end
      end

      ##
      # Returns true if the given node matches the given frame.
      #
      # Matches either based on explicit type inclusion where the node has any type listed in the frame. If the frame has empty types defined matches nodes not having a @type. If the frame has a type of {} defined matches nodes having any type defined.
      #
      # Otherwise, does duck typing, where the node must have any or all of the properties defined in the frame, depending on the `requireAll` flag.
      #
      # @param [Hash{String => Object}] subject the subject to check.
      # @param [Hash{String => Object}] frame the frame to check.
      # @param [Hash{Symbol => Object}] state Current framing state
      # @param [Hash{Symbol => Object}] flags the frame flags.
      #
      # @return [Boolean] true if the node matches, false if not.
      def filter_subject(subject, frame, state, flags)
        # Duck typing, for nodes not having a type, but having @id
        wildcard = true
        matches_some = false

        frame.each do |k, v|
          node_values = subject.fetch(k, [])

          case k
          when '@id'
            ids = v || []

            # Match on specific @id.
            match_this = case ids
            when [], [{}]
              # Match on no @id or any @id
              true
            else
              # Match on specific @id
              ids.include?(subject['@id'])
            end
            return match_this unless flags[:requireAll]
          when '@type'
            # No longer a wildcard pattern
            wildcard = false

            match_this = case v
            when []
              # Don't match with any @type
              return false unless node_values.empty?

              true
            when [{}]
              # Match with any @type
              !node_values.empty?
            else
              # Treat a map with @default like an empty map
              if v.first.is_a?(Hash) && v.first.keys == %w[@default]
                true
              else
                !(v & node_values).empty?
              end
            end
            return match_this unless flags[:requireAll]
          when /@/
            # Skip other keywords
            next
          else
            is_empty = v.empty?
            if (v = v.first)
              validate_frame(v)
              has_default = v.key?('@default')
            end

            # No longer a wildcard pattern if frame has any non-keyword properties
            wildcard = false

            # Skip, but allow match if node has no value for property, and frame has a default value
            next if node_values.empty? && has_default

            # If frame value is empty, don't match if subject has any value
            return false if !node_values.empty? && is_empty

            match_this = case
            when v.nil?
              # node does not match if values is not empty and the value of property in frame is match none.
              return false unless node_values.empty?

              true
            when v.is_a?(Hash) && (v.keys - FRAMING_KEYWORDS).empty?
              # node matches if values is not empty and the value of property in frame is wildcard (frame with properties other than framing keywords)
              !node_values.empty?
            when value?(v)
              # Match on any matching value
              node_values.any? { |nv| value_match?(v, nv) }
            when node?(v) || node_reference?(v)
              node_values.any? do |nv|
                node_match?(v, nv, state, flags)
              end
            when list?(v)
              vv = v['@list'].first
              node_values = if list?(node_values.first)
                node_values.first['@list']
              else
                false
              end
              if !node_values
                false # Lists match Lists
              elsif value?(vv)
                # Match on any matching value
                node_values.any? { |nv| value_match?(vv, nv) }
              elsif node?(vv) || node_reference?(vv)
                node_values.any? do |nv|
                  node_match?(vv, nv, state, flags)
                end
              else
                false
              end
            else
              false # No matching on non-value or node values
            end
          end

          # All non-defaulted values must match if @requireAll is set
          return false if !match_this && flags[:requireAll]

          matches_some ||= match_this
        end

        # return true if wildcard or subject matches some properties
        wildcard || matches_some
      end

      def validate_frame(frame)
        unless frame.is_a?(Hash) || (frame.is_a?(Array) && frame.first.is_a?(Hash) && frame.length == 1)
          raise JsonLdError::InvalidFrame,
            "Invalid JSON-LD frame syntax; a JSON-LD frame must be an object: #{frame.inspect}"
        end
        frame = frame.first if frame.is_a?(Array)

        # Check values of @id and @type
        unless Array(frame['@id']) == [{}] || Array(frame['@id']).all? { |v| RDF::URI(v).valid? }
          raise JsonLdError::InvalidFrame,
            "Invalid JSON-LD frame syntax; invalid value of @id: #{frame['@id']}"
        end
        unless Array(frame['@type']).all? do |v|
                 (v.is_a?(Hash) && (v.keys - %w[@default]).empty?) || RDF::URI(v).valid?
               end
          raise JsonLdError::InvalidFrame,
            "Invalid JSON-LD frame syntax; invalid value of @type: #{frame['@type']}"
        end
      end

      # Checks the current subject stack to see if embedding the given subject would cause a circular reference.
      #
      # @param subject_to_embed the subject to embed.
      # @param graph the graph the subject to embed is in.
      # @param subject_stack the current stack of subjects.
      #
      # @return true if a circular reference would be created, false if not.
      def creates_circular_reference(subject_to_embed, graph, subject_stack)
        subject_stack[0..-2].any? do |subject|
          subject[:graph] == graph && subject[:subject]['@id'] == subject_to_embed['@id']
        end
      end

      ##
      # Gets the frame flag value for the given flag name.
      #
      # @param frame the frame.
      # @param options the framing options.
      # @param name the flag name.
      #
      # @return the flag value.
      def get_frame_flag(frame, options, name)
        rval = frame.fetch("@#{name}", [options[name]]).first
        rval = rval.values.first if value?(rval)
        if name == :embed
          rval = case rval
          when true then '@once'
          when false then '@never'
          when '@always', '@first', '@last', '@link', '@once', '@never' then rval
          else
            raise JsonLdError::InvalidEmbedValue,
              "Invalid JSON-LD frame syntax; invalid value of @embed: #{rval}"
          end
        end
        rval
      end

      ##
      # Removes an existing embed.
      #
      # @param state the current framing state.
      # @param id the @id of the embed to remove.
      def remove_embed(state, id)
        # get existing embed
        embeds = state[:uniqueEmbeds][state[:graph]]
        embed = embeds[id]
        property = embed[:property]

        # create reference to replace embed
        subject = { '@id' => id }

        if embed[:parent].is_a?(Array)
          # replace subject with reference
          embed[:parent].map! do |parent|
            compare_values(parent, subject) ? subject : parent
          end
        else
          parent = embed[:parent]
          # replace node with reference
          if parent[property].is_a?(Array)
            parent[property].reject! { |v| compare_values(v, subject) }
            parent[property] << subject
          elsif compare_values(parent[property], subject)
            parent[property] = subject
          end
        end

        # recursively remove dependent dangling embeds
        def remove_dependents(id, embeds)
          # get embed keys as a separate array to enable deleting keys in map
          embeds.each do |id_dep, e|
            p = e.fetch(:parent, {}) if e.is_a?(Hash)
            next unless p.is_a?(Hash)

            pid = p.fetch('@id', nil)
            if pid == id
              embeds.delete(id_dep)
              remove_dependents(id_dep, embeds)
            end
          end
        end

        remove_dependents(id, embeds)
      end

      ##
      # Adds framing output to the given parent.
      #
      # @param parent the parent to add to.
      # @param property the parent property, null for an array parent.
      # @param output the output to add.
      def add_frame_output(parent, property, output)
        if parent.is_a?(Hash)
          parent[property] ||= []
          parent[property] << output
        else
          parent << output
        end
      end

      # Creates an implicit frame when recursing through subject matches. If a frame doesn't have an explicit frame for a particular property, then a wildcard child frame will be created that uses the same flags that the parent frame used.
      #
      # @param [Hash] flags the current framing flags.
      # @return [Array<Hash>] the implicit frame.
      def create_implicit_frame(flags)
        {}.tap do |memo|
          flags.each_pair do |key, val|
            memo["@#{key}"] = [val]
          end
        end
      end

      # Node matches if it is a node, and matches the pattern as a frame
      def node_match?(pattern, value, state, flags)
        return false unless value['@id']

        node_object = state[:subjects][value['@id']]
        node_object && filter_subject(node_object, pattern, state, flags)
      end

      # Value matches if it is a value, and matches the value pattern.
      #
      # * `pattern` is empty
      # * @values are the same, or `pattern[@value]` is a wildcard, and
      # * @types are the same or `value[@type]` is not null and `pattern[@type]` is `{}`, or `value[@type]` is null and `pattern[@type]` is null or `[]`, and
      # * @languages are the same or `value[@language]` is not null and `pattern[@language]` is `{}`, or `value[@language]` is null and `pattern[@language]` is null or `[]`.
      def value_match?(pattern, value)
        v1 = value['@value']
        t1 = value['@type']
        l1 = value['@language']
        v2 = Array(pattern['@value'])
        t2 = Array(pattern['@type'])
        l2 = Array(pattern['@language']).map do |v|
          v.is_a?(String) ? v.downcase : v
        end
        return true if (v2 + t2 + l2).empty?
        return false unless v2.include?(v1) || v2 == [{}]
        return false unless t2.include?(t1) || (t1 && t2 == [{}]) || (t1.nil? && (t2 || []).empty?)
        return false unless l2.include?(l1.to_s.downcase) || (l1 && l2 == [{}]) || (l1.nil? && (l2 || []).empty?)

        true
      end

      FRAMING_KEYWORDS = %w[@default @embed @explicit @omitDefault @requireAll].freeze
    end
  end
end


text:
# frozen_string_literal: true

module JSON
  module LD
    ##
    # A JSON-LD parser in Ruby.
    #
    # @see https://www.w3.org/TR/json-ld11-api
    # @author [Gregg Kellogg](http://greggkellogg.net/)
    class Reader < RDF::Reader
      include StreamingReader
      format Format

      ##
      # JSON-LD Reader options
      # @see https://ruby-rdf.github.io/rdf/RDF/Reader#options-class_method
      def self.options
        super + [
          RDF::CLI::Option.new(
            symbol: :expandContext,
            control: :url2,
            datatype: RDF::URI,
            on: ["--expand-context CONTEXT"],
            description: "Context to use when expanding."
          ) { |arg| RDF::URI(arg).absolute? ? RDF::URI(arg) : StringIO.new(File.read(arg)) },
          RDF::CLI::Option.new(
            symbol: :extractAllScripts,
            datatype: TrueClass,
            default: false,
            control: :checkbox,
            on: ["--[no-]extract-all-scripts"],
            description: "If set to true, when extracting JSON-LD script elements from HTML, unless a specific fragment identifier is targeted, extracts all encountered JSON-LD script elements using an array form, if necessary."
          ) { |arg| RDF::URI(arg) },
          RDF::CLI::Option.new(
            symbol: :lowercaseLanguage,
            datatype: TrueClass,
            control: :checkbox,
            on: ["--[no-]lowercase-language"],
            description: "By default, language tags are left as is. To normalize to lowercase, set this option to `true`."
          ),
          RDF::CLI::Option.new(
            symbol: :processingMode,
            datatype: %w[json-ld-1.0 json-ld-1.1],
            control: :radio,
            on: ["--processingMode MODE", %w[json-ld-1.0 json-ld-1.1]],
            description: "Set Processing Mode (json-ld-1.0 or json-ld-1.1)"
          ),
          RDF::CLI::Option.new(
            symbol: :rdfDirection,
            datatype: %w[i18n-datatype compound-literal],
            default: 'null',
            control: :select,
            on: ["--rdf-direction DIR", %w[i18n-datatype compound-literal]],
            description: "How to serialize literal direction (i18n-datatype compound-literal)"
          ) { |arg| RDF::URI(arg) },
          RDF::CLI::Option.new(
            symbol: :stream,
            datatype: TrueClass,
            control: :checkbox,
            on: ["--[no-]stream"],
            description: "Optimize for streaming JSON-LD to RDF."
          ) { |arg| arg }
        ]
      end

      ##
      # Initializes the JSON-LD reader instance.
      #
      # @param  [IO, File, String]       input
      # @param  [Hash{Symbol => Object}] options
      #   any additional options (see `RDF::Reader#initialize` and {JSON::LD::API.initialize})
      # @yield  [reader] `self`
      # @yieldparam  [RDF::Reader] reader
      # @yieldreturn [void] ignored
      # @raise [RDF::ReaderError] if the JSON document cannot be loaded
      def initialize(input = $stdin, **options, &block)
        options[:base_uri] ||= options[:base]
        options[:rename_bnodes] = false unless options.key?(:rename_bnodes)
        super do
          @options[:base] ||= base_uri.to_s if base_uri
          # Trim non-JSON stuff in script.
          @doc = if input.respond_to?(:read)
            input
          else
            StringIO.new(input.to_s.sub(/\A[^{\[]*/m, '').sub(/[^}\]]*\Z/m, ''))
          end

          if block
            case block.arity
            when 0 then instance_eval(&block)
            else yield(self)
            end
          end
        end
      end

      ##
      # @private
      # @see   RDF::Reader#each_statement
      def each_statement(&block)
        if @options[:stream]
          stream_statement(&block)
        else
          API.toRdf(@doc, **@options, &block)
        end
      rescue ::JSON::ParserError, ::JSON::LD::JsonLdError => e
        log_fatal("Failed to parse input document: #{e.message}", exception: RDF::ReaderError)
      end

      ##
      # @private
      # @see   RDF::Reader#each_triple
      def each_triple
        if block_given?
          each_statement do |statement|
            yield(*statement.to_triple)
          end
        end
        enum_for(:each_triple)
      end
    end
  end
end


text:
# frozen_string_literal: true

require 'English'

require 'rack'
require 'link_header'

module JSON
  module LD
    ##
    # Rack middleware for JSON-LD content negotiation.
    #
    # Uses HTTP Content Negotiation to serialize `Array` and `Hash` results as JSON-LD using 'profile' accept-params to invoke appropriate JSON-LD API methods.
    #
    # Allows black-listing and white-listing of two-part profiles where the second part denotes a URL of a _context_ or _frame_. (See {JSON::LD::Writer.accept?})
    #
    # Works along with `rack-linkeddata` for serializing data which is not in the form of an `RDF::Repository`.
    #
    #
    # @example
    #     use JSON::LD::Rack
    #
    # @see https://www.w3.org/TR/json-ld11/#iana-considerations
    # @see https://www.rubydoc.info/github/rack/rack/master/file/SPEC
    class ContentNegotiation
      VARY = { 'Vary' => 'Accept' }.freeze

      # @return [#call]
      attr_reader :app

      ##
      # * Registers JSON::LD::Rack, suitable for Sinatra application
      # * adds helpers
      #
      # @param  [Sinatra::Base] app
      # @return [void]
      def self.registered(app)
        options = {}
        app.use(JSON::LD::Rack, **options)
      end

      def initialize(app)
        @app = app
      end

      ##
      # Handles a Rack protocol request.
      # Parses Accept header to find appropriate mime-type and sets content_type accordingly.
      #
      # @param  [Hash{String => String}] env
      # @return [Array(Integer, Hash, #each)] Status, Headers and Body
      # @see    https://rubydoc.info/github/rack/rack/file/SPEC
      def call(env)
        response = app.call(env)
        body = response[2].respond_to?(:body) ? response[2].body : response[2]
        case body
        when Array, Hash
          response[2] = body # Put it back in the response, it might have been a proxy
          serialize(env, *response)
        else response
        end
      end

      ##
      # Serializes objects as JSON-LD. Defaults to expanded form, other forms
      # determined by presense of `profile` in accept-parms.
      #
      # @param  [Hash{String => String}] env
      # @param  [Integer]                status
      # @param  [Hash{String => Object}] headers
      # @param  [RDF::Enumerable]        body
      # @return [Array(Integer, Hash, #each)] Status, Headers and Body
      def serialize(env, status, headers, body)
        # This will only return json-ld content types, possibly with parameters
        content_types = parse_accept_header(env['HTTP_ACCEPT'] || 'application/ld+json')
        content_types = content_types.select do |content_type|
          _, *params = content_type.split(';').map(&:strip)
          accept_params = params.inject({}) do |memo, pv|
            p, v = pv.split('=').map(&:strip)
            memo.merge(p.downcase.to_sym => v.sub(/^["']?([^"']*)["']?$/, '\1'))
          end
          JSON::LD::Writer.accept?(accept_params)
        end
        if content_types.empty?
          not_acceptable("No appropriate combinaion of media-type and parameters found")
        else
          ct, *params = content_types.first.split(';').map(&:strip)
          accept_params = params.inject({}) do |memo, pv|
            p, v = pv.split('=').map(&:strip)
            memo.merge(p.downcase.to_sym => v.sub(/^["']?([^"']*)["']?$/, '\1'))
          end

          # Determine API method from profile
          profile = accept_params[:profile].to_s.split

          # Get context from Link header
          links = LinkHeader.parse(env['HTTP_LINK'])
          context = begin
            links.find_link(['rel', JSON_LD_NS + "context"]).href
          rescue StandardError
            nil
          end
          frame = begin
            links.find_link(['rel', JSON_LD_NS + "frame"]).href
          rescue StandardError
            nil
          end

          if profile.include?(JSON_LD_NS + "framed") && frame.nil?
            return not_acceptable("framed profile without a frame")
          end

          # accept? already determined that there are appropriate contexts
          # If profile also includes a URI which is not a namespace, use it for compaction.
          context ||= Writer.default_context if profile.include?(JSON_LD_NS + "compacted")

          result = if profile.include?(JSON_LD_NS + "flattened")
            API.flatten(body, context)
          elsif profile.include?(JSON_LD_NS + "framed")
            API.frame(body, frame)
          elsif context
            API.compact(body, context)
          elsif profile.include?(JSON_LD_NS + "expanded")
            API.expand(body)
          else
            body
          end

          headers = headers.merge(VARY).merge('Content-Type' => ct)
          [status, headers, [result.to_json]]
        end
      rescue StandardError
        http_error(500, $ERROR_INFO.message)
      end

      protected

      ##
      # Parses an HTTP `Accept` header, returning an array of MIME content
      # types ordered by the precedence rules defined in HTTP/1.1 §14.1.
      #
      # @param  [String, #to_s] header
      # @return [Array<String>]
      # @see    http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.1
      def parse_accept_header(header)
        entries = header.to_s.split(',')
        entries = entries
          .map { |e| accept_entry(e) }
          .sort_by(&:last)
          .map(&:first)
        entries.map { |e| find_content_type_for_media_range(e) }.compact
      end

      # Returns an array of quality, number of '*' in content-type, and number of non-'q' parameters
      def accept_entry(entry)
        type, *options = entry.split(';').map(&:strip)
        quality = 0 # we sort smallest first
        options.delete_if { |e| quality = 1 - e[2..].to_f if e.start_with? 'q=' }
        [options.unshift(type).join(';'), [quality, type.count('*'), 1 - options.size]]
      end

      ##
      # Returns a content type appropriate for the given `media_range`,
      # returns `nil` if `media_range` contains a wildcard subtype
      # that is not mapped.
      #
      # @param  [String, #to_s] media_range
      # @return [String, nil]
      def find_content_type_for_media_range(media_range)
        media_range = media_range.sub('*/*', 'application/ld+json') if media_range.to_s.start_with?('*/*')
        if media_range.to_s.start_with?('application/*')
          media_range = media_range.sub('application/*',
            'application/ld+json')
        end
        if media_range.to_s.start_with?('application/json')
          media_range = media_range.sub('application/json',
            'application/ld+json')
        end

        media_range.start_with?('application/ld+json') ? media_range : nil
      end

      ##
      # Outputs an HTTP `406 Not Acceptable` response.
      #
      # @param  [String, #to_s] message
      # @return [Array(Integer, Hash, #each)]
      def not_acceptable(message = nil)
        http_error(406, message, VARY)
      end

      ##
      # Outputs an HTTP `4xx` or `5xx` response.
      #
      # @param  [Integer, #to_i]         code
      # @param  [String, #to_s]          message
      # @param  [Hash{String => String}] headers
      # @return [Array(Integer, Hash, #each)]
      def http_error(code, message = nil, headers = {})
        message = [code, Rack::Utils::HTTP_STATUS_CODES[code]].join(' ') +
                  (message.nil? ? "\n" : " (#{message})\n")
        [code, { 'Content-Type' => "text/plain" }.merge(headers), [message]]
      end
    end
  end
end