lib/haml/attribute_parser.rb



# frozen_string_literal: true

begin
  require 'ripper'
rescue LoadError
end
require 'temple/static_analyzer'

module Haml
  # Haml::AttriubuteParser parses Hash literal to { String (key name) => String (value literal) }.
  module AttributeParser
    class UnexpectedTokenError < StandardError; end
    class UnexpectedKeyError < StandardError; end

    # Indices in Ripper tokens
    TYPE = 1
    TEXT = 2

    IGNORED_TYPES = %i[on_sp on_ignored_nl]

    class << self
      # @return [Boolean] - return true if AttributeParser.parse can be used.
      def available?
        defined?(Ripper) && Temple::StaticAnalyzer.available?
      end

      # @param  [String] exp - Old attributes literal or Hash literal generated from new attributes.
      # @return [Hash<String, String>,nil] - Return parsed attribute Hash whose values are Ruby literals, or return nil if argument is not a single Hash literal.
      def parse(exp)
        return nil unless hash_literal?(exp)

        hash = {}
        each_attribute(exp) do |key, value|
          hash[key] = value
        end
        hash
      rescue UnexpectedTokenError, UnexpectedKeyError
        nil
      end

      private

      # @param  [String] exp - Ruby expression
      # @return [Boolean] - Return true if exp is a single Hash literal
      def hash_literal?(exp)
        return false if Temple::StaticAnalyzer.syntax_error?(exp)
        sym, body = Ripper.sexp(exp)
        sym == :program && body.is_a?(Array) && body.size == 1 && body[0] && body[0][0] == :hash
      end

      # @param [Array] tokens - Ripper tokens. Scanned tokens will be destructively removed from this argument.
      # @return [String] - attribute name in String
      def shift_key!(tokens)
        while !tokens.empty? && IGNORED_TYPES.include?(tokens.first[TYPE])
          tokens.shift # ignore spaces
        end

        _, type, first_text = tokens.shift
        case type
        when :on_label # `key:`
          first_text.tr(':', '')
        when :on_symbeg # `:key =>`, `:'key' =>` or `:"key" =>`
          key = tokens.shift[TEXT]
          if first_text != ':' # `:'key'` or `:"key"`
            expect_string_end!(tokens.shift)
          end
          shift_hash_rocket!(tokens)
          key
        when :on_tstring_beg # `"key":`, `'key':` or `"key" =>`
          key = tokens.shift[TEXT]
          next_token = tokens.shift
          if next_token[TYPE] != :on_label_end # on_label_end is `":` or `':`, so `"key" =>`
            expect_string_end!(next_token)
            shift_hash_rocket!(tokens)
          end
          key
        else
          raise UnexpectedKeyError.new("unexpected token is given!: #{first_text} (#{type})")
        end
      end

      # @param [Array] token - Ripper token
      def expect_string_end!(token)
        if token[TYPE] != :on_tstring_end
          raise UnexpectedTokenError
        end
      end

      # @param [Array] tokens - Ripper tokens
      def shift_hash_rocket!(tokens)
        until tokens.empty?
          _, type, str = tokens.shift
          break if type == :on_op && str == '=>'
        end
      end

      # @param [String] hash_literal
      # @param [Proc] block - that takes [String, String] as arguments
      def each_attribute(hash_literal, &block)
        all_tokens = Ripper.lex(hash_literal.strip)
        all_tokens = all_tokens[1...-1] || [] # strip tokens for brackets

        each_balanced_tokens(all_tokens) do |tokens|
          key   = shift_key!(tokens)
          value = tokens.map {|t| t[2] }.join.strip
          block.call(key, value)
        end
      end

      # @param [Array] tokens - Ripper tokens
      # @param [Proc] block - that takes balanced Ripper tokens as arguments
      def each_balanced_tokens(tokens, &block)
        attr_tokens = []
        open_tokens = Hash.new { |h, k| h[k] = 0 }

        tokens.each do |token|
          case token[TYPE]
          when :on_comma
            if open_tokens.values.all?(&:zero?)
              block.call(attr_tokens)
              attr_tokens = []
              next
            end
          when :on_lbracket
            open_tokens[:array] += 1
          when :on_rbracket
            open_tokens[:array] -= 1
          when :on_lbrace
            open_tokens[:block] += 1
          when :on_rbrace
            open_tokens[:block] -= 1
          when :on_lparen
            open_tokens[:paren] += 1
          when :on_rparen
            open_tokens[:paren] -= 1
          when :on_embexpr_beg
            open_tokens[:embexpr] += 1
          when :on_embexpr_end
            open_tokens[:embexpr] -= 1
          when *IGNORED_TYPES
            next if attr_tokens.empty?
          end

          attr_tokens << token
        end
        block.call(attr_tokens) unless attr_tokens.empty?
      end
    end
  end
end