summaryrefslogtreecommitdiff
path: root/lib/prism/parse_result/comments.rb
blob: f8f74d25032912c781d863b537986f19bfc1d7fd (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# frozen_string_literal: true

module Prism
  class ParseResult
    # When we've parsed the source, we have both the syntax tree and the list of
    # comments that we found in the source. This class is responsible for
    # walking the tree and finding the nearest location to attach each comment.
    #
    # It does this by first finding the nearest locations to each comment.
    # Locations can either come from nodes directly or from location fields on
    # nodes. For example, a `ClassNode` has an overall location encompassing the
    # entire class, but it also has a location for the `class` keyword.
    #
    # Once the nearest locations are found, it determines which one to attach
    # to. If it's a trailing comment (a comment on the same line as other source
    # code), it will favor attaching to the nearest location that occurs before
    # the comment. Otherwise it will favor attaching to the nearest location
    # that is after the comment.
    class Comments
      # A target for attaching comments that is based on a specific node's
      # location.
      class NodeTarget # :nodoc:
        attr_reader :node

        def initialize(node)
          @node = node
        end

        def start_offset
          node.start_offset
        end

        def end_offset
          node.end_offset
        end

        def encloses?(comment)
          start_offset <= comment.location.start_offset &&
            comment.location.end_offset <= end_offset
        end

        def leading_comment(comment)
          node.location.leading_comment(comment)
        end

        def trailing_comment(comment)
          node.location.trailing_comment(comment)
        end
      end

      # A target for attaching comments that is based on a location field on a
      # node. For example, the `end` token of a ClassNode.
      class LocationTarget # :nodoc:
        attr_reader :location

        def initialize(location)
          @location = location
        end

        def start_offset
          location.start_offset
        end

        def end_offset
          location.end_offset
        end

        def encloses?(comment)
          false
        end

        def leading_comment(comment)
          location.leading_comment(comment)
        end

        def trailing_comment(comment)
          location.trailing_comment(comment)
        end
      end

      # The parse result that we are attaching comments to.
      attr_reader :parse_result

      # Create a new Comments object that will attach comments to the given
      # parse result.
      def initialize(parse_result)
        @parse_result = parse_result
      end

      # Attach the comments to their respective locations in the tree by
      # mutating the parse result.
      def attach!
        parse_result.comments.each do |comment|
          preceding, enclosing, following = nearest_targets(parse_result.value, comment)

          if comment.trailing?
            if preceding
              preceding.trailing_comment(comment)
            else
              (following || enclosing || NodeTarget.new(parse_result.value)).leading_comment(comment)
            end
          else
            # If a comment exists on its own line, prefer a leading comment.
            if following
              following.leading_comment(comment)
            elsif preceding
              preceding.trailing_comment(comment)
            else
              (enclosing || NodeTarget.new(parse_result.value)).leading_comment(comment)
            end
          end
        end
      end

      private

      # Responsible for finding the nearest targets to the given comment within
      # the context of the given encapsulating node.
      def nearest_targets(node, comment)
        comment_start = comment.location.start_offset
        comment_end = comment.location.end_offset

        targets = [] #: Array[_Target]
        node.comment_targets.map do |value|
          case value
          when StatementsNode
            targets.concat(value.body.map { |node| NodeTarget.new(node) })
          when Node
            targets << NodeTarget.new(value)
          when Location
            targets << LocationTarget.new(value)
          end
        end

        targets.sort_by!(&:start_offset)
        preceding = nil #: _Target?
        following = nil #: _Target?

        left = 0
        right = targets.length

        # This is a custom binary search that finds the nearest nodes to the
        # given comment. When it finds a node that completely encapsulates the
        # comment, it recurses downward into the tree.
        while left < right
          middle = (left + right) / 2
          target = targets[middle]

          target_start = target.start_offset
          target_end = target.end_offset

          if target.encloses?(comment)
            # @type var target: NodeTarget
            # The comment is completely contained by this target. Abandon the
            # binary search at this level.
            return nearest_targets(target.node, comment)
          end

          if target_end <= comment_start
            # This target falls completely before the comment. Because we will
            # never consider this target or any targets before it again, this
            # target must be the closest preceding target we have encountered so
            # far.
            preceding = target
            left = middle + 1
            next
          end

          if comment_end <= target_start
            # This target falls completely after the comment. Because we will
            # never consider this target or any targets after it again, this
            # target must be the closest following target we have encountered so
            # far.
            following = target
            right = middle
            next
          end

          # This should only happen if there is a bug in this parser.
          raise "Comment location overlaps with a target location"
        end

        [preceding, NodeTarget.new(node), following]
      end
    end

    private_constant :Comments

    # Attach the list of comments to their respective locations in the tree.
    def attach_comments!
      Comments.new(self).attach! # steep:ignore
    end
  end
end