semver.gno
9.16 Kb · 374 lines
1// Package semver is an on-chain port of the core of golang.org/x/mod/semver
2// (and the spirit of github.com/Masterminds/semver): parse "vMAJOR.MINOR.PATCH
3// [-prerelease][+build]" strings and compare them with correct Semantic
4// Versioning 2.0.0 precedence. Pure and deterministic — no time, randomness,
5// goroutines, or I/O.
6package semver
7
8import (
9 "chain"
10 "chain/runtime/unsafe"
11 "errors"
12 "sort"
13 "strings"
14
15 "gno.land/p/nt/avl/v0"
16)
17
18// Version is a parsed semantic version. Build metadata is retained for display
19// but, per the spec, is ignored when comparing precedence.
20type Version struct {
21 Major, Minor, Patch int
22 Pre []string // pre-release identifiers (dot-separated)
23 Build string // build metadata (after '+')
24 Orig string // original input
25}
26
27var errBadVersion = errors.New("invalid semantic version")
28
29// submitted holds versions users have posted on-chain: version string -> submitter.
30var submitted = avl.NewTree()
31
32// Parse reads "vMAJOR.MINOR.PATCH[-prerelease][+build]". The leading 'v' is
33// optional. Numeric fields must be non-empty digit runs without leading zeros.
34func Parse(s string) (Version, error) {
35 v := Version{Orig: s}
36 body := strings.TrimPrefix(s, "v")
37
38 if i := strings.IndexByte(body, '+'); i >= 0 {
39 v.Build = body[i+1:]
40 body = body[:i]
41 if v.Build == "" {
42 return Version{}, errBadVersion
43 }
44 }
45 if i := strings.IndexByte(body, '-'); i >= 0 {
46 pre := body[i+1:]
47 body = body[:i]
48 v.Pre = strings.Split(pre, ".")
49 for _, id := range v.Pre {
50 if !validPreIdent(id) {
51 return Version{}, errBadVersion
52 }
53 }
54 }
55
56 parts := strings.Split(body, ".")
57 if len(parts) != 3 {
58 return Version{}, errBadVersion
59 }
60 nums := [3]int{}
61 for i, p := range parts {
62 n, ok := parseNum(p)
63 if !ok {
64 return Version{}, errBadVersion
65 }
66 nums[i] = n
67 }
68 v.Major, v.Minor, v.Patch = nums[0], nums[1], nums[2]
69 return v, nil
70}
71
72// Compare returns -1, 0, or +1 as a < b, a == b, or a > b under semver
73// precedence. Unparseable inputs sort after everything valid (and equal to
74// each other), so Compare never panics.
75func Compare(a, b string) int {
76 va, ea := Parse(a)
77 vb, eb := Parse(b)
78 switch {
79 case ea != nil && eb != nil:
80 return 0
81 case ea != nil:
82 return 1
83 case eb != nil:
84 return -1
85 }
86 return va.compare(vb)
87}
88
89func (v Version) compare(o Version) int {
90 if c := cmpInt(v.Major, o.Major); c != 0 {
91 return c
92 }
93 if c := cmpInt(v.Minor, o.Minor); c != 0 {
94 return c
95 }
96 if c := cmpInt(v.Patch, o.Patch); c != 0 {
97 return c
98 }
99 return comparePre(v.Pre, o.Pre)
100}
101
102// comparePre implements SemVer §11: a version WITH a pre-release has lower
103// precedence than the same version WITHOUT one.
104func comparePre(a, b []string) int {
105 if len(a) == 0 && len(b) == 0 {
106 return 0
107 }
108 if len(a) == 0 { // a is a release, b is a pre-release
109 return 1
110 }
111 if len(b) == 0 {
112 return -1
113 }
114 for i := 0; i < len(a) && i < len(b); i++ {
115 if c := compareIdent(a[i], b[i]); c != 0 {
116 return c
117 }
118 }
119 return cmpInt(len(a), len(b))
120}
121
122// compareIdent: numeric identifiers compare numerically and always rank below
123// alphanumeric ones; alphanumerics compare in ASCII order.
124func compareIdent(a, b string) int {
125 an, aNum := parseNum(a)
126 bn, bNum := parseNum(b)
127 switch {
128 case aNum && bNum:
129 return cmpInt(an, bn)
130 case aNum:
131 return -1
132 case bNum:
133 return 1
134 }
135 return strings.Compare(a, b)
136}
137
138func cmpInt(a, b int) int {
139 switch {
140 case a < b:
141 return -1
142 case a > b:
143 return 1
144 }
145 return 0
146}
147
148// parseNum accepts a non-empty run of ASCII digits with no leading zero (except
149// "0" itself), returning the value and whether it qualified.
150func parseNum(s string) (int, bool) {
151 if s == "" {
152 return 0, false
153 }
154 if len(s) > 1 && s[0] == '0' {
155 return 0, false
156 }
157 n := 0
158 for i := 0; i < len(s); i++ {
159 c := s[i]
160 if c < '0' || c > '9' {
161 return 0, false
162 }
163 n = n*10 + int(c-'0')
164 }
165 return n, true
166}
167
168// validPreIdent allows [0-9A-Za-z-] and rejects empty identifiers.
169func validPreIdent(s string) bool {
170 if s == "" {
171 return false
172 }
173 for i := 0; i < len(s); i++ {
174 c := s[i]
175 ok := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') ||
176 (c >= 'A' && c <= 'Z') || c == '-'
177 if !ok {
178 return false
179 }
180 }
181 return true
182}
183
184// Canonical renders the parsed version back to a canonical string.
185func (v Version) Canonical() string {
186 s := ufmt(v.Major) + "." + ufmt(v.Minor) + "." + ufmt(v.Patch)
187 if len(v.Pre) > 0 {
188 s += "-" + strings.Join(v.Pre, ".")
189 }
190 if v.Build != "" {
191 s += "+" + v.Build
192 }
193 return s
194}
195
196func ufmt(n int) string {
197 if n == 0 {
198 return "0"
199 }
200 var b []byte
201 for n > 0 {
202 b = append([]byte{byte('0' + n%10)}, b...)
203 n /= 10
204 }
205 return string(b)
206}
207
208// Submit stores a valid version on-chain, tagged with the caller's address, so
209// Render("/sorted") can list everyone's submissions in precedence order.
210func Submit(cur realm, version string) {
211 if _, err := Parse(version); err != nil {
212 panic("invalid semantic version: " + version)
213 }
214 who := unsafe.PreviousRealm().Address()
215 submitted.Set(version, who.String())
216 chain.Emit("VersionSubmitted", "version", version, "by", who.String())
217}
218
219// ---- Render ----------------------------------------------------------------
220
221func Render(path string) string {
222 path = strings.TrimPrefix(path, "/")
223 switch {
224 case path == "":
225 return renderHome()
226 case path == "sorted":
227 return renderSorted()
228 default:
229 parts := strings.SplitN(path, "/", 2)
230 if len(parts) != 2 {
231 return renderHome()
232 }
233 return renderCompare(parts[0], parts[1])
234 }
235}
236
237func renderHome() string {
238 var b strings.Builder
239 b.WriteString("# semver — Semantic Versioning on-chain\n\n")
240 b.WriteString("A port of `golang.org/x/mod/semver`. Parse ")
241 b.WriteString("`vMAJOR.MINOR.PATCH[-prerelease][+build]` and compare with ")
242 b.WriteString("SemVer 2.0.0 precedence.\n\n")
243
244 b.WriteString("## Examples\n\n")
245 b.WriteString("| a | rel | b | note |\n|---|:---:|---|---|\n")
246 examples := [][2]string{
247 {"1.2.3", "1.2.10"},
248 {"1.0.0-alpha", "1.0.0"},
249 {"1.0.0-alpha.1", "1.0.0-alpha.beta"},
250 {"1.0.0-rc.1", "1.0.0"},
251 {"2.0.0", "2.0.0+build.5"},
252 }
253 notes := []string{
254 "numeric patch, not lexical",
255 "pre-release < release",
256 "numeric id < alphanumeric id",
257 "release candidate < final",
258 "build metadata ignored",
259 }
260 for i, ex := range examples {
261 b.WriteString("| `" + ex[0] + "` | " + relSym(Compare(ex[0], ex[1])) +
262 " | `" + ex[1] + "` | " + notes[i] + " |\n")
263 }
264
265 b.WriteString("\n## Try it\n\n")
266 b.WriteString("- Compare two versions: [`/1.2.3/1.2.10`](/r/moul/x/daily/semver:1.2.3/1.2.10)\n")
267 b.WriteString("- Submitted list: [`/sorted`](/r/moul/x/daily/semver:sorted)\n")
268 b.WriteString("- Submit on-chain: `Submit(\"v1.4.2\")`\n")
269 return b.String()
270}
271
272func renderCompare(a, b string) string {
273 var sb strings.Builder
274 sb.WriteString("# Compare\n\n")
275 sb.WriteString(describe("A", a))
276 sb.WriteString(describe("B", b))
277
278 va, ea := Parse(a)
279 vb, eb := Parse(b)
280 if ea != nil || eb != nil {
281 sb.WriteString("\n> One or both inputs are not valid semver.\n")
282 return sb.String()
283 }
284 c := va.compare(vb)
285 sb.WriteString("\n## Result\n\n")
286 sb.WriteString("`" + a + "` **" + word(c) + "** `" + b + "` ")
287 sb.WriteString("→ `Compare = " + ufmtSigned(c) + "`\n")
288 return sb.String()
289}
290
291func describe(label, s string) string {
292 v, err := Parse(s)
293 if err != nil {
294 return "## " + label + ": `" + s + "`\n\n> invalid\n\n"
295 }
296 pre := "—"
297 if len(v.Pre) > 0 {
298 pre = "`" + strings.Join(v.Pre, ".") + "`"
299 }
300 build := "—"
301 if v.Build != "" {
302 build = "`" + v.Build + "`"
303 }
304 return "## " + label + ": `" + v.Canonical() + "`\n\n" +
305 "| major | minor | patch | pre | build |\n|---|---|---|---|---|\n" +
306 "| " + ufmt(v.Major) + " | " + ufmt(v.Minor) + " | " + ufmt(v.Patch) +
307 " | " + pre + " | " + build + " |\n\n"
308}
309
310func renderSorted() string {
311 var es []entry
312 // Capture the submitter straight from Iterate's callback value, avoiding a
313 // separate avl.Get whose arity differs across gno versions.
314 submitted.Iterate("", "", func(key string, value any) bool {
315 if v, err := Parse(key); err == nil {
316 who, _ := value.(string)
317 es = append(es, entry{v: v, who: who})
318 }
319 return false
320 })
321 if len(es) == 0 {
322 return "# Submitted versions\n\n_None yet._ Call `Submit(\"v1.0.0\")` to add one.\n"
323 }
324 sort.Stable(byPrecedence(es))
325
326 var b strings.Builder
327 b.WriteString("# Submitted versions (lowest → highest)\n\n")
328 b.WriteString("| # | version | submitter |\n|---|---|---|\n")
329 for i, e := range es {
330 b.WriteString("| " + ufmt(i+1) + " | `" + e.v.Canonical() + "` | `" + e.who + "` |\n")
331 }
332 return b.String()
333}
334
335// entry pairs a parsed version with the address that submitted it.
336type entry struct {
337 v Version
338 who string
339}
340
341// byPrecedence sorts entries ascending. `sort` on-chain has no Slice helper,
342// so we implement sort.Interface.
343type byPrecedence []entry
344
345func (p byPrecedence) Len() int { return len(p) }
346func (p byPrecedence) Less(i, j int) bool { return p[i].v.compare(p[j].v) < 0 }
347func (p byPrecedence) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
348
349func relSym(c int) string {
350 switch {
351 case c < 0:
352 return "<"
353 case c > 0:
354 return ">"
355 }
356 return "="
357}
358
359func word(c int) string {
360 switch {
361 case c < 0:
362 return "<"
363 case c > 0:
364 return ">"
365 }
366 return "=="
367}
368
369func ufmtSigned(c int) string {
370 if c < 0 {
371 return "-1"
372 }
373 return ufmt(c)
374}