# frozen_string_literal: false module TidyJson ## # A purpose-built JSON formatter. # # @api private class Formatter # @return [Hash] the JSON format options specified by this +Formatter+ # instance. attr_reader :format ## # Returns a new instance of +Formatter+. # # @param opts [Hash] Formatting options. # @option opts [[2,4,6,8,10,12]] :indent (2) The number of spaces to indent # each object member. # @option opts [[1..8]] :space_before (0) The number of spaces to put after # property names. # @option opts [[1..8]] :space (1) The number of spaces to put before # property values. # @option opts [String] :object_nl ("\n") A string of whitespace to delimit # object members. # @option opts [String] :array_nl ("\n") A string of whitespace to delimit # array elements. # @option opts [Numeric] :max_nesting (100) The maximum level of data # structure nesting in the generated JSON. Disable depth checking by # passing +max_nesting: 0+. # @option opts [Boolean] :escape_slash (false) Whether or not a forward # slash (/) should be escaped. # @option opts [Boolean] :ascii_only (false) Whether or not only ASCII # characters should be generated. # @option opts [Boolean] :allow_nan (false) Whether or not to allow +NaN+, # +Infinity+ and +-Infinity+. If +false+, an exception is thrown if one # of these values is encountered. # @option opts [Boolean] :sort (false) Whether or not object members should # be sorted by property name. # @see https://github.com/flori/json/blob/b8c1c640cd375f2e2ccca1b18bf943f80ad04816/lib/json/pure/generator.rb#L111 JSON::Pure::Generator def initialize(opts = {}) # The number of times to reduce the left indent of a nested array's # opening bracket @left_bracket_offset = 0 # True if printing a nested array @need_offset = false valid_indent = (2..12).step(2).include?(opts[:indent]) valid_space_before = (1..8).include?(opts[:space_before]) valid_space_after = (1..8).include?(opts[:space]) # don't test for the more explicit :integer? method because it's defined # for floating point numbers also valid_depth = opts[:max_nesting] >= 0 \ if opts[:max_nesting].respond_to?(:times) valid_newline = ->(str) { str.respond_to?(:strip) && str.strip.empty? } @format = { indent: "\s" * (valid_indent ? opts[:indent] : 2), space_before: "\s" * (valid_space_before ? opts[:space_before] : 0), space: "\s" * (valid_space_after ? opts[:space] : 1), object_nl: (valid_newline.call(opts[:object_nl]) ? opts[:object_nl] : "\n"), array_nl: (valid_newline.call(opts[:array_nl]) ? opts[:array_nl] : "\n"), max_nesting: valid_depth ? opts[:max_nesting] : 100, escape_slash: opts[:escape_slash] || false, ascii_only: opts[:ascii_only] || false, allow_nan: opts[:allow_nan] || false, sorted: opts[:sort] || false } end # ~Formatter#initialize ## # Returns the given +node+ as pretty-printed JSON. # # @param node [#to_s] A visible attribute of +obj+. # @param obj [{Object => #to_s}, <#to_s>] The enumerable object # containing +node+. # @return [String] A formatted string representation of +node+. def format_node(node, obj) str = '' indent = @format[:indent] is_last = (obj.length <= 1) || (obj.length > 1 && (obj.instance_of?(Array) && !(node === obj.first) && (obj.size.pred == obj.rindex(node)))) if node.instance_of?(Array) str << '[' str << "\n" unless node.empty? # format array elements node.each do |elem| if elem.instance_of?(Hash) str << "#{indent * 2}{" str << "\n" unless elem.empty? elem.each_with_index do |inner_h, h_idx| str << "#{indent * 3}\"#{inner_h.first}\": " str << node_to_str(inner_h.last, 4) str << ',' unless h_idx == elem.to_a.length.pred str << "\n" end str << (indent * 2).to_s unless elem.empty? str << '}' # element a scalar, or a nested array else is_nested_array = elem.instance_of?(Array) && elem.any? { |e| e.instance_of?(Array) } if is_nested_array @left_bracket_offset = \ elem.take_while { |e| e.instance_of?(Array) }.size end str << (indent * 2) << node_to_str(elem) end str << ",\n" unless node.index(elem) == node.length.pred end str << "\n#{indent}" unless node.empty? str << ']' str << ",\n" unless is_last elsif node.instance_of?(Hash) str << '{' str << "\n" unless node.empty? # format elements as key-value pairs node.each_with_index do |h, idx| # format values which are hashes themselves if h.last.instance_of?(Hash) key = if h.first.eql? '' "#{indent * 2}\"<##{h.last.class.name.downcase}>\": " else "#{indent * 2}\"#{h.first}\": " end str << key << '{' str << "\n" unless h.last.empty? h.last.each_with_index do |inner_h, inner_h_idx| str << "#{indent * 3}\"#{inner_h.first}\": " str << node_to_str(inner_h.last, 4) str << ",\n" unless inner_h_idx == h.last.to_a.length.pred end str << "\n#{indent * 2}" unless h.last.empty? str << '}' # format scalar values else str << "#{indent * 2}\"#{h.first}\": " << node_to_str(h.last) end str << ",\n" unless idx == node.to_a.length.pred end str << "\n#{indent}" unless node.empty? str << '}' str << ',' unless is_last str << "\n" # scalars else str << node_to_str(node) str << ',' unless is_last str << "\n" end trim str.gsub(/(#{indent})+[\n\r]+/, '') .gsub(/\}\,+/, '},') .gsub(/\]\,+/, '],') end # ~Formatter#format_node ## # Returns a JSON-appropriate string representation of +node+. # # @param node [#to_s] A visible attribute of a Ruby object. # @param tabs [Integer] Tab width at which to start printing this node. # @return [String] A formatted string representation of +node+. def node_to_str(node, tabs = 0) graft = '' tabs += 2 if tabs.zero? if @need_offset tabs -= 1 @left_bracket_offset -= 1 end indent = @format[:indent] * (tabs / 2) if node.nil? then graft << 'null' elsif node.instance_of?(Hash) format_node(node, node).scan(/.*$/) do |n| graft << "\n" << indent << n end elsif node.instance_of?(Array) @need_offset = @left_bracket_offset.positive? format_node(node, {}).scan(/.*$/) do |n| graft << "\n" << indent << n end elsif !node.instance_of?(String) then graft << node.to_s else graft << "\"#{node.gsub(/\"/, '\\"')}\"" end graft.strip end # ~Formatter#node_to_str ## # Removes any trailing comma from serialized object members. # # @param node [String] A serialized object member. # @return [String] A copy of +node+ without a trailing comma. def trim(node) if (extra_comma = /(?,\s*[\]\}]\s*)$/.match(node)) node.sub(extra_comma[:trail], extra_comma[:trail] .slice(1, node.length.pred) .sub(/^\s/, "\n")) else node end end # ~Formatter#trim end private_constant :Formatter end