lib/semverify/semver.rb



# frozen_string_literal: true

require 'semverify/regexp'

module Semverify
  # Parse and compare semver version strings
  #
  # This class will parse a semver version string that complies to Semantic
  # Versioning 2.0.0.
  #
  # Two Semver objects can be compared using the spaceship operator (<=>)
  # according to the rules of Semantic Versioning 2.0.0.
  #
  # @example Basic version parsing
  #   semver = Semverify::Semver.new('1.2.3')
  #   semver.major # => '1'
  #   semver.minor # => '2'
  #   semver.patch # => '3'
  #
  # @example Parsing a version with a pre-release identifier
  #   semver = Semverify::Semver.new('1.2.3-alpha.1')
  #   semver.pre_release # => 'alpha.1'
  #   semver.pre_release_identifiers # => ['alpha', '1']
  #
  # @example A version with build metadata
  #   semver = Semverify::Semver.new('1.2.3+build.1')
  #   semver.build_metadata # => 'build.1'
  #
  # @example Comparing versions
  #   semver1 = Semverify::Semver.new('1.2.3')
  #   semver2 = Semverify::Semver.new('1.2.4')
  #   semver1 <=> semver2 # => true
  #
  # See the Semantic Versioning 2.0.0 specification for more details.
  #
  # @see https://semver.org/spec/v2.0.0.html Semantic Versioning 2.0.0
  #
  # @api public
  #
  class Semver
    include Comparable

    # Create a new Semver object
    #
    # @example
    #   version = Semverify::Semver.new('1.2.3-alpha.1')
    #
    # @param version [String] The version string to parse
    #
    # @raise [Semverify::Error] version is not a string or not a valid semver version
    #
    def initialize(version)
      assert_version_must_be_a_string(version)
      @version = version
      parse
      assert_valid_version
    end

    # @!attribute version [r]
    #
    # The complete version string
    #
    # @example
    #   semver = Semverify::Semver.new('1.2.3-alpha.1+build.001')
    #   semver.version #=> '1.2.3-alpha.1+build.001'
    #
    # @return [String]
    #
    # @api public
    #
    attr_reader :version

    # @attribute major [r]
    #
    # The major part of the version
    #
    # @example
    #   semver = Semverify::Semver.new('1.2.3-alpha.1+build.001')
    #   semver.major #=> '1'
    #
    # @return [String]
    #
    # @api public
    #
    attr_reader :major

    # @attribute minor [r]
    #
    # The minor part of the version
    #
    # @example
    #   semver = Semverify::Semver.new('1.2.3-alpha.1+build.001')
    #   semver.minor #=> '2'
    #
    # @return [String]
    #
    # @api public
    #
    attr_reader :minor

    # @attribute patch [r]
    #
    # The patch part of the version
    #
    # @example
    #   semver = Semverify::Semver.new('1.2.3-alpha.1+build.001')
    #   semver.patch #=> '3'
    #
    # @return [String]
    #
    # @api public
    #
    attr_reader :patch

    # @attribute pre_release [r]
    #
    # The pre_release part of the version
    #
    # Will be an empty string if the version has no pre_release part.
    #
    # @example
    #   semver = Semverify::Semver.new('1.2.3-alpha.1+build.001')
    #   semver.pre_release #=> 'alpha.1'
    #
    # @example When the version has no pre_release part
    #   semver = Semverify::Semver.new('1.2.3')
    #   semver.pre_release #=> ''
    #
    # @return [String]
    #
    # @api public
    #
    attr_reader :pre_release

    # @attribute pre_release_identifiers [r]
    #
    # The pre_release identifiers of the version
    #
    # @example
    #   semver = Semverify::Semver.new('1.2.3-alpha.1+build.001')
    #   semver.pre_release_identifiers #=> ['alpha', '1']
    #
    # @example When the version has no pre_release part
    #   semver = Semverify::Semver.new('1.2.3')
    #   semver.pre_release_identifiers #=> []
    #
    # @return [Array<String>]
    #
    # @api public
    #
    attr_reader :pre_release_identifiers

    # @attribute build_metadata [r]
    #
    # The build_metadata part of the version
    #
    # Will be an empty string if the version has no build_metadata part.
    #
    # @example
    #   semver = Semverify::Semver.new('1.2.3-alpha.1+build.001')
    #   semver.build_metadata #=> 'build.001'
    #
    # @example When the version has no build_metadata part
    #   semver = Semverify::Semver.new('1.2.3')
    #   semver.build_metadata #=> ''
    #
    # @return [String]
    #
    # @api public
    #
    attr_reader :build_metadata

    # Compare two Semver objects
    #
    # See the [Precedence Rules](https://semver.org/spec/v2.0.0.html#spec-item-11)
    # in the Semantic Versioning 2.0.0 Specification for more details.
    #
    # @example
    #   semver1 = Semverify::Semver.new('1.2.3')
    #   semver2 = Semverify::Semver.new('1.2.4')
    #   semver1 <=> semver2 # => -1
    #   semver2 <=> semver1 # => 1
    #
    # @example A Semver is equal to itself
    #   semver1 = Semverify::Semver.new('1.2.3')
    #   semver1 <=> semver1 # => 0
    #
    # @example A pre-release version is always older than a normal version
    #   semver1 = Semverify::Semver.new('1.2.3-alpha.1')
    #   semver2 = Semverify::Semver.new('1.2.3')
    #   semver1 <=> semver2 # => -1
    #
    # @example Pre-releases are compared by the parts of the pre-release version
    #   semver1 = Semverify::Semver.new('1.2.3-alpha.1')
    #   semver2 = Semverify::Semver.new('1.2.3-alpha.2')
    #   semver1 <=> semver2 # => -1
    #
    # @example Build metadata is ignored when comparing versions
    #   semver1 = Semverify::Semver.new('1.2.3+build.100')
    #   semver2 = Semverify::Semver.new('1.2.3+build.101')
    #   semver1 <=> semver2 # => 0
    #
    # @param other [Semver] the other Semver to compare to
    #
    # @return [Integer] -1 if self < other, 0 if self == other, or 1 if self > other
    #
    # @raise [Semverify::Error] other is not a semver
    #
    def <=>(other)
      assert_other_is_a_semver(other)

      result = compare_core_parts(other)

      return result unless result.zero? && pre_release != other.pre_release
      return 1 if pre_release.empty?
      return -1 if other.pre_release.empty?

      compare_pre_release_part(other)
    end

    # Determine if the version string is a valid semver
    #
    # Override this method in a subclass to provide extra or custom validation.
    #
    # @example
    #   Semverify::Semver.new('1.2.3').valid? # => true
    #   Semverify::Semver.new('1.2.3-alpha.1+build.001').valid? # => true
    #   Semverify::Semver.new('bogus').valid? # => raises Semverify::Error
    #
    # @return [Boolean] true if the version string is a valid semver
    #
    def valid?
      # If major is set, then so is everything else
      !major.nil?
    end

    # Two versions are equal if their version strings are equal
    #
    # @example
    #   Semverify::Semver.new('1.2.3') == '1.2.3' # => true
    #
    # @param other [Semver] the other Semver to compare to
    #
    # @return [Boolean] true if the version strings are equal
    #
    def ==(other)
      version == other.to_s
    end

    # The string representation of a Semver is its version string
    #
    # @example
    #   Semverify::Semver.new('1.2.3').to_s # => '1.2.3'
    #
    # @return [String] the version string
    #
    def to_s
      version
    end

    private

    # Parse @version into its parts
    # @return [void]
    # @api private
    def parse
      return unless (match_data = version.match(Semverify::SEMVER_REGEXP_FULL))

      core_parts(match_data)
      pre_release_part(match_data)
      build_metadata_part(match_data)
    end

    # Compare the major, minor, and patch parts of this Semver to other
    # @param other [Semver] the other Semver to compare to
    # @return [Integer] -1 if self < other, 0 if self == other, or 1 if self > other
    # @api private
    def compare_core_parts(other)
      identifiers = [major.to_i, minor.to_i, patch.to_i]
      other_identifiers = [other.major.to_i, other.minor.to_i, other.patch.to_i]

      identifiers <=> other_identifiers
    end

    # Compare two pre-release identifiers
    #
    # Implements the rules for precedence for comparing two pre-release identifiers
    # from the Semantic Versioning 2.0.0 Specification.
    #
    # @param identifier [String, Integer] the identifier to compare
    # @param other_identifier [String, Integer] the other identifier to compare
    # @return [Integer] -1, 0, or 1
    # @api private
    def compare_identifiers(identifier, other_identifier)
      return 1 if other_identifier.nil?
      return -1 if identifier.is_a?(Integer) && other_identifier.is_a?(String)
      return 1 if other_identifier.is_a?(Integer) && identifier.is_a?(String)

      identifier <=> other_identifier
    end

    # Compare two pre-release version parts
    #
    # Implements the rules for precedence for comparing the pre-release part of
    # one version with the pre-release part of another version from the Semantic
    # Versioning 2.0.0 Specification.
    #
    # @param other [Semver] the other Semver to compare to
    # @return [Integer] -1, 0, or 1
    # @api private
    def compare_pre_release_part(other)
      pre_release_identifiers.zip(other.pre_release_identifiers).each do |field, other_field|
        result = compare_identifiers(field, other_field)
        return result unless result.zero?
      end
      pre_release_identifiers.size < other.pre_release_identifiers.size ? -1 : 0
    end

    # Raise a error if other is not a valid Semver
    # @param other [Semver] the other to check
    # @return [void]
    # @raise [Semverify::Error] if other is not a valid Semver
    # @api private
    def assert_other_is_a_semver(other)
      raise Semverify::Error, 'other must be a Semver' unless other.is_a?(Semver)
    end

    # Raise a error if the given version is not a string
    # @param version [Semver] the version to check
    # @return [void]
    # @raise [Semverify::Error] if the given version is not a string
    # @api private
    def assert_version_must_be_a_string(version)
      raise Semverify::Error, 'Version must be a string' unless version.is_a?(String)
    end

    # Raise a error if this version object is not a valid Semver
    # @return [void]
    # @raise [Semverify::Error] if other is not a valid Semver
    # @api private
    def assert_valid_version
      raise Semverify::Error, "Not a valid version string: #{version}" unless valid?
    end

    # Set the major, minor, and patch parts of this Semver
    # @param match_data [MatchData] the match data from the version string
    # @return [void]
    # @api private
    def core_parts(match_data)
      @major = match_data[:major]
      @minor = match_data[:minor]
      @patch = match_data[:patch]
    end

    # Set the pre-release of this Semver
    # @param match_data [MatchData] the match data from the version string
    # @return [void]
    # @api private
    def pre_release_part(match_data)
      @pre_release = match_data[:pre_release] || ''
      @pre_release_identifiers = @pre_release.split('.').map { |f| f =~ /\A\d+\z/ ? f.to_i : f }
    end

    # Set the build_metadata of this Semver
    # @param match_data [MatchData] the match data from the version string
    # @return [void]
    # @api private
    def build_metadata_part(match_data)
      @build_metadata = match_data[:build_metadata] || ''
    end
  end
end