summaryrefslogtreecommitdiff
path: root/lib/gitlab/diff/lines_unfolder.rb
blob: 9306b7e16a269ee569d488c34d9d8443d6880629 (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
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
# frozen_string_literal: true

# Given a position, calculates which Blob lines should be extracted, treated and
# injected in the current diff file lines in order to present a "unfolded" diff.
module Gitlab
  module Diff
    class LinesUnfolder
      include Gitlab::Utils::StrongMemoize

      UNFOLD_CONTEXT_SIZE = 3

      def initialize(diff_file, position)
        @diff_file = diff_file
        @blob = diff_file.old_blob
        @position = position
        @generate_top_match_line = true
        @generate_bottom_match_line = true

        # These methods update `@generate_top_match_line` and
        # `@generate_bottom_match_line`.
        @from_blob_line = calculate_from_blob_line!
        @to_blob_line = calculate_to_blob_line!
      end

      # Returns merged diff lines with required blob lines with correct
      # positions.
      def unfolded_diff_lines
        strong_memoize(:unfolded_diff_lines) do
          next unless unfold_required?

          merged_diff_with_blob_lines
        end
      end

      # Returns the extracted lines from the old blob which should be merged
      # with the current diff lines.
      def blob_lines
        strong_memoize(:blob_lines) do
          # Blob lines, unlike diffs, doesn't start with an empty space for
          # unchanged line, so the parsing and highlighting step can get fuzzy
          # without the following change.
          line_prefix = ' '
          blob_as_diff_lines = @blob.data.each_line.map { |line| "#{line_prefix}#{line}" }

          lines = Gitlab::Diff::Parser.new.parse(blob_as_diff_lines, diff_file: @diff_file).to_a

          from = from_blob_line - 1
          to = to_blob_line - 1

          lines[from..to]
        end
      end

      def unfold_required?
        strong_memoize(:unfold_required) do
          next false unless @diff_file.text?
          next false unless @position.unchanged?
          next false if @diff_file.new_file? || @diff_file.deleted_file?
          next false unless @position.old_line
          # Invalid position (MR import scenario)
          next false if @position.old_line > @blob.lines.size
          next false if @diff_file.diff_lines.empty?
          next false if @diff_file.line_for_position(@position)
          next false unless unfold_line

          true
        end
      end

      private

      attr_reader :from_blob_line, :to_blob_line

      def merged_diff_with_blob_lines
        lines = @diff_file.diff_lines
        match_line = unfold_line
        insert_index = bottom? ? -1 : match_line.index

        lines -= [match_line] unless bottom?

        lines.insert(insert_index, *blob_lines_with_matches)

        # The inserted blob lines have invalid indexes, so we need
        # to reindex them.
        reindex(lines)

        lines
      end

      # Returns 'unchanged' blob lines with recalculated `old_pos` and
      # `new_pos` and the recalculated new match line (needed if we for instance
      # we unfolded once, but there are still folded lines).
      def blob_lines_with_matches
        old_pos = from_blob_line
        new_pos = from_blob_line + offset

        new_blob_lines = []

        new_blob_lines.push(top_blob_match_line) if top_blob_match_line

        blob_lines.each do |line|
          new_blob_lines << Gitlab::Diff::Line.new(line.text, line.type, nil, old_pos, new_pos,
                                                   parent_file: @diff_file)

          old_pos += 1
          new_pos += 1
        end

        new_blob_lines.push(bottom_blob_match_line) if bottom_blob_match_line

        new_blob_lines
      end

      def reindex(lines)
        lines.each_with_index { |line, i| line.index = i }
      end

      def top_blob_match_line
        strong_memoize(:top_blob_match_line) do
          next unless @generate_top_match_line

          old_pos = from_blob_line
          new_pos = from_blob_line + offset

          build_match_line(old_pos, new_pos)
        end
      end

      def bottom_blob_match_line
        strong_memoize(:bottom_blob_match_line) do
          # The bottom line match addition is already handled on
          # Diff::File#diff_lines_for_serializer
          next if bottom?
          next unless @generate_bottom_match_line

          position = line_after_unfold_position.old_pos

          old_pos = position
          new_pos = position + offset

          build_match_line(old_pos, new_pos)
        end
      end

      def build_match_line(old_pos, new_pos)
        blob_lines_length = blob_lines.length
        old_line_ref = [old_pos, blob_lines_length].join(',')
        new_line_ref = [new_pos, blob_lines_length].join(',')
        new_match_line_str = "@@ -#{old_line_ref}+#{new_line_ref} @@"

        Gitlab::Diff::Line.new(new_match_line_str, 'match', nil, old_pos, new_pos)
      end

      # Returns the first line position that should be extracted
      # from `blob_lines`.
      def calculate_from_blob_line!
        return unless unfold_required?

        from = comment_position - UNFOLD_CONTEXT_SIZE

        # There's no line before the match if it's in the top-most
        # position.
        prev_line_number = line_before_unfold_position&.old_pos || 0

        if from <= prev_line_number + 1
          @generate_top_match_line = false
          from = prev_line_number + 1
        end

        from
      end

      # Returns the last line position that should be extracted
      # from `blob_lines`.
      def calculate_to_blob_line!
        return unless unfold_required?

        to = comment_position + UNFOLD_CONTEXT_SIZE

        return to if bottom?

        next_line_number = line_after_unfold_position.old_pos

        if to >= next_line_number - 1
          @generate_bottom_match_line = false
          to = next_line_number - 1
        end

        to
      end

      def offset
        unfold_line.new_pos - unfold_line.old_pos
      end

      def line_before_unfold_position
        return unless index = unfold_line&.index

        @diff_file.diff_lines[index - 1] if index > 0
      end

      def line_after_unfold_position
        return unless index = unfold_line&.index

        @diff_file.diff_lines[index + 1] if index >= 0
      end

      def bottom?
        strong_memoize(:bottom) do
          @position.old_line > last_line.old_pos
        end
      end

      # Returns the line which needed to be expanded in order to send a comment
      # in `@position`.
      def unfold_line
        strong_memoize(:unfold_line) do
          next last_line if bottom?

          @diff_file.diff_lines.find do |line|
            line.old_pos > comment_position && line.type == 'match'
          end
        end
      end

      def comment_position
        @position.old_line
      end

      def last_line
        @diff_file.diff_lines.last
      end
    end
  end
end