require 'time' require 'nrser/refinements/types' using NRSER::Types require 'qb/util/docker_mixin' module QB module Package # An attempt to unify NPM and Gem version schemes to a reasonable extend, # and hopefully cover whatever else the cat may drag in. # # Intended to be immutable for practical purposes. # class Version < NRSER::Meta::Props::Base # Mixins # ===================================================================== include QB::Util::DockerMixin # Constants # ===================================================================== NUMBER_SEGMENT = t.non_neg_int NAME_SEGMENT = t.str MIXED_SEGMENT = t.union NUMBER_SEGMENT, NAME_SEGMENT # Props # ===================================================================== prop :raw, type: t.maybe(t.str), default: nil prop :major, type: NUMBER_SEGMENT prop :minor, type: NUMBER_SEGMENT, default: 0 prop :patch, type: NUMBER_SEGMENT, default: 0 prop :prerelease, type: t.array(MIXED_SEGMENT), default: [] prop :build, type: t.array(MIXED_SEGMENT), default: [] prop :release, type: t.str, source: :@release prop :level, type: t.str, source: :@level prop :is_release, type: t.bool, source: :release? prop :is_prerelease, type: t.bool, source: :prerelease? prop :is_build, type: t.bool, source: :build? prop :is_dev, type: t.bool, source: :dev? prop :is_rc, type: t.bool, source: :rc? prop :has_level, type: t.bool, source: :level? prop :semver, type: t.str, source: :semver prop :docker_tag, type: t.str, source: :docker_tag # Attributes # ===================================================================== attr_reader :release, :level # Class Methods # ===================================================================== # Utilities # --------------------------------------------------------------------- # @return [String] # Time formatted to be stuck in a version segment per Semver spec. # We also strip out '-' to avoid possible parsing weirdness. def self.to_time_segment time time.utc.iso8601.gsub /[^0-9A-Za-z]/, '' end # Instance Builders # --------------------------------------------------------------------- # Create a Version instance from a Gem::Version def self.from_gem_version version # release segments are everything before a string release_segments = version.segments.take_while { |seg| !seg.is_a?(String) } # We don't support > 3 release segments to make life somewhat # reasonable. Yeah, I think I've seen projects do it. We'll cross that # bridge if and when we get to it. if release_segments.length > 3 raise ArgumentError, "We don't handle releases with more than 3 segments " + "(found #{ release_segments.inspect } in #{ version })" end prerelease_segments = version.segments[release_segments.length..-1] new raw: version.to_s, major: release_segments[0] || 0, minor: release_segments[1] || 0, patch: release_segments[2] || 0, prerelease: prerelease_segments, build: [] end def self.from_npm_version version stmt = NRSER.squish <<-END var Semver = require('semver'); console.log( JSON.stringify( Semver(#{ JSON.dump version }) ) ); END parse = JSON.load Cmds.new( "node --eval %s", args: [stmt], chdir: QB::ROOT ).out! new raw: version, major: parse['major'], minor: parse['minor'], patch: parse['patch'], prerelease: parse['prerelease'], build: parse['build'] end # Parse Docker image tag version into a string. Reverse of # {QB::Package::Version#docker_tag}. # # @param [String] version # String version to parse. # # @return [QB::Package::Version] # def self.from_docker_tag version from_string version.gsub('_', '+') end # .from_docker_tag # Parse string version into an instance. Accept Semver, Ruby Gem and # Docker image tag formats. # # @param [String] # String version to parse. # # @return [QB::Package::Version] # def self.from_string string if string.include? '_' self.from_docker_tag string elsif string.include? '-' self.from_npm_version string else self.from_gem_version Gem::Version.new(string) end end # Constructor # ===================================================================== # Construct a new Version def initialize **values super **values @release = [major, minor, patch].join '.' @level = t.match prerelease[0], { t.is(nil) => ->(_) { nil }, NAME_SEGMENT => ->(str) { str }, NUMBER_SEGMENT => ->(int) { nil }, } end # Instance Methods # ===================================================================== # Tests # --------------------------------------------------------------------- # @return [Boolean] # True if this version is a release (no prerelease or build values). # def release? prerelease.empty? && build.empty? end # @return [Boolean] # True if any prerelease segments are present (stuff after '-' in # SemVer / "NPM" format, or the first string segment and anything # following it in "Gem" format). Tests if {@prerelease} is not # empty. # def prerelease? !prerelease.empty? end # @return [Boolean] # True if any build segments are present (stuff after '+' character # in SemVer / "NPM" format). Tests if {@build} is empty. # # As of writing, we don't have a way to convey build segments in # "Gem" version format, so this will always be false when loading a # Gem version. # def build? !build.empty? end # @return [Boolean] # True if self is a prerelease version that starts with a string that # we consider the 'level'. # def level? !level.nil? end # @return [Boolean] # True if this version is a dev prerelease (first prerelease element # is 'dev'). # def dev? level == 'dev' end # @return [Boolean] # True if this version is a release candidate (first prerelease element # is 'rc'). # def rc? level == 'rc' end # Transformations # --------------------------------------------------------------------- # @return [String] # The Semver version string # (`Major.minor.patch-prerelease+build` format). # def semver result = release unless prerelease.empty? result += "-#{ prerelease.join '.' }" end unless build.empty? result += "+#{ build.join '.' }" end result end # #semver alias_method :normalized, :semver # Related Versions # --------------------------------------------------------------------- # # Functions that construct new version instances based on the current # one as well as additional information provided. # # @return [QB::Package::Version] # A new {QB::Package::Version} created from {#release}. Even if `self` # *is* a release version already, still returns a new instance. # def release_version self.class.from_string release end # #release_version # Return a new {QB::Package::Version} with build information added. # # @return [QB::Package::Version] # def build_version branch: nil, ref: nil, time: nil, dirty: nil time = self.class.to_time_segment(time) unless time.nil? segments = [ branch, ref, ('dirty' if dirty), time, ].reject &:nil? if segments.empty? raise ArgumentError, "Need to provide at least one of branch, ref, time." end merge raw: nil, build: segments end # @return [QB::Package::Version] # A new {QB::Package::Version} created from {#release} and # {#prerelease} data, but without any build information. # def prerelease_version merge raw: nil, build: [] end # #prerelease_version # Docker image tag for the version. # # See {QB::Util::DockerMixin::ClassMethods#to_docker_tag}. # # @return [String] # def docker_tag self.class.to_docker_tag semver end # #docker_tag # Language Interface # ===================================================================== # Test for equality. # # Compares classes then {QB::Package::Version#to_a} results. # # @param [Object] other # Object to compare to self. # # @return [Boolean] # True if self and other are considered equal. # def == other other.class == self.class && other.to_a == self.to_a end # #== # Return array of the version elements in order from greatest to least # precedence. # # This is considered the representative structure for the object's data, # from which all other values are dependently derived, and is used in # {#==}, {#hash} and {#eql?}. # # @example # # version = QB::Package::Version.from_string( # "0.1.2-rc.10+master.0ab1c3d" # ) # # version.to_a # # => [0, 1, 2, ['rc', 10], ['master', '0ab1c3d']] # # QB::Package::Version.from_string('1').to_a # # => [1, nil, nil, [], []] # # @return [Array] # def to_a [ major, minor, patch, prerelease, build, ] end # #to_a def hash to_a.hash end def eql? other self == other && self.hash == other.hash end def to_s "#<QB::Package::Version #{ @raw }>" end end # class Version end # Package end # QB