gitlabhq/gitlabhq

View on GitHub
lib/gitlab/diff/lines_unfolder.rb

Summary

Maintainability
B
5 hrs
Test Coverage
# 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

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

        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