1# frozen_string_literal: true
2
3# Given a position, calculates which Blob lines should be extracted, treated and
4# injected in the current diff file lines in order to present a "unfolded" diff.
5module Gitlab
6  module Diff
7    class LinesUnfolder
8      include Gitlab::Utils::StrongMemoize
9
10      UNFOLD_CONTEXT_SIZE = 3
11
12      def initialize(diff_file, position)
13        @diff_file = diff_file
14        @blob = diff_file.old_blob
15        @position = position
16        @generate_top_match_line = true
17        @generate_bottom_match_line = true
18
19        # These methods update `@generate_top_match_line` and
20        # `@generate_bottom_match_line`.
21        @from_blob_line = calculate_from_blob_line!
22        @to_blob_line = calculate_to_blob_line!
23      end
24
25      # Returns merged diff lines with required blob lines with correct
26      # positions.
27      def unfolded_diff_lines
28        strong_memoize(:unfolded_diff_lines) do
29          next unless unfold_required?
30
31          merged_diff_with_blob_lines
32        end
33      end
34
35      # Returns the extracted lines from the old blob which should be merged
36      # with the current diff lines.
37      def blob_lines
38        strong_memoize(:blob_lines) do
39          # Blob lines, unlike diffs, doesn't start with an empty space for
40          # unchanged line, so the parsing and highlighting step can get fuzzy
41          # without the following change.
42          line_prefix = ' '
43          blob_as_diff_lines = @blob.data.each_line.map { |line| "#{line_prefix}#{line}" }
44
45          lines = Gitlab::Diff::Parser.new.parse(blob_as_diff_lines, diff_file: @diff_file).to_a
46
47          from = from_blob_line - 1
48          to = to_blob_line - 1
49
50          lines[from..to]
51        end
52      end
53
54      def unfold_required?
55        strong_memoize(:unfold_required) do
56          next false unless @diff_file.text?
57          next false unless @position.unfoldable?
58          next false if @diff_file.new_file? || @diff_file.deleted_file?
59          next false unless @position.old_line
60          next false unless @position.old_line.is_a?(Integer)
61          # Invalid position (MR import scenario)
62          next false if @position.old_line > @blob.lines.size
63          next false if @diff_file.diff_lines.empty?
64          next false if @diff_file.line_for_position(@position)
65          next false unless unfold_line
66
67          true
68        end
69      end
70
71      private
72
73      attr_reader :from_blob_line, :to_blob_line
74
75      def merged_diff_with_blob_lines
76        lines = @diff_file.diff_lines
77        match_line = unfold_line
78        insert_index = bottom? ? -1 : match_line.index
79
80        lines -= [match_line] unless bottom?
81
82        lines.insert(insert_index, *blob_lines_with_matches)
83
84        # The inserted blob lines have invalid indexes, so we need
85        # to reindex them.
86        reindex(lines)
87
88        lines
89      end
90
91      # Returns 'unchanged' blob lines with recalculated `old_pos` and
92      # `new_pos` and the recalculated new match line (needed if we for instance
93      # we unfolded once, but there are still folded lines).
94      def blob_lines_with_matches
95        old_pos = from_blob_line
96        new_pos = from_blob_line + offset
97
98        new_blob_lines = []
99
100        new_blob_lines.push(top_blob_match_line) if top_blob_match_line
101
102        blob_lines.each do |line|
103          new_blob_lines << Gitlab::Diff::Line.new(line.text, line.type, nil, old_pos, new_pos,
104                                                   parent_file: @diff_file)
105
106          old_pos += 1
107          new_pos += 1
108        end
109
110        new_blob_lines.push(bottom_blob_match_line) if bottom_blob_match_line
111
112        new_blob_lines
113      end
114
115      def reindex(lines)
116        lines.each_with_index { |line, i| line.index = i }
117      end
118
119      def top_blob_match_line
120        strong_memoize(:top_blob_match_line) do
121          next unless @generate_top_match_line
122
123          old_pos = from_blob_line
124          new_pos = from_blob_line + offset
125
126          build_match_line(old_pos, new_pos)
127        end
128      end
129
130      def bottom_blob_match_line
131        strong_memoize(:bottom_blob_match_line) do
132          # The bottom line match addition is already handled on
133          # Diff::File#diff_lines_for_serializer
134          next if bottom?
135          next unless @generate_bottom_match_line
136
137          position = line_after_unfold_position.old_pos
138
139          old_pos = position
140          new_pos = position + offset
141
142          build_match_line(old_pos, new_pos)
143        end
144      end
145
146      def build_match_line(old_pos, new_pos)
147        blob_lines_length = blob_lines.length
148        old_line_ref = [old_pos, blob_lines_length].join(',')
149        new_line_ref = [new_pos, blob_lines_length].join(',')
150        new_match_line_str = "@@ -#{old_line_ref}+#{new_line_ref} @@"
151
152        Gitlab::Diff::Line.new(new_match_line_str, 'match', nil, old_pos, new_pos)
153      end
154
155      # Returns the first line position that should be extracted
156      # from `blob_lines`.
157      def calculate_from_blob_line!
158        return unless unfold_required?
159
160        from = comment_position - UNFOLD_CONTEXT_SIZE
161
162        prev_line_number =
163          if bottom?
164            last_line.old_pos
165          else
166            # There's no line before the match if it's in the top-most
167            # position.
168            line_before_unfold_position&.old_pos || 0
169          end
170
171        if from <= prev_line_number + 1
172          @generate_top_match_line = false
173          from = prev_line_number + 1
174        end
175
176        from
177      end
178
179      # Returns the last line position that should be extracted
180      # from `blob_lines`.
181      def calculate_to_blob_line!
182        return unless unfold_required?
183
184        to = comment_position + UNFOLD_CONTEXT_SIZE
185
186        return to if bottom?
187
188        next_line_number = line_after_unfold_position.old_pos
189
190        if to >= next_line_number - 1
191          @generate_bottom_match_line = false
192          to = next_line_number - 1
193        end
194
195        to
196      end
197
198      def offset
199        unfold_line.new_pos - unfold_line.old_pos
200      end
201
202      def line_before_unfold_position
203        return unless index = unfold_line&.index
204
205        @diff_file.diff_lines[index - 1] if index > 0
206      end
207
208      def line_after_unfold_position
209        return unless index = unfold_line&.index
210
211        @diff_file.diff_lines[index + 1] if index >= 0
212      end
213
214      def bottom?
215        strong_memoize(:bottom) do
216          @position.old_line > last_line.old_pos
217        end
218      end
219
220      # Returns the line which needed to be expanded in order to send a comment
221      # in `@position`.
222      def unfold_line
223        strong_memoize(:unfold_line) do
224          next last_line if bottom?
225
226          @diff_file.diff_lines.find do |line|
227            line.old_pos > comment_position && line.type == 'match'
228          end
229        end
230      end
231
232      def comment_position
233        @position.old_line
234      end
235
236      def last_line
237        @diff_file.diff_lines.last
238      end
239    end
240  end
241end
242