Project P103

Version sorter

Semantic Versioning: one list sorted as text, by a natural sort and by the SemVer rule, with the pairs they disagree on; two versions compared with the rule that decides; and the newest version matching a caret, tilde or comparator range.

2 modules · 2 recorded sessionstext-menu UI in the terminalupdated 2026-10-16

Every screen below was recorded under CPython. When this page was built, the EML interpreter replayed each session from the same input and printed the same bytes.

About

Version numbers by Semantic Versioning 2.0.0: MAJOR.MINOR.PATCH, then an optional pre-release after - and build metadata after +. One list is sorted three ways, two versions are compared with the rule that decides, and the newest version in a range is picked from the list.

  • main.eml - the menu, the three sorts and their disagreements, comparing two, ranges, and a new list
  • versions.eml - reading a version, the SemVer rule, a natural sort, sorting, and range bounds

How each part works:

  • Reading: three whole numbers without leading zeros. A pre-release is dot-separated identifiers of letters, digits and hyphens, and a number among them has no leading zero. Build metadata is the same without that rule.
  • The SemVer rule: - MAJOR, MINOR, then PATCH, as numbers; - a pre-release comes before its release; - pre-release identifiers one by one: numbers as numbers, words in ASCII order, a number before a word; - with every earlier identifier equal, more identifiers come later; - build metadata is ignored.
  • Three sorts, as in the corpus case version-string-ordering: - as text, which puts 1.10.0 before 1.9.1; - a natural sort that reads runs of digits as numbers, but puts 1.0.0 before 1.0.0-alpha, the shorter text first; - SemVer.

The program counts the pairs where text and the natural sort disagree with SemVer. - Ranges: - ^ keeps the first non-zero part: ^1.2.3 is >=1.2.3 <2.0.0, ^0.2.3 is >=0.2.3 <0.3.0, and ^0.0.3 is >=0.0.3 <0.0.4; - ~ keeps the minor: ~1.2.3 is >=1.2.3 <1.3.0; - comparators >=, >, <=, < and = can be combined with spaces.

Pre-releases never match, and a range takes releases only.

What is checked: menu choices 1 to 5; every version in the form above; ranges of releases only; a list of 2 to 20 versions. An empty answer cancels.

Sessions: sessions/basic.in gives: - the starting list of 13 sorted three ways: 17 and 14 of the 78 pairs disagree with SemVer; - six comparisons, each decided by a different rule; - the ranges ^1.0.0, ~1.9.0, >=1.2.0 <1.10.0 and ^2.0.0; - a list of 0.x versions, with ^0.2.3 and ^0.0.3, and its three sorts.

sessions/bad-input.in gives: - menu choices 0 and x; - versions with two parts, a leading zero in the core and in a pre-release, an empty pre-release and build, a stray #, and a v in front; two cancels; - a cancel, a range with two parts, a range with a pre-release, and a range nothing matches; - a cancel, a list of one, a list with a word in it; then the sorts again.

Built on the verified corpus case version-string-ordering (a version is a tuple of numbers; as text it goes wrong at the tenth release).

Recorded sessions

What the screen shows while someone uses the program. Each typed line appears after its prompt, the way a terminal shows it.

bad-input

interpreter: byte-equal
== Version sorter ==
Semantic Versioning: MAJOR.MINOR.PATCH, then a pre-release after - and build metadata after +.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 0
Pick a number from 1 to 5.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> x
Pick a number from 1 to 5.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 2
first version> 1.2
Not a version: a version has three numbers, MAJOR.MINOR.PATCH.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 2
first version> 01.2.3
Not a version: MAJOR, MINOR and PATCH have no leading zeros (01).

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 2
first version> 1.2.3-01
Not a version: a number in a pre-release has no leading zero (01).

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 2
first version> 1.2.3-
Not a version: a pre-release is dot-separated letters, digits and hyphens, none empty.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 2
first version> 1.2.3+
Not a version: build metadata is dot-separated letters, digits and hyphens.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 2
first version> 1.2.3-al#pha
Not a version: a pre-release is dot-separated letters, digits and hyphens, none empty.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 2
first version> v1.2.3
Not a version: MAJOR, MINOR and PATCH are whole numbers.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 2
first version> 
Cancelled.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 2
first version> 1.0.0
second version> 
Cancelled.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 3
A range: ^1.2.3 (same major), ~1.2.3 (same minor), or comparators such as >=1.2.0 <1.5.0. Pre-releases never match.
range> 
Cancelled.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 3
A range: ^1.2.3 (same major), ~1.2.3 (same minor), or comparators such as >=1.2.0 <1.5.0. Pre-releases never match.
range> ^1.2
Not a range: 1.2: a version has three numbers, MAJOR.MINOR.PATCH.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 3
A range: ^1.2.3 (same major), ~1.2.3 (same minor), or comparators such as >=1.2.0 <1.5.0. Pre-releases never match.
range> ^1.2.3-beta
Not a range: a range here takes releases only, without a pre-release.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 3
A range: ^1.2.3 (same major), ~1.2.3 (same minor), or comparators such as >=1.2.0 <1.5.0. Pre-releases never match.
range> >=3.0.0
>=3.0.0 means >=3.0.0.
No version in the list matches.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 4
Versions separated by spaces, 2 to 20 of them.
versions> 
Cancelled.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 4
Versions separated by spaces, 2 to 20 of them.
versions> 1.0.0
Type 2 to 20 versions.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 4
Versions separated by spaces, 2 to 20 of them.
versions> 1.0.0 banana
Not a version: banana - a version has three numbers, MAJOR.MINOR.PATCH.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 1
  as text      1.0.0  1.0.0+build.5  1.0.0-alpha  1.0.0-alpha.1  1.0.0-alpha.beta  1.0.0-beta  1.0.0-beta.11  1.0.0-beta.2  1.10.0  1.2.0  1.9.1  2.0.0  2.0.0-rc.1
  natural sort 1.0.0  1.0.0+build.5  1.0.0-alpha  1.0.0-alpha.1  1.0.0-alpha.beta  1.0.0-beta  1.0.0-beta.2  1.0.0-beta.11  1.2.0  1.9.1  1.10.0  2.0.0  2.0.0-rc.1
  SemVer       1.0.0-alpha  1.0.0-alpha.1  1.0.0-alpha.beta  1.0.0-beta  1.0.0-beta.2  1.0.0-beta.11  1.0.0  1.0.0+build.5  1.2.0  1.9.1  1.10.0  2.0.0-rc.1  2.0.0
Of the 78 pairs, text order disagrees with SemVer on 17 and the natural sort on 14.
  First text disagreement: 1.10.0 and 1.2.0.
  First natural-sort disagreement: 1.0.0-alpha and 1.0.0.
Text compares character by character, so 1.10.0 comes before 1.9.1. A natural sort reads numbers as numbers, but puts 1.0.0 before 1.0.0-alpha, the shorter text first. SemVer puts every pre-release before its release.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 5
Bye.
What was typed (37 lines)
0
x
2
1.2
2
01.2.3
2
1.2.3-01
2
1.2.3-
2
1.2.3+
2
1.2.3-al#pha
2
v1.2.3
2

2
1.0.0

3

3
^1.2
3
^1.2.3-beta
3
>=3.0.0
4

4
1.0.0
4
1.0.0 banana
1
5

basic

interpreter: byte-equal
== Version sorter ==
Semantic Versioning: MAJOR.MINOR.PATCH, then a pre-release after - and build metadata after +.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 1
  as text      1.0.0  1.0.0+build.5  1.0.0-alpha  1.0.0-alpha.1  1.0.0-alpha.beta  1.0.0-beta  1.0.0-beta.11  1.0.0-beta.2  1.10.0  1.2.0  1.9.1  2.0.0  2.0.0-rc.1
  natural sort 1.0.0  1.0.0+build.5  1.0.0-alpha  1.0.0-alpha.1  1.0.0-alpha.beta  1.0.0-beta  1.0.0-beta.2  1.0.0-beta.11  1.2.0  1.9.1  1.10.0  2.0.0  2.0.0-rc.1
  SemVer       1.0.0-alpha  1.0.0-alpha.1  1.0.0-alpha.beta  1.0.0-beta  1.0.0-beta.2  1.0.0-beta.11  1.0.0  1.0.0+build.5  1.2.0  1.9.1  1.10.0  2.0.0-rc.1  2.0.0
Of the 78 pairs, text order disagrees with SemVer on 17 and the natural sort on 14.
  First text disagreement: 1.10.0 and 1.2.0.
  First natural-sort disagreement: 1.0.0-alpha and 1.0.0.
Text compares character by character, so 1.10.0 comes before 1.9.1. A natural sort reads numbers as numbers, but puts 1.0.0 before 1.0.0-alpha, the shorter text first. SemVer puts every pre-release before its release.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 2
first version> 1.9.1
second version> 1.10.0
1.9.1 comes before 1.10.0: minor 9 vs 10.
  As text the order is the other way round.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 2
first version> 1.0.0-beta.2
second version> 1.0.0-beta.11
1.0.0-beta.2 comes before 1.0.0-beta.11: pre-release part 2: 2 vs 11 as numbers.
  As text the order is the other way round.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 2
first version> 1.0.0-alpha
second version> 1.0.0
1.0.0-alpha comes before 1.0.0: a pre-release comes before its release.
  As text the order is the other way round.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 2
first version> 1.0.0-alpha
second version> 1.0.0-alpha.1
1.0.0-alpha comes before 1.0.0-alpha.1: every earlier pre-release part is equal, and more parts come later.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 2
first version> 1.0.0-alpha.1
second version> 1.0.0-alpha.beta
1.0.0-alpha.1 comes before 1.0.0-alpha.beta: pre-release part 2: a number comes before a word.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 2
first version> 1.0.0
second version> 1.0.0+build.5
1.0.0 and 1.0.0+build.5 rank the same: build metadata is ignored.
  As text they differ, so a text sort still puts one first.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 3
A range: ^1.2.3 (same major), ~1.2.3 (same minor), or comparators such as >=1.2.0 <1.5.0. Pre-releases never match.
range> ^1.0.0
^1.0.0 means >=1.0.0 <2.0.0.
Matching: 1.0.0  1.0.0+build.5  1.2.0  1.9.1  1.10.0. Newest: 1.10.0.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 3
A range: ^1.2.3 (same major), ~1.2.3 (same minor), or comparators such as >=1.2.0 <1.5.0. Pre-releases never match.
range> ~1.9.0
~1.9.0 means >=1.9.0 <1.10.0.
Matching: 1.9.1. Newest: 1.9.1.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 3
A range: ^1.2.3 (same major), ~1.2.3 (same minor), or comparators such as >=1.2.0 <1.5.0. Pre-releases never match.
range> >=1.2.0 <1.10.0
>=1.2.0 <1.10.0 means >=1.2.0 <1.10.0.
Matching: 1.2.0  1.9.1. Newest: 1.9.1.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 3
A range: ^1.2.3 (same major), ~1.2.3 (same minor), or comparators such as >=1.2.0 <1.5.0. Pre-releases never match.
range> ^2.0.0
^2.0.0 means >=2.0.0 <3.0.0.
Matching: 2.0.0. Newest: 2.0.0.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 4
Versions separated by spaces, 2 to 20 of them.
versions> 0.2.3 0.2.10 0.3.0 0.0.3 0.0.4 0.10.0
6 versions.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 3
A range: ^1.2.3 (same major), ~1.2.3 (same minor), or comparators such as >=1.2.0 <1.5.0. Pre-releases never match.
range> ^0.2.3
^0.2.3 means >=0.2.3 <0.3.0.
Matching: 0.2.3  0.2.10. Newest: 0.2.10.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 3
A range: ^1.2.3 (same major), ~1.2.3 (same minor), or comparators such as >=1.2.0 <1.5.0. Pre-releases never match.
range> ^0.0.3
^0.0.3 means >=0.0.3 <0.0.4.
Matching: 0.0.3. Newest: 0.0.3.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 1
  as text      0.0.3  0.0.4  0.10.0  0.2.10  0.2.3  0.3.0
  natural sort 0.0.3  0.0.4  0.2.3  0.2.10  0.3.0  0.10.0
  SemVer       0.0.3  0.0.4  0.2.3  0.2.10  0.3.0  0.10.0
Of the 15 pairs, text order disagrees with SemVer on 4 and the natural sort on 0.
  First text disagreement: 0.2.3 and 0.2.10.
Text compares character by character, so 1.10.0 comes before 1.9.1. A natural sort reads numbers as numbers, but puts 1.0.0 before 1.0.0-alpha, the shorter text first. SemVer puts every pre-release before its release.

1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit
choice> 5
Bye.
What was typed (35 lines)
1
2
1.9.1
1.10.0
2
1.0.0-beta.2
1.0.0-beta.11
2
1.0.0-alpha
1.0.0
2
1.0.0-alpha
1.0.0-alpha.1
2
1.0.0-alpha.1
1.0.0-alpha.beta
2
1.0.0
1.0.0+build.5
3
^1.0.0
3
~1.9.0
3
>=1.2.0 <1.10.0
3
^2.0.0
4
0.2.3 0.2.10 0.3.0 0.0.3 0.0.4 0.10.0
3
^0.2.3
3
^0.0.3
1
5

Modules

The program as written, entry module first. Each module transpiles to its own Python file, which is what eml project run executes.

main.eml(entry)

eml
# P103 version sorter: one list of versions sorted three ways, two versions
# compared with the rule that decides, and the newest version in a range.
import versions

def plain(s):
    0 => i
    len(s) => j
    while i < j and s[i] == " ":
        i + 1 => i
    while j > i and s[j - 1] == " ":
        j - 1 => j
    return s[i:j]

def words(s):
    [] => out
    "" => w
    for c in s + " ":
        if c == " " or c == ",":
            if w != "":
                out + [w] => out
            "" => w
        else:
            w + c => w
    return out

def joined(xs):
    "" => out
    for x in xs:
        if out != "":
            out + "  " => out
        out + x => out
    return out

def show_sorts(texts):
    ["text", "natural", "semver"] => rules
    ["as text      ", "natural sort ", "SemVer       "] => labels
    for k in [0:2]:
        ("  " + labels[k] + joined(versions.order(texts, rules[k]))) ^0
    0 => pairs
    0 => text_off
    0 => natural_off
    "" => example_text
    "" => example_natural
    for i in [0:len(texts) - 1]:
        for j in [i + 1:len(texts) - 1]:
            pairs + 1 => pairs
            versions.compare(texts[i], texts[j], "semver") => s
            if versions.compare(texts[i], texts[j], "text") != s:
                text_off + 1 => text_off
                if example_text == "":
                    texts[i] + " and " + texts[j] => example_text
            if versions.compare(texts[i], texts[j], "natural") != s:
                natural_off + 1 => natural_off
                if example_natural == "":
                    texts[i] + " and " + texts[j] => example_natural
    ("Of the " + str(pairs) + " pairs, text order disagrees with SemVer on " + str(text_off) + " and the natural sort on " + str(natural_off) + ".") ^0
    if example_text != "":
        ("  First text disagreement: " + example_text + ".") ^0
    if example_natural != "":
        ("  First natural-sort disagreement: " + example_natural + ".") ^0
    "Text compares character by character, so 1.10.0 comes before 1.9.1. A natural sort reads numbers as numbers, but puts 1.0.0 before 1.0.0-alpha, the shorter text first. SemVer puts every pre-release before its release." ^0

def ask_version(prompt):
    plain(input(prompt + "> ")) => t
    if t == "":
        return ["", []]
    versions.parse(t) => p
    if p[1] != "":
        ("Not a version: " + p[1] + ".") ^0
        return ["!", []]
    return [t, p[0]]

def compare_two():
    ask_version("first version") => a
    if a[0] == "":
        "Cancelled." ^0
        return
    if a[0] == "!":
        return
    ask_version("second version") => b
    if b[0] == "":
        "Cancelled." ^0
        return
    if b[0] == "!":
        return
    versions.semver(a[1], b[1]) => r
    if r[0] < 0:
        (a[0] + " comes before " + b[0] + ": " + r[1] + ".") ^0
    elif r[0] > 0:
        (a[0] + " comes after " + b[0] + ": " + r[1] + ".") ^0
    else:
        (a[0] + " and " + b[0] + " rank the same: " + r[1] + ".") ^0
    versions.sign(a[0], b[0]) => t
    if r[0] != 0 and t == 0 - r[0]:
        "  As text the order is the other way round." ^0
    elif r[0] == 0 and t != 0:
        "  As text they differ, so a text sort still puts one first." ^0

def bound_text(b):
    "" => out
    if len(b[0]) > 0:
        ">=" + str(b[0][0]) + "." + str(b[0][1]) + "." + str(b[0][2]) => out
    if len(b[1]) > 0:
        "<" => op
        if b[2]:
            "<=" => op
        if out != "":
            out + " " => out
        out + op + str(b[1][0]) + "." + str(b[1][1]) + "." + str(b[1][2]) => out
    return out

def pick(texts):
    "A range: ^1.2.3 (same major), ~1.2.3 (same minor), or comparators such as >=1.2.0 <1.5.0. Pre-releases never match." ^0
    plain(input("range> ")) => r
    if r == "":
        "Cancelled." ^0
        return
    [] => all_bounds
    "" => means
    for w in words(r):
        versions.range_bounds(w) => b
        if b[3] != "":
            ("Not a range: " + b[3] + ".") ^0
            return
        all_bounds + [b] => all_bounds
        if means != "":
            means + " " => means
        means + bound_text(b) => means
    (r + " means " + means + ".") ^0
    [] => hits
    for t in texts:
        versions.parse(t)[0] => v
        True => ok
        for b in all_bounds:
            if not versions.in_range(v, b):
                False => ok
        if ok:
            hits + [t] => hits
    if len(hits) == 0:
        "No version in the list matches." ^0
        return
    versions.order(hits, "semver") => hits
    ("Matching: " + joined(hits) + ". Newest: " + hits[len(hits) - 1] + ".") ^0

def new_list():
    "Versions separated by spaces, 2 to 20 of them." ^0
    plain(input("versions> ")) => t
    if t == "":
        "Cancelled." ^0
        return []
    words(t) => out
    if len(out) < 2 or len(out) > 20:
        "Type 2 to 20 versions." ^0
        return []
    for w in out:
        versions.parse(w) => p
        if p[1] != "":
            ("Not a version: " + w + " - " + p[1] + ".") ^0
            return []
    (str(len(out)) + " versions.") ^0
    return out

["1.10.0", "1.2.0", "1.9.1", "1.0.0-alpha", "1.0.0", "1.0.0-beta.11", "1.0.0-beta.2", "1.0.0-alpha.1", "2.0.0-rc.1", "1.0.0-beta", "1.0.0+build.5", "1.0.0-alpha.beta", "2.0.0"] => texts

"== Version sorter ==" ^0
"Semantic Versioning: MAJOR.MINOR.PATCH, then a pre-release after - and build metadata after +." ^0
True => running
while running:
    "" ^0
    "1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit" ^0
    plain(input("choice> ")) => choice
    if choice == "1":
        show_sorts(texts)
    elif choice == "2":
        compare_two()
    elif choice == "3":
        pick(texts)
    elif choice == "4":
        new_list() => got
        if len(got) > 0:
            got => texts
    elif choice == "5":
        False => running
    else:
        "Pick a number from 1 to 5." ^0
"Bye." ^0
Python projection (main.py)
import versions

def plain(s):
    i = 0
    j = len(s)
    while i < j and s[i] == " ":
        i = i + 1
    while j > i and s[j - 1] == " ":
        j = j - 1
    return s[i:j]

def words(s):
    out = []
    w = ""
    for c in s + " ":
        if c == " " or c == ",":
            if w != "":
                out = out + [w]
            w = ""
        else:
            w = w + c
    return out

def joined(xs):
    out = ""
    for x in xs:
        if out != "":
            out = out + "  "
        out = out + x
    return out

def show_sorts(texts):
    rules = ["text", "natural", "semver"]
    labels = ["as text      ", "natural sort ", "SemVer       "]
    for k in range(0, 3):
        print("  " + labels[k] + joined(versions.order(texts, rules[k])))
    pairs = 0
    text_off = 0
    natural_off = 0
    example_text = ""
    example_natural = ""
    for i in range(0, len(texts)):
        for j in range(i + 1, len(texts)):
            pairs = pairs + 1
            s = versions.compare(texts[i], texts[j], "semver")
            if versions.compare(texts[i], texts[j], "text") != s:
                text_off = text_off + 1
                if example_text == "":
                    example_text = texts[i] + " and " + texts[j]
            if versions.compare(texts[i], texts[j], "natural") != s:
                natural_off = natural_off + 1
                if example_natural == "":
                    example_natural = texts[i] + " and " + texts[j]
    print("Of the " + str(pairs) + " pairs, text order disagrees with SemVer on " + str(text_off) + " and the natural sort on " + str(natural_off) + ".")
    if example_text != "":
        print("  First text disagreement: " + example_text + ".")
    if example_natural != "":
        print("  First natural-sort disagreement: " + example_natural + ".")
    print("Text compares character by character, so 1.10.0 comes before 1.9.1. A natural sort reads numbers as numbers, but puts 1.0.0 before 1.0.0-alpha, the shorter text first. SemVer puts every pre-release before its release.")

def ask_version(prompt):
    t = plain(input(prompt + "> "))
    if t == "":
        return ["", []]
    p = versions.parse(t)
    if p[1] != "":
        print("Not a version: " + p[1] + ".")
        return ["!", []]
    return [t, p[0]]

def compare_two():
    a = ask_version("first version")
    if a[0] == "":
        print("Cancelled.")
        return
    if a[0] == "!":
        return
    b = ask_version("second version")
    if b[0] == "":
        print("Cancelled.")
        return
    if b[0] == "!":
        return
    r = versions.semver(a[1], b[1])
    if r[0] < 0:
        print(a[0] + " comes before " + b[0] + ": " + r[1] + ".")
    elif r[0] > 0:
        print(a[0] + " comes after " + b[0] + ": " + r[1] + ".")
    else:
        print(a[0] + " and " + b[0] + " rank the same: " + r[1] + ".")
    t = versions.sign(a[0], b[0])
    if r[0] != 0 and t == 0 - r[0]:
        print("  As text the order is the other way round.")
    elif r[0] == 0 and t != 0:
        print("  As text they differ, so a text sort still puts one first.")

def bound_text(b):
    out = ""
    if len(b[0]) > 0:
        out = ">=" + str(b[0][0]) + "." + str(b[0][1]) + "." + str(b[0][2])
    if len(b[1]) > 0:
        op = "<"
        if b[2]:
            op = "<="
        if out != "":
            out = out + " "
        out = out + op + str(b[1][0]) + "." + str(b[1][1]) + "." + str(b[1][2])
    return out

def pick(texts):
    print("A range: ^1.2.3 (same major), ~1.2.3 (same minor), or comparators such as >=1.2.0 <1.5.0. Pre-releases never match.")
    r = plain(input("range> "))
    if r == "":
        print("Cancelled.")
        return
    all_bounds = []
    means = ""
    for w in words(r):
        b = versions.range_bounds(w)
        if b[3] != "":
            print("Not a range: " + b[3] + ".")
            return
        all_bounds = all_bounds + [b]
        if means != "":
            means = means + " "
        means = means + bound_text(b)
    print(r + " means " + means + ".")
    hits = []
    for t in texts:
        v = versions.parse(t)[0]
        ok = True
        for b in all_bounds:
            if not versions.in_range(v, b):
                ok = False
        if ok:
            hits = hits + [t]
    if len(hits) == 0:
        print("No version in the list matches.")
        return
    hits = versions.order(hits, "semver")
    print("Matching: " + joined(hits) + ". Newest: " + hits[len(hits) - 1] + ".")

def new_list():
    print("Versions separated by spaces, 2 to 20 of them.")
    t = plain(input("versions> "))
    if t == "":
        print("Cancelled.")
        return []
    out = words(t)
    if len(out) < 2 or len(out) > 20:
        print("Type 2 to 20 versions.")
        return []
    for w in out:
        p = versions.parse(w)
        if p[1] != "":
            print("Not a version: " + w + " - " + p[1] + ".")
            return []
    print(str(len(out)) + " versions.")
    return out

texts = ["1.10.0", "1.2.0", "1.9.1", "1.0.0-alpha", "1.0.0", "1.0.0-beta.11", "1.0.0-beta.2", "1.0.0-alpha.1", "2.0.0-rc.1", "1.0.0-beta", "1.0.0+build.5", "1.0.0-alpha.beta", "2.0.0"]
print("== Version sorter ==")
print("Semantic Versioning: MAJOR.MINOR.PATCH, then a pre-release after - and build metadata after +.")
running = True
while running:
    print("")
    print("1) sort the list  2) compare two  3) newest in a range  4) new list  5) quit")
    choice = plain(input("choice> "))
    if choice == "1":
        show_sorts(texts)
    elif choice == "2":
        compare_two()
    elif choice == "3":
        pick(texts)
    elif choice == "4":
        got = new_list()
        if len(got) > 0:
            texts = got
    elif choice == "5":
        running = False
    else:
        print("Pick a number from 1 to 5.")
print("Bye.")

versions.eml

eml
# P103 version sorter - Semantic Versioning 2.0.0: MAJOR.MINOR.PATCH, an
# optional pre-release after "-" and build metadata after "+". As in the
# corpus case version-string-ordering, a version is a tuple of numbers
# written with dots, and comparing it as text goes wrong at the tenth
# release; here it is set against a natural sort and the SemVer rule.
#
# A parsed version is [major, minor, patch, pre, build]: pre is a list of
# identifiers (strings), build the text after "+" or "".

def is_digits(s):
    if len(s) == 0:
        return False
    for c in s:
        if not (c >= "0" and c <= "9"):
            return False
    return True

def ident_ok(s):
    if len(s) == 0:
        return False
    for c in s:
        if not ((c >= "0" and c <= "9") or (c >= "a" and c <= "z") or (c >= "A" and c <= "Z") or c == "-"):
            return False
    return True

def split(s, sep):
    [] => out
    "" => w
    for c in s:
        if c == sep:
            out + [w] => out
            "" => w
        else:
            w + c => w
    out + [w] => out
    return out

def parse(s):
    # [version, ""] or [[], why]
    "" => build
    -1 => plus
    for i in [0:len(s) - 1]:
        if plus == -1 and s[i] == "+":
            i => plus
    if plus != -1:
        s[plus + 1:len(s)] => build
        s[0:plus] => s
        for b in split(build, "."):
            if not ident_ok(b):
                return [[], "build metadata is dot-separated letters, digits and hyphens"]
    [] => pre
    -1 => dash
    for i in [0:len(s) - 1]:
        if dash == -1 and s[i] == "-":
            i => dash
    if dash != -1:
        split(s[dash + 1:len(s)], ".") => pre
        s[0:dash] => s
        for p in pre:
            if not ident_ok(p):
                return [[], "a pre-release is dot-separated letters, digits and hyphens, none empty"]
            if is_digits(p) and len(p) > 1 and p[0] == "0":
                return [[], "a number in a pre-release has no leading zero (" + p + ")"]
    split(s, ".") => core
    if len(core) != 3:
        return [[], "a version has three numbers, MAJOR.MINOR.PATCH"]
    for c in core:
        if not is_digits(c):
            return [[], "MAJOR, MINOR and PATCH are whole numbers"]
        if len(c) > 1 and c[0] == "0":
            return [[], "MAJOR, MINOR and PATCH have no leading zeros (" + c + ")"]
    return [[int(core[0]), int(core[1]), int(core[2]), pre, build], ""]

def text(v):
    str(v[0]) + "." + str(v[1]) + "." + str(v[2]) => s
    if len(v[3]) > 0:
        "" => p
        for x in v[3]:
            if p != "":
                p + "." => p
            p + x => p
        s + "-" + p => s
    if v[4] != "":
        s + "+" + v[4] => s
    return s

def sign(a, b):
    if a < b:
        return -1
    if a > b:
        return 1
    return 0

def semver(a, b):
    # [-1, 0 or 1, the rule that decided]
    ["major", "minor", "patch"] => names
    for i in [0:2]:
        if a[i] != b[i]:
            return [sign(a[i], b[i]), names[i] + " " + str(a[i]) + " vs " + str(b[i])]
    if len(a[3]) == 0 and len(b[3]) == 0:
        if a[4] != b[4]:
            return [0, "build metadata is ignored"]
        return [0, "the same version"]
    if len(a[3]) == 0:
        return [1, "a pre-release comes before its release"]
    if len(b[3]) == 0:
        return [-1, "a pre-release comes before its release"]
    0 => i
    while i < len(a[3]) and i < len(b[3]):
        a[3][i] => x
        b[3][i] => y
        if x != y:
            if is_digits(x) and is_digits(y):
                return [sign(int(x), int(y)), "pre-release part " + str(i + 1) + ": " + x + " vs " + y + " as numbers"]
            if is_digits(x):
                return [-1, "pre-release part " + str(i + 1) + ": a number comes before a word"]
            if is_digits(y):
                return [1, "pre-release part " + str(i + 1) + ": a number comes before a word"]
            return [sign(x, y), "pre-release part " + str(i + 1) + ": " + x + " vs " + y + " in ASCII order"]
        i + 1 => i
    if len(a[3]) != len(b[3]):
        return [sign(len(a[3]), len(b[3])), "every earlier pre-release part is equal, and more parts come later"]
    if a[4] != b[4]:
        return [0, "build metadata is ignored"]
    return [0, "the same version"]

def chunks(s):
    # a natural sort's view: runs of digits as numbers, everything else as text
    [] => out
    "" => w
    False => digit
    for c in s:
        (c >= "0" and c <= "9") => d
        if w != "" and d != digit:
            out + [w] => out
            "" => w
        w + c => w
        d => digit
    if w != "":
        out + [w] => out
    return out

def natural(a, b):
    chunks(a) => x
    chunks(b) => y
    0 => i
    while i < len(x) and i < len(y):
        if x[i] != y[i]:
            if is_digits(x[i]) and is_digits(y[i]):
                if int(x[i]) != int(y[i]):
                    return sign(int(x[i]), int(y[i]))
            return sign(x[i], y[i])
        i + 1 => i
    return sign(len(x), len(y))

def order(texts, rule):
    # the texts sorted by rule ("text", "natural" or "semver"); stable
    [] => out
    for t in texts:
        out + [t] => out
    for i in [1:len(out) - 1]:
        i => j
        while j > 0 and compare(out[j - 1], out[j], rule) > 0:
            out[j] => t
            out[j - 1] => out[j]
            t => out[j - 1]
            j - 1 => j
    return out

def compare(a, b, rule):
    if rule == "text":
        return sign(a, b)
    if rule == "natural":
        return natural(a, b)
    return semver(parse(a)[0], parse(b)[0])[0]

def range_bounds(r):
    # [low, high, inclusive high, why]: ^V and ~V, or one comparator
    # >=V, >V, <=V, <V, =V or V; versions are [major, minor, patch];
    # high [] for none, low [] for none
    "" => op
    for o in [">=", "<=", "^", "~", ">", "<", "="]:
        if op == "" and r[0:len(o)] == o:
            o => op
    r[len(op):len(r)] => rest
    parse(rest) => p
    if p[1] != "":
        return [[], [], False, rest + ": " + p[1]]
    p[0] => v
    if len(v[3]) > 0:
        return [[], [], False, "a range here takes releases only, without a pre-release"]
    [v[0], v[1], v[2]] => base
    if op == "^":
        if v[0] > 0:
            return [base, [v[0] + 1, 0, 0], False, ""]
        if v[1] > 0:
            return [base, [0, v[1] + 1, 0], False, ""]
        return [base, [0, 0, v[2] + 1], False, ""]
    if op == "~":
        return [base, [v[0], v[1] + 1, 0], False, ""]
    if op == ">=":
        return [base, [], False, ""]
    if op == ">":
        return [[v[0], v[1], v[2] + 1], [], False, ""]
    if op == "<=":
        return [[], base, True, ""]
    if op == "<":
        return [[], base, False, ""]
    return [base, base, True, ""]

def triple_cmp(a, b):
    for i in [0:2]:
        if a[i] != b[i]:
            return sign(a[i], b[i])
    return 0

def in_range(v, bounds):
    # releases only: a pre-release never matches
    if len(v[3]) > 0:
        return False
    [v[0], v[1], v[2]] => t
    if len(bounds[0]) > 0 and triple_cmp(t, bounds[0]) < 0:
        return False
    if len(bounds[1]) > 0:
        triple_cmp(t, bounds[1]) => c
        if c > 0 or (c == 0 and not bounds[2]):
            return False
    return True
Python projection (versions.py)
def is_digits(s):
    if len(s) == 0:
        return False
    for c in s:
        if not (c >= "0" and c <= "9"):
            return False
    return True

def ident_ok(s):
    if len(s) == 0:
        return False
    for c in s:
        if not (c >= "0" and c <= "9" or c >= "a" and c <= "z" or c >= "A" and c <= "Z" or c == "-"):
            return False
    return True

def split(s, sep):
    out = []
    w = ""
    for c in s:
        if c == sep:
            out = out + [w]
            w = ""
        else:
            w = w + c
    out = out + [w]
    return out

def parse(s):
    build = ""
    plus = -1
    for i in range(0, len(s)):
        if plus == -1 and s[i] == "+":
            plus = i
    if plus != -1:
        build = s[plus + 1:len(s)]
        s = s[0:plus]
        for b in split(build, "."):
            if not ident_ok(b):
                return [[], "build metadata is dot-separated letters, digits and hyphens"]
    pre = []
    dash = -1
    for i in range(0, len(s)):
        if dash == -1 and s[i] == "-":
            dash = i
    if dash != -1:
        pre = split(s[dash + 1:len(s)], ".")
        s = s[0:dash]
        for p in pre:
            if not ident_ok(p):
                return [[], "a pre-release is dot-separated letters, digits and hyphens, none empty"]
            if is_digits(p) and len(p) > 1 and p[0] == "0":
                return [[], "a number in a pre-release has no leading zero (" + p + ")"]
    core = split(s, ".")
    if len(core) != 3:
        return [[], "a version has three numbers, MAJOR.MINOR.PATCH"]
    for c in core:
        if not is_digits(c):
            return [[], "MAJOR, MINOR and PATCH are whole numbers"]
        if len(c) > 1 and c[0] == "0":
            return [[], "MAJOR, MINOR and PATCH have no leading zeros (" + c + ")"]
    return [[int(core[0]), int(core[1]), int(core[2]), pre, build], ""]

def text(v):
    s = str(v[0]) + "." + str(v[1]) + "." + str(v[2])
    if len(v[3]) > 0:
        p = ""
        for x in v[3]:
            if p != "":
                p = p + "."
            p = p + x
        s = s + "-" + p
    if v[4] != "":
        s = s + "+" + v[4]
    return s

def sign(a, b):
    if a < b:
        return -1
    if a > b:
        return 1
    return 0

def semver(a, b):
    names = ["major", "minor", "patch"]
    for i in range(0, 3):
        if a[i] != b[i]:
            return [sign(a[i], b[i]), names[i] + " " + str(a[i]) + " vs " + str(b[i])]
    if len(a[3]) == 0 and len(b[3]) == 0:
        if a[4] != b[4]:
            return [0, "build metadata is ignored"]
        return [0, "the same version"]
    if len(a[3]) == 0:
        return [1, "a pre-release comes before its release"]
    if len(b[3]) == 0:
        return [-1, "a pre-release comes before its release"]
    i = 0
    while i < len(a[3]) and i < len(b[3]):
        x = a[3][i]
        y = b[3][i]
        if x != y:
            if is_digits(x) and is_digits(y):
                return [sign(int(x), int(y)), "pre-release part " + str(i + 1) + ": " + x + " vs " + y + " as numbers"]
            if is_digits(x):
                return [-1, "pre-release part " + str(i + 1) + ": a number comes before a word"]
            if is_digits(y):
                return [1, "pre-release part " + str(i + 1) + ": a number comes before a word"]
            return [sign(x, y), "pre-release part " + str(i + 1) + ": " + x + " vs " + y + " in ASCII order"]
        i = i + 1
    if len(a[3]) != len(b[3]):
        return [sign(len(a[3]), len(b[3])), "every earlier pre-release part is equal, and more parts come later"]
    if a[4] != b[4]:
        return [0, "build metadata is ignored"]
    return [0, "the same version"]

def chunks(s):
    out = []
    w = ""
    digit = False
    for c in s:
        d = c >= "0" and c <= "9"
        if w != "" and d != digit:
            out = out + [w]
            w = ""
        w = w + c
        digit = d
    if w != "":
        out = out + [w]
    return out

def natural(a, b):
    x = chunks(a)
    y = chunks(b)
    i = 0
    while i < len(x) and i < len(y):
        if x[i] != y[i]:
            if is_digits(x[i]) and is_digits(y[i]):
                if int(x[i]) != int(y[i]):
                    return sign(int(x[i]), int(y[i]))
            return sign(x[i], y[i])
        i = i + 1
    return sign(len(x), len(y))

def order(texts, rule):
    out = []
    for t in texts:
        out = out + [t]
    for i in range(1, len(out)):
        j = i
        while j > 0 and compare(out[j - 1], out[j], rule) > 0:
            t = out[j]
            out[j] = out[j - 1]
            out[j - 1] = t
            j = j - 1
    return out

def compare(a, b, rule):
    if rule == "text":
        return sign(a, b)
    if rule == "natural":
        return natural(a, b)
    return semver(parse(a)[0], parse(b)[0])[0]

def range_bounds(r):
    op = ""
    for o in [">=", "<=", "^", "~", ">", "<", "="]:
        if op == "" and r[0:len(o)] == o:
            op = o
    rest = r[len(op):len(r)]
    p = parse(rest)
    if p[1] != "":
        return [[], [], False, rest + ": " + p[1]]
    v = p[0]
    if len(v[3]) > 0:
        return [[], [], False, "a range here takes releases only, without a pre-release"]
    base = [v[0], v[1], v[2]]
    if op == "^":
        if v[0] > 0:
            return [base, [v[0] + 1, 0, 0], False, ""]
        if v[1] > 0:
            return [base, [0, v[1] + 1, 0], False, ""]
        return [base, [0, 0, v[2] + 1], False, ""]
    if op == "~":
        return [base, [v[0], v[1] + 1, 0], False, ""]
    if op == ">=":
        return [base, [], False, ""]
    if op == ">":
        return [[v[0], v[1], v[2] + 1], [], False, ""]
    if op == "<=":
        return [[], base, True, ""]
    if op == "<":
        return [[], base, False, ""]
    return [base, base, True, ""]

def triple_cmp(a, b):
    for i in range(0, 3):
        if a[i] != b[i]:
            return sign(a[i], b[i])
    return 0

def in_range(v, bounds):
    if len(v[3]) > 0:
        return False
    t = [v[0], v[1], v[2]]
    if len(bounds[0]) > 0 and triple_cmp(t, bounds[0]) < 0:
        return False
    if len(bounds[1]) > 0:
        c = triple_cmp(t, bounds[1])
        if c > 0 or c == 0 and not bounds[2]:
            return False
    return True

Built on these corpus cases

ΣEML 2026

Efficient New Language — a high-density semantic overlay for humans and AI agents.

This site’s demos run on the real EML toolchain, in your browser.

© 2026 EveMissLab (一言諾科技有限公司) / Neo.K. All rights reserved.