lib/parser/source/comment/associator.rb



module Parser
  module Source

    ##
    # A processor which associates AST nodes with comments based on their
    # location in source code. It may be used, for example, to implement
    # rdoc-style processing.
    #
    # @example
    #   require 'parser/current'
    #
    #   ast, comments = Parser::CurrentRuby.parse_with_comments(<<-CODE)
    #   # Class stuff
    #   class Foo
    #     # Attr stuff
    #     # @see bar
    #     attr_accessor :foo
    #   end
    #   CODE
    #
    #   p Parser::Source::Comment.associate(ast, comments)
    #   # => {
    #   #   (class (const nil :Foo) ...) =>
    #   #     [#<Parser::Source::Comment (string):1:1 "# Class stuff">],
    #   #   (send nil :attr_accessor (sym :foo)) =>
    #   #     [#<Parser::Source::Comment (string):3:3 "# Attr stuff">,
    #   #      #<Parser::Source::Comment (string):4:3 "# @see bar">]
    #   # }
    #
    # @see {associate}
    #
    # @!attribute skip_directives
    #  Skip file processing directives disguised as comments.
    #  Namely:
    #
    #    * Shebang line,
    #    * Magic encoding comment.
    #
    #  @return [Boolean]
    #
    # @api public
    #
    class Comment::Associator
      attr_accessor :skip_directives

      ##
      # @param [Parser::AST::Node] ast
      # @param [Array(Parser::Source::Comment)] comments
      def initialize(ast, comments)
        @ast         = ast
        @comments    = comments

        @skip_directives = true
      end

      ##
      # Compute a mapping between AST nodes and comments.
      #
      # A comment belongs to a certain node if it begins after end
      # of the previous node (if one exists) and ends before beginning of
      # the current node.
      #
      # This rule is unambiguous and produces the result
      # one could reasonably expect; for example, this code
      #
      #     # foo
      #     hoge # bar
      #       + fuga
      #
      # will result in the following association:
      #
      #     {
      #       (send (lvar :hoge) :+ (lvar :fuga)) =>
      #         [#<Parser::Source::Comment (string):2:1 "# foo">],
      #       (lvar :fuga) =>
      #         [#<Parser::Source::Comment (string):3:8 "# bar">]
      #     }
      #
      # Note that {associate} produces unexpected result for nodes which are
      # equal but have distinct locations; comments for these nodes are merged.
      #
      # @return [Hash(Parser::AST::Node, Array(Parser::Source::Comment))]
      # @deprecated Use {associate_locations}.
      #
      def associate
        @map_using_locations = false
        do_associate
      end

      ##
      # Same as {associate}, but uses `node.loc` instead of `node` as
      # the hash key, thus producing an unambiguous result even in presence
      # of equal nodes.
      #
      # @return [Hash(Parser::Source::Map, Array(Parser::Source::Comment))]
      #
      def associate_locations
        @map_using_locations = true
        do_associate
      end

      private

      def do_associate
        @mapping     = Hash.new { |h, k| h[k] = [] }
        @comment_num = -1
        advance_comment

        advance_through_directives if @skip_directives

        @prev_node = nil
        visit(@ast)

        @mapping
      end

      def visit(node)
        process_node(node)

        if node.children.length > 0
          node.children.each do |child|
            next unless child.is_a?(AST::Node) && child.loc && child.loc.expression
            visit(child)
          end
          process_trailing_comments(node)
          @prev_node = node
        end
      end

      def process_node(node)
        return unless node.type != :begin
        while current_comment_between?(@prev_node, node)
          associate_and_advance_comment(@prev_node, node)
        end
        @prev_node = node
      end

      def process_trailing_comments(parent)
        while current_comment_decorates?(@prev_node)
          associate_and_advance_comment(@prev_node, nil)
        end
        while current_comment_before_end?(parent)
          associate_and_advance_comment(@prev_node, nil)
        end
      end

      def advance_comment
        @comment_num += 1
        @current_comment = @comments[@comment_num]
      end

      def current_comment_between?(prev_node, next_node)
        return false if !@current_comment
        comment_loc = @current_comment.location.expression

        if next_node
          next_loc = next_node.location.expression
          return false if comment_loc.end_pos > next_loc.begin_pos
        end
        if prev_node
          prev_loc = prev_node.location.expression
          return false if comment_loc.begin_pos < prev_loc.begin_pos
        end
        true
      end

      def current_comment_decorates?(prev_node)
        return false if !@current_comment
        @current_comment.location.line == prev_node.location.line
      end

      def current_comment_before_end?(parent)
        return false if !@current_comment
        comment_loc = @current_comment.location.expression
        parent_loc = parent.location.expression
        comment_loc.end_pos <= parent_loc.end_pos
      end

      def associate_and_advance_comment(prev_node, node)
        if prev_node && node
          owner_node = (@current_comment.location.line == prev_node.location.line) ? prev_node : node
        else
          owner_node = prev_node ? prev_node : node
        end
        key = @map_using_locations ? owner_node.location : owner_node
        @mapping[key] << @current_comment
        advance_comment
      end

      def advance_through_directives
        # Skip shebang.
        if @current_comment && @current_comment.text =~ /^#!/
          advance_comment
        end

        # Skip encoding line.
        if @current_comment && @current_comment.text =~ Buffer::ENCODING_RE
          advance_comment
        end
      end
    end

  end
end