humanize.gno
3.44 Kb · 145 lines
1// Package humanize formats numbers for people rather than machines, as a pure,
2// reusable package: byte sizes, thousands separators, ordinals, pluralisation
3// and block-height "durations".
4//
5// Everything is integer-only. There are no floats here on purpose: gno has no
6// float determinism guarantees worth relying on for consensus output, and a
7// rendered value that differs between nodes would be a consensus bug. One
8// decimal place is produced by scaling by 10 and taking a remainder.
9//
10// Durations are expressed in BLOCKS, not seconds. There is no wall clock on
11// chain, so "about 2 hours" is a lie dressed as precision; this package says
12// "~1200 blocks" and lets the caller decide what that means on their chain.
13//
14// A live demo of this package is at
15// [r/moul/x/daily/humanizedemo](/r/moul/x/daily/humanizedemo/v0).
16package humanize
17
18import (
19 "strconv"
20 "strings"
21)
22
23// Bytes renders n bytes with SI-ish binary units and one decimal place.
24// Negative input is rendered with a leading minus rather than rejected.
25func Bytes(n int64) string {
26 neg := n < 0
27 if neg {
28 n = -n
29 }
30 const unit = 1024
31 if n < unit {
32 s := strconv.FormatInt(n, 10) + " B"
33 if neg {
34 return "-" + s
35 }
36 return s
37 }
38 units := []string{"KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
39 div := int64(unit)
40 i := 0
41 for n/div >= unit && i < len(units)-1 {
42 div *= unit
43 i++
44 }
45 // one decimal place without floats: scale by 10, then split
46 scaled := n * 10 / div
47 whole, freq := scaled/10, scaled%10
48 s := strconv.FormatInt(whole, 10)
49 if freq != 0 {
50 s += "." + strconv.FormatInt(freq, 10)
51 }
52 s += " " + units[i]
53 if neg {
54 return "-" + s
55 }
56 return s
57}
58
59// Comma inserts thousands separators: 1234567 -> "1,234,567".
60func Comma(n int64) string {
61 neg := n < 0
62 if neg {
63 n = -n
64 }
65 s := strconv.FormatInt(n, 10)
66 var b strings.Builder
67 for i, c := range []byte(s) {
68 if i > 0 && (len(s)-i)%3 == 0 {
69 b.WriteByte(',')
70 }
71 b.WriteByte(c)
72 }
73 if neg {
74 return "-" + b.String()
75 }
76 return b.String()
77}
78
79// Ordinal renders 1 -> "1st", 2 -> "2nd", 11 -> "11th".
80//
81// The teens are the trap: 11/12/13 take "th" despite ending in 1/2/3, so the
82// 11–13 case must be checked before the last digit.
83func Ordinal(n int64) string {
84 s := strconv.FormatInt(n, 10)
85 a := n
86 if a < 0 {
87 a = -a
88 }
89 if a%100 >= 11 && a%100 <= 13 {
90 return s + "th"
91 }
92 switch a % 10 {
93 case 1:
94 return s + "st"
95 case 2:
96 return s + "nd"
97 case 3:
98 return s + "rd"
99 }
100 return s + "th"
101}
102
103// Plural returns "1 block" / "2 blocks", using plural when given, else word+"s".
104func Plural(n int64, word, plural string) string {
105 if n == 1 || n == -1 {
106 return strconv.FormatInt(n, 10) + " " + word
107 }
108 if plural == "" {
109 plural = word + "s"
110 }
111 return strconv.FormatInt(n, 10) + " " + plural
112}
113
114// Blocks renders a block count at a coarse magnitude — deliberately vague,
115// because a block count is not a wall-clock duration.
116func Blocks(n int64) string {
117 switch {
118 case n < 0:
119 return "in the past"
120 case n == 0:
121 return "now"
122 case n < 10:
123 return Plural(n, "block", "")
124 case n < 1000:
125 return "~" + Comma(n/10*10) + " blocks"
126 default:
127 return "~" + Comma(n/100*100) + " blocks"
128 }
129}
130
131// Truncate shortens s to at most max runes, appending "…" when it cut.
132// Counts RUNES, not bytes, so a multi-byte string is not sliced mid-character.
133func Truncate(s string, max int) string {
134 if max <= 0 {
135 return ""
136 }
137 r := []rune(s)
138 if len(r) <= max {
139 return s
140 }
141 if max == 1 {
142 return "…"
143 }
144 return string(r[:max-1]) + "…"
145}