Sorting semantic version numbers

Has anyone come up with a clever way to sort version numbers?

I’m trying something like this:

{{ $numbers := slice "3.2.3" "3.1.2" "3.10.0" }}
{{ $sortNumbers := sort $numbers "value" "desc" }}

Ideally $sortNumbers would return ["3.10.0", "3.2.3", "3.1.2"], but each item is a string so it returns ["3.2.3", "3.10.0", "3.1.2"].

There’s an open GitHub issue about this, if you’d like to follow it.

Took a stab at this.

Assuming your input strings are always in the format of MAJOR.MINOR.PATCH, then you can do this:

{{ $semVers := slice
  "3.2.3"
  "3.1.2"
  "3.10.0"
}}

{{ $arrayOfMaps := slice }}

{{ range $semVers }}
  {{ $separated := split . "." }}
  {{ $major := index $separated 0 }}
  {{ $minor := index $separated 1 }}
  {{ $patch := index $separated 2 }}
  {{ $semVerAsInt := printf "%s%s%s" $major $minor $patch | int }}
  {{ $map := dict
    "semVer" .
    "semVerAsInt" $semVerAsInt
  }}
  {{ $arrayOfMaps = $arrayOfMaps | append $map }}
{{ end }}

{{ $arrayOfMaps = sort $arrayOfMaps "semVerAsInt" "desc" }}

{{ $sortedSemVers := slice }}

{{ range $arrayOfMaps }}
  {{ $sortedSemVers = $sortedSemVers | append .semVer }}
{{ end }}

{{ $sortedSemVers }}

Which will output:

[3.10.0 3.2.3 3.1.2]
1 Like

This is the solution I came up with. I’m all ears if someone can come up with a simpler solution or ways to simplify this.

I should add that I had a few more interesting cases than I initially mentioned, like a few pre-release version numbers.

Also, the solution above from @zwbetz can’t handle a number like “3.9.10” because 3910 is greater than 3100, even though the semver number is lower than “3.10.0”. Thanks for the suggestion though.

Basically my solution is to create a set of nested maps with each major, minor, and patch numbers, and prerelease version numbers are a slice in the value of the patch map.

Each new version number is converted to a map and then merged in. Then I range through each level of each map creating sorted slices of integers and then index the map of version numbers by each item of the slice. Once it’s ranged all the way up to the patch or pre-release version numbers, it appends the whole semver version number to a slice of version numbers.

One big caveat, it doesn’t sort pre-release version numbers correctly, but I have so few that it wasn’t worth my time to address this. If anyone is planning to use this and has a lot of pre-release version numbers, you’re going to want to spend some time figuring out a way to sort those.

I’ve also left a bunch of headings and text in the page output so it’s easier to make sense of for anyone who wants is crazy enough to try this.


<!-- Create a bunch of numbers for testing -->
{{ $map1 := dict "17" (dict "1" (dict "52" slice )) }}
{{ $map2 := dict "17" (dict "21" (dict "46" slice)) }}
{{ $map3 := dict "17" (dict "11" (dict "42" slice)) }}
{{ $map4 := dict "17" (dict "9" (dict "26" slice)) }}
{{ $map5 := dict "17" (dict "9" (dict "200" slice)) }}

{{ $map6 := dict "17" (dict "8" (dict "20" slice)) }}
{{ $map7 := dict "17" (dict "8" (dict "10" slice)) }}

{{ $map8 := dict "16" (dict "8" (dict "21" slice )) }}
{{ $map9 := dict "16" (dict "8" (dict "25" (slice "-rc.2") )) }}
{{ $map10 := dict "16" (dict "8" (dict "93" slice)) }}
{{ $map11 := dict "16" (dict "8" (dict "201" slice)) }}
{{ $map12 := dict "16" (dict "8" (dict "2" slice)) }}
{{ $map15 := dict "16" (dict "8" (dict "100" slice)) }}

{{ $map13 := dict "20" (dict "8" (dict "2" slice)) }}
{{ $map14 := dict "2" (dict "8" (dict "2" slice)) }}

{{ $versionsMap := merge $map2 $map1 $map3 $map4 $map5 $map6 $map7 $map8 $map9 $map10 $map11 $map12 $map13 $map14 $map15 }}

<h2>Existing Map of Versions</h2>
<p>VersionsMap:</p>
{{ range $k, $v := $versionsMap }}
  <p>Major Version:{{ $k }} Major Version Map:{{ $v }}</p>
{{ end }}


<h2>Create new version number</h2>
<!-- A bunch of other numbers for testing how they will be added/sorted -->
{{/* $newNumber := "17.9.21" */}}
{{ $newNumber := "16.8.25-rc.0" }}
{{/* $newNumber := "1.2.3" */}}
{{/* $newNumber := "20.100.3" */}}
<p>Add new number: {{ $newNumber }}</p>

{{ $preReleaseVersion := index (findRE `\-\w+\.{0,1}\d{0,}$` $newNumber 1) 0 }}
{{ $versionCore := strings.TrimSuffix $preReleaseVersion $newNumber }}
{{ $versionNumberSlice := split $versionCore "." }}
{{ $majorVersion := index $versionNumberSlice 0 }}
{{ $minorVersion := index $versionNumberSlice 1 }}
{{ $patchVersion := index $versionNumberSlice 2 }}


<p>Version Core: {{ $versionCore }}</p>
<p>Major Version: {{ $majorVersion }}</p>
<p>Minor Version: {{ $minorVersion }}</p>
<p>Patch Version: {{ $patchVersion }}</p>
<p>Pre Release Version: {{ $preReleaseVersion }}</p>

{{ $newMap := dict }}
{{ if ne $preReleaseVersion nil }}
  {{ $newMap = dict $majorVersion (dict $minorVersion (dict $patchVersion (slice $preReleaseVersion))) }}
{{ else }}
  {{ $newMap = dict $majorVersion (dict $minorVersion (dict $patchVersion slice )) }}
{{ end }}

<p>New Version Number Map: {{ $newMap }}</p>

<h2>Merge Version Number Map into existing dict of version numbers</h2>

{{ if isset $versionsMap $majorVersion }}
  {{ if isset (index $versionsMap $majorVersion ) $minorVersion }}
    {{ if isset (index $versionsMap $majorVersion $minorVersion) $patchVersion }}
      {{ if ne (index $versionsMap $majorVersion $minorVersion $patchVersion) nil }}
        {{ $preReleaseVersions := index $versionsMap $majorVersion $minorVersion $patchVersion }}
        {{ $preReleaseVersions = sort ($preReleaseVersions | append $preReleaseVersion) "value" "desc" }}
        {{ $newMap = dict $majorVersion (dict $minorVersion (dict $patchVersion $preReleaseVersions )) }}
      {{ end }}
    {{ end }}
  {{ end }}
{{ end }}

{{ $versionsMap = merge $versionsMap $newMap }}

<p>VersionsMap:</p>
{{ range $k, $v := $versionsMap }}
  <p>{{ $k }} {{ $v }}</p>
{{ end }}

<h2>Convert Map to Slice</h2>

{{ $versionsSlice := slice }}

{{$majVersions := slice }}
{{ range $maj, $minPatchPre := $versionsMap }}
  {{ $majVersions = sort ($majVersions | append (int $maj)) "value" "desc" }}
{{ end }}

<p>Sorted Major Versions: {{ $majVersions }}</p>

{{ range $maj := $majVersions }}
  <p>Major version: {{ $maj }}</p>
  <p>{{ index $versionsMap (string $maj) }}</p>
  {{ $minVersions := slice }}
  {{ range $min, $patchPre := index $versionsMap (string $maj) }}
    {{ $minVersions = sort ($minVersions | append (int $min)) "value" "desc" }}
    <p>Min version: {{ $min }}</p>
  {{ end }}
  <p>Minor Versions: {{ $minVersions }}</p>

  {{ range $min := $minVersions }}
    <p>{{ index $versionsMap (string $maj) (string $min) }}</p>
    {{ $patchVersions := slice }}
    {{ range $patch, $pre := index $versionsMap (string $maj) (string $min) }}
      <p>Patch version: {{ $patch }}</p>
      {{ $patchVersions = sort ($patchVersions | append $patch ) "value" "desc" }}
    {{ end }}
    <p>Patch Versions: {{ $patchVersions }}</p>
    {{ range $patch := $patchVersions }}
      <p>Index of maj, min, patch: {{ index $versionsMap (string $maj) (string $min) (string $patch) }}</p>
      {{ range $pre := sort (index $versionsMap (string $maj) (string $min) (string $patch)) "value" "desc" }}
        <p>Pre release version: {{ $pre }}</p>
        <p>Full version: {{ (printf "%v%s%v%s%v%s" $maj "." $min "." $patch $pre ) }}</p>
        {{ $versionsSlice = $versionsSlice | append (printf "%v%s%v%s%v%s" $maj "." $min "." $patch $pre )}}
      {{ else }}
        <p>Full version: {{ (printf "%v%s%v%s%v" $maj "." $min "." $patch ) }}</p>
        {{ $versionsSlice = $versionsSlice | append (printf "%v%s%v%s%v" $maj "." $min "." $patch)}}
      {{ end }}
    {{ end }}
  {{ end }}
{{ end }}

<h2>Final Slice of sorted versions</h2>
<p>Versions Slice: {{ $versionsSlice }}</p>

<ul>
  {{ range $version := $versionsSlice }}
    <li>{{ $version }}</li>
  {{ end }}
</ul>

The final UL list output is this:

20.8.2
17.21.46
17.11.42
17.9.200
17.9.26
17.8.20
17.8.10
17.1.52
16.8.201
16.8.100
16.8.93
16.8.25-rc.2
16.8.25-rc.0
16.8.21
16.8.2
2.8.2

I think @jmooring had a solution for this somewhere in this forum, but I can’t find it.

The following solution should be able to sort all semver strings, although it doesn’t assume MAJOR.MINOR.PATCH format and can handle any strings with numbers, including those with “beta” or “rc” suffixes.

{{- $versions := slice "17.9.200" "17.1.52" "16.8.100" "3.2.3" "3.1.2" "3.10.0" "17.8.20" "3.9.10" "16.8.93" "16.8.201" "17.8.10" "16.8.25-rc.0" "2.8.2" "16.8.21" "16.8.25-rc.2" "16.8.2" "17.21.46" "17.11.42" "20.8.2" "17.9.26" }}

{{- $paddedVersions := apply $versions "partial" "padZeroPrefix" "." }}
{{- $sortedVersions := (sort $paddedVersions "value" "desc") }}
{{- $sortedVersions = apply $sortedVersions "partial" "trimZeroPrefix" "." }}

{{- range $sortedVersions }}
	<li>{{- . }}</li>
{{- end }}

The slice has the padZeroPrefix partial function applied over it, which pads all numbers in the string with 0 to the max length of 6 digits (this length can be adjusted in the partial). The slice is then sorted in descending order before the trimZeroPrefix is applied to remove the padding.

As long as the padding length is greater than the length of the largest number, everything should sort correctly. The strings in $paddedVersions look something like this before they’re sorted:

...
000016.000008.000201
000017.000008.000010
000016.000008.000025-rc.000000
...

The resulting output is this:

20.8.2
17.21.46
17.11.42
17.9.200
17.9.26
17.8.20
17.8.10
17.1.52
16.8.201
16.8.100
16.8.93
16.8.25-rc.2
16.8.25-rc.0
16.8.21
16.8.2
3.10.0
3.9.10
3.2.3
3.1.2
2.8.2

padZeroPrefix.html:

{{- $padSize := 6 }}
{{- $paddedString := replaceRE "(\\d+)" (print (strings.Repeat (sub $padSize 1) "0") "$1") . }}
{{- $trimmedString := replaceRE (print "0+(\\d{" $padSize "})") "$1" $paddedString }}
{{- return $trimmedString }}

trimZeroPrefix.html:

{{- return replaceRE "0+(\\d+)" "$1" . }}
2 Likes

It’s true what they say. Cunningham’s Law is real.

It’s fun to see yall come up with creative solutions for this problem.

golang has a version module. Can hugo bring it to the surface? a variable type version?
Or sort “asVersion” …

only my 2 cents here

3 Likes

This topic was automatically closed 2 days after the last reply. New replies are no longer allowed.