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