python/lib/dependabot/python/version.rb
# typed: strict
# frozen_string_literal: true

require "sorbet-runtime"
require "dependabot/version"
require "dependabot/utils"

# See https://packaging.python.org/en/latest/specifications/version-specifiers for spec details.

module Dependabot
  module Python
    class Version < Dependabot::Version
      extend T::Sig

      sig { returns(Integer) }
      attr_reader :epoch

      sig { returns(T::Array[Integer]) }
      attr_reader :release_segment

      sig { returns(T.nilable(T::Array[T.any(String, Integer)])) }
      attr_reader :dev

      sig { returns(T.nilable(T::Array[T.any(String, Integer)])) }
      attr_reader :pre

      sig { returns(T.nilable(T::Array[T.any(String, Integer)])) }
      attr_reader :post

      sig { returns(T.nilable(T::Array[T.any(String, Integer)])) }
      attr_reader :local

      INFINITY = T.let(1000, Integer)
      NEGATIVE_INFINITY = T.let(-INFINITY, Integer)

      # See https://peps.python.org/pep-0440/#appendix-b-parsing-version-strings-with-regular-expressions
      VERSION_PATTERN = /
        v?
        (?:
          (?:(?<epoch>[0-9]+)!)?                          # epoch
          (?<release>[0-9]+(?:\.[0-9]+)*)                 # release
          (?<pre>                                         # prerelease
            [-_\.]?
            (?<pre_l>(a|b|c|rc|alpha|beta|pre|preview))
            [-_\.]?
            (?<pre_n>[0-9]+)?
          )?
          (?<post>                                        # post release
            (?:-(?<post_n1>[0-9]+))
            |
            (?:
                [-_\.]?
                (?<post_l>post|rev|r)
                [-_\.]?
                (?<post_n2>[0-9]+)?
            )
          )?
          (?<dev>                                          # dev release
            [-_\.]?
            (?<dev_l>dev)
            [-_\.]?
            (?<dev_n>[0-9]+)?
          )?
        )
        (?:\+(?<local>[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?    # local version
      /ix

      ANCHORED_VERSION_PATTERN = /\A\s*#{VERSION_PATTERN}\s*\z/

      sig { override.params(version: VersionParameter).returns(T::Boolean) }
      def self.correct?(version)
        return false if version.nil?

        version.to_s.match?(ANCHORED_VERSION_PATTERN)
      end

      sig { override.params(version: VersionParameter).void }
      def initialize(version) # rubocop:disable Metrics/AbcSize
        raise Dependabot::BadRequirementError, "Malformed version string - string is nil" if version.nil?

        @version_string = T.let(version.to_s, String)

        raise Dependabot::BadRequirementError, "Malformed version string - string is empty" if @version_string.empty?

        matches = ANCHORED_VERSION_PATTERN.match(@version_string.downcase)

        unless matches
          raise Dependabot::BadRequirementError,
                "Malformed version string - #{@version_string} does not match regex"
        end

        @epoch = T.let(matches["epoch"].to_i, Integer)
        @release_segment = T.let(matches["release"]&.split(".")&.map(&:to_i) || [], T::Array[Integer])
        @pre = T.let(
          parse_letter_version(matches["pre_l"], matches["pre_n"]),
          T.nilable(T::Array[T.any(String, Integer)])
        )
        @post = T.let(
          parse_letter_version(matches["post_l"], matches["post_n1"] || matches["post_n2"]),
          T.nilable(T::Array[T.any(String, Integer)])
        )
        @dev = T.let(
          parse_letter_version(matches["dev_l"], matches["dev_n"]),
          T.nilable(T::Array[T.any(String, Integer)])
        )
        @local = T.let(parse_local_version(matches["local"]), T.nilable(T::Array[T.any(String, Integer)]))
        super(matches["release"] || "")
      end

      sig { override.params(version: VersionParameter).returns(Dependabot::Python::Version) }
      def self.new(version)
        T.cast(super, Dependabot::Python::Version)
      end

      sig { returns(String) }
      def to_s
        @version_string
      end

      sig { returns(String) }
      def inspect # :nodoc:
        "#<#{self.class} #{@version_string}>"
      end

      sig { returns(T::Boolean) }
      def prerelease?
        !!(pre || dev)
      end

      sig { returns(Dependabot::Python::Version) }
      def release
        Dependabot::Python::Version.new(release_segment.join("."))
      end

      sig { params(other: VersionParameter).returns(Integer) }
      def <=>(other)
        other = Dependabot::Python::Version.new(other.to_s) unless other.is_a?(Dependabot::Python::Version)

        epoch_comparison = epoch <=> other.epoch
        return epoch_comparison unless epoch_comparison.zero?

        release_comparison = release_version_comparison(other)
        return release_comparison unless release_comparison.zero?

        pre_comparison = compare_keys(pre_cmp_key, other.pre_cmp_key)
        return pre_comparison unless pre_comparison.zero?

        post_comparison = compare_keys(post_cmp_key, other.post_cmp_key)
        return post_comparison unless post_comparison.zero?

        dev_comparison = compare_keys(dev_cmp_key, other.dev_cmp_key)
        return dev_comparison unless dev_comparison.zero?

        compare_keys(local_cmp_key, other.local_cmp_key)
      end

      sig do
        params(
          key: T.any(Integer, T::Array[T.any(String, Integer)]),
          other_key: T.any(Integer, T::Array[T.any(String, Integer)])
        ).returns(Integer)
      end
      def compare_keys(key, other_key)
        if key.is_a?(Integer) && other_key.is_a?(Integer)
          key <=> other_key
        elsif key.is_a?(Array) && other_key.is_a?(Array)
          key <=> other_key
        elsif key.is_a?(Integer)
          key == NEGATIVE_INFINITY ? -1 : 1
        elsif other_key.is_a?(Integer)
          other_key == NEGATIVE_INFINITY ? 1 : -1
        end
      end

      sig { returns(T.any(Integer, T::Array[T.any(String, Integer)])) }
      def pre_cmp_key
        if pre.nil? && post.nil? && dev # sort 1.0.dev0 before 1.0a0
          NEGATIVE_INFINITY
        elsif pre.nil?
          INFINITY # versions without a pre-release should sort after those with one.
        else
          T.must(pre)
        end
      end

      sig { returns(T.any(Integer, T::Array[T.any(String, Integer)])) }
      def local_cmp_key
        if local.nil?
          # Versions without a local segment should sort before those with one.
          NEGATIVE_INFINITY
        else
          # According to PEP440.
          # - Alphanumeric segments sort before numeric segments
          # - Alphanumeric segments sort lexicographically
          # - Numeric segments sort numerically
          # - Shorter versions sort before longer versions when the prefixes match exactly
          T.must(local).map do |token|
            if token.is_a?(Integer)
              [token, ""]
            else
              [NEGATIVE_INFINITY, token]
            end
          end.flatten
        end
      end

      sig { returns(T.any(Integer, T::Array[T.any(String, Integer)])) }
      def post_cmp_key
        # Versions without a post segment should sort before those with one.
        return NEGATIVE_INFINITY if post.nil?

        T.must(post)
      end

      sig { returns(T.any(Integer, T::Array[T.any(String, Integer)])) }
      def dev_cmp_key
        # Versions without a dev segment should sort after those with one.
        return INFINITY if dev.nil?

        T.must(dev)
      end

      sig { returns(String) }
      def lowest_prerelease_suffix
        "dev0"
      end

      sig { override.returns(T::Array[String]) }
      def ignored_patch_versions
        parts = release_segment # e.g [1,2,3] if version is 1.2.3-alpha3
        version_parts = parts.fill(0, parts.length...2)
        upper_parts = version_parts.first(1) + [version_parts[1].to_i + 1] + [lowest_prerelease_suffix]
        lower_bound = "> #{self}"
        upper_bound = "< #{upper_parts.join('.')}"

        ["#{lower_bound}, #{upper_bound}"]
      end

      sig { override.returns(T::Array[String]) }
      def ignored_minor_versions
        parts = release_segment # e.g [1,2,3] if version is 1.2.3-alpha3
        version_parts = parts.fill(0, parts.length...2)
        lower_parts = version_parts.first(1) + [version_parts[1].to_i + 1] + [lowest_prerelease_suffix]
        upper_parts = version_parts.first(0) + [version_parts[0].to_i + 1] + [lowest_prerelease_suffix]
        lower_bound = ">= #{lower_parts.join('.')}"
        upper_bound = "< #{upper_parts.join('.')}"

        ["#{lower_bound}, #{upper_bound}"]
      end

      sig { override.returns(T::Array[String]) }
      def ignored_major_versions
        version_parts = release_segment # e.g [1,2,3] if version is 1.2.3-alpha3
        lower_parts = [version_parts[0].to_i + 1] + [lowest_prerelease_suffix] # earliest next major version prerelease
        lower_bound = ">= #{lower_parts.join('.')}"

        [lower_bound]
      end

      private

      sig { params(other: Dependabot::Python::Version).returns(Integer) }
      def release_version_comparison(other)
        tokens, other_tokens = pad_for_comparison(release_segment, other.release_segment)
        tokens <=> other_tokens
      end

      sig do
        params(
          tokens: T::Array[Integer],
          other_tokens: T::Array[Integer]
        ).returns(T::Array[T::Array[Integer]])
      end
      def pad_for_comparison(tokens, other_tokens)
        tokens = tokens.dup
        other_tokens = other_tokens.dup

        longer = [tokens, other_tokens].max_by(&:count)
        shorter = [tokens, other_tokens].min_by(&:count)

        difference = T.must(longer).length - T.must(shorter).length

        difference.times { T.must(shorter) << 0 }

        [tokens, other_tokens]
      end

      sig { params(local: T.nilable(String)).returns(T.nilable(T::Array[T.any(String, Integer)])) }
      def parse_local_version(local)
        return if local.nil?

        # Takes a string like abc.1.twelve and turns it into ["abc", 1, "twelve"]
        local.split(/[\._-]/).map { |s| /^\d+$/.match?(s) ? s.to_i : s }
      end

      sig do
        params(
          letter: T.nilable(String), number: T.nilable(String)
        ).returns(T.nilable(T::Array[T.any(String, Integer)]))
      end
      def parse_letter_version(letter = nil, number = nil)
        return if letter.nil? && number.nil?

        if letter
          # Implicit 0 for cases where prerelease has no numeral
          number ||= 0

          # Normalize alternate spellings
          if letter == "alpha"
            letter = "a"
          elsif letter == "beta"
            letter = "b"
          elsif %w(c pre preview).include? letter
            letter = "rc"
          elsif %w(rev r).include? letter
            letter = "post"
          end

          return letter, number.to_i
        end

        # Number but no letter i.e. implicit post release syntax (e.g. 1.0-1)
        letter = "post"

        [letter, number.to_i]
      end
    end
  end
end

Dependabot::Utils
  .register_version_class("pip", Dependabot::Python::Version)
