Versionian is a Ruby library for declaring, parsing, comparing, and rendering version schemes. It provides model-driven primitives for defining how versions work, supporting semantic versioning, calendar versioning, and unlimited custom schemes through declarative YAML configuration.
Ruby’s built-in Gem::Version handles semantic versioning well, but real-world
projects use diverse versioning schemes that Gem::Version cannot parse or
compare correctly:
-
Calendar versioning (
2024.01.17,2024.03) -Gem::Versionparses these but cannot enforce date validation -
Single-number versioning (
5,5+hotfix,5-beta) -Gem::Versiontreats postfixes incorrectly -
Human-readable versions (
Alpha.1.5,Beta.2.0) -Gem::Versioncannot compare these -
Hash-based versions (
2024.01.abc123) -Gem::Versionhas no concept of commit hashes -
Custom schemes - Every project has unique versioning needs
| Feature | Gem::Version | Versionian |
|---|---|---|
Supported schemes |
Semantic versioning only |
4 built-in schemes, unlimited custom |
Extensibility |
Not extensible |
Define custom schemes via declarative YAML or Ruby |
Component types |
Integers and dot-separated strings |
Integer, Float, Enum, DatePart, Hash, Postfix, Prerelease, String, Wildcard |
Comparison strategy |
Fixed SemVer rules |
Per-scheme comparison (lexicographic arrays) |
Validation |
Basic format validation |
Type-aware validation (date ranges, enum values, hash formats) |
Programmatic building |
Not supported |
Build versions from component values |
Versionian fills the gap between `Gem::Version’s semantic versioning focus and the diverse versioning needs of real-world projects.
Get started with Versionian in three steps:
require 'versionian'# Use built-in semantic versioning
scheme = Versionian.get_scheme(:semantic)
version = scheme.parse("1.12.0")
puts version.to_s # => "1.12.0"
# Compare versions
result = scheme.compare("1.12.0", "2.0.0")
puts result # => -1 (less than)# schemes/my_scheme.yaml
name: my_scheme
type: declarative
description: Custom version scheme
components:
- name: major
type: integer
separator: "."
- name: minor
type: integer
separator: "."
- name: patch
type: integer
optional: true# Load and use custom scheme
scheme = Versionian::SchemeLoader.from_yaml_file('schemes/my_scheme.yaml')
Versionian.register_scheme(:my_scheme, scheme)
version = scheme.parse("1.2.3")
puts version.major # => 1Versionian follows a model-driven architecture where version schemes define their own parsing, comparison, and rendering behavior.
Versionian uses lexicographic array comparison instead of weighted integer sums.
Each version stores a comparable_array where each element represents a
component in comparison order:
# Version stores array for comparison
version.comparable_array # => [2024, 1, 17, :rc, 1]
# Comparison is element-by-element
[2024, 1, 17] <=> [2024, 1, 18] # => -1
[2024, 1, 17] <=> [2024, 2, 1] # => -1
[2024, 1, 17] <=> [2024, 1, 17] # => 0This approach avoids integer overflow, provides better performance, and allows each component type to define its own comparison semantics.
Versionian provides two ways to create version identifiers:
-
Parse: Extract components from a version string
-
Build: Create a version from component values
# Parse: from string
version = scheme.parse("1.12.0")
# Build: from component values
version = scheme.build(major: 1, minor: 12, patch: 0)Both methods return the same VersionIdentifier object with a
comparable_array for comparison.
Versionian::VersionScheme (abstract)
├── parse(version_string) -> VersionIdentifier
├── build(component_values) -> VersionIdentifier
├── compare_arrays(a, b) -> Integer
├── render(version) -> String
└── matches_range?(version_string, range) -> Boolean
Implemented Schemes:
├── Semantic (SemVer)
├── CalVer (Calendar versioning)
├── SoloVer (Single number with postfix)
├── WendtVer (Auto-incrementing)
└── Declarative (Custom segment-based)
VersionIdentifier objects:
├── raw_string (original input)
├── scheme (reference to scheme)
├── components (array of VersionComponent)
└── comparable_array (for lexicographic comparison)
Component type registry:
├── Integer (numeric sequences)
├── Float (IEEE754 floats)
├── String (arbitrary text)
├── Enum (ordered stages)
├── DatePart (calendar components)
├── Prerelease (SemVer prereleases)
├── Postfix (SoloVer suffixes)
├── Hash (git commit hashes)
└── Custom types (user-defined)Add this line to your application’s Gemfile:
gem 'versionian'And then execute:
bundle installOr install it yourself as:
gem install versionianVersionian provides built-in support for common versioning schemes. Each scheme is pre-registered and ready to use.
scheme = Versionian.get_scheme(:semantic) (1)
version = scheme.parse("1.12.0") (2)
result = scheme.compare("1.12.0", "2.0.0") (3)-
Get a built-in scheme by name.
-
Parse a version string.
-
Compare two versions (returns -1, 0, or 1).
Where,
scheme-
A VersionScheme instance.
version-
A VersionIdentifier object.
result-
Integer: -1 (less than), 0 (equal), 1 (greater than).
Declarative schemes define version formats using segment definitions instead of regular expressions. Each component specifies its type, separator, prefix, and optional status, allowing the parser to extract components in O(n) time.
Benefits over regex-based patterns:
-
No ReDoS vulnerabilities - State machine parsing is immune to catastrophic backtracking
-
O(n) performance - Single pass through the string
-
Declarative syntax - Clear, readable component definitions
-
Type-aware parsing - Each component type handles its own validation
name: my_scheme (1)
type: declarative (2)
description: Custom scheme (3)
components: (4)
- name: major
type: integer
separator: "." (5)
- name: minor
type: integer
separator: "." (6)
- name: patch
type: integer (7)-
Unique scheme identifier (symbol).
-
Scheme type:
declarative. -
Optional description of the scheme.
-
Array of component definitions in order.
-
Separator that precedes this component.
-
Each segment can have its own separator.
-
Last segment typically has no separator.
Where,
name-
Symbol identifier for the scheme.
- `type
-
Scheme type (
declarative). - `description
-
Human-readable description.
- `components
-
Array of component definitions.
| Attribute | Purpose |
|---|---|
name |
Component name (symbol) |
type |
Component type (integer, string, enum, date_part, etc.) |
separator |
String that precedes this component (e.g., |
prefix |
String that identifies an optional component (e.g., |
optional |
Boolean: true if component may be absent |
include_prefix_in_value |
Boolean: include prefix in value for component type parsing |
subtype |
For date_part: |
values |
For enum type: allowed values |
order |
For enum type: comparison order |
validate |
Hash with |
# Load from YAML file
scheme = Versionian::SchemeLoader.from_yaml_file('schemes/custom.yaml')
# Register for use
Versionian.register_scheme(:custom, scheme)
# Use the scheme
version = scheme.parse("1.2.3")
puts version.major # => 1name: semantic
type: declarative
description: Semantic versioning
components:
- name: major
type: integer
separator: "."
- name: minor
type: integer
separator: "."
- name: patch
type: integer
- name: prerelease
type: string
prefix: "-"
optional: true
- name: build
type: string
prefix: "+"
optional: trueUsage:
scheme = Versionian::SchemeLoader.from_yaml_file('schemes/semantic.yaml')
v = scheme.parse("1.2.3-alpha.1+build.123")
v.major # => 1
v.minor # => 2
v.patch # => 3
v.prerelease # => "alpha.1"
v.build # => "build.123"
# Optional components are nil when absent
v = scheme.parse("1.2.3")
v.prerelease # => nil
v.build # => nilname: calver
type: declarative
description: Calendar versioning YYYY.MM.DD
components:
- name: year
type: date_part
subtype: year
separator: "."
- name: month
type: date_part
subtype: month
separator: "."
- name: day
type: date_part
subtype: dayUsage:
scheme = Versionian::SchemeLoader.from_yaml_file('schemes/calver.yaml')
v = scheme.parse("2024.01.17")
v.year # => 2024
v.month # => 1 (not "01")
v.day # => 17
# Invalid dates raise ParseError
scheme.parse("2024.13.17") # => Invalid month '13'
scheme.parse("2024.02.30") # => Invalid day '30'name: solover
type: declarative
description: Single number with optional postfix
components:
- name: number
type: integer
- name: postfix
type: postfix
prefix: "+"
optional: true
include_prefix_in_value: true (1)-
Postfix type handles prefix internally, so include it in the value.
Usage:
scheme = Versionian::SchemeLoader.from_yaml_file('schemes/solover.yaml')
v = scheme.parse("5")
v.number # => 5
v.postfix # => nil
v = scheme.parse("5+hotfix")
v.number # => 5
v.postfix # => {:prefix=>"+", :identifier=>"hotfix"}
v = scheme.parse("5-beta")
v.number # => 5
v.postfix # => {:prefix=>"-", :identifier=>"beta"}name: stage
type: declarative
description: Release stage with ordering
components:
- name: major
type: integer
separator: "."
- name: minor
type: integer
separator: "."
- name: stage
type: enum
prefix: "-"
optional: true
values: [alpha, beta, rc, stable]
order: [alpha, beta, rc, stable]Usage:
scheme = Versionian::SchemeLoader.from_yaml_file('schemes/stage.yaml')
v = scheme.parse("1.2.0-beta")
v.stage # => :beta
# Comparison respects order
scheme.compare("1.2.0-alpha", "1.2.0-beta") # => -1 (alpha < beta)
scheme.compare("1.2.0-rc", "1.2.0-stable") # => -1 (rc < stable)Versionian provides several ways to discover available schemes:
# List all registered scheme names
scheme_names = Versionian.scheme_registry.registered
puts scheme_names.inspect # => [:semantic, :calver, :solover, :wendtver]
# Check if a scheme exists
Versionian.scheme_registry.registered.include?(:semantic) # => true# Auto-detect which scheme matches a version string
detected = Versionian.detect_scheme("1.2.3")
puts detected.name # => :semantic
detected = Versionian.detect_scheme("2024.01.17")
puts detected.name # => :calver
detected = Versionian.detect_scheme("5+hotfix")
puts detected.name # => :solover
# Returns nil for unrecognised version strings
detected = Versionian.detect_scheme("not-a-version")
puts detected # => nil# Get a scheme by name
scheme = Versionian.get_scheme(:semantic)
# Check scheme capabilities
puts scheme.supports?("1.2.3") # => true
puts scheme.supports?("invalid") # => false
# Parse and compare
version = scheme.parse("1.12.0")
puts version.to_s # => "1.12.0"
# Raises error for unknown schemes
begin
Versionian.get_scheme(:unknown)
rescue Versionian::Errors::InvalidSchemeError => e
puts e.message # => "Unknown scheme: unknown"
endVersionian provides built-in component types for parsing and comparing version components. Each type defines its own parsing, comparison, and formatting behavior.
| Type | Purpose | Comparison |
|---|---|---|
Integer |
Numeric sequences (major, minor, patch) |
Numeric |
Float |
FloatVer, IEEE754 floats |
Float comparison |
String |
Arbitrary text |
Lexicographic |
Enum |
Ordered stages (alpha < beta < rc) |
Order array index |
DatePart |
Calendar components (year, month, day, week) |
Numeric with validation |
Prerelease |
SemVer prereleases (alpha.1, beta.2) |
SemVer rules (numeric < alphanumeric) |
Postfix |
SoloVer suffixes (+hotfix, -beta) |
Prefix-sensitive (none < + < -) |
Hash |
Git commit hashes |
Length first, then lexicographic |
Wildcard |
Ignored components |
Always equal |
components:
- name: major
type: integercomponents:
- name: version
type: floatcomponents:
- name: build
type: stringcomponents:
- name: stage
type: enum
prefix: "-"
optional: true
values: [alpha, beta, rc, stable]
order: [alpha, beta, rc, stable]components:
- name: year
type: date_part
subtype: year
separator: "."
- name: month
type: date_part
subtype: month
separator: "."
- name: day
type: date_part
subtype: daycomponents:
- name: prerelease
type: prerelease
prefix: "-"
optional: truecomponents:
- name: postfix
type: postfix
prefix: "+"
optional: true
include_prefix_in_value: truecomponents:
- name: hash
type: hash-
equals: Exact version match
-
before: Less than boundary version
-
after: Greater than or equal to boundary version
-
between: Inclusive range between two versions
scheme = Versionian.get_scheme(:semantic)
# Exact version match
range = Versionian::VersionRange.new(:equals, scheme, version: "1.12.0")
range.matches?("1.12.0") # => true
range.matches?("1.12.1") # => false
range.matches?("1.11.0") # => false
# Before a specific version (exclusive)
range = Versionian::VersionRange.new(:before, scheme, version: "1.12.0")
range.matches?("1.11.0") # => true
range.matches?("1.12.0") # => false
range.matches?("1.13.0") # => false
# After a specific version (inclusive)
range = Versionian::VersionRange.new(:after, scheme, version: "1.12.0")
range.matches?("1.13.0") # => true
range.matches?("1.12.0") # => true
range.matches?("1.11.0") # => false
# Between two versions
range = Versionian::VersionRange.new(:between, scheme, from: "1.12.0", to: "2.0.0")
range.matches?("1.15.0") # => true
range.matches?("2.1.0") # => falseclass FloatVerScheme < Versionian::VersionScheme
def initialize(name: :floatver, description: "Float-based versioning")
super
end
def parse(version_string)
float_value = Float(version_string)
Versionian::VersionIdentifier.new(
raw_string: version_string,
scheme: self,
components: [],
comparable_array: [float_value]
)
end
def compare_arrays(a, b)
a.first <=> b.first # Direct float comparison
end
def render(version)
version.raw_string
end
end
# Register and use
Versionian.register_scheme(:floatver, FloatVerScheme.new)Versionian uses Psych.safe_load with a restricted set of permitted classes for
security:
ALLOWED_CLASSES = [Symbol, Integer, String, Array, Hash, TrueClass, FalseClass, NilClass].freeze# From file
scheme = Versionian::SchemeLoader.from_yaml_file('schemes/custom.yaml')
# From string
yaml_string = <<~YAML
name: custom
type: declarative
components:
- { name: major, type: integer, separator: "." }
- { name: minor, type: integer }
YAML
scheme = Versionian::SchemeLoader.from_yaml_string(yaml_string)After checking out the repo, run:
bundle installRun tests:
bundle exec rspecRun linting:
bundle exec rubocop