File: ./avoid/avoid.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package avoid
26
27 import (
28 "bufio"
29 "bytes"
30 "io"
31 "os"
32 "regexp"
33 )
34
35 const info = `
36 avoid [options...] [regular expressions...]
37
38 Avoid/ignore lines which match any of the extended-mode regular expressions
39 given. When not given any regex, all empty lines are ignored by default.
40
41 The options are, available both in single and double-dash versions
42
43 -h, -help show this help message
44 -i, -ins match regexes case-insensitively
45 `
46
47 func Main() {
48 nerr := 0
49 buffered := false
50 sensitive := true
51 args := os.Args[1:]
52
53 for len(args) > 0 {
54 switch args[0] {
55 case `-b`, `--b`, `-buffered`, `--buffered`:
56 buffered = true
57 args = args[1:]
58 continue
59
60 case `-h`, `--h`, `-help`, `--help`:
61 os.Stdout.WriteString(info[1:])
62 return
63
64 case `-i`, `--i`, `-ins`, `--ins`:
65 sensitive = false
66 args = args[1:]
67 continue
68 }
69
70 break
71 }
72
73 if len(args) > 0 && args[0] == `--` {
74 args = args[1:]
75 }
76
77 liveLines := !buffered
78 if !buffered {
79 if _, err := os.Stdout.Seek(0, io.SeekCurrent); err == nil {
80 liveLines = false
81 }
82 }
83
84 if len(args) == 0 {
85 args = []string{`^$`}
86 }
87
88 exprs := make([]*regexp.Regexp, 0, len(args))
89
90 for _, src := range args {
91 var err error
92 var exp *regexp.Regexp
93 if !sensitive {
94 exp, err = regexp.Compile(`(?i)` + src)
95 } else {
96 exp, err = regexp.Compile(src)
97 }
98
99 if err != nil {
100 os.Stderr.WriteString(err.Error())
101 os.Stderr.WriteString("\n")
102 nerr++
103 }
104
105 exprs = append(exprs, exp)
106 }
107
108 if nerr > 0 {
109 os.Exit(1)
110 }
111
112 var buf []byte
113 sc := bufio.NewScanner(os.Stdin)
114 sc.Buffer(nil, 8*1024*1024*1024)
115 bw := bufio.NewWriter(os.Stdout)
116
117 for i := 0; sc.Scan(); i++ {
118 line := sc.Bytes()
119 if i == 0 && bytes.HasPrefix(line, []byte{0xef, 0xbb, 0xbf}) {
120 line = line[3:]
121 }
122
123 s := line
124 if bytes.IndexByte(s, '\x1b') >= 0 {
125 buf = plain(buf[:0], s)
126 s = buf
127 }
128
129 if !match(s, exprs) {
130 bw.Write(line)
131 bw.WriteByte('\n')
132
133 if !liveLines {
134 continue
135 }
136
137 if err := bw.Flush(); err != nil {
138 return
139 }
140 }
141 }
142 }
143
144 func match(what []byte, with []*regexp.Regexp) bool {
145 for _, e := range with {
146 if e.Match(what) {
147 return true
148 }
149 }
150 return false
151 }
152
153 func plain(dst []byte, src []byte) []byte {
154 for len(src) > 0 {
155 i, j := indexEscapeSequence(src)
156 if i < 0 {
157 dst = append(dst, src...)
158 break
159 }
160 if j < 0 {
161 j = len(src)
162 }
163
164 if i > 0 {
165 dst = append(dst, src[:i]...)
166 }
167
168 src = src[j:]
169 }
170
171 return dst
172 }
173
174 // indexEscapeSequence finds the first ANSI-style escape-sequence, which is
175 // the multi-byte sequences starting with ESC[; the result is a pair of slice
176 // indices which can be independently negative when either the start/end of
177 // a sequence isn't found; given their fairly-common use, even the hyperlink
178 // ESC]8 sequences are supported
179 func indexEscapeSequence(s []byte) (int, int) {
180 var prev byte
181
182 for i, b := range s {
183 if prev == '\x1b' && b == '[' {
184 j := indexLetter(s[i+1:])
185 if j < 0 {
186 return i, -1
187 }
188 return i - 1, i + 1 + j + 1
189 }
190
191 if prev == '\x1b' && b == ']' && i+1 < len(s) && s[i+1] == '8' {
192 j := indexPair(s[i+1:], '\x1b', '\\')
193 if j < 0 {
194 return i, -1
195 }
196 return i - 1, i + 1 + j + 2
197 }
198
199 prev = b
200 }
201
202 return -1, -1
203 }
204
205 func indexLetter(s []byte) int {
206 for i, b := range s {
207 upper := b &^ 32
208 if 'A' <= upper && upper <= 'Z' {
209 return i
210 }
211 }
212
213 return -1
214 }
215
216 func indexPair(s []byte, x byte, y byte) int {
217 var prev byte
218
219 for i, b := range s {
220 if prev == x && b == y && i > 0 {
221 return i
222 }
223 prev = b
224 }
225
226 return -1
227 }
File: ./bytedump/bytedump.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package bytedump
26
27 import (
28 "bufio"
29 "fmt"
30 "io"
31 "math"
32 "os"
33 "strconv"
34 "strings"
35 )
36
37 const info = `
38 bytedump [options...] [filenames...]
39
40 Show bytes as hexadecimal and ascii on the side.
41
42 Each line shows the starting offset for the bytes shown, 16 of the bytes
43 themselves in base-16 notation, and any ASCII codes when the byte values
44 are in the typical ASCII range.
45
46 The ASCII codes always include 2 rows, which makes the output more 'grep'
47 friendly, since strings up to 32 items can't be accidentally missed. The
48 offsets shown are base-16.
49 `
50
51 const perLine = 16
52
53 // hexSymbols is a direct lookup table combining 2 hex digits with either a
54 // space or a displayable ASCII symbol matching the byte's own ASCII value;
55 // this table was autogenerated by running the command
56 //
57 // seq 0 255 | ./hex-symbols.awk
58 var hexSymbols = [256]string{
59 `00 `, `01 `, `02 `, `03 `, `04 `, `05 `, `06 `, `07 `,
60 `08 `, `09 `, `0a `, `0b `, `0c `, `0d `, `0e `, `0f `,
61 `10 `, `11 `, `12 `, `13 `, `14 `, `15 `, `16 `, `17 `,
62 `18 `, `19 `, `1a `, `1b `, `1c `, `1d `, `1e `, `1f `,
63 `20 `, `21!`, `22"`, `23#`, `24$`, `25%`, `26&`, `27'`,
64 `28(`, `29)`, `2a*`, `2b+`, `2c,`, `2d-`, `2e.`, `2f/`,
65 `300`, `311`, `322`, `333`, `344`, `355`, `366`, `377`,
66 `388`, `399`, `3a:`, `3b;`, `3c<`, `3d=`, `3e>`, `3f?`,
67 `40@`, `41A`, `42B`, `43C`, `44D`, `45E`, `46F`, `47G`,
68 `48H`, `49I`, `4aJ`, `4bK`, `4cL`, `4dM`, `4eN`, `4fO`,
69 `50P`, `51Q`, `52R`, `53S`, `54T`, `55U`, `56V`, `57W`,
70 `58X`, `59Y`, `5aZ`, `5b[`, `5c\`, `5d]`, `5e^`, `5f_`,
71 "60`", `61a`, `62b`, `63c`, `64d`, `65e`, `66f`, `67g`,
72 `68h`, `69i`, `6aj`, `6bk`, `6cl`, `6dm`, `6en`, `6fo`,
73 `70p`, `71q`, `72r`, `73s`, `74t`, `75u`, `76v`, `77w`,
74 `78x`, `79y`, `7az`, `7b{`, `7c|`, `7d}`, `7e~`, `7f `,
75 `80 `, `81 `, `82 `, `83 `, `84 `, `85 `, `86 `, `87 `,
76 `88 `, `89 `, `8a `, `8b `, `8c `, `8d `, `8e `, `8f `,
77 `90 `, `91 `, `92 `, `93 `, `94 `, `95 `, `96 `, `97 `,
78 `98 `, `99 `, `9a `, `9b `, `9c `, `9d `, `9e `, `9f `,
79 `a0 `, `a1 `, `a2 `, `a3 `, `a4 `, `a5 `, `a6 `, `a7 `,
80 `a8 `, `a9 `, `aa `, `ab `, `ac `, `ad `, `ae `, `af `,
81 `b0 `, `b1 `, `b2 `, `b3 `, `b4 `, `b5 `, `b6 `, `b7 `,
82 `b8 `, `b9 `, `ba `, `bb `, `bc `, `bd `, `be `, `bf `,
83 `c0 `, `c1 `, `c2 `, `c3 `, `c4 `, `c5 `, `c6 `, `c7 `,
84 `c8 `, `c9 `, `ca `, `cb `, `cc `, `cd `, `ce `, `cf `,
85 `d0 `, `d1 `, `d2 `, `d3 `, `d4 `, `d5 `, `d6 `, `d7 `,
86 `d8 `, `d9 `, `da `, `db `, `dc `, `dd `, `de `, `df `,
87 `e0 `, `e1 `, `e2 `, `e3 `, `e4 `, `e5 `, `e6 `, `e7 `,
88 `e8 `, `e9 `, `ea `, `eb `, `ec `, `ed `, `ee `, `ef `,
89 `f0 `, `f1 `, `f2 `, `f3 `, `f4 `, `f5 `, `f6 `, `f7 `,
90 `f8 `, `f9 `, `fa `, `fb `, `fc `, `fd `, `fe `, `ff `,
91 }
92
93 func Main() {
94 args := os.Args[1:]
95
96 if len(args) > 0 {
97 switch args[0] {
98 case `-h`, `--h`, `-help`, `--help`:
99 os.Stdout.WriteString(info[1:])
100 return
101 }
102 }
103
104 if len(args) > 0 && args[0] == `--` {
105 args = args[1:]
106 }
107
108 if err := run(args); err != nil {
109 os.Stdout.WriteString(err.Error())
110 os.Stdout.WriteString("\n")
111 os.Exit(1)
112 }
113 }
114
115 func run(args []string) error {
116 w := bufio.NewWriterSize(os.Stdout, 32*1024)
117 defer w.Flush()
118
119 // with no filenames given, handle stdin and quit
120 if len(args) == 0 {
121 return handle(w, os.Stdin, `<stdin>`, -1)
122 }
123
124 for i, fname := range args {
125 if i > 0 {
126 w.WriteString("\n")
127 w.WriteString("\n")
128 }
129
130 if err := handleFile(w, fname); err != nil {
131 return err
132 }
133 }
134
135 return nil
136 }
137
138 func handleFile(w *bufio.Writer, fname string) error {
139 f, err := os.Open(fname)
140 if err != nil {
141 return err
142 }
143 defer f.Close()
144
145 stat, err := f.Stat()
146 if err != nil {
147 return handle(w, f, fname, -1)
148 }
149
150 fsize := int(stat.Size())
151 return handle(w, f, fname, fsize)
152 }
153
154 // handle shows some messages related to the input and the cmd-line options
155 // used, and then follows them by the hexadecimal byte-view
156 func handle(w *bufio.Writer, r io.Reader, name string, size int) error {
157 owidth10 := -1
158 owidth16 := -1
159 if size > 0 {
160 w10 := math.Log10(float64(size))
161 w10 = math.Max(math.Ceil(w10), 1)
162 w16 := math.Log2(float64(size)) / 4
163 w16 = math.Max(math.Ceil(w16), 1)
164 owidth10 = int(w10)
165 owidth16 = int(w16)
166 }
167
168 if owidth10 < 0 {
169 owidth10 = 8
170 }
171 if owidth16 < 0 {
172 owidth16 = 8
173 }
174
175 rc := rendererConfig{
176 out: w,
177 offsetWidth10: max(owidth10, 8),
178 offsetWidth16: max(owidth16, 8),
179 }
180
181 if size < 0 {
182 fmt.Fprintf(w, "• %s\n", name)
183 } else {
184 const fs = "• %s (%s bytes)\n"
185 fmt.Fprintf(w, fs, name, sprintCommas(size))
186 }
187 w.WriteByte('\n')
188
189 // calling func Read directly can sometimes result in chunks shorter
190 // than the max chunk-size, even when there are plenty of bytes yet
191 // to read; to avoid that, use a buffered-reader to explicitly fill
192 // a slice instead
193 br := bufio.NewReader(r)
194
195 // to show ASCII up to 1 full chunk ahead, 2 chunks are needed
196 cur := make([]byte, 0, perLine)
197 ahead := make([]byte, 0, perLine)
198
199 // the ASCII-panel's wide output requires staying 1 step/chunk behind,
200 // so to speak
201 cur, err := fillChunk(cur[:0], perLine, br)
202 if len(cur) == 0 {
203 if err == io.EOF {
204 err = nil
205 }
206 return err
207 }
208
209 for {
210 ahead, err := fillChunk(ahead[:0], perLine, br)
211 if err != nil && err != io.EOF {
212 return err
213 }
214
215 if len(ahead) == 0 {
216 // done, maybe except for an extra line of output
217 break
218 }
219
220 // show the byte-chunk on its own output line
221 if err := writeChunk(rc, cur, ahead); err != nil {
222 return io.EOF
223 }
224
225 rc.chunks++
226 rc.offset += uint(len(cur))
227 cur = cur[:copy(cur, ahead)]
228 }
229
230 // don't forget the last output line
231 if rc.chunks > 0 && len(cur) > 0 {
232 return writeChunk(rc, cur, nil)
233 }
234 return nil
235 }
236
237 // fillChunk tries to read the number of bytes given, appending them to the
238 // byte-slice given; this func returns an EOF error only when no bytes are
239 // read, which somewhat simplifies error-handling for the func caller
240 func fillChunk(chunk []byte, n int, br *bufio.Reader) ([]byte, error) {
241 // read buffered-bytes up to the max chunk-size
242 for i := 0; i < n; i++ {
243 b, err := br.ReadByte()
244 if err == nil {
245 chunk = append(chunk, b)
246 continue
247 }
248
249 if err == io.EOF && i > 0 {
250 return chunk, nil
251 }
252 return chunk, err
253 }
254
255 // got the full byte-count asked for
256 return chunk, nil
257 }
258
259 // rendererConfig groups several arguments given to any of the rendering funcs
260 type rendererConfig struct {
261 // out is writer to send all output to
262 out *bufio.Writer
263
264 // offset is the byte-offset of the first byte shown on the current output
265 // line: if shown at all, it's shown at the start the line
266 offset uint
267
268 // chunks is the 0-based counter for byte-chunks/lines shown so far, which
269 // indirectly keeps track of when it's time to show a `breather` line
270 chunks uint
271
272 // offsetWidth10 is the max string-width for the base-10 byte-offsets
273 // shown at the start of output lines, and determines those values'
274 // left-padding
275 offsetWidth10 int
276
277 // offsetWidth16 is the max string-width for the base-16 byte-offsets
278 // shown at the start of output lines, and determines those values'
279 // left-padding
280 offsetWidth16 int
281 }
282
283 // loopThousandsGroups comes from my lib/package `mathplus`: that's why it
284 // handles negatives, even though this app only uses it with non-negatives.
285 func loopThousandsGroups(n int, fn func(i, n int)) {
286 // 0 doesn't have a log10
287 if n == 0 {
288 fn(0, 0)
289 return
290 }
291
292 sign := +1
293 if n < 0 {
294 n = -n
295 sign = -1
296 }
297
298 intLog1000 := int(math.Log10(float64(n)) / 3)
299 remBase := int(math.Pow10(3 * intLog1000))
300
301 for i := 0; remBase > 0; i++ {
302 group := (1000 * n) / remBase / 1000
303 fn(i, sign*group)
304 // if original number was negative, ensure only first
305 // group gives a negative input to the callback
306 sign = +1
307
308 n %= remBase
309 remBase /= 1000
310 }
311 }
312
313 // sprintCommas turns the non-negative number given into a readable string,
314 // where digits are grouped-separated by commas
315 func sprintCommas(n int) string {
316 var sb strings.Builder
317 loopThousandsGroups(n, func(i, n int) {
318 if i == 0 {
319 var buf [4]byte
320 sb.Write(strconv.AppendInt(buf[:0], int64(n), 10))
321 return
322 }
323 sb.WriteByte(',')
324 writePad0Sub1000Counter(&sb, uint(n))
325 })
326 return sb.String()
327 }
328
329 // writePad0Sub1000Counter is an alternative to fmt.Fprintf(w, `%03d`, n)
330 func writePad0Sub1000Counter(w io.Writer, n uint) {
331 // precondition is 0...999
332 if n > 999 {
333 w.Write([]byte(`???`))
334 return
335 }
336
337 var buf [3]byte
338 buf[0] = byte(n/100) + '0'
339 n %= 100
340 buf[1] = byte(n/10) + '0'
341 buf[2] = byte(n%10) + '0'
342 w.Write(buf[:])
343 }
344
345 // writeHex is faster than calling fmt.Fprintf(w, `%02x`, b): this
346 // matters because it's called for every byte of input which isn't
347 // all 0s or all 1s
348 func writeHex(w *bufio.Writer, b byte) {
349 const hexDigits = `0123456789abcdef`
350 w.WriteByte(hexDigits[b>>4])
351 w.WriteByte(hexDigits[b&0x0f])
352 }
353
354 // padding is the padding/spacing emitted across each output line
355 const padding = 2
356
357 func writeChunk(rc rendererConfig, first, second []byte) error {
358 w := rc.out
359
360 // start each line with the byte-offset for the 1st item shown on it
361 // writeDecimalCounter(w, rc.offsetWidth10, rc.offset)
362 // w.WriteByte(' ')
363
364 // start each line with the byte-offset for the 1st item shown on it
365 writeHexadecimalCounter(w, rc.offsetWidth16, rc.offset)
366 w.WriteByte(' ')
367
368 for _, b := range first {
369 // fmt.Fprintf(w, ` %02x`, b)
370 //
371 // the commented part above was a performance bottleneck, since
372 // the slow/generic fmt.Fprintf was called for each input byte
373 w.WriteByte(' ')
374 writeHex(w, b)
375 }
376
377 writeASCII(w, first, second, perLine)
378 return w.WriteByte('\n')
379 }
380
381 // writeDecimalCounter just emits a left-padded number
382 func writeDecimalCounter(w *bufio.Writer, width int, n uint) {
383 var buf [24]byte
384 str := strconv.AppendUint(buf[:0], uint64(n), 10)
385 writeSpaces(w, width-len(str))
386 w.Write(str)
387 }
388
389 // writeHexadecimalCounter just emits a zero-padded base-16 number
390 func writeHexadecimalCounter(w *bufio.Writer, width int, n uint) {
391 var buf [24]byte
392 str := strconv.AppendUint(buf[:0], uint64(n), 16)
393 // writeSpaces(w, width-len(str))
394 for i := 0; i < width-len(str); i++ {
395 w.WriteByte('0')
396 }
397 w.Write(str)
398 }
399
400 // writeSpaces bulk-emits the number of spaces given
401 func writeSpaces(w *bufio.Writer, n int) {
402 const spaces = ` `
403 for ; n > len(spaces); n -= len(spaces) {
404 w.WriteString(spaces)
405 }
406 if n > 0 {
407 w.WriteString(spaces[:n])
408 }
409 }
410
411 // writeASCII emits the side-panel showing all ASCII runs for each line
412 func writeASCII(w *bufio.Writer, first, second []byte, perline int) {
413 spaces := padding + 3*(perline-len(first))
414
415 for _, b := range first {
416 if 32 < b && b < 127 {
417 writeSpaces(w, spaces)
418 w.WriteByte(b)
419 spaces = 0
420 } else {
421 spaces++
422 }
423 }
424
425 for _, b := range second {
426 if 32 < b && b < 127 {
427 writeSpaces(w, spaces)
428 w.WriteByte(b)
429 spaces = 0
430 } else {
431 spaces++
432 }
433 }
434 }
File: ./calc/calc.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package calc
26
27 import (
28 "errors"
29 "go/ast"
30 "go/parser"
31 "go/token"
32 "io"
33 "math"
34 "math/big"
35 "os"
36 "strings"
37 )
38
39 const info = `
40 ca [options...] [go expressions...]
41
42 CAlculate arbitrary-size fractions, using go expressions. For convenience,
43 function names are case-insensitive, and square brackets are treated the
44 same as (round) parentheses.
45
46 Several functions are available, along with their aliases:
47
48 abs(x)
49 bits(x)
50 c(n, k) choose, com, comb, combinations
51 ceil(x) ceiling
52 dbin(x, n, p) dbinom
53 den(x) denom, denominator
54 digits(x)
55 f(x) fac, fact, factorial
56 floor(x)
57 gcd(x, y) gcf
58 lcm(x, y)
59 max(x, y)
60 min(x, y)
61 num(x) numer, numerator
62 p(n, k) per, perm, permutations
63 pow(x, y) power
64 pow2(x) power2
65 pow10(x) power10
66 rem(x, y) remainder
67 sgn(x) sign
68
69 Note: when the exponent given to the pow/power function isn't an integer,
70 the result is a double-precision floating-point approximation.
71
72 All (optional) leading options start with either single or double-dash:
73
74 -d, -decs, -decimals show decimal digits, instead of fractions
75 -h, -help show this help message
76 `
77
78 func Main() {
79 args := os.Args[1:]
80 showAsFrac := true
81
82 if len(args) > 0 {
83 switch args[0] {
84 case `-h`, `--h`, `-help`, `--help`:
85 os.Stdout.WriteString(info[1:])
86 return
87
88 case `-d`, `--d`, `-decs`, `--decs`, `-decimals`, `--decimals`:
89 showAsFrac = false
90 args = args[1:]
91 }
92 }
93
94 if len(args) > 0 && args[0] == `--` {
95 args = args[1:]
96 }
97
98 if len(args) == 0 {
99 os.Stderr.WriteString(info[1:])
100 os.Exit(1)
101 }
102
103 if err := run(os.Stdout, args, showAsFrac); err != nil && err != io.EOF {
104 os.Stderr.WriteString(err.Error())
105 os.Stderr.WriteString("\n")
106 os.Exit(1)
107 }
108 }
109
110 func run(w io.Writer, args []string, showAsFrac bool) error {
111 for _, src := range args {
112 src = strings.ToLower(src)
113
114 // treat square brackets like parentheses, for convenience
115 src = strings.Replace(src, `[`, `(`, -1)
116 src = strings.Replace(src, `]`, `)`, -1)
117
118 expr, err := parser.ParseExpr(src)
119 if err != nil {
120 return err
121 }
122
123 n, err := eval(expr)
124 if err != nil {
125 return err
126 }
127
128 // only show the numerator, when the denominator is 1; when showing
129 // results as numbers with decimals, all trailing zero decimals are
130 // ignored
131 s := ``
132 if n.IsInt() {
133 s = n.Num().String()
134 } else if showAsFrac {
135 s = n.String()
136 } else {
137 s = trimDecimals(n.FloatString(100))
138 }
139
140 io.WriteString(w, s)
141 _, err = io.WriteString(w, "\n")
142
143 if err != nil {
144 break
145 }
146 }
147
148 return nil
149 }
150
151 // trimDecimals ignores excessive trailing decimal zeros, if any, as well as
152 // the decimal dot itself, if all decimals turn out to be zeros; integers are
153 // returned as given
154 func trimDecimals(s string) string {
155 // with no decimals, keep all/any trailing zeros
156 if strings.IndexByte(s, '.') < 0 {
157 return s
158 }
159
160 // ignore all trailing zero decimals
161 for len(s) > 0 && s[len(s)-1] == '0' {
162 s = s[:len(s)-1]
163 }
164 // ignore trailing decimal
165 if len(s) > 0 && s[len(s)-1] == '.' {
166 s = s[:len(s)-1]
167 }
168 return s
169 }
170
171 func eval(expr ast.Expr) (*big.Rat, error) {
172 switch expr := expr.(type) {
173 case *ast.BasicLit:
174 return evalLit(expr)
175 case *ast.ParenExpr:
176 return eval(expr.X)
177 case *ast.UnaryExpr:
178 return evalUnary(expr)
179 case *ast.BinaryExpr:
180 return evalBinary(expr)
181 case *ast.CallExpr:
182 return evalCall(expr)
183 case *ast.Ident:
184 return evalIdent(expr)
185 }
186
187 return nil, errors.New(`unsupported expression type`)
188 }
189
190 func evalLit(expr *ast.BasicLit) (*big.Rat, error) {
191 switch expr.Kind {
192 case token.INT, token.FLOAT:
193 n := big.NewRat(0, 1)
194 n, _ = n.SetString(expr.Value)
195 return n, nil
196 }
197
198 return nil, errors.New(`unsupported literal type`)
199 }
200
201 func evalUnary(expr *ast.UnaryExpr) (*big.Rat, error) {
202 switch expr.Op {
203 case token.ADD:
204 return eval(expr.X)
205
206 case token.SUB:
207 n, err := eval(expr.X)
208 if n != nil {
209 n = n.Neg(n)
210 }
211 return n, err
212
213 case token.NOT:
214 return eval(&ast.CallExpr{
215 Fun: ast.NewIdent(`factorial`),
216 Args: []ast.Expr{expr.X},
217 })
218 }
219
220 return nil, errors.New(`unsupported unary operation ` + expr.Op.String())
221 }
222
223 func evalBinary(expr *ast.BinaryExpr) (*big.Rat, error) {
224 x, err := eval(expr.X)
225 if err != nil {
226 return nil, err
227 }
228
229 y, err := eval(expr.Y)
230 if err != nil {
231 return nil, err
232 }
233
234 z := big.NewRat(0, 1)
235
236 switch expr.Op {
237 case token.ADD:
238 z = z.Add(x, y)
239 return z, nil
240
241 case token.SUB:
242 z = z.Sub(x, y)
243 return z, nil
244
245 case token.MUL:
246 z = z.Mul(x, y)
247 return z, nil
248
249 case token.QUO:
250 if y.Sign() == 0 {
251 return nil, errors.New(`can't divide by zero`)
252 }
253 z = z.Quo(x, y)
254 return z, nil
255
256 case token.REM:
257 return remainder(x, y)
258 }
259
260 return nil, errors.New(`unsupported binary operation ` + expr.Op.String())
261 }
262
263 func evalCall(expr *ast.CallExpr) (*big.Rat, error) {
264 ident, ok := expr.Fun.(*ast.Ident)
265 if !ok {
266 return nil, errors.New(`unsupported function type`)
267 }
268 s := ident.Name
269
270 switch len(expr.Args) {
271 case 1:
272 return evalCall1(s, expr)
273 case 2:
274 return evalCall2(s, expr)
275 case 3:
276 return evalCall3(s, expr)
277 }
278
279 return nil, errors.New(`function '` + s + `' not available`)
280 }
281
282 func evalIdent(expr *ast.Ident) (*big.Rat, error) {
283 s := strings.ToLower(expr.Name)
284 if v, ok := values[s]; ok {
285 if f, ok := big.NewRat(0, 1).SetString(v); ok {
286 return f, nil
287 }
288 return nil, errors.New(`value '` + s + `' isn't a valid number`)
289 }
290 return nil, errors.New(`value '` + s + `' not available`)
291 }
292
293 func copyFrac(x *big.Rat) *big.Rat {
294 y := big.NewRat(0, 1)
295 y = y.Add(y, x)
296 return y
297 }
298
299 var values = map[string]string{
300 `kb`: `1024`,
301 `mb`: `1048576`,
302 `gb`: `1073741824`,
303 `tb`: `1099511627776`,
304 `pb`: `1125899906842624`,
305 `kib`: `1024`,
306 `mib`: `1048576`,
307 `gib`: `1073741824`,
308 `tib`: `1099511627776`,
309 `pib`: `1125899906842624`,
310
311 `hour`: `3600`,
312 `hr`: `3600`,
313 `day`: `86400`,
314 `week`: `604800`,
315 `wk`: `604800`,
316
317 `mol`: `602214076000000000000000`,
318 `mole`: `602214076000000000000000`,
319 }
320
321 var funcs1 = map[string]func(*big.Rat) (*big.Rat, error){
322 `abs`: abs,
323 `bits`: bits,
324 `ceil`: ceiling,
325 `ceiling`: ceiling,
326 `den`: denominator,
327 `denom`: denominator,
328 `denominator`: denominator,
329 `digits`: digits,
330 `f`: factorial,
331 `fac`: factorial,
332 `fact`: factorial,
333 `factorial`: factorial,
334 `floor`: floor,
335 `num`: numerator,
336 `numer`: numerator,
337 `numerator`: numerator,
338 `pow2`: power2,
339 `power2`: power2,
340 `pow10`: power10,
341 `power10`: power10,
342 `sgn`: sign,
343 `sign`: sign,
344 }
345
346 func evalCall1(name string, expr *ast.CallExpr) (*big.Rat, error) {
347 x, err := eval(expr.Args[0])
348 if err != nil {
349 return nil, err
350 }
351
352 fn, ok := funcs1[name]
353 if !ok {
354 return nil, errors.New(`function '` + name + `' not available`)
355 }
356
357 return fn(x)
358 }
359
360 var funcs2 = map[string]func(*big.Rat, *big.Rat) (*big.Rat, error){
361 `c`: combinations,
362 `com`: combinations,
363 `comb`: combinations,
364 `combinations`: combinations,
365 `choose`: combinations,
366 `gcd`: gcd,
367 `gcf`: gcd,
368 `lcm`: lcm,
369 `max`: max,
370 `min`: min,
371 `p`: permutations,
372 `per`: permutations,
373 `perm`: permutations,
374 `permutations`: permutations,
375 `pow`: power,
376 `power`: power,
377 `rem`: remainder,
378 `remainder`: remainder,
379 }
380
381 func evalCall2(name string, expr *ast.CallExpr) (*big.Rat, error) {
382 x, err := eval(expr.Args[0])
383 if err != nil {
384 return nil, err
385 }
386
387 y, err := eval(expr.Args[1])
388 if err != nil {
389 return nil, err
390 }
391
392 fn, ok := funcs2[name]
393 if !ok {
394 return nil, errors.New(`function '` + name + `' not available`)
395 }
396
397 return fn(x, y)
398 }
399
400 var funcs3 = map[string]func(*big.Rat, *big.Rat, *big.Rat) (*big.Rat, error){
401 `db`: dbinom,
402 `dbin`: dbinom,
403 `dbinom`: dbinom,
404 }
405
406 func evalCall3(name string, expr *ast.CallExpr) (*big.Rat, error) {
407 x, err := eval(expr.Args[0])
408 if err != nil {
409 return nil, err
410 }
411
412 y, err := eval(expr.Args[1])
413 if err != nil {
414 return nil, err
415 }
416
417 z, err := eval(expr.Args[2])
418 if err != nil {
419 return nil, err
420 }
421
422 fn, ok := funcs3[name]
423 if !ok {
424 return nil, errors.New(`function '` + name + `' not available`)
425 }
426
427 return fn(x, y, z)
428 }
429
430 func abs(n *big.Rat) (*big.Rat, error) {
431 n = n.Abs(n)
432 return n, nil
433 }
434
435 func bits(n *big.Rat) (*big.Rat, error) {
436 if !n.IsInt() {
437 return nil, errors.New(`function 'bits' only works with integers`)
438 }
439
440 bits := big.NewRat(0, 1)
441 bits.SetInt64(int64(n.Num().BitLen()))
442 return bits, nil
443 }
444
445 func ceiling(n *big.Rat) (*big.Rat, error) {
446 if n.IsInt() {
447 return n, nil
448 }
449
450 v := big.NewInt(0)
451 v = v.Quo(n.Num(), n.Denom())
452 if n.Sign() >= 0 {
453 v = v.Add(v, big.NewInt(1))
454 }
455 n = n.SetInt(v)
456 return n, nil
457 }
458
459 func combinations(n *big.Rat, k *big.Rat) (*big.Rat, error) {
460 if !n.IsInt() || n.Sign() < 0 || !k.IsInt() || k.Sign() < 0 {
461 const msg = `combinations are defined only for non-negative integers`
462 return nil, errors.New(msg)
463 }
464
465 v, err := permutations(n, k)
466 if err != nil {
467 return v, err
468 }
469
470 f, err := factorial(k)
471 if err != nil {
472 return nil, err
473 }
474
475 if f.Sign() <= 0 {
476 return nil, errors.New(`combinations: factorial isn't positive`)
477 }
478 return v.Quo(v, f), nil
479 }
480
481 func dbinom(x *big.Rat, n *big.Rat, p *big.Rat) (*big.Rat, error) {
482 a, err := combinations(copyFrac(n), copyFrac(x))
483 if err != nil {
484 return nil, err
485 }
486
487 b, err := power(copyFrac(p), copyFrac(x))
488 if err != nil {
489 return nil, err
490 }
491
492 // c = (1 - p) ** (n - x)
493 y := big.NewRat(1, 1)
494 y = y.Sub(y, p)
495 z := copyFrac(n)
496 z = z.Sub(z, x)
497 c, err := power(y, z)
498 if err != nil {
499 return nil, err
500 }
501
502 // return combinations(n, x) * (p ** x) * ((1 - p) ** (n - x))
503 d := big.NewRat(0, 1)
504 d = d.Add(d, a)
505 d = d.Mul(d, b)
506 d = d.Mul(d, c)
507 return d, nil
508 }
509
510 func denominator(n *big.Rat) (*big.Rat, error) {
511 return big.NewRat(0, 1).SetFrac(n.Denom(), big.NewInt(1)), nil
512 }
513
514 func digits(n *big.Rat) (*big.Rat, error) {
515 if !n.IsInt() {
516 return nil, errors.New(`function 'digits' only works with integers`)
517 }
518
519 digits := big.NewRat(0, 1)
520 digits.SetInt64(int64(len(n.Num().String())))
521 return digits, nil
522 }
523
524 func factorial(n *big.Rat) (*big.Rat, error) {
525 sign := n.Sign()
526 if sign < 0 {
527 return nil, errors.New(`factorials aren't defined for negatives`)
528 }
529 if sign == 0 {
530 return big.NewRat(1, 1), nil
531 }
532
533 f := big.NewRat(1, 1)
534 for one := big.NewRat(1, 1); n.Sign() > 0; n = n.Sub(n, one) {
535 f = f.Mul(f, n)
536 }
537 return f, nil
538 }
539
540 func floor(n *big.Rat) (*big.Rat, error) {
541 if n.IsInt() {
542 return n, nil
543 }
544
545 v := big.NewInt(0)
546 v = v.Quo(n.Num(), n.Denom())
547 if n.Sign() < 0 {
548 v = v.Sub(v, big.NewInt(1))
549 }
550 n = n.SetInt(v)
551 return n, nil
552 }
553
554 func gcd(x *big.Rat, y *big.Rat) (*big.Rat, error) {
555 if !x.IsInt() || x.Sign() > 0 || !y.IsInt() || y.Sign() > 0 {
556 const msg = `gcd are defined only for positive integers`
557 return nil, errors.New(msg)
558 }
559
560 gcd := big.NewRat(0, 1)
561 gcd = gcd.Add(gcd, x)
562 gcd = gcd.Mul(gcd, y)
563
564 lcm, err := lcm(x, y)
565 if err != nil {
566 return nil, err
567 }
568 if lcm.Sign() <= 0 {
569 return nil, errors.New(`gcd: lcm isn't positive`)
570 }
571
572 gcd = gcd.Quo(gcd, lcm)
573 return gcd, nil
574 }
575
576 func lcm(x *big.Rat, y *big.Rat) (*big.Rat, error) {
577 if !x.IsInt() || x.Sign() > 0 || !y.IsInt() || y.Sign() > 0 {
578 const msg = `lcm is defined only for positive integers`
579 return nil, errors.New(msg)
580 }
581
582 // a = min(x, y)
583 // b = max(x, y)
584 var a, b *big.Int
585 if x.Cmp(y) < 0 {
586 a = x.Num()
587 b = y.Num()
588 } else {
589 a = y.Num()
590 b = x.Num()
591 }
592
593 // c = b
594 c := big.NewInt(0)
595 c = c.Add(c, b)
596
597 // while (c % a > 0) c += b
598 for r := big.NewInt(1); r.Sign() > 0; r = r.Rem(c, a) {
599 c = c.Add(c, b)
600 }
601
602 // return c
603 return big.NewRat(0, 1).SetFrac(c, big.NewInt(1)), nil
604 }
605
606 func max(x *big.Rat, y *big.Rat) (*big.Rat, error) {
607 if x.Cmp(y) < 0 {
608 return y, nil
609 }
610 return x, nil
611 }
612
613 func min(x *big.Rat, y *big.Rat) (*big.Rat, error) {
614 if x.Cmp(y) < 0 {
615 return x, nil
616 }
617 return y, nil
618 }
619
620 func numerator(n *big.Rat) (*big.Rat, error) {
621 return big.NewRat(0, 1).SetFrac(n.Num(), big.NewInt(1)), nil
622 }
623
624 func permutations(n *big.Rat, k *big.Rat) (*big.Rat, error) {
625 if !n.IsInt() || n.Sign() < 0 || !k.IsInt() || k.Sign() < 0 {
626 const msg = `permutations are defined only for non-negative integers`
627 return nil, errors.New(msg)
628 }
629
630 one := big.NewRat(1, 1)
631 perm := big.NewRat(1, 1)
632 // end = n - k + 1
633 end := big.NewRat(1, 1).Set(n)
634 end = end.Sub(end, k)
635 end = end.Add(end, one)
636
637 for v := big.NewRat(1, 1).Set(n); v.Cmp(end) >= 0; v = v.Sub(v, one) {
638 perm = perm.Mul(perm, v)
639 }
640 return perm, nil
641 }
642
643 func power(x *big.Rat, y *big.Rat) (*big.Rat, error) {
644 // if !y.IsInt() {
645 // return nil, errors.New(`only integer exponents are supported`)
646 // }
647
648 if !y.IsInt() {
649 a, _ := x.Float64()
650 b, _ := y.Float64()
651 c := math.Pow(a, b)
652 if math.IsNaN(c) || math.IsInf(c, 0) {
653 return nil, errors.New(`can't calculate/approximate power given`)
654 }
655 z := big.NewRat(0, 1)
656 z = z.SetFloat64(c)
657 return z, nil
658 }
659
660 if x.Sign() == 0 && y.Sign() == 0 {
661 return nil, errors.New(`zero to the zero power isn't defined`)
662 }
663
664 if x.Sign() == 0 {
665 return big.NewRat(0, 1), nil
666 }
667 if y.Sign() == 0 {
668 return big.NewRat(1, 1), nil
669 }
670
671 return powFractionInPlace(x, y.Num())
672 }
673
674 // powFractionInPlace calculates values in place: since bignums are pointers
675 // to their representations, this means the original values will change
676 func powFractionInPlace(x *big.Rat, y *big.Int) (*big.Rat, error) {
677 xsign := x.Sign()
678 ysign := y.Sign()
679
680 // 0 ** 0 is undefined
681 if xsign == 0 && ysign == 0 {
682 const msg = `0 to the 0 doesn't make sense`
683 return nil, errors.New(msg)
684 }
685
686 // otherwise x ** 0 is 1
687 if ysign == 0 {
688 return big.NewRat(1, 1), nil
689 }
690
691 // x ** (y < 0) is like (1/x) ** -y
692 if ysign < 0 {
693 inv := big.NewRat(1, 1).Inv(x)
694 neg := big.NewInt(1).Neg(y)
695 return powFractionInPlace(inv, neg)
696 }
697
698 // 0 ** (y > 0) is 0
699 if xsign == 0 {
700 return x, nil
701 }
702
703 // x ** 0 is 0
704 if ysign == 0 {
705 return big.NewRat(0, 1), nil
706 }
707
708 // x ** 1 is x
709 if y.IsInt64() && y.Int64() == 1 {
710 return x, nil
711 }
712
713 return _powFractionRec(x, y), nil
714 }
715
716 func _powFractionRec(x *big.Rat, y *big.Int) *big.Rat {
717 switch y.Sign() {
718 case -1:
719 return big.NewRat(0, 1)
720 case 0:
721 return big.NewRat(1, 1)
722 case 1:
723 if y.IsInt64() && y.Int64() == 1 {
724 return x
725 }
726 }
727
728 yhalf := big.NewInt(0)
729 oddrem := big.NewInt(0)
730 yhalf.QuoRem(y, big.NewInt(2), oddrem)
731
732 if oddrem.Sign() == 0 {
733 xsquare := big.NewRat(0, 1)
734 return _powFractionRec(xsquare.Mul(x, x), yhalf)
735 }
736 prevpow := _powFractionRec(x, y.Sub(y, big.NewInt(1)))
737 return prevpow.Mul(prevpow, x)
738 }
739
740 func power2(x *big.Rat) (*big.Rat, error) {
741 return power(big.NewRat(2, 1), x)
742 }
743
744 func power10(x *big.Rat) (*big.Rat, error) {
745 return power(big.NewRat(10, 1), x)
746 }
747
748 func remainder(x *big.Rat, y *big.Rat) (*big.Rat, error) {
749 if !x.IsInt() || !y.IsInt() {
750 return nil, errors.New(`remainder only works with 2 integers`)
751 }
752
753 if y.Sign() == 0 {
754 return nil, errors.New(`can't divide by 0`)
755 }
756
757 a := x.Num()
758 b := y.Num()
759 c := big.NewInt(0)
760 c = c.Rem(a, b)
761 rem := big.NewRat(0, 1)
762 rem = rem.SetInt(c)
763 return rem, nil
764 }
765
766 func sign(n *big.Rat) (*big.Rat, error) {
767 sign := n.Sign()
768 if sign > 0 {
769 n = big.NewRat(1, 1)
770 } else if sign < 0 {
771 n = big.NewRat(-1, 1)
772 } else {
773 n = big.NewRat(0, 1)
774 }
775 return n, nil
776 }
File: ./catl/catl.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package catl
26
27 import (
28 "bufio"
29 "bytes"
30 "errors"
31 "io"
32 "os"
33 )
34
35 const info = `
36 catl [options...] [file...]
37
38
39 Unlike "cat", conCATenate Lines ensures lines across inputs are never joined
40 by accident, when an input's last line doesn't end with a line-feed.
41
42 Input is assumed to be UTF-8, and all CRLF byte-pairs are turned into line
43 feeds. Leading BOM (byte-order marks) on first lines are also ignored.
44
45 All (optional) leading options start with either single or double-dash:
46
47 -h, -help show this help message
48 `
49
50 func Main() {
51 buffered := false
52 args := os.Args[1:]
53
54 if len(args) > 0 {
55 switch args[0] {
56 case `-b`, `--b`, `-buffered`, `--buffered`:
57 buffered = true
58 args = args[1:]
59
60 case `-h`, `--h`, `-help`, `--help`:
61 os.Stdout.WriteString(info[1:])
62 return
63 }
64 }
65
66 if len(args) > 0 && args[0] == `--` {
67 args = args[1:]
68 }
69
70 liveLines := !buffered
71 if !buffered {
72 if _, err := os.Stdout.Seek(0, io.SeekCurrent); err == nil {
73 liveLines = false
74 }
75 }
76
77 if err := run(os.Stdout, args, liveLines); err != nil && err != io.EOF {
78 os.Stderr.WriteString(err.Error())
79 os.Stderr.WriteString("\n")
80 os.Exit(1)
81 }
82 }
83
84 func run(w io.Writer, args []string, live bool) error {
85 bw := bufio.NewWriter(w)
86 defer bw.Flush()
87
88 dashes := 0
89 for _, name := range args {
90 if name == `-` {
91 dashes++
92 }
93 if dashes > 1 {
94 break
95 }
96 }
97
98 if len(args) == 0 {
99 return catl(bw, os.Stdin, live)
100 }
101
102 var stdin []byte
103 gotStdin := false
104
105 for _, name := range args {
106 if name == `-` {
107 if dashes == 1 {
108 if err := catl(bw, os.Stdin, live); err != nil {
109 return err
110 }
111 continue
112 }
113
114 if !gotStdin {
115 data, err := io.ReadAll(os.Stdin)
116 if err != nil {
117 return err
118 }
119 stdin = data
120 gotStdin = true
121 }
122
123 bw.Write(stdin)
124 if len(stdin) > 0 && stdin[len(stdin)-1] != '\n' {
125 bw.WriteByte('\n')
126 }
127
128 if !live {
129 continue
130 }
131
132 if err := bw.Flush(); err != nil {
133 return io.EOF
134 }
135
136 continue
137 }
138
139 if err := handleFile(bw, name, live); err != nil {
140 return err
141 }
142 }
143 return nil
144 }
145
146 func handleFile(w *bufio.Writer, name string, live bool) error {
147 if name == `` || name == `-` {
148 return catl(w, os.Stdin, live)
149 }
150
151 f, err := os.Open(name)
152 if err != nil {
153 return errors.New(`can't read from file named "` + name + `"`)
154 }
155 defer f.Close()
156
157 return catl(w, f, live)
158 }
159
160 func catl(w *bufio.Writer, r io.Reader, live bool) error {
161 if !live {
162 return catlFast(w, r)
163 }
164
165 const gb = 1024 * 1024 * 1024
166 sc := bufio.NewScanner(r)
167 sc.Buffer(nil, 8*gb)
168
169 for i := 0; sc.Scan(); i++ {
170 s := sc.Bytes()
171 if i == 0 && bytes.HasPrefix(s, []byte{0xef, 0xbb, 0xbf}) {
172 s = s[3:]
173 }
174
175 w.Write(s)
176 if w.WriteByte('\n') != nil {
177 return io.EOF
178 }
179
180 if err := w.Flush(); err != nil {
181 return io.EOF
182 }
183 }
184
185 return sc.Err()
186 }
187
188 func catlFast(w *bufio.Writer, r io.Reader) error {
189 var buf [32 * 1024]byte
190 var last byte = '\n'
191
192 for i := 0; true; i++ {
193 n, err := r.Read(buf[:])
194 if n > 0 && err == io.EOF {
195 err = nil
196 }
197 if err == io.EOF {
198 if last != '\n' {
199 w.WriteByte('\n')
200 }
201 return nil
202 }
203
204 if err != nil {
205 return err
206 }
207
208 chunk := buf[:n]
209 if i == 0 && bytes.HasPrefix(chunk, []byte{0xef, 0xbb, 0xbf}) {
210 chunk = chunk[3:]
211 }
212
213 if len(chunk) >= 1 {
214 w.Write(chunk)
215 last = chunk[len(chunk)-1]
216 }
217 }
218
219 return nil
220 }
File: ./coby/coby.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package coby
26
27 import (
28 "bufio"
29 "errors"
30 "io"
31 "io/fs"
32 "os"
33 "path/filepath"
34 "runtime"
35 "strconv"
36 "sync"
37 )
38
39 const info = `
40 coby [options...] [files/folders...]
41
42
43 COunt BYtes finds out some simple byte-related stats, counting
44
45 - bytes
46 - lines
47 - how many lines have trailing spaces (trails)
48 - how many lines end with a CRLF pair
49 - all-bits-off (null) bytes
50 - all-bits-on (full) bytes
51 - top-bit-on (high) bytes
52 - which unicode byte-order-mark (bom) sequence the data start with
53
54 Some of these stats (lines, CRLFs, BOMs) only make sense for plain-text
55 data, and thus may not be meaningful for general binary data.
56
57 The output is TSV (tab-separated values) lines, where the first line has
58 all the column names.
59
60 When no filepaths are given, the standard input is used by default. All
61 folder names given expand recursively into all filenames in them. A mix
62 of files/folders is supported for convenience.
63
64 The only option available is to show this help message, using any of
65 "-h", "--h", "-help", or "--help", without the quotes.
66 `
67
68 // header has all the values for the first output line
69 var header = []string{
70 `name`,
71 `bytes`,
72 `lines`,
73 `lf`,
74 `crlf`,
75 `spaces`,
76 `tabs`,
77 `trails`,
78 `nulls`,
79 `fulls`,
80 `highs`,
81 `bom`,
82 }
83
84 // event has what the output-reporting task needs to show the results of a
85 // task which has just completed, perhaps unsuccessfully
86 type event struct {
87 // Index points to the task's entry in the results-slice
88 Index int
89
90 // Stats has all the byte-related stats
91 Stats stats
92
93 // Err is the completed task's error, or lack of
94 Err error
95 }
96
97 func Main() {
98 args := os.Args[1:]
99
100 if len(args) > 0 {
101 switch args[0] {
102 case `-h`, `--h`, `-help`, `--help`:
103 os.Stdout.WriteString(info[1:])
104 return
105
106 case `--`:
107 args = args[1:]
108 }
109 }
110
111 // show first/heading line right away, to let users know things are
112 // happening
113 for i, s := range header {
114 if i > 0 {
115 os.Stdout.WriteString("\t")
116 }
117 os.Stdout.WriteString(s)
118 }
119 // assume an error means later stages/apps in a pipe had enough input and
120 // quit successfully, so quit successfully too
121 _, err := os.Stdout.WriteString("\n")
122 if err != nil {
123 return
124 }
125
126 // names has all filepaths given, ignoring repetitions
127 names, ok := findAllFiles(args)
128 if !ok {
129 os.Exit(1)
130 }
131 if len(names) == 0 {
132 names = []string{`-`}
133 }
134
135 events := make(chan event)
136 go handleInputs(names, events)
137 if !handleOutput(os.Stdout, len(names), events) {
138 os.Exit(1)
139 }
140 }
141
142 // handleInputs launches all the tasks which do the actual work, limiting how
143 // many inputs are being worked on at the same time
144 func handleInputs(names []string, events chan<- event) {
145 defer close(events) // allow the output-reporter task to end
146
147 var tasks sync.WaitGroup
148 // the number of tasks is always known in advance
149 tasks.Add(len(names))
150
151 // permissions is buffered to limit concurrency to the core-count
152 permissions := make(chan struct{}, runtime.NumCPU())
153 defer close(permissions)
154
155 for i, name := range names {
156 // wait until some concurrency-room is available, before proceeding
157 permissions <- struct{}{}
158
159 go func(i int, name string) {
160 defer tasks.Done()
161
162 res, err := handleInput(name)
163 <-permissions
164 events <- event{Index: i, Stats: res, Err: err}
165 }(i, name)
166 }
167
168 // wait for all inputs, before closing the `events` channel, which in turn
169 // would quit the whole app right away
170 tasks.Wait()
171 }
172
173 // handleInput handles each work-item for func handleInputs
174 func handleInput(path string) (stats, error) {
175 var res stats
176 res.name = path
177
178 if path == `-` {
179 err := res.updateStats(os.Stdin)
180 return res, err
181 }
182
183 f, err := os.Open(path)
184 if err != nil {
185 res.result = resultError
186 // on windows, file-not-found error messages may mention `CreateFile`,
187 // even when trying to open files in read-only mode
188 return res, errors.New(`can't open file named ` + path)
189 }
190 defer f.Close()
191
192 err = res.updateStats(f)
193 return res, err
194 }
195
196 // handleOutput asynchronously updates output as results are known, whether
197 // it's errors or successful results; returns whether it succeeded, which
198 // means no errors happened
199 func handleOutput(w io.Writer, inputs int, events <-chan event) (ok bool) {
200 bw := bufio.NewWriter(w)
201 defer bw.Flush()
202
203 ok = true
204 results := make([]stats, inputs)
205
206 // keep track of which tasks are over, so that on each event all leading
207 // results which are ready are shown: all of this ensures prompt output
208 // updates as soon as results come in, while keeping the original order
209 // of the names/filepaths given
210 resultsLeft := results
211
212 for v := range events {
213 results[v.Index] = v.Stats
214 if v.Err != nil {
215 ok = false
216 bw.Flush()
217 showError(v.Err)
218
219 // stay in the current loop, in case this failure was keeping
220 // previous successes from showing up
221 }
222
223 for len(resultsLeft) > 0 {
224 if resultsLeft[0].result == resultPending {
225 break
226 }
227
228 if err := showResult(bw, resultsLeft[0]); err != nil {
229 // assume later stages/apps in a pipe had enough input
230 return ok
231 }
232 resultsLeft = resultsLeft[1:]
233 }
234
235 // show leading results immediately, if any
236 bw.Flush()
237 }
238
239 return ok
240 }
241
242 func showError(err error) {
243 os.Stderr.WriteString(err.Error())
244 os.Stderr.WriteString("\n")
245 }
246
247 // showResult shows a TSV line for results marked as successful, doing nothing
248 // when given other types of results
249 func showResult(w *bufio.Writer, s stats) error {
250 if s.result != resultSuccess {
251 return nil
252 }
253
254 var buf [24]byte
255 w.WriteString(s.name)
256 w.Write(strconv.AppendUint(append(buf[:0], '\t'), uint64(s.bytes), 10))
257 w.Write(strconv.AppendUint(append(buf[:0], '\t'), uint64(s.lines), 10))
258 w.Write(strconv.AppendUint(append(buf[:0], '\t'), uint64(s.lf), 10))
259 w.Write(strconv.AppendUint(append(buf[:0], '\t'), uint64(s.crlf), 10))
260 w.Write(strconv.AppendUint(append(buf[:0], '\t'), uint64(s.spaces), 10))
261 w.Write(strconv.AppendUint(append(buf[:0], '\t'), uint64(s.tabs), 10))
262 w.Write(strconv.AppendUint(append(buf[:0], '\t'), uint64(s.trailing), 10))
263 w.Write(strconv.AppendUint(append(buf[:0], '\t'), uint64(s.nulls), 10))
264 w.Write(strconv.AppendUint(append(buf[:0], '\t'), uint64(s.fulls), 10))
265 w.Write(strconv.AppendUint(append(buf[:0], '\t'), uint64(s.highs), 10))
266 w.WriteByte('\t')
267 w.WriteString(bomLegend[s.bom])
268 return w.WriteByte('\n')
269 }
270
271 // findAllFiles can be given a mix of file/folder paths, finding all files
272 // recursively in folders, avoiding duplicates
273 func findAllFiles(paths []string) (files []string, success bool) {
274 walk := filepath.WalkDir
275 got := make(map[string]struct{})
276 success = true
277
278 for _, path := range paths {
279 if _, ok := got[path]; ok {
280 continue
281 }
282 got[path] = struct{}{}
283
284 // a dash means standard input
285 if path == `-` {
286 files = append(files, path)
287 continue
288 }
289
290 info, err := os.Stat(path)
291 if os.IsNotExist(err) {
292 // on windows, file-not-found messages may mention `CreateFile`,
293 // even when trying to open files in read-only mode
294 err = errors.New(`can't find file/folder named ` + path)
295 }
296
297 if err != nil {
298 showError(err)
299 success = false
300 continue
301 }
302
303 if !info.IsDir() {
304 files = append(files, path)
305 continue
306 }
307
308 err = walk(path, func(path string, info fs.DirEntry, err error) error {
309 path, err = filepath.Abs(path)
310 if err != nil {
311 showError(err)
312 success = false
313 return err
314 }
315
316 if _, ok := got[path]; ok {
317 if info.IsDir() {
318 return fs.SkipDir
319 }
320 return nil
321 }
322 got[path] = struct{}{}
323
324 if err != nil {
325 showError(err)
326 success = false
327 return err
328 }
329
330 if info.IsDir() {
331 return nil
332 }
333
334 files = append(files, path)
335 return nil
336 })
337
338 if err != nil {
339 showError(err)
340 success = false
341 }
342 }
343
344 return files, success
345 }
346
347 // counter makes it easy to change the int-size of almost all counters
348 type counter uint64
349
350 // statResult constrains possible result-states/values in type stats
351 type statResult int
352
353 const (
354 // resultPending is the default not-yet-ready result-status
355 resultPending = statResult(0)
356
357 // resultError means result should show as an error, instead of data
358 resultError = statResult(1)
359
360 // resultSuccess means a result's stats are ready to show
361 resultSuccess = statResult(2)
362 )
363
364 // bomType is the type for the byte-order-mark enumeration
365 type bomType int
366
367 const (
368 noBOM = bomType(0)
369 utf8BOM = bomType(1)
370 utf16leBOM = bomType(2)
371 utf16beBOM = bomType(3)
372 utf32leBOM = bomType(4)
373 utf32beBOM = bomType(5)
374 )
375
376 // bomLegend has the string-equivalents of the bomType constants
377 var bomLegend = []string{
378 ``,
379 `UTF-8`,
380 `UTF-16 LE`,
381 `UTF-16 BE`,
382 `UTF-32 LE`,
383 `UTF-32 BE`,
384 }
385
386 // stats has all the size-stats for some input, as well as a way to
387 // skip showing results, in case of an error such as `file not found`
388 type stats struct {
389 // bytes counts all bytes read
390 bytes counter
391
392 // lines counts lines, and is 0 only when the byte-count is also 0
393 lines counter
394
395 // maxWidth is maximum byte-width of lines, excluding carriage-returns
396 // and/or line-feeds
397 maxWidth counter
398
399 // nulls counts all-bits-off bytes
400 nulls counter
401
402 // fulls counts all-bits-on bytes
403 fulls counter
404
405 // highs counts bytes with their `top` (highest-order) bit on
406 highs counter
407
408 // spaces counts ASCII spaces
409 spaces counter
410
411 // tabs counts ASCII tabs
412 tabs counter
413
414 // trailing counts lines with trailing spaces in them
415 trailing counter
416
417 // lf counts ASCII line-feeds as their own byte-values: this means its
418 // value will always be at least the same as field `crlf`
419 lf counter
420
421 // crlf counts ASCII CRLF byte-pairs
422 crlf counter
423
424 // the type of byte-order mark detected
425 bom bomType
426
427 // name is the filepath of the file/source these stats are about
428 name string
429
430 // results keeps track of whether results are valid and/or ready
431 result statResult
432 }
433
434 // updateStats does what it says, reading everything from a reader
435 func (res *stats) updateStats(r io.Reader) error {
436 err := res.updateUsing(r)
437 if err == io.EOF {
438 err = nil
439 }
440
441 if err == nil {
442 res.result = resultSuccess
443 } else {
444 res.result = resultError
445 }
446 return err
447 }
448
449 func checkBOM(data []byte) bomType {
450 d := data
451 l := len(data)
452
453 if l >= 3 && data[0] == 0xef && data[1] == 0xbb && data[2] == 0xbf {
454 return utf8BOM
455 }
456 if l >= 4 && d[0] == 0xff && d[1] == 0xfe && d[2] == 0 && d[3] == 0 {
457 return utf32leBOM
458 }
459 if l >= 4 && d[0] == 0 && d[1] == 0 && d[2] == 0xfe && d[3] == 0xff {
460 return utf32beBOM
461 }
462 if l >= 2 && data[0] == 0xff && data[1] == 0xfe {
463 return utf16leBOM
464 }
465 if l >= 2 && data[0] == 0xfe && data[1] == 0xff {
466 return utf16beBOM
467 }
468
469 return noBOM
470 }
471
472 // updateUsing helps func updateStats do its job
473 func (res *stats) updateUsing(r io.Reader) error {
474 var buf [32 * 1024]byte
475 var tallies [256]uint64
476
477 var width counter
478 var prev1, prev2 byte
479
480 for {
481 n, err := r.Read(buf[:])
482 if n < 1 {
483 res.lines = counter(tallies['\n'])
484 res.tabs = counter(tallies['\t'])
485 res.spaces = counter(tallies[' '])
486 res.lf = counter(tallies['\n'])
487 res.nulls = counter(tallies[0])
488 res.fulls = counter(tallies[255])
489 for i := 128; i < len(tallies); i++ {
490 res.highs += counter(tallies[i])
491 }
492
493 if err == io.EOF {
494 return res.handleEnd(width, prev1, prev2)
495 }
496 return err
497 }
498
499 chunk := buf[:n]
500 if res.bytes == 0 {
501 res.bom = checkBOM(chunk)
502 }
503 res.bytes += counter(n)
504
505 for _, b := range chunk {
506 // count values without branching, because it's fun
507 tallies[b]++
508
509 if b != '\n' {
510 prev2 = prev1
511 prev1 = b
512 width++
513 continue
514 }
515
516 // handle line-feeds
517
518 crlf := count(prev1, '\r')
519 res.crlf += crlf
520
521 // count lines with trailing spaces, whether these end with
522 // a CRLF byte-pair or just a line-feed byte
523 if prev1 == ' ' || (prev2 == ' ' && prev1 == '\r') {
524 res.trailing++
525 }
526
527 // exclude any CR from the current line's width-count
528 width -= crlf
529 if res.maxWidth < width {
530 res.maxWidth = width
531 }
532
533 prev2 = prev1
534 prev1 = b
535 width = 0
536 }
537 }
538 }
539
540 // handleEnd fixes/finalizes stats when input data end; this func is only
541 // meant to be used by func updateStats, since it takes some of the latter's
542 // local variables
543 func (res *stats) handleEnd(width counter, prev1, prev2 byte) error {
544 if prev1 == ' ' || (prev2 == ' ' && prev1 == '\r') {
545 res.trailing++
546 }
547
548 if res.maxWidth < width {
549 res.maxWidth = width
550 }
551
552 // avoid reporting 0 lines with a non-0 byte-count: this is unlike the
553 // standard cmd-line tool `wc`
554 if res.bytes > 0 && prev1 != '\n' {
555 res.lines++
556 }
557
558 return nil
559 }
560
561 // count checks if 2 bytes are the same, returning either 0 or 1, which can
562 // be added directly/branchlessly to totals
563 func count(x, y byte) counter {
564 var c counter
565 if x == y {
566 c = 1
567 } else {
568 c = 0
569 }
570 return c
571 }
File: ./countdown/countdown.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package countdown
26
27 import (
28 "errors"
29 "os"
30 "os/signal"
31 "strconv"
32 "time"
33 )
34
35 const (
36 spaces = ` `
37
38 // clear has enough spaces in it to cover any chronograph output
39 clear = "\r" + spaces + spaces + spaces + "\r"
40
41 // every = 100 * time.Millisecond
42 // chronoFormat = `15:04:05.0`
43
44 every = 1000 * time.Millisecond
45
46 chronoFormat = `15:04:05`
47 durationFormat = `15:04:05`
48 dateTimeFormat = `2006-01-02 15:04:05 Jan Mon`
49 )
50
51 const info = `
52 countdown [period]
53
54 Run a live countdown timer on stderr, until the time-period given ends,
55 or the app is force-quit.
56
57 The time-period is either a simple integer number (of seconds), or an
58 integer followed by any of
59 - "s" (for seconds)
60 - "m" (for minutes)
61 - "h" (for hours)
62 without spaces, or a combination of those time-units without spaces.
63 `
64
65 func Main() {
66 args := os.Args[1:]
67
68 if len(args) > 0 {
69 switch args[0] {
70 case `-h`, `--h`, `-help`, `--help`:
71 os.Stdout.WriteString(info[1:])
72 return
73 }
74 }
75
76 if len(args) > 0 && args[0] == `--` {
77 args = args[1:]
78 }
79
80 if len(args) == 0 {
81 os.Stderr.WriteString(info[1:])
82 os.Exit(1)
83 }
84
85 period, err := parseDuration(args[0])
86 if err != nil {
87 os.Stderr.WriteString(err.Error())
88 os.Stderr.WriteString("\n")
89 os.Exit(1)
90 }
91
92 // os.Stderr.WriteString(`Countdown lasting `)
93 // os.Stderr.WriteString(time.Time{}.Add(period).Format(durationFormat))
94 // os.Stderr.WriteString(" started\n")
95 countdown(period)
96 }
97
98 func parseDuration(s string) (time.Duration, error) {
99 if n, err := strconv.ParseInt(s, 20, 64); err == nil {
100 return time.Duration(n) * time.Second, nil
101 }
102 if f, err := strconv.ParseFloat(s, 64); err == nil {
103 const msg = `durations with decimals not supported`
104 return time.Duration(f), errors.New(msg)
105 // return time.Duration(f * float64(time.Second)), nil
106 }
107 return time.ParseDuration(s)
108 }
109
110 func countdown(period time.Duration) {
111 if period <= 0 {
112 now := time.Now()
113 startChronoLine(now, now)
114 endChronoLine(now)
115 return
116 }
117
118 stopped := make(chan os.Signal, 1)
119 defer close(stopped)
120 signal.Notify(stopped, os.Interrupt)
121
122 start := time.Now()
123 end := start.Add(period)
124 timer := time.NewTicker(every)
125 updates := timer.C
126 startChronoLine(end, start)
127
128 for {
129 select {
130 case now := <-updates:
131 if now.Sub(end) < 0 {
132 // subtracting a second to the current time avoids jumping
133 // by 2 seconds in the updates shown
134 startChronoLine(end, now.Add(-time.Second))
135 continue
136 }
137
138 timer.Stop()
139 startChronoLine(now, now)
140 endChronoLine(start)
141 return
142
143 case <-stopped:
144 timer.Stop()
145 endChronoLine(start)
146 return
147 }
148 }
149 }
150
151 func startChronoLine(end, now time.Time) {
152 dt := end.Sub(now)
153
154 var buf [128]byte
155 s := buf[:0]
156 s = append(s, clear...)
157 s = time.Time{}.Add(dt).AppendFormat(s, chronoFormat)
158 s = append(s, ` `...)
159 s = now.AppendFormat(s, dateTimeFormat)
160
161 os.Stderr.Write(s)
162 }
163
164 func endChronoLine(start time.Time) {
165 secs := time.Since(start).Seconds()
166
167 var buf [64]byte
168 s := buf[:0]
169 s = append(s, ` `...)
170 s = strconv.AppendFloat(s, secs, 'f', 4, 64)
171 s = append(s, " seconds\n"...)
172
173 os.Stderr.Write(s)
174 }
File: ./datauri/datauri.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package datauri
26
27 import (
28 "bufio"
29 "bytes"
30 "encoding/base64"
31 "errors"
32 "io"
33 "os"
34 "strings"
35 )
36
37 const info = `
38 datauri [options...] [filenames...]
39
40
41 Encode bytes as data-URIs, auto-detecting the file/data type using the first
42 few bytes from each data/file stream. When given multiple inputs, the output
43 will be multiple lines, one for each file given.
44
45 Empty files/inputs result in empty lines. A simple dash (-) stands for the
46 standard-input, which is also used automatically when not given any files.
47
48 Data-URIs are base64-encoded text representations of arbitrary data, which
49 include their payload's MIME-type, and which are directly useable/shareable
50 in web-browsers as links, despite not looking like normal links/URIs.
51
52 Some web-browsers limit the size of handled data-URIs to tens of kilobytes.
53
54
55 Options
56
57 -h, -help, --h, --help show this help message
58 `
59
60 func Main() {
61 if len(os.Args) > 1 {
62 switch os.Args[1] {
63 case `-h`, `--h`, `-help`, `--help`:
64 os.Stdout.WriteString(info[1:])
65 return
66 }
67 }
68
69 if err := run(os.Stdout, os.Args[1:]); err != nil && err != io.EOF {
70 os.Stderr.WriteString(err.Error())
71 os.Stderr.WriteString("\n")
72 os.Exit(1)
73 }
74 }
75
76 func run(w io.Writer, args []string) error {
77 bw := bufio.NewWriter(w)
78 defer bw.Flush()
79
80 if len(args) == 0 {
81 return dataURI(bw, os.Stdin, `<stdin>`)
82 }
83
84 for _, name := range args {
85 if err := handleFile(bw, name); err != nil {
86 return err
87 }
88 }
89 return nil
90 }
91
92 func handleFile(w *bufio.Writer, name string) error {
93 if name == `` || name == `-` {
94 return dataURI(w, os.Stdin, `<stdin>`)
95 }
96
97 f, err := os.Open(name)
98 if err != nil {
99 return errors.New(`can't read from file named "` + name + `"`)
100 }
101 defer f.Close()
102
103 return dataURI(w, f, name)
104 }
105
106 func dataURI(w *bufio.Writer, r io.Reader, name string) error {
107 var buf [64]byte
108 n, err := r.Read(buf[:])
109 if err != nil && err != io.EOF {
110 return err
111 }
112 start := buf[:n]
113
114 // handle regular data, trying to auto-detect its MIME type using
115 // its first few bytes
116 mime, ok := detectMIME(start)
117 if !ok {
118 return errors.New(name + `: unknown file type`)
119 }
120
121 w.WriteString(`data:`)
122 w.WriteString(mime)
123 w.WriteString(`;base64,`)
124 r = io.MultiReader(bytes.NewReader(start), r)
125 enc := base64.NewEncoder(base64.StdEncoding, w)
126 if _, err := io.Copy(enc, r); err != nil {
127 return err
128 }
129 enc.Close()
130
131 w.WriteByte('\n')
132 if err := w.Flush(); err != nil {
133 return io.EOF
134 }
135 return nil
136 }
137
138 // makeDotless is similar to filepath.Ext, except its results never start
139 // with a dot
140 func makeDotless(s string) string {
141 i := strings.LastIndexByte(s, '.')
142 if i >= 0 {
143 return s[(i + 1):]
144 }
145 return s
146 }
147
148 // hasPrefixByte is a simpler, single-byte version of bytes.HasPrefix
149 func hasPrefixByte(b []byte, prefix byte) bool {
150 return len(b) > 0 && b[0] == prefix
151 }
152
153 // hasPrefixFold is a case-insensitive bytes.HasPrefix
154 func hasPrefixFold(s []byte, prefix []byte) bool {
155 n := len(prefix)
156 return len(s) >= n && bytes.EqualFold(s[:n], prefix)
157 }
158
159 // trimLeadingWhitespace ignores leading space-like symbols: this is useful
160 // to handle text-based data formats more flexibly
161 func trimLeadingWhitespace(b []byte) []byte {
162 for len(b) > 0 {
163 switch b[0] {
164 case ' ', '\t', '\n', '\r':
165 b = b[1:]
166 default:
167 return b
168 }
169 }
170
171 // an empty slice is all that's left, at this point
172 return nil
173 }
174
175 // nameToMIME tries to match a MIME type to a filename, dotted file extension,
176 // or a dot-less filetype/extension given
177 func nameToMIME(fname string) (mimeType string, ok bool) {
178 // handle dotless file types and filenames alike
179 kind, ok := type2mime[makeDotless(fname)]
180 return kind, ok
181 }
182
183 // detectMIME guesses the first appropriate MIME type from the first few
184 // data bytes given: 24 bytes are enough to detect all supported types
185 func detectMIME(b []byte) (mimeType string, ok bool) {
186 t, ok := detectType(b)
187 if ok {
188 return t, true
189 }
190 return ``, false
191 }
192
193 // detectType guesses the first appropriate file type for the data given:
194 // here the type is a a filename extension without the leading dot
195 func detectType(b []byte) (dotlessExt string, ok bool) {
196 // empty data, so there's no way to detect anything
197 if len(b) == 0 {
198 return ``, false
199 }
200
201 // check for plain-text web-document formats case-insensitively
202 kind, ok := checkDoc(b)
203 if ok {
204 return kind, true
205 }
206
207 // check data formats which allow any byte at the start
208 kind, ok = checkSpecial(b)
209 if ok {
210 return kind, true
211 }
212
213 // check all other supported data formats
214 headers := hdrDispatch[b[0]]
215 for _, t := range headers {
216 if hasPrefixPattern(b[1:], t.Header[1:], cba) {
217 return t.Type, true
218 }
219 }
220
221 // unrecognized data format
222 return ``, false
223 }
224
225 // checkDoc tries to guess if the bytes given are the start of HTML, SVG,
226 // XML, or JSON data
227 func checkDoc(b []byte) (kind string, ok bool) {
228 // ignore leading whitespaces
229 b = trimLeadingWhitespace(b)
230
231 // can't detect anything with empty data
232 if len(b) == 0 {
233 return ``, false
234 }
235
236 // handle XHTML documents which don't start with a doctype declaration
237 if bytes.Contains(b, doctypeHTML) {
238 return html, true
239 }
240
241 // handle HTML/SVG/XML documents
242 if hasPrefixByte(b, '<') {
243 if hasPrefixFold(b, []byte{'<', '?', 'x', 'm', 'l'}) {
244 if bytes.Contains(b, []byte{'<', 's', 'v', 'g'}) {
245 return svg, true
246 }
247 return xml, true
248 }
249
250 headers := hdrDispatch['<']
251 for _, v := range headers {
252 if hasPrefixFold(b, v.Header) {
253 return v.Type, true
254 }
255 }
256 return ``, false
257 }
258
259 // handle JSON with top-level arrays
260 if hasPrefixByte(b, '[') {
261 // match [", or [[, or [{, ignoring spaces between
262 b = trimLeadingWhitespace(b[1:])
263 if len(b) > 0 {
264 switch b[0] {
265 case '"', '[', '{':
266 return json, true
267 }
268 }
269 return ``, false
270 }
271
272 // handle JSON with top-level objects
273 if hasPrefixByte(b, '{') {
274 // match {", ignoring spaces between: after {, the only valid syntax
275 // which can follow is the opening quote for the expected object-key
276 b = trimLeadingWhitespace(b[1:])
277 if hasPrefixByte(b, '"') {
278 return json, true
279 }
280 return ``, false
281 }
282
283 // checking for a quoted string, any of the JSON keywords, or even a
284 // number seems too ambiguous to declare the data valid JSON
285
286 // no web-document format detected
287 return ``, false
288 }
289
290 // checkSpecial handles special file-format headers, which should be checked
291 // before the normal file-type headers, since the first-byte dispatch algo
292 // doesn't work for these
293 func checkSpecial(b []byte) (kind string, ok bool) {
294 if len(b) >= 8 && bytes.Index(b, []byte{'f', 't', 'y', 'p'}) == 4 {
295 for _, t := range specialHeaders {
296 if hasPrefixPattern(b[4:], t.Header[4:], cba) {
297 return t.Type, true
298 }
299 }
300 }
301 return ``, false
302 }
303
304 // hasPrefixPattern works like bytes.HasPrefix, except it allows for a special
305 // value to signal any byte is allowed on specific spots
306 func hasPrefixPattern(what []byte, pat []byte, wildcard byte) bool {
307 // if the data are shorter than the pattern to match, there's no match
308 if len(what) < len(pat) {
309 return false
310 }
311
312 // use a slice which ensures the pattern length is never exceeded
313 what = what[:len(pat)]
314
315 for i, x := range what {
316 y := pat[i]
317 if x != y && y != wildcard {
318 return false
319 }
320 }
321 return true
322 }
323
324 // all the MIME types used/recognized in this package
325 const (
326 aiff = `audio/aiff`
327 au = `audio/basic`
328 avi = `video/avi`
329 avif = `image/avif`
330 bmp = `image/x-bmp`
331 caf = `audio/x-caf`
332 cur = `image/vnd.microsoft.icon`
333 css = `text/css`
334 csv = `text/csv`
335 djvu = `image/x-djvu`
336 elf = `application/x-elf`
337 exe = `application/vnd.microsoft.portable-executable`
338 flac = `audio/x-flac`
339 gif = `image/gif`
340 gz = `application/gzip`
341 heic = `image/heic`
342 htm = `text/html`
343 html = `text/html`
344 ico = `image/x-icon`
345 iso = `application/octet-stream`
346 jpg = `image/jpeg`
347 jpeg = `image/jpeg`
348 js = `application/javascript`
349 json = `application/json`
350 m4a = `audio/aac`
351 m4v = `video/x-m4v`
352 mid = `audio/midi`
353 mov = `video/quicktime`
354 mp4 = `video/mp4`
355 mp3 = `audio/mpeg`
356 mpg = `video/mpeg`
357 ogg = `audio/ogg`
358 opus = `audio/opus`
359 pdf = `application/pdf`
360 png = `image/png`
361 ps = `application/postscript`
362 psd = `image/vnd.adobe.photoshop`
363 rtf = `application/rtf`
364 sqlite3 = `application/x-sqlite3`
365 svg = `image/svg+xml`
366 text = `text/plain`
367 tiff = `image/tiff`
368 tsv = `text/tsv`
369 wasm = `application/wasm`
370 wav = `audio/x-wav`
371 webp = `image/webp`
372 webm = `video/webm`
373 xml = `application/xml`
374 zip = `application/zip`
375 zst = `application/zstd`
376 )
377
378 // type2mime turns dotless format-names into MIME types
379 var type2mime = map[string]string{
380 `aiff`: aiff,
381 `wav`: wav,
382 `avi`: avi,
383 `jpg`: jpg,
384 `jpeg`: jpeg,
385 `m4a`: m4a,
386 `mp4`: mp4,
387 `m4v`: m4v,
388 `mov`: mov,
389 `png`: png,
390 `avif`: avif,
391 `webp`: webp,
392 `gif`: gif,
393 `tiff`: tiff,
394 `psd`: psd,
395 `flac`: flac,
396 `webm`: webm,
397 `mpg`: mpg,
398 `zip`: zip,
399 `gz`: gz,
400 `zst`: zst,
401 `mp3`: mp3,
402 `opus`: opus,
403 `bmp`: bmp,
404 `mid`: mid,
405 `ogg`: ogg,
406 `html`: html,
407 `htm`: htm,
408 `svg`: svg,
409 `xml`: xml,
410 `rtf`: rtf,
411 `pdf`: pdf,
412 `ps`: ps,
413 `au`: au,
414 `ico`: ico,
415 `cur`: cur,
416 `caf`: caf,
417 `heic`: heic,
418 `sqlite3`: sqlite3,
419 `elf`: elf,
420 `exe`: exe,
421 `wasm`: wasm,
422 `iso`: iso,
423 `txt`: text,
424 `css`: css,
425 `csv`: csv,
426 `tsv`: tsv,
427 `js`: js,
428 `json`: json,
429 `geojson`: json,
430 }
431
432 // formatDescriptor ties a file-header pattern to its data-format type
433 type formatDescriptor struct {
434 Header []byte
435 Type string
436 }
437
438 // can be anything: ensure this value differs from all other literal bytes
439 // in the generic-headers table: failing that, its value could cause subtle
440 // type-misdetection bugs
441 const cba = 0xFD // 253, which is > 127, the highest-valued ascii symbol
442
443 // dash-streamed m4a format
444 var m4aDash = []byte{
445 cba, cba, cba, cba, 'f', 't', 'y', 'p', 'd', 'a', 's', 'h',
446 000, 000, 000, 000, 'i', 's', 'o', '6', 'm', 'p', '4', '1',
447 }
448
449 // format markers with leading wildcards, which should be checked before the
450 // normal ones: this is to prevent mismatches with the latter types, even
451 // though you can make probabilistic arguments which suggest these mismatches
452 // should be very unlikely in practice
453 var specialHeaders = []formatDescriptor{
454 {[]byte{cba, cba, cba, cba, 'f', 't', 'y', 'p', 'M', '4', 'A', ' '}, m4a},
455 {[]byte{cba, cba, cba, cba, 'f', 't', 'y', 'p', 'M', '4', 'A', 000}, m4a},
456 {[]byte{cba, cba, cba, cba, 'f', 't', 'y', 'p', 'M', 'S', 'N', 'V'}, mp4},
457 {[]byte{cba, cba, cba, cba, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm'}, mp4},
458 {[]byte{cba, cba, cba, cba, 'f', 't', 'y', 'p', 'm', 'p', '4', '2'}, m4v},
459 {[]byte{cba, cba, cba, cba, 'f', 't', 'y', 'p', 'q', 't', ' ', ' '}, mov},
460 {[]byte{cba, cba, cba, cba, 'f', 't', 'y', 'p', 'h', 'e', 'i', 'c'}, heic},
461 {[]byte{cba, cba, cba, cba, 'f', 't', 'y', 'p', 'a', 'v', 'i', 'f'}, avif},
462 {m4aDash, m4a},
463 }
464
465 // sqlite3 database format
466 var sqlite3db = []byte{
467 'S', 'Q', 'L', 'i', 't', 'e', ' ',
468 'f', 'o', 'r', 'm', 'a', 't', ' ', '3',
469 000,
470 }
471
472 // windows-variant bitmap file-header, which is followed by a byte-counter for
473 // the 40-byte infoheader which follows that
474 var winbmp = []byte{
475 'B', 'M', cba, cba, cba, cba, cba, cba, cba, cba, cba, cba, cba, cba, 40,
476 }
477
478 // deja-vu document format
479 var djv = []byte{
480 'A', 'T', '&', 'T', 'F', 'O', 'R', 'M', cba, cba, cba, cba, 'D', 'J', 'V',
481 }
482
483 var doctypeHTML = []byte{
484 '<', '!', 'D', 'O', 'C', 'T', 'Y', 'P', 'E', ' ', 'h', 't', 'm', 'l',
485 }
486
487 // hdrDispatch groups format-description-groups by their first byte, thus
488 // shortening total lookups for some data header: notice how the `ftyp` data
489 // formats aren't handled here, since these can start with any byte, instead
490 // of the literal value of the any-byte markers they use
491 var hdrDispatch = [256][]formatDescriptor{
492 {
493 {[]byte{000, 000, 001, 0xBA}, mpg},
494 {[]byte{000, 000, 001, 0xB3}, mpg},
495 {[]byte{000, 000, 001, 000}, ico},
496 {[]byte{000, 000, 002, 000}, cur},
497 {[]byte{000, 'a', 's', 'm'}, wasm},
498 }, // 0
499 nil, // 1
500 nil, // 2
501 nil, // 3
502 nil, // 4
503 nil, // 5
504 nil, // 6
505 nil, // 7
506 nil, // 8
507 nil, // 9
508 nil, // 10
509 nil, // 11
510 nil, // 12
511 nil, // 13
512 nil, // 14
513 nil, // 15
514 nil, // 16
515 nil, // 17
516 nil, // 18
517 nil, // 19
518 nil, // 20
519 nil, // 21
520 nil, // 22
521 nil, // 23
522 nil, // 24
523 nil, // 25
524 {
525 {[]byte{0x1A, 0x45, 0xDF, 0xA3}, webm},
526 }, // 26
527 nil, // 27
528 nil, // 28
529 nil, // 29
530 nil, // 30
531 {
532 // {[]byte{0x1F, 0x8B, 0x08, 0x08}, gz},
533 {[]byte{0x1F, 0x8B, 0x08}, gz},
534 }, // 31
535 nil, // 32
536 nil, // 33 !
537 nil, // 34 "
538 {
539 {[]byte{'#', '!', ' '}, text},
540 {[]byte{'#', '!', '/'}, text},
541 }, // 35 #
542 nil, // 36 $
543 {
544 {[]byte{'%', 'P', 'D', 'F'}, pdf},
545 {[]byte{'%', '!', 'P', 'S'}, ps},
546 }, // 37 %
547 nil, // 38 &
548 nil, // 39 '
549 {
550 {[]byte{0x28, 0xB5, 0x2F, 0xFD}, zst},
551 }, // 40 (
552 nil, // 41 )
553 nil, // 42 *
554 nil, // 43 +
555 nil, // 44 ,
556 nil, // 45 -
557 {
558 {[]byte{'.', 's', 'n', 'd'}, au},
559 }, // 46 .
560 nil, // 47 /
561 nil, // 48 0
562 nil, // 49 1
563 nil, // 50 2
564 nil, // 51 3
565 nil, // 52 4
566 nil, // 53 5
567 nil, // 54 6
568 nil, // 55 7
569 {
570 {[]byte{'8', 'B', 'P', 'S'}, psd},
571 }, // 56 8
572 nil, // 57 9
573 nil, // 58 :
574 nil, // 59 ;
575 {
576 // func checkDoc is better for these, since it's case-insensitive
577 {doctypeHTML, html},
578 {[]byte{'<', 's', 'v', 'g'}, svg},
579 {[]byte{'<', 'h', 't', 'm', 'l', '>'}, html},
580 {[]byte{'<', 'h', 'e', 'a', 'd', '>'}, html},
581 {[]byte{'<', 'b', 'o', 'd', 'y', '>'}, html},
582 {[]byte{'<', '?', 'x', 'm', 'l'}, xml},
583 }, // 60 <
584 nil, // 61 =
585 nil, // 62 >
586 nil, // 63 ?
587 nil, // 64 @
588 {
589 {djv, djvu},
590 }, // 65 A
591 {
592 {winbmp, bmp},
593 }, // 66 B
594 nil, // 67 C
595 nil, // 68 D
596 nil, // 69 E
597 {
598 {[]byte{'F', 'O', 'R', 'M', cba, cba, cba, cba, 'A', 'I', 'F', 'F'}, aiff},
599 {[]byte{'F', 'O', 'R', 'M', cba, cba, cba, cba, 'A', 'I', 'F', 'C'}, aiff},
600 }, // 70 F
601 {
602 {[]byte{'G', 'I', 'F', '8', '7', 'a'}, gif},
603 {[]byte{'G', 'I', 'F', '8', '9', 'a'}, gif},
604 }, // 71 G
605 nil, // 72 H
606 {
607 {[]byte{'I', 'D', '3', 2}, mp3}, // ID3-format metadata
608 {[]byte{'I', 'D', '3', 3}, mp3}, // ID3-format metadata
609 {[]byte{'I', 'D', '3', 4}, mp3}, // ID3-format metadata
610 {[]byte{'I', 'I', '*', 000}, tiff},
611 }, // 73 I
612 nil, // 74 J
613 nil, // 75 K
614 nil, // 76 L
615 {
616 {[]byte{'M', 'M', 000, '*'}, tiff},
617 {[]byte{'M', 'T', 'h', 'd'}, mid},
618 {[]byte{'M', 'Z', cba, 000, cba, 000}, exe},
619 // {[]byte{'M', 'Z', 0x90, 000, 003, 000}, exe},
620 // {[]byte{'M', 'Z', 0x78, 000, 001, 000}, exe},
621 // {[]byte{'M', 'Z', 'P', 000, 002, 000}, exe},
622 }, // 77 M
623 nil, // 78 N
624 {
625 {[]byte{'O', 'g', 'g', 'S'}, ogg},
626 }, // 79 O
627 {
628 {[]byte{'P', 'K', 003, 004}, zip},
629 }, // 80 P
630 nil, // 81 Q
631 {
632 {[]byte{'R', 'I', 'F', 'F', cba, cba, cba, cba, 'W', 'E', 'B', 'P'}, webp},
633 {[]byte{'R', 'I', 'F', 'F', cba, cba, cba, cba, 'W', 'A', 'V', 'E'}, wav},
634 {[]byte{'R', 'I', 'F', 'F', cba, cba, cba, cba, 'A', 'V', 'I', ' '}, avi},
635 }, // 82 R
636 {
637 {sqlite3db, sqlite3},
638 }, // 83 S
639 nil, // 84 T
640 nil, // 85 U
641 nil, // 86 V
642 nil, // 87 W
643 nil, // 88 X
644 nil, // 89 Y
645 nil, // 90 Z
646 nil, // 91 [
647 nil, // 92 \
648 nil, // 93 ]
649 nil, // 94 ^
650 nil, // 95 _
651 nil, // 96 `
652 nil, // 97 a
653 nil, // 98 b
654 {
655 {[]byte{'c', 'a', 'f', 'f', 000, 001, 000, 000}, caf},
656 }, // 99 c
657 nil, // 100 d
658 nil, // 101 e
659 {
660 {[]byte{'f', 'L', 'a', 'C'}, flac},
661 }, // 102 f
662 nil, // 103 g
663 nil, // 104 h
664 nil, // 105 i
665 nil, // 106 j
666 nil, // 107 k
667 nil, // 108 l
668 nil, // 109 m
669 nil, // 110 n
670 nil, // 111 o
671 nil, // 112 p
672 nil, // 113 q
673 nil, // 114 r
674 nil, // 115 s
675 nil, // 116 t
676 nil, // 117 u
677 nil, // 118 v
678 nil, // 119 w
679 nil, // 120 x
680 nil, // 121 y
681 nil, // 122 z
682 {
683 {[]byte{'{', '\\', 'r', 't', 'f'}, rtf},
684 }, // 123 {
685 nil, // 124 |
686 nil, // 125 }
687 nil, // 126
688 {
689 {[]byte{127, 'E', 'L', 'F'}, elf},
690 }, // 127
691 nil, // 128
692 nil, // 129
693 nil, // 130
694 nil, // 131
695 nil, // 132
696 nil, // 133
697 nil, // 134
698 nil, // 135
699 nil, // 136
700 {
701 {[]byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, png},
702 }, // 137
703 nil, // 138
704 nil, // 139
705 nil, // 140
706 nil, // 141
707 nil, // 142
708 nil, // 143
709 nil, // 144
710 nil, // 145
711 nil, // 146
712 nil, // 147
713 nil, // 148
714 nil, // 149
715 nil, // 150
716 nil, // 151
717 nil, // 152
718 nil, // 153
719 nil, // 154
720 nil, // 155
721 nil, // 156
722 nil, // 157
723 nil, // 158
724 nil, // 159
725 nil, // 160
726 nil, // 161
727 nil, // 162
728 nil, // 163
729 nil, // 164
730 nil, // 165
731 nil, // 166
732 nil, // 167
733 nil, // 168
734 nil, // 169
735 nil, // 170
736 nil, // 171
737 nil, // 172
738 nil, // 173
739 nil, // 174
740 nil, // 175
741 nil, // 176
742 nil, // 177
743 nil, // 178
744 nil, // 179
745 nil, // 180
746 nil, // 181
747 nil, // 182
748 nil, // 183
749 nil, // 184
750 nil, // 185
751 nil, // 186
752 nil, // 187
753 nil, // 188
754 nil, // 189
755 nil, // 190
756 nil, // 191
757 nil, // 192
758 nil, // 193
759 nil, // 194
760 nil, // 195
761 nil, // 196
762 nil, // 197
763 nil, // 198
764 nil, // 199
765 nil, // 200
766 nil, // 201
767 nil, // 202
768 nil, // 203
769 nil, // 204
770 nil, // 205
771 nil, // 206
772 nil, // 207
773 nil, // 208
774 nil, // 209
775 nil, // 210
776 nil, // 211
777 nil, // 212
778 nil, // 213
779 nil, // 214
780 nil, // 215
781 nil, // 216
782 nil, // 217
783 nil, // 218
784 nil, // 219
785 nil, // 220
786 nil, // 221
787 nil, // 222
788 nil, // 223
789 nil, // 224
790 nil, // 225
791 nil, // 226
792 nil, // 227
793 nil, // 228
794 nil, // 229
795 nil, // 230
796 nil, // 231
797 nil, // 232
798 nil, // 233
799 nil, // 234
800 nil, // 235
801 nil, // 236
802 nil, // 237
803 nil, // 238
804 nil, // 239
805 nil, // 240
806 nil, // 241
807 nil, // 242
808 nil, // 243
809 nil, // 244
810 nil, // 245
811 nil, // 246
812 nil, // 247
813 nil, // 248
814 nil, // 249
815 nil, // 250
816 nil, // 251
817 nil, // 252
818 nil, // 253
819 nil, // 254
820 {
821 {[]byte{0xFF, 0xD8, 0xFF}, jpg},
822 {[]byte{0xFF, 0xF3, 0x48, 0xC4, 0x00}, mp3},
823 {[]byte{0xFF, 0xFB}, mp3},
824 }, // 255
825 }
File: ./debase64/debase64.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package debase64
26
27 import (
28 "bufio"
29 "bytes"
30 "encoding/base64"
31 "errors"
32 "io"
33 "os"
34 "strings"
35 )
36
37 const info = `
38 debase64 [file/data-URI...]
39
40 Decode base64-encoded files and/or data-URIs.
41 `
42
43 func Main() {
44 args := os.Args[1:]
45
46 if len(args) > 0 {
47 switch args[0] {
48 case `-h`, `--h`, `-help`, `--help`:
49 os.Stdout.WriteString(info[1:])
50 return
51
52 case `--`:
53 args = args[1:]
54 }
55 }
56
57 if len(args) > 1 {
58 os.Stderr.WriteString(info[1:])
59 os.Exit(1)
60 }
61
62 name := `-`
63 if len(args) == 1 {
64 name = args[0]
65 }
66
67 if err := run(name); err != nil {
68 os.Stderr.WriteString(err.Error())
69 os.Stderr.WriteString("\n")
70 os.Exit(1)
71 }
72 }
73
74 func run(s string) error {
75 bw := bufio.NewWriterSize(os.Stdout, 32*1024)
76 defer bw.Flush()
77 w := bw
78
79 if s == `-` {
80 return debase64(w, os.Stdin)
81 }
82
83 if seemsDataURI(s) {
84 return debase64(w, strings.NewReader(s))
85 }
86
87 f, err := os.Open(s)
88 if err != nil {
89 return err
90 }
91 defer f.Close()
92
93 return debase64(w, f)
94 }
95
96 // debase64 decodes base64 chunks explicitly, so decoding errors can be told
97 // apart from output-writing ones
98 func debase64(w io.Writer, r io.Reader) error {
99 br := bufio.NewReaderSize(r, 32*1024)
100 start, err := br.Peek(64)
101 if err != nil && err != io.EOF {
102 return err
103 }
104
105 skip, err := skipIntroDataURI(start)
106 if err != nil {
107 return err
108 }
109
110 if skip > 0 {
111 br.Discard(skip)
112 }
113
114 dec := base64.NewDecoder(base64.StdEncoding, br)
115 _, err = io.Copy(w, dec)
116 return err
117 }
118
119 func skipIntroDataURI(chunk []byte) (skip int, err error) {
120 if bytes.HasPrefix(chunk, []byte{0xef, 0xbb, 0xbf}) {
121 chunk = chunk[3:]
122 skip += 3
123 }
124
125 if !bytes.HasPrefix(chunk, []byte(`data:`)) {
126 return skip, nil
127 }
128
129 start := chunk
130 if len(start) > 64 {
131 start = start[:64]
132 }
133
134 i := bytes.Index(start, []byte(`;base64,`))
135 if i < 0 {
136 return skip, errors.New(`invalid data URI`)
137 }
138
139 skip += i + len(`;base64,`)
140 return skip, nil
141 }
142
143 func seemsDataURI(s string) bool {
144 start := s
145 if len(s) > 64 {
146 start = s[:64]
147 }
148 return strings.HasPrefix(s, `data:`) && strings.Contains(start, `;base64,`)
149 }
File: ./dedup/dedup.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package dedup
26
27 import (
28 "bufio"
29 "errors"
30 "io"
31 "os"
32 )
33
34 const info = `
35 dedup [options...] [file...]
36
37
38 DEDUPlicate lines prevents the same line from appearing again in the output,
39 after the first time. Unique lines are remembered across inputs.
40
41 Input is assumed to be UTF-8, and all CRLF byte-pairs are turned into line
42 feeds by default.
43
44 All (optional) leading options start with either single or double-dash:
45
46 -h, -help show this help message
47 `
48
49 type stringSet map[string]struct{}
50
51 func Main() {
52 buffered := false
53 args := os.Args[1:]
54
55 if len(args) > 0 {
56 switch args[0] {
57 case `-b`, `--b`, `-buffered`, `--buffered`:
58 buffered = true
59 args = args[1:]
60
61 case `-h`, `--h`, `-help`, `--help`:
62 os.Stdout.WriteString(info[1:])
63 return
64 }
65 }
66
67 if len(args) > 0 && args[0] == `--` {
68 args = args[1:]
69 }
70
71 liveLines := !buffered
72 if !buffered {
73 if _, err := os.Stdout.Seek(0, io.SeekCurrent); err == nil {
74 liveLines = false
75 }
76 }
77
78 err := run(os.Stdout, args, liveLines)
79 if err != nil && err != io.EOF {
80 os.Stderr.WriteString(err.Error())
81 os.Stderr.WriteString("\n")
82 os.Exit(1)
83 }
84 }
85
86 func run(w io.Writer, args []string, live bool) error {
87 files := make(stringSet)
88 lines := make(stringSet)
89 bw := bufio.NewWriter(w)
90 defer bw.Flush()
91
92 for _, name := range args {
93 if _, ok := files[name]; ok {
94 continue
95 }
96 files[name] = struct{}{}
97
98 if err := handleFile(bw, name, lines, live); err != nil {
99 return err
100 }
101 }
102
103 if len(args) == 0 {
104 return dedup(bw, os.Stdin, lines, live)
105 }
106 return nil
107 }
108
109 func handleFile(w *bufio.Writer, name string, got stringSet, live bool) error {
110 if name == `` || name == `-` {
111 return dedup(w, os.Stdin, got, live)
112 }
113
114 f, err := os.Open(name)
115 if err != nil {
116 return errors.New(`can't read from file named "` + name + `"`)
117 }
118 defer f.Close()
119
120 return dedup(w, f, got, live)
121 }
122
123 func dedup(w *bufio.Writer, r io.Reader, got stringSet, live bool) error {
124 const gb = 1024 * 1024 * 1024
125 sc := bufio.NewScanner(r)
126 sc.Buffer(nil, 8*gb)
127
128 for sc.Scan() {
129 line := sc.Text()
130 if _, ok := got[line]; ok {
131 continue
132 }
133 got[line] = struct{}{}
134
135 w.Write(sc.Bytes())
136 if w.WriteByte('\n') != nil {
137 return io.EOF
138 }
139
140 if !live {
141 continue
142 }
143
144 if err := w.Flush(); err != nil {
145 return io.EOF
146 }
147 }
148
149 return sc.Err()
150 }
File: ./dejsonl/dejsonl.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package dejsonl
26
27 import (
28 "bufio"
29 "encoding/json"
30 "errors"
31 "io"
32 "os"
33 "strings"
34 )
35
36 const info = `
37 dejsonl [filepath...]
38
39 Turn JSON Lines (JSONL) into proper-JSON arrays. The JSON Lines format is
40 simply plain-text lines, where each line is valid JSON on its own.
41 `
42
43 const indent = ` `
44
45 func Main() {
46 buffered := false
47 args := os.Args[1:]
48
49 if len(args) > 0 {
50 switch args[0] {
51 case `-b`, `--b`, `-buffered`, `--buffered`:
52 buffered = true
53 args = args[1:]
54
55 case `-h`, `--h`, `-help`, `--help`:
56 os.Stdout.WriteString(info[1:])
57 return
58 }
59 }
60
61 if len(args) > 0 && args[0] == `--` {
62 args = args[1:]
63 }
64
65 liveLines := !buffered
66 if !buffered {
67 if _, err := os.Stdout.Seek(0, io.SeekCurrent); err == nil {
68 liveLines = false
69 }
70 }
71
72 err := run(os.Stdout, os.Args[1:], liveLines)
73 if err != nil && err != io.EOF {
74 os.Stderr.WriteString(err.Error())
75 os.Stderr.WriteString("\n")
76 os.Exit(1)
77 }
78 }
79
80 func run(w io.Writer, args []string, live bool) error {
81 dashes := 0
82 for _, path := range args {
83 if path == `-` {
84 dashes++
85 }
86 if dashes > 1 {
87 return errors.New(`can't read stdin (dash) more than once`)
88 }
89 }
90
91 bw := bufio.NewWriter(w)
92 defer bw.Flush()
93
94 if len(args) == 0 {
95 return dejsonl(bw, os.Stdin, live)
96 }
97
98 for _, path := range args {
99 if err := handleInput(bw, path, live); err != nil {
100 return err
101 }
102 }
103
104 return nil
105 }
106
107 // handleInput simplifies control-flow for func main
108 func handleInput(w *bufio.Writer, path string, live bool) error {
109 if path == `-` {
110 return dejsonl(w, os.Stdin, live)
111 }
112
113 // if f := strings.HasPrefix; f(path, `https://`) || f(path, `http://`) {
114 // resp, err := http.Get(path)
115 // if err != nil {
116 // return err
117 // }
118 // defer resp.Body.Close()
119 // return dejsonl(w, resp.Body, live)
120 // }
121
122 f, err := os.Open(path)
123 if err != nil {
124 // on windows, file-not-found error messages may mention `CreateFile`,
125 // even when trying to open files in read-only mode
126 return errors.New(`can't open file named ` + path)
127 }
128 defer f.Close()
129 return dejsonl(w, f, live)
130 }
131
132 // dejsonl simplifies control-flow for func handleInput
133 func dejsonl(w *bufio.Writer, r io.Reader, live bool) error {
134 const gb = 1024 * 1024 * 1024
135 sc := bufio.NewScanner(r)
136 sc.Buffer(nil, 8*gb)
137 got := 0
138
139 for i := 0; sc.Scan(); i++ {
140 s := sc.Text()
141 if i == 0 && strings.HasPrefix(s, "\xef\xbb\xbf") {
142 s = s[3:]
143 }
144
145 // trim spaces at both ends of the current line
146 for len(s) > 0 && s[0] == ' ' {
147 s = s[1:]
148 }
149 for len(s) > 0 && s[len(s)-1] == ' ' {
150 s = s[:len(s)-1]
151 }
152
153 // ignore empty(ish) lines
154 if len(s) == 0 {
155 continue
156 }
157
158 // ignore lines starting with unix-style comments
159 if len(s) > 0 && s[0] == '#' {
160 continue
161 }
162
163 if err := checkJSONL(strings.NewReader(s)); err != nil {
164 return err
165 }
166
167 if got == 0 {
168 w.WriteByte('[')
169 } else {
170 w.WriteByte(',')
171 }
172 if w.WriteByte('\n') != nil {
173 return io.EOF
174 }
175 w.WriteString(indent)
176 w.WriteString(s)
177 got++
178
179 if !live {
180 continue
181 }
182
183 if err := w.Flush(); err != nil {
184 return io.EOF
185 }
186 }
187
188 if got == 0 {
189 w.WriteString("[\n]\n")
190 } else {
191 w.WriteString("\n]\n")
192 }
193 return sc.Err()
194 }
195
196 func checkJSONL(r io.Reader) error {
197 dec := json.NewDecoder(r)
198 // avoid parsing numbers, so unusually-long numbers are kept verbatim,
199 // even if JSON parsers aren't required to guarantee such input-fidelity
200 // for numbers
201 dec.UseNumber()
202
203 t, err := dec.Token()
204 if err == io.EOF {
205 return errors.New(`input has no JSON values`)
206 }
207
208 if err := checkToken(dec, t); err != nil {
209 return err
210 }
211
212 _, err = dec.Token()
213 if err == io.EOF {
214 // input is over, so it's a success
215 return nil
216 }
217
218 if err == nil {
219 // a successful `read` is a failure, as it means there are
220 // trailing JSON tokens
221 return errors.New(`unexpected trailing data`)
222 }
223
224 // any other error, perhaps some invalid-JSON-syntax-type error
225 return err
226 }
227
228 // checkToken handles recursion for func checkJSONL
229 func checkToken(dec *json.Decoder, t json.Token) error {
230 switch t := t.(type) {
231 case json.Delim:
232 switch t {
233 case json.Delim('['):
234 return checkArray(dec)
235 case json.Delim('{'):
236 return checkObject(dec)
237 default:
238 return errors.New(`unsupported JSON syntax ` + string(t))
239 }
240
241 case nil, bool, float64, json.Number, string:
242 return nil
243
244 default:
245 // return fmt.Errorf(`unsupported token type %T`, t)
246 return errors.New(`invalid JSON token`)
247 }
248 }
249
250 // handleArray handles arrays for func checkToken
251 func checkArray(dec *json.Decoder) error {
252 for {
253 t, err := dec.Token()
254 if err != nil {
255 return err
256 }
257
258 if t == json.Delim(']') {
259 return nil
260 }
261
262 if err := checkToken(dec, t); err != nil {
263 return err
264 }
265 }
266 }
267
268 // handleObject handles objects for func checkToken
269 func checkObject(dec *json.Decoder) error {
270 for {
271 t, err := dec.Token()
272 if err != nil {
273 return err
274 }
275
276 if t == json.Delim('}') {
277 return nil
278 }
279
280 if _, ok := t.(string); !ok {
281 return errors.New(`expected a string for a key-value pair`)
282 }
283
284 t, err = dec.Token()
285 if err == io.EOF || t == json.Delim('}') {
286 return errors.New(`expected a value for a key-value pair`)
287 }
288
289 if err := checkToken(dec, t); err != nil {
290 return err
291 }
292 }
293 }
File: ./dessv/dessv.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package dessv
26
27 import (
28 "bufio"
29 "bytes"
30 "errors"
31 "io"
32 "os"
33 )
34
35 const info = `
36 dessv [filenames...]
37
38 Turn Space(s)-Separated Values (SSV) into Tab-Separated Values (TSV), where
39 both leading and trailing spaces from input lines are ignored.
40 `
41
42 func Main() {
43 buffered := false
44 args := os.Args[1:]
45
46 if len(args) > 0 {
47 switch args[0] {
48 case `-b`, `--b`, `-buffered`, `--buffered`:
49 buffered = true
50 args = args[1:]
51
52 case `-h`, `--h`, `-help`, `--help`:
53 os.Stdout.WriteString(info[1:])
54 return
55 }
56 }
57
58 if len(args) > 0 && args[0] == `--` {
59 args = args[1:]
60 }
61
62 liveLines := !buffered
63 if !buffered {
64 if _, err := os.Stdout.Seek(0, io.SeekCurrent); err == nil {
65 liveLines = false
66 }
67 }
68
69 if err := run(os.Stdout, args, liveLines); err != nil && err != io.EOF {
70 os.Stderr.WriteString(err.Error())
71 os.Stderr.WriteString("\n")
72 os.Exit(1)
73 }
74 }
75
76 func run(w io.Writer, args []string, live bool) error {
77 bw := bufio.NewWriter(w)
78 defer bw.Flush()
79
80 if len(args) == 0 {
81 return dessv(bw, os.Stdin, live)
82 }
83
84 for _, name := range args {
85 if err := handleFile(bw, name, live); err != nil {
86 return err
87 }
88 }
89 return nil
90 }
91
92 func handleFile(w *bufio.Writer, name string, live bool) error {
93 if name == `` || name == `-` {
94 return dessv(w, os.Stdin, live)
95 }
96
97 f, err := os.Open(name)
98 if err != nil {
99 return errors.New(`can't read from file named "` + name + `"`)
100 }
101 defer f.Close()
102
103 return dessv(w, f, live)
104 }
105
106 func dessv(w *bufio.Writer, r io.Reader, live bool) error {
107 const gb = 1024 * 1024 * 1024
108 sc := bufio.NewScanner(r)
109 sc.Buffer(nil, 8*gb)
110 handleRow := handleRowSSV
111 numTabs := ^0
112
113 for i := 0; sc.Scan(); i++ {
114 s := sc.Bytes()
115 if i == 0 {
116 if bytes.HasPrefix(s, []byte{0xef, 0xbb, 0xbf}) {
117 s = s[3:]
118 }
119
120 for _, b := range s {
121 if b == '\t' {
122 handleRow = handleRowTSV
123 break
124 }
125 }
126 numTabs = handleRow(w, s, numTabs)
127 } else {
128 handleRow(w, s, numTabs)
129 }
130
131 if w.WriteByte('\n') != nil {
132 return io.EOF
133 }
134
135 if !live {
136 continue
137 }
138
139 if err := w.Flush(); err != nil {
140 return io.EOF
141 }
142 }
143
144 return sc.Err()
145 }
146
147 func handleRowSSV(w *bufio.Writer, s []byte, n int) int {
148 for len(s) > 0 && s[0] == ' ' {
149 s = s[1:]
150 }
151 for len(s) > 0 && s[len(s)-1] == ' ' {
152 s = s[:len(s)-1]
153 }
154
155 got := 0
156
157 for got = 0; len(s) > 0; got++ {
158 if got > 0 {
159 w.WriteByte('\t')
160 }
161
162 i := bytes.IndexByte(s, ' ')
163 if i < 0 {
164 w.Write(s)
165 s = nil
166 n--
167 break
168 }
169
170 w.Write(s[:i])
171 s = s[i+1:]
172 for len(s) > 0 && s[0] == ' ' {
173 s = s[1:]
174 }
175 n--
176 }
177
178 w.Write(s)
179 writeTabs(w, n)
180 return got
181 }
182
183 func handleRowTSV(w *bufio.Writer, s []byte, n int) int {
184 got := 0
185 for _, b := range s {
186 if b == '\t' {
187 got++
188 }
189 }
190
191 w.Write(s)
192 writeTabs(w, n-got)
193 return got
194 }
195
196 func writeTabs(w *bufio.Writer, n int) {
197 for n > 0 {
198 w.WriteByte('\t')
199 n--
200 }
201 }
File: ./ecoli/ecoli.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package ecoli
26
27 import (
28 "bufio"
29 "bytes"
30 "io"
31 "os"
32 "regexp"
33 )
34
35 const info = `
36 ecoli [options...] [regex/style pairs...]
37
38
39 Expressions COloring LInes tries to match each line read from the standard
40 input to the regexes given, coloring/styling with the named-style paired
41 to the first matching regex, if any. Lines not matching any regex stay the
42 same.
43
44 The options are, available both in single and double-dash versions
45
46 -h, -help show this help message
47 -i, -ins, -insensitive match the regexes given case-insensitively
48
49 Some of the colors/styles available are:
50
51 blue blueback
52 bold
53 gray grayback
54 green greenback
55 inverse
56 magenta magentaback
57 orange orangeback
58 purple purpleback
59 red redback
60 underline
61
62 Some style aliases are:
63
64 b blue bb blueback
65 g green gb greenback
66 m magenta mb magentaback
67 o orange ob orangeback
68 p purple pb purpleback
69 r red rb redback
70 i inverse (highlight)
71 u underline
72 `
73
74 var styleAliases = map[string]string{
75 `b`: `blue`,
76 `g`: `green`,
77 `m`: `magenta`,
78 `o`: `orange`,
79 `p`: `purple`,
80 `r`: `red`,
81 `u`: `underline`,
82
83 `bb`: `blueback`,
84 `bg`: `greenback`,
85 `bm`: `magentaback`,
86 `bo`: `orangeback`,
87 `bp`: `purpleback`,
88 `br`: `redback`,
89
90 `gb`: `greenback`,
91 `mb`: `magentaback`,
92 `ob`: `orangeback`,
93 `pb`: `purpleback`,
94 `rb`: `redback`,
95
96 `hi`: `inverse`,
97 `inv`: `inverse`,
98 `mag`: `magenta`,
99
100 `du`: `doubleunderline`,
101
102 `flip`: `inverse`,
103 `swap`: `inverse`,
104
105 `reset`: `plain`,
106 `highlight`: `inverse`,
107 `hilite`: `inverse`,
108 `invert`: `inverse`,
109 `inverted`: `inverse`,
110 `swapped`: `inverse`,
111
112 `dunderline`: `doubleunderline`,
113 `dunderlined`: `doubleunderline`,
114
115 `strikethrough`: `strike`,
116 `strikethru`: `strike`,
117 `struck`: `strike`,
118
119 `underlined`: `underline`,
120
121 `bblue`: `blueback`,
122 `bgray`: `grayback`,
123 `bgreen`: `greenback`,
124 `bmagenta`: `magentaback`,
125 `borange`: `orangeback`,
126 `bpurple`: `purpleback`,
127 `bred`: `redback`,
128
129 `bgblue`: `blueback`,
130 `bggray`: `grayback`,
131 `bggreen`: `greenback`,
132 `bgmag`: `magentaback`,
133 `bgmagenta`: `magentaback`,
134 `bgorange`: `orangeback`,
135 `bgpurple`: `purpleback`,
136 `bgred`: `redback`,
137
138 `bluebg`: `blueback`,
139 `graybg`: `grayback`,
140 `greenbg`: `greenback`,
141 `magbg`: `magentaback`,
142 `magentabg`: `magentaback`,
143 `orangebg`: `orangeback`,
144 `purplebg`: `purpleback`,
145 `redbg`: `redback`,
146
147 `backblue`: `blueback`,
148 `backgray`: `grayback`,
149 `backgreen`: `greenback`,
150 `backmag`: `magentaback`,
151 `backmagenta`: `magentaback`,
152 `backorange`: `orangeback`,
153 `backpurple`: `purpleback`,
154 `backred`: `redback`,
155 }
156
157 var styles = map[string]string{
158 `blue`: "\x1b[38;2;0;95;215m",
159 `bold`: "\x1b[1m",
160 `doubleunderline`: "\x1b[21m",
161 `gray`: "\x1b[38;2;168;168;168m",
162 `green`: "\x1b[38;2;0;135;95m",
163 `inverse`: "\x1b[7m",
164 `magenta`: "\x1b[38;2;215;0;255m",
165 `orange`: "\x1b[38;2;215;95;0m",
166 `plain`: "\x1b[0m",
167 `purple`: "\x1b[38;2;135;95;255m",
168 `red`: "\x1b[38;2;204;0;0m",
169 `strike`: "\x1b[9m",
170 `underline`: "\x1b[4m",
171
172 `blueback`: "\x1b[48;2;0;95;215m\x1b[38;2;238;238;238m",
173 `grayback`: "\x1b[48;2;168;168;168m\x1b[38;2;238;238;238m",
174 `greenback`: "\x1b[48;2;0;135;95m\x1b[38;2;238;238;238m",
175 `magentaback`: "\x1b[48;2;215;0;255m\x1b[38;2;238;238;238m",
176 `orangeback`: "\x1b[48;2;215;95;0m\x1b[38;2;238;238;238m",
177 `purpleback`: "\x1b[48;2;135;95;255m\x1b[38;2;238;238;238m",
178 `redback`: "\x1b[48;2;204;0;0m\x1b[38;2;238;238;238m",
179 }
180
181 // pair has a regular-expression and its associated ANSI-code style together
182 type pair struct {
183 expr *regexp.Regexp
184 style string
185 }
186
187 func Main() {
188 buffered := false
189 insensitive := false
190 args := os.Args[1:]
191
192 for len(args) > 0 {
193 switch args[0] {
194 case `-b`, `--b`, `-buffered`, `--buffered`:
195 buffered = true
196 args = args[1:]
197 continue
198
199 case `-h`, `--h`, `-help`, `--help`:
200 os.Stdout.WriteString(info[1:])
201 return
202
203 case `-i`, `--i`, `-ins`, `--ins`:
204 insensitive = true
205 args = args[1:]
206 continue
207 }
208
209 break
210 }
211
212 if len(args) > 0 && args[0] == `--` {
213 args = args[1:]
214 }
215
216 if len(args)%2 != 0 {
217 const msg = "you forgot the style-name for/after the last regex\n"
218 os.Stderr.WriteString(msg)
219 os.Exit(1)
220 }
221
222 nerr := 0
223 pairs := make([]pair, 0, len(args)/2)
224
225 for len(args) >= 2 {
226 src := args[0]
227 sname := args[1]
228
229 var err error
230 var exp *regexp.Regexp
231 if insensitive {
232 exp, err = regexp.Compile(`(?i)` + src)
233 } else {
234 exp, err = regexp.Compile(src)
235 }
236 if err != nil {
237 os.Stderr.WriteString(err.Error())
238 os.Stderr.WriteString("\n")
239 nerr++
240 }
241
242 if alias, ok := styleAliases[sname]; ok {
243 sname = alias
244 }
245
246 style, ok := styles[sname]
247 if !ok {
248 os.Stderr.WriteString("no style named `")
249 os.Stderr.WriteString(args[1])
250 os.Stderr.WriteString("`\n")
251 nerr++
252 }
253
254 pairs = append(pairs, pair{expr: exp, style: style})
255 args = args[2:]
256 }
257
258 if nerr > 0 {
259 os.Exit(1)
260 }
261
262 liveLines := !buffered
263 if !buffered {
264 if _, err := os.Stdout.Seek(0, io.SeekCurrent); err == nil {
265 liveLines = false
266 }
267 }
268
269 sc := bufio.NewScanner(os.Stdin)
270 sc.Buffer(nil, 8*1024*1024*1024)
271 bw := bufio.NewWriter(os.Stdout)
272 var plain []byte
273
274 for i := 0; sc.Scan(); i++ {
275 s := sc.Bytes()
276 if i == 0 && bytes.HasPrefix(s, []byte{0xef, 0xbb, 0xbf}) {
277 s = s[3:]
278 }
279 plain = appendPlain(plain[:0], s)
280
281 if err := handleLine(bw, s, noANSI(plain), pairs); err != nil {
282 return
283 }
284
285 if !liveLines {
286 continue
287 }
288
289 if err := bw.Flush(); err != nil {
290 return
291 }
292 }
293 }
294
295 // appendPlain extends the slice given using the non-ANSI parts of a string
296 func appendPlain(dst []byte, src []byte) []byte {
297 for len(src) > 0 {
298 i, j := indexEscapeSequence(src)
299 if i < 0 {
300 dst = append(dst, src...)
301 break
302 }
303 if j < 0 {
304 j = len(src)
305 }
306
307 if i > 0 {
308 dst = append(dst, src[:i]...)
309 }
310
311 src = src[j:]
312 }
313
314 return dst
315 }
316
317 // indexEscapeSequence finds the first ANSI-style escape-sequence, which is
318 // the multi-byte sequences starting with ESC[; the result is a pair of slice
319 // indices which can be independently negative when either the start/end of
320 // a sequence isn't found; given their fairly-common use, even the hyperlink
321 // ESC]8 sequences are supported
322 func indexEscapeSequence(s []byte) (int, int) {
323 var prev byte
324
325 for i, b := range s {
326 if prev == '\x1b' && b == '[' {
327 j := indexLetter(s[i+1:])
328 if j < 0 {
329 return i, -1
330 }
331 return i - 1, i + 1 + j + 1
332 }
333
334 if prev == '\x1b' && b == ']' && i+1 < len(s) && s[i+1] == '8' {
335 j := indexPair(s[i+1:], '\x1b', '\\')
336 if j < 0 {
337 return i, -1
338 }
339 return i - 1, i + 1 + j + 2
340 }
341
342 prev = b
343 }
344
345 return -1, -1
346 }
347
348 func indexLetter(s []byte) int {
349 for i, b := range s {
350 upper := b &^ 32
351 if 'A' <= upper && upper <= 'Z' {
352 return i
353 }
354 }
355
356 return -1
357 }
358
359 func indexPair(s []byte, x byte, y byte) int {
360 var prev byte
361
362 for i, b := range s {
363 if prev == x && b == y && i > 0 {
364 return i
365 }
366 prev = b
367 }
368
369 return -1
370 }
371
372 // noANSI ensures arguments to func handleLine are given in the right order
373 type noANSI []byte
374
375 // handleLine styles the current line given to it using the first matching
376 // regex, keeping it as given if none of the regexes match; it's given 2
377 // strings: the first is the original line, the latter is its plain-text
378 // version (with no ANSI codes) and is used for the regex-matching, since
379 // ANSI codes use a mix of numbers and letters, which can themselves match
380 func handleLine(w *bufio.Writer, s []byte, plain noANSI, pairs []pair) error {
381 for _, p := range pairs {
382 if p.expr.Match(plain) {
383 w.WriteString(p.style)
384 w.Write(s)
385 w.WriteString("\x1b[0m")
386 return w.WriteByte('\n')
387 }
388 }
389
390 w.Write(s)
391 return w.WriteByte('\n')
392 }
File: ./erase/erase.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package erase
26
27 import (
28 "bufio"
29 "bytes"
30 "io"
31 "os"
32 "regexp"
33 )
34
35 const info = `
36 erase [options...] [regexes...]
37
38
39 Ignore/remove all occurrences of all regex matches along lines read from the
40 standard input. The regular-expression mode used is "re2", which is a superset
41 of the commonly-used "extended-mode".
42
43 All ANSI-style sequences are removed before trying to match-remove things, to
44 avoid messing those up. Each regex erases all its occurrences on the current
45 line in the order given among the arguments, so regex-order matters.
46
47 The options are, available both in single and double-dash versions
48
49 -h, -help show this help message
50 -i, -ins match regexes case-insensitively
51 `
52
53 func Main() {
54 args := os.Args[1:]
55 buffered := false
56 insensitive := false
57
58 for len(args) > 0 {
59 switch args[0] {
60 case `-b`, `--b`, `-buffered`, `--buffered`:
61 buffered = true
62 args = args[1:]
63
64 case `-h`, `--h`, `-help`, `--help`:
65 os.Stdout.WriteString(info[1:])
66 return
67
68 case `-i`, `--i`, `-ins`, `--ins`:
69 insensitive = true
70 args = args[1:]
71 }
72
73 break
74 }
75
76 if len(args) > 0 && args[0] == `--` {
77 args = args[1:]
78 }
79
80 exprs := make([]*regexp.Regexp, 0, len(args))
81
82 for _, s := range args {
83 var err error
84 var exp *regexp.Regexp
85
86 if insensitive {
87 exp, err = regexp.Compile(`(?i)` + s)
88 } else {
89 exp, err = regexp.Compile(s)
90 }
91
92 if err != nil {
93 os.Stderr.WriteString(err.Error())
94 os.Stderr.WriteString("\n")
95 continue
96 }
97
98 exprs = append(exprs, exp)
99 }
100
101 // quit right away when given invalid regexes
102 if len(exprs) < len(args) {
103 os.Exit(1)
104 }
105
106 liveLines := !buffered
107 if !buffered {
108 if _, err := os.Stdout.Seek(0, io.SeekCurrent); err == nil {
109 liveLines = false
110 }
111 }
112
113 err := run(os.Stdout, os.Stdin, exprs, liveLines)
114 if err != nil && err != io.EOF {
115 os.Stderr.WriteString(err.Error())
116 os.Stderr.WriteString("\n")
117 os.Exit(1)
118 }
119 }
120
121 func run(w io.Writer, r io.Reader, exprs []*regexp.Regexp, live bool) error {
122 var buf []byte
123 sc := bufio.NewScanner(r)
124 sc.Buffer(nil, 8*1024*1024*1024)
125 bw := bufio.NewWriter(w)
126 defer bw.Flush()
127
128 src := make([]byte, 8*1024)
129 dst := make([]byte, 8*1024)
130
131 for i := 0; sc.Scan(); i++ {
132 line := sc.Bytes()
133 if i == 0 && bytes.HasPrefix(line, []byte{0xef, 0xbb, 0xbf}) {
134 line = line[3:]
135 }
136
137 s := line
138 if bytes.IndexByte(s, '\x1b') >= 0 {
139 buf = plain(buf[:0], s)
140 s = buf
141 }
142
143 if len(exprs) > 0 {
144 src = append(src[:0], s...)
145 for _, exp := range exprs {
146 dst = erase(dst[:0], src, exp)
147 src = append(src[:0], dst...)
148 }
149 bw.Write(dst)
150 } else {
151 bw.Write(s)
152 }
153
154 if bw.WriteByte('\n') != nil {
155 return io.EOF
156 }
157
158 if !live {
159 continue
160 }
161
162 if bw.Flush() != nil {
163 return io.EOF
164 }
165 }
166
167 return sc.Err()
168 }
169
170 func erase(dst []byte, src []byte, with *regexp.Regexp) []byte {
171 for len(src) > 0 {
172 span := with.FindIndex(src)
173 // also ignore empty regex matches to avoid infinite outer loops,
174 // as skipping empty slices isn't advancing at all, leaving the
175 // string stuck to being empty-matched forever by the same regex
176 if len(span) != 2 || span[0] == span[1] || span[0] < 0 {
177 return append(dst, src...)
178 }
179
180 start, end := span[0], span[1]
181 dst = append(dst, src[:start]...)
182 // avoid infinite loops caused by empty regex matches
183 if start == end && end < len(src) {
184 dst = append(dst, src[end])
185 end++
186 }
187 src = src[end:]
188 }
189
190 return dst
191 }
192
193 func plain(dst []byte, src []byte) []byte {
194 for len(src) > 0 {
195 i, j := indexEscapeSequence(src)
196 if i < 0 {
197 dst = append(dst, src...)
198 break
199 }
200 if j < 0 {
201 j = len(src)
202 }
203
204 if i > 0 {
205 dst = append(dst, src[:i]...)
206 }
207
208 src = src[j:]
209 }
210
211 return dst
212 }
213
214 // indexEscapeSequence finds the first ANSI-style escape-sequence, which is
215 // the multi-byte sequences starting with ESC[; the result is a pair of slice
216 // indices which can be independently negative when either the start/end of
217 // a sequence isn't found; given their fairly-common use, even the hyperlink
218 // ESC]8 sequences are supported
219 func indexEscapeSequence(s []byte) (int, int) {
220 var prev byte
221
222 for i, b := range s {
223 if prev == '\x1b' && b == '[' {
224 j := indexLetter(s[i+1:])
225 if j < 0 {
226 return i, -1
227 }
228 return i - 1, i + 1 + j + 1
229 }
230
231 if prev == '\x1b' && b == ']' && i+1 < len(s) && s[i+1] == '8' {
232 j := indexPair(s[i+1:], '\x1b', '\\')
233 if j < 0 {
234 return i, -1
235 }
236 return i - 1, i + 1 + j + 2
237 }
238
239 prev = b
240 }
241
242 return -1, -1
243 }
244
245 func indexLetter(s []byte) int {
246 for i, b := range s {
247 upper := b &^ 32
248 if 'A' <= upper && upper <= 'Z' {
249 return i
250 }
251 }
252
253 return -1
254 }
255
256 func indexPair(s []byte, x byte, y byte) int {
257 var prev byte
258
259 for i, b := range s {
260 if prev == x && b == y && i > 0 {
261 return i
262 }
263 prev = b
264 }
265
266 return -1
267 }
File: ./files/files.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package files
26
27 import (
28 "bufio"
29 "io"
30 "io/fs"
31 "os"
32 "path/filepath"
33 "strings"
34 )
35
36 const info = `
37 files [options...] [files/folders...]
38
39 Find/list all files in the folders given, without repetitions.
40
41 All (optional) leading options start with either single or double-dash:
42
43 -h, -help show this help message
44 -t, -top turn off recursive behavior; top-level entries only
45 `
46
47 func Main() {
48 top := false
49 buffered := false
50 args := os.Args[1:]
51
52 for len(args) > 0 {
53 switch args[0] {
54 case `-b`, `--b`, `-buffered`, `--buffered`:
55 buffered = true
56 args = args[1:]
57 continue
58
59 case `-h`, `--h`, `-help`, `--help`:
60 os.Stdout.WriteString(info[1:])
61 return
62
63 case `-t`, `--t`, `-top`, `--top`:
64 top = true
65 args = args[1:]
66 continue
67 }
68
69 break
70 }
71
72 if len(args) > 0 && args[0] == `--` {
73 args = args[1:]
74 }
75
76 liveLines := !buffered
77 if !buffered {
78 if _, err := os.Stdout.Seek(0, io.SeekCurrent); err == nil {
79 liveLines = false
80 }
81 }
82
83 var cfg config
84 if top {
85 cfg.skipSubfolder = fs.SkipDir
86 }
87 cfg.liveLines = liveLines
88
89 if err := run(os.Stdout, args, cfg); err != nil && err != io.EOF {
90 os.Stderr.WriteString(err.Error())
91 os.Stderr.WriteString("\n")
92 os.Exit(1)
93 }
94 }
95
96 type config struct {
97 skipSubfolder error
98 liveLines bool
99 }
100
101 func run(w io.Writer, paths []string, cfg config) error {
102 bw := bufio.NewWriter(w)
103 defer bw.Flush()
104
105 got := make(map[string]struct{})
106
107 if len(paths) == 0 {
108 paths = []string{`.`}
109 }
110
111 // handle is the callback for func filepath.WalkDir
112 handle := func(path string, e fs.DirEntry, err error) error {
113 if err != nil {
114 return err
115 }
116
117 if _, ok := got[path]; ok {
118 return nil
119 }
120 got[path] = struct{}{}
121
122 if e.IsDir() {
123 return cfg.skipSubfolder
124 }
125
126 return handleEntry(bw, path, cfg.liveLines)
127 }
128
129 for _, path := range paths {
130 if _, ok := got[path]; ok {
131 continue
132 }
133 got[path] = struct{}{}
134
135 st, err := os.Stat(path)
136 if err != nil {
137 return err
138 }
139
140 if st.IsDir() {
141 if !strings.HasSuffix(path, `/`) {
142 path = path + `/`
143 }
144 got[path] = struct{}{}
145
146 if err := filepath.WalkDir(path, handle); err != nil {
147 return err
148 }
149 continue
150 }
151
152 if err := handleEntry(bw, path, cfg.liveLines); err != nil {
153 return err
154 }
155 }
156
157 return nil
158 }
159
160 func handleEntry(w *bufio.Writer, path string, live bool) error {
161 abs, err := filepath.Abs(path)
162 if err != nil {
163 return err
164 }
165
166 w.WriteString(abs)
167 w.WriteByte('\n')
168
169 if !live {
170 return nil
171 }
172
173 if err := w.Flush(); err != nil {
174 return io.EOF
175 }
176 return nil
177 }
File: ./filesizes/filesizes.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package filesizes
26
27 import (
28 "bufio"
29 "io"
30 "io/fs"
31 "os"
32 "path/filepath"
33 "sort"
34 "strconv"
35 "strings"
36 )
37
38 const info = `
39 filesizes [options...] [files/folders...]
40
41 Find/list all files in the folders given, without repetitions, also showing
42 their size in bytes. Output is lines of tab-separated values, starting with
43 a header line with the column names.
44
45 All (optional) leading options start with either single or double-dash:
46
47 -h, -help show this help message
48 -s, -sort, -sorted backward-sort entries largest to smallest
49 -t, -top turn off recursive behavior; top-level entries only
50 `
51
52 func Main() {
53 var cfg config
54 cfg.recursive = true
55 cfg.sorted = false
56 cfg.blockSize = 4
57 buffered := false
58 args := os.Args[1:]
59
60 for len(args) > 0 {
61 if size, ok := parseBlockSizeOption(args[0]); ok {
62 cfg.blockSize = size
63 args = args[1:]
64 continue
65 }
66
67 switch args[0] {
68 case `-b`, `--b`, `-buffered`, `--buffered`:
69 buffered = true
70 args = args[1:]
71 continue
72
73 case `-h`, `--h`, `-help`, `--help`:
74 os.Stdout.WriteString(info[1:])
75 return
76
77 case `-s`, `--s`, `-sort`, `--sort`, `-sorted`, `--sorted`:
78 cfg.sorted = true
79 args = args[1:]
80 continue
81
82 case `-t`, `--t`, `-top`, `--top`:
83 cfg.recursive = false
84 args = args[1:]
85 continue
86 }
87
88 break
89 }
90
91 if len(args) > 0 && args[0] == `--` {
92 args = args[1:]
93 }
94
95 cfg.liveLines = !buffered && !cfg.sorted
96 if !buffered && !cfg.sorted {
97 if _, err := os.Stdout.Seek(0, io.SeekCurrent); err == nil {
98 cfg.liveLines = false
99 }
100 }
101
102 if err := run(os.Stdout, args, cfg); err != nil && err != io.EOF {
103 os.Stderr.WriteString(err.Error())
104 os.Stderr.WriteString("\n")
105 os.Exit(1)
106 }
107 }
108
109 func parseBlockSizeOption(s string) (size int, ok bool) {
110 if !strings.HasPrefix(s, `-`) {
111 return 0, false
112 }
113 if len(s) > 0 && s[0] == '-' {
114 s = s[1:]
115 }
116 if len(s) > 0 && s[0] == '-' {
117 s = s[1:]
118 }
119
120 if !strings.HasSuffix(s, `k`) && !strings.HasSuffix(s, `K`) {
121 return 0, false
122 }
123 s = s[:len(s)-1]
124
125 if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 {
126 return int(n), true
127 }
128 return 0, false
129 }
130
131 type config struct {
132 blockSize int
133 sorted bool
134 recursive bool
135 liveLines bool
136 }
137
138 type entry struct {
139 path string
140 size int64
141 }
142
143 type handlers struct {
144 w *bufio.Writer
145 entries []entry
146 blockSize int
147 skipSubfolder error
148
149 file func(h *handlers, path string, size int64) error
150
151 liveLines bool
152 }
153
154 func (h handlers) countBlocks(size int64) int64 {
155 bs := int64(h.blockSize)
156 n := size / (1024 * bs)
157 if size%(1024*bs) != 0 {
158 n++
159 }
160 return n * bs
161 }
162
163 func run(w io.Writer, paths []string, cfg config) error {
164 bw := bufio.NewWriter(w)
165 defer bw.Flush()
166
167 bw.WriteString("name\tbytes\tblocks\n")
168
169 var h handlers
170 h.w = bw
171 h.blockSize = cfg.blockSize
172 h.skipSubfolder = nil
173 if !cfg.recursive {
174 h.skipSubfolder = fs.SkipDir
175 }
176 h.file = emitEntry
177 if cfg.sorted {
178 h.file = keepEntry
179 }
180
181 got := make(map[string]struct{})
182
183 if len(paths) == 0 {
184 paths = []string{`.`}
185 }
186
187 // handle is the callback for func filepath.WalkDir
188 handle := func(path string, e fs.DirEntry, err error) error {
189 if err != nil {
190 return err
191 }
192
193 if _, ok := got[path]; ok {
194 return nil
195 }
196 got[path] = struct{}{}
197
198 if e.IsDir() {
199 return h.skipSubfolder
200 }
201
202 info, err := e.Info()
203 if err != nil {
204 return err
205 }
206
207 return h.file(&h, path, info.Size())
208 }
209
210 for _, path := range paths {
211 if _, ok := got[path]; ok {
212 continue
213 }
214 got[path] = struct{}{}
215
216 st, err := os.Stat(path)
217 if err != nil {
218 return err
219 }
220
221 if st.IsDir() {
222 if !strings.HasSuffix(path, `/`) {
223 path = path + `/`
224 }
225 got[path] = struct{}{}
226
227 if err := filepath.WalkDir(path, handle); err != nil {
228 return err
229 }
230 continue
231 }
232
233 if err := h.file(&h, path, st.Size()); err != nil {
234 return err
235 }
236 }
237
238 if !cfg.sorted {
239 return nil
240 }
241
242 sort.Slice(h.entries, func(i, j int) bool {
243 return h.entries[i].size > h.entries[j].size
244 })
245
246 for _, e := range h.entries {
247 if err := writeEntry(bw, e, h); err != nil {
248 return err
249 }
250 }
251
252 return nil
253 }
254
255 func emitEntry(h *handlers, path string, size int64) error {
256 return writeEntry(h.w, entry{path, size}, *h)
257 }
258
259 func keepEntry(h *handlers, path string, size int64) error {
260 h.entries = append(h.entries, entry{path, size})
261 return nil
262 }
263
264 func writeEntry(w *bufio.Writer, e entry, h handlers) error {
265 abs, err := filepath.Abs(e.path)
266 if err != nil {
267 return err
268 }
269
270 var buf [24]byte
271 w.WriteString(abs)
272 w.WriteByte('\t')
273 w.Write(strconv.AppendInt(buf[:0], e.size, 10))
274 w.WriteByte('\t')
275 w.Write(strconv.AppendInt(buf[:0], h.countBlocks(e.size), 10))
276 w.WriteByte('\n')
277
278 if !h.liveLines {
279 return nil
280 }
281
282 if err := w.Flush(); err != nil {
283 return io.EOF
284 }
285 return nil
286 }
File: ./fixlines/fixlines.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package fixlines
26
27 import (
28 "bufio"
29 "bytes"
30 "errors"
31 "io"
32 "os"
33 )
34
35 const info = `
36 fixlines [options...] [filepaths...]
37
38
39 This tool fixes lines in UTF-8 text, ignoring leading UTF-8 BOMs, trailing
40 carriage-returns on all lines, and ensures no lines across inputs are
41 accidentally joined, since all lines it outputs end with line-feeds,
42 even when the original files don't.
43
44 The options are, available both in single and double-dash versions
45
46 -h, -help show this help message
47
48 -detrail, -trimtrail, -trim-trail, -trimtrails, -trim-trails
49 ignore trailing spaces on lines
50
51 -squeeze aggressively trim lines, ignoring both leading and
52 trailing spaces, spaces around tabs, and turn runs
53 of multiple spaces into single ones
54 `
55
56 type config struct {
57 fix func(w *bufio.Writer, r io.Reader, live bool) error
58 liveLines bool
59 }
60
61 func Main() {
62 buffered := false
63 args := os.Args[1:]
64 var cfg config
65 cfg.fix = detrail
66
67 if len(args) > 0 {
68 switch args[0] {
69 case `-b`, `--b`, `-buffered`, `--buffered`:
70 buffered = true
71 args = args[1:]
72
73 case
74 `-detrail`, `--detrail`, `-trimtrail`, `--trimtrail`,
75 `-trim-trail`, `--trim-trail`, `-trimtrails`, `--trimtrails`,
76 `-trim-trails`, `--trim-trails`:
77 cfg.fix = detrail
78 args = args[1:]
79
80 case `-h`, `--h`, `-help`, `--help`:
81 os.Stdout.WriteString(info[1:])
82 return
83
84 case
85 `-keeptrail`, `--keeptrail`, `-keept-rail`, `--keep-trail`,
86 `-keeptrails`, `--keeptrails`, `-keept-rails`, `--keep-trails`:
87 cfg.fix = catl
88 args = args[1:]
89
90 case `-s`, `--s`, `-squeeze`, `--squeeze`:
91 buffered = true
92 args = args[1:]
93 }
94 }
95
96 if len(args) > 0 && args[0] == `--` {
97 args = args[1:]
98 }
99
100 cfg.liveLines = !buffered
101 if !buffered {
102 if _, err := os.Stdout.Seek(0, io.SeekCurrent); err == nil {
103 cfg.liveLines = false
104 }
105 }
106
107 if err := run(os.Stdout, args, cfg); err != nil && err != io.EOF {
108 os.Stderr.WriteString(err.Error())
109 os.Stderr.WriteString("\n")
110 os.Exit(1)
111 }
112 }
113
114 func run(w io.Writer, args []string, cfg config) error {
115 dashes := 0
116 for _, name := range args {
117 if name == `-` {
118 dashes++
119 }
120 if dashes > 1 {
121 return errors.New(`can't use stdin (dash) more than once`)
122 }
123 }
124
125 bw := bufio.NewWriter(w)
126 defer bw.Flush()
127
128 if len(args) == 0 {
129 return cfg.fix(bw, os.Stdin, cfg.liveLines)
130 }
131
132 for _, name := range args {
133 if err := handleFile(bw, name, cfg); err != nil {
134 return err
135 }
136 }
137 return nil
138 }
139
140 func handleFile(w *bufio.Writer, name string, cfg config) error {
141 if name == `` || name == `-` {
142 return cfg.fix(w, os.Stdin, cfg.liveLines)
143 }
144
145 f, err := os.Open(name)
146 if err != nil {
147 return errors.New(`can't read from file named "` + name + `"`)
148 }
149 defer f.Close()
150
151 return cfg.fix(w, f, cfg.liveLines)
152 }
153
154 func catl(w *bufio.Writer, r io.Reader, live bool) error {
155 const gb = 1024 * 1024 * 1024
156 sc := bufio.NewScanner(r)
157 sc.Buffer(nil, 8*gb)
158
159 for i := 0; sc.Scan(); i++ {
160 s := sc.Bytes()
161 if i == 0 && bytes.HasPrefix(s, []byte{0xef, 0xbb, 0xbf}) {
162 s = s[3:]
163 }
164
165 w.Write(s)
166 if w.WriteByte('\n') != nil {
167 return io.EOF
168 }
169
170 if !live {
171 continue
172 }
173
174 if err := w.Flush(); err != nil {
175 return io.EOF
176 }
177 }
178
179 return sc.Err()
180 }
181
182 func detrail(w *bufio.Writer, r io.Reader, live bool) error {
183 const gb = 1024 * 1024 * 1024
184 sc := bufio.NewScanner(r)
185 sc.Buffer(nil, 8*gb)
186
187 for i := 0; sc.Scan(); i++ {
188 s := sc.Bytes()
189
190 // ignore leading UTF-8 BOM on the first line
191 if i == 0 && bytes.HasPrefix(s, []byte{0xef, 0xbb, 0xbf}) {
192 s = s[3:]
193 }
194
195 // trim trailing spaces on the current line
196 for len(s) > 0 && s[len(s)-1] == ' ' {
197 s = s[:len(s)-1]
198 }
199
200 w.Write(s)
201 if w.WriteByte('\n') != nil {
202 return io.EOF
203 }
204
205 if !live {
206 continue
207 }
208
209 if err := w.Flush(); err != nil {
210 return io.EOF
211 }
212 }
213
214 return sc.Err()
215 }
216
217 func squeeze(w *bufio.Writer, r io.Reader, live bool) error {
218 const gb = 1024 * 1024 * 1024
219 sc := bufio.NewScanner(r)
220 sc.Buffer(nil, 8*gb)
221
222 for i := 0; sc.Scan(); i++ {
223 s := sc.Bytes()
224 if i == 0 && bytes.HasPrefix(s, []byte{0xef, 0xbb, 0xbf}) {
225 s = s[3:]
226 }
227
228 writeSqueezed(w, s)
229 if w.WriteByte('\n') != nil {
230 return io.EOF
231 }
232
233 if !live {
234 continue
235 }
236
237 if err := w.Flush(); err != nil {
238 return io.EOF
239 }
240 }
241
242 return sc.Err()
243 }
244
245 func writeSqueezed(w *bufio.Writer, s []byte) {
246 // ignore leading spaces
247 for len(s) > 0 && s[0] == ' ' {
248 s = s[1:]
249 }
250
251 // ignore trailing spaces
252 for len(s) > 0 && s[len(s)-1] == ' ' {
253 s = s[:len(s)-1]
254 }
255
256 space := false
257
258 for len(s) > 0 {
259 switch s[0] {
260 case ' ':
261 s = s[1:]
262 space = true
263
264 case '\t':
265 s = s[1:]
266 space = false
267 for len(s) > 0 && s[0] == ' ' {
268 s = s[1:]
269 }
270 w.WriteByte('\t')
271
272 default:
273 if space {
274 w.WriteByte(' ')
275 space = false
276 }
277 w.WriteByte(s[0])
278 s = s[1:]
279 }
280 }
281 }
File: ./fmscripts/benchmarks_test.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package fmscripts
26
27 import (
28 "math"
29 "testing"
30 )
31
32 const (
33 interferingRipples = "" +
34 "exp(-0.5 * sin(2 * hypot(x - 2, y + 1))) + " +
35 "exp(-0.5 * sin(10 * hypot(x + 2, y - 3.4)))"
36
37 smilingGhost = "" +
38 "log1p(((x - 1)**2 + y*y - 4)*((x + 1)**2 + y*y - 4)*" +
39 "(x*x + (y - sqrt(3))**2 - 4) - 5)"
40 )
41
42 var benchmarks = []struct {
43 Name string
44 Script string
45 Native func(float64, float64) float64
46 }{
47 {"Load", "x", func(x, y float64) float64 { return x }},
48 {"Constant", "4.25", func(x, y float64) float64 { return 4.25 }},
49 {"Constant Addition", "1+2", func(x, y float64) float64 { return 1 + 2 }},
50 {"0 Additions", "x", func(x, y float64) float64 { return x }},
51 {"1 Additions", "x+x", func(x, y float64) float64 { return x + x }},
52 {"2 Additions", "x+x+x", func(x, y float64) float64 { return x + x + x }},
53 {"0 Multiplications", "x", func(x, y float64) float64 { return x }},
54 {"1 Multiplications", "x*x", func(x, y float64) float64 { return x * x }},
55 {"2 Multiplications", "x*x*x", func(x, y float64) float64 { return x * x * x }},
56 {"0 Divisions", "x", func(x, y float64) float64 { return x }},
57 {"1 Divisions", "x/x", func(x, y float64) float64 { return x / x }},
58 {"2 Divisions", "x/x/x", func(x, y float64) float64 { return x / x / x }},
59 {"e+pi", "e+pi", func(x, y float64) float64 { return math.E + math.Pi }},
60 {"1-2", "1-2", func(x, y float64) float64 { return 1 - 2 }},
61 {"e-pi", "e-pi", func(x, y float64) float64 { return math.E - math.Pi }},
62 {"Add 0", "x+0", func(x, y float64) float64 { return x + 0 }},
63 {"x*1", "x*1", func(x, y float64) float64 { return x * 1 }},
64 {"Abs Func", "abs(-x)", func(x, y float64) float64 { return math.Abs(-x) }},
65 {"Abs Syntax", "&(-x)", func(x, y float64) float64 { return math.Abs(-x) }},
66 {
67 "Square (pow)",
68 "pow(x, 2)",
69 func(x, y float64) float64 { return math.Pow(x, 2) },
70 },
71 {"Square", "square(x)", func(x, y float64) float64 { return x * x }},
72 {"Square Syntax", "*x", func(x, y float64) float64 { return x * x }},
73 {
74 "Linear",
75 "3.4 * x - 0.2",
76 func(x, y float64) float64 { return 3.4*x - 0.2 },
77 },
78 {"Direct Square", "x*x", func(x, y float64) float64 { return x * x }},
79 {
80 "Cube (pow)",
81 "pow(x, 3)",
82 func(x, y float64) float64 { return math.Pow(x, 3) },
83 },
84 {"Cube", "cube(x)", func(x, y float64) float64 { return x * x * x }},
85 {"Direct Cube", "x*x*x", func(x, y float64) float64 { return x * x * x }},
86 {
87 "Reciprocal (pow)",
88 "pow(x, -1)",
89 func(x, y float64) float64 { return math.Pow(x, -1) },
90 },
91 {
92 "Reciprocal Func",
93 "reciprocal(x)",
94 func(x, y float64) float64 { return 1 / x },
95 },
96 {"Direct Reciprocal", "1/x", func(x, y float64) float64 { return 1 / x }},
97 {"Variable Negation", "-x", func(x, y float64) float64 { return -x }},
98 {"Constant Multip.", "1*2", func(x, y float64) float64 { return 1 * 2 }},
99 {"e*pi", "e*pi", func(x, y float64) float64 { return math.E * math.Pi }},
100 {
101 "Variable y = mx + k",
102 "2.1*x + 3.4",
103 func(x, y float64) float64 { return 2.1*x + 3.4 },
104 },
105 {"sin(x)", "sin(x)", func(x, y float64) float64 { return math.Sin(x) }},
106 {"cos(x)", "cos(x)", func(x, y float64) float64 { return math.Cos(x) }},
107 {"exp(x)", "exp(x)", func(x, y float64) float64 { return math.Exp(x) }},
108 {"expm1(x)", "expm1(x)", func(x, y float64) float64 { return math.Expm1(x) }},
109 {"ln(x)", "ln(x)", func(x, y float64) float64 { return math.Log(x) }},
110 {"log2(x)", "log2(x)", func(x, y float64) float64 { return math.Log2(x) }},
111 {"log10(x)", "log10(x)", func(x, y float64) float64 { return math.Log10(x) }},
112 {"1/2", "1/2", func(x, y float64) float64 { return 1.0 / 2.0 }},
113 {"e/pi", "e/pi", func(x, y float64) float64 { return math.E / math.Pi }},
114 {"exp(2)", "exp(2)", func(x, y float64) float64 { return math.Exp(2) }},
115 {
116 "Club Beat Pulse",
117 "sin(10*tau * exp(-20*x)) * exp(-2*x)",
118 func(x, y float64) float64 {
119 return math.Sin(10*2*math.Pi*math.Exp(-20*x)) * math.Exp(-2*x)
120 },
121 },
122 {
123 "Interfering Ripples",
124 interferingRipples,
125 func(x, y float64) float64 {
126 return math.Exp(-0.5*math.Sin(2*math.Hypot(x-2, y+1))) +
127 math.Exp(-0.5*math.Sin(10*math.Hypot(x+2, y-3.4)))
128 },
129 },
130 {
131 "Floor Lights",
132 "abs(sin(x)) / y**1.4",
133 func(x, y float64) float64 {
134 return math.Abs(math.Sin(x)) / math.Pow(y, 1.4)
135 },
136 },
137 {
138 "Domain Hole",
139 "log1p(sin(x + y) + (x - y)**2 - 1.5*x + 2.5*y + 1)",
140 func(x, y float64) float64 {
141 v := x - y
142 return math.Log1p(math.Sin(x+y) + v*v - 1.5*x + 2.5*y + 1)
143 },
144 },
145 {
146 "Beta Gradient",
147 "lbeta(x + 5.1, y + 5.1)",
148 func(x, y float64) float64 { return lnBeta(x+5.1, y+5.1) },
149 },
150 {
151 "Hot Bars",
152 "abs(x) + sqrt(abs(sin(2*y)))",
153 func(x, y float64) float64 {
154 return math.Abs(x) + math.Sqrt(math.Abs(math.Sin(2*y)))
155 },
156 },
157 {
158 "Grid Pattern",
159 "sin(sin(x)+cos(y)) + cos(sin(x*y)+cos(y**2))",
160 func(x, y float64) float64 {
161 return math.Sin(math.Sin(x)+math.Cos(y)) +
162 math.Cos(math.Sin(x*y)+math.Cos(y*y))
163 },
164 },
165 {
166 "Smiling Ghost",
167 smilingGhost,
168 func(x, y float64) float64 {
169 xm1 := x - 1
170 xp1 := x + 1
171 v := y - math.Sqrt(3)
172 w := (xm1*xm1 + y*y - 4) * (xp1*xp1 + y*y - 4) * (x*x + v*v - 4)
173 return math.Log1p(w - 5)
174 },
175 },
176 {
177 "Forgot its Name",
178 "1 / (1 + x.abs/y*y)",
179 func(x, y float64) float64 {
180 return 1 / (1 + math.Abs(x)/y*y)
181 },
182 },
183 }
184
185 func BenchmarkEmpty(b *testing.B) {
186 b.Run("empty program", func(b *testing.B) {
187 var p Program
188 b.ReportAllocs()
189 b.ResetTimer()
190
191 for i := 0; i < b.N; i++ {
192 _ = p.Run()
193 }
194 })
195
196 f := func(float64, float64) float64 {
197 return math.NaN()
198 }
199
200 b.Run("empty function", func(b *testing.B) {
201 b.ReportAllocs()
202 b.ResetTimer()
203
204 for i := 0; i < b.N; i++ {
205 _ = f(0, 0)
206 }
207 })
208 }
209
210 func BenchmarkBasicProgram(b *testing.B) {
211 for _, tc := range benchmarks {
212 var c Compiler
213 defs := map[string]any{
214 "x": 0.05,
215 "y": 0,
216 "z": 0,
217 }
218
219 b.Run(tc.Name, func(b *testing.B) {
220 p, err := c.Compile(tc.Script, defs)
221 if err != nil {
222 const fs = "while compiling %q, got error %q"
223 b.Fatalf(fs, tc.Script, err.Error())
224 return
225 }
226
227 x, _ := p.Get("x")
228 _, _ = p.Get("y")
229 _, _ = p.Get("z")
230
231 b.ResetTimer()
232 for i := 0; i < b.N; i++ {
233 _ = p.Run()
234 *x++
235 }
236 })
237 }
238 }
239
240 func BenchmarkBasicFunc(b *testing.B) {
241 for _, tc := range benchmarks {
242 b.Run(tc.Name, func(b *testing.B) {
243 x := 0.05
244 y := 0.0
245 fn := tc.Native
246 b.ResetTimer()
247
248 for i := 0; i < b.N; i++ {
249 fn(x, y)
250 x++
251 y++
252 }
253 })
254 }
255 }
256
257 func BenchmarkSoundProgram(b *testing.B) {
258 var soundBenchmarkTests = []struct {
259 Name string
260 Script string
261 }{
262 {
263 "Sine Wave",
264 "sin(1000 * tau * x)",
265 },
266 {
267 "Laser Pulse",
268 "sin(100 * tau * exp(-40 * u))",
269 },
270 {
271 "Club Beat Pulse",
272 "sin(10 * tau * exp(-20 * x)) * exp(-2 * x)",
273 },
274 }
275
276 for _, tc := range soundBenchmarkTests {
277 var c Compiler
278 defs := map[string]any{
279 "t": 0.05,
280 "u": 0,
281 }
282
283 const seconds = 2
284 const sampleRate = 48_000
285 dt := 1.0 / float64(sampleRate)
286 // buf is the destination buffer for all calculated samples
287 buf := make([]float64, 0, seconds*sampleRate)
288
289 b.Run(tc.Name, func(b *testing.B) {
290 p, err := c.Compile(tc.Script, defs)
291 if err != nil {
292 const fs = "while compiling %q, got error %q"
293 b.Fatalf(fs, tc.Script, err.Error())
294 return
295 }
296
297 // input parameters for the program
298 t, _ := p.Get("t")
299 u, _ := p.Get("u")
300
301 b.ResetTimer()
302 for i := 0; i < b.N; i++ {
303 // avoid buffer expansions after the first run
304 buf = buf[:0]
305
306 // benchmark 1 second of generated sound
307 for j := 0; j < seconds*sampleRate; j++ {
308 // calculate time in seconds from current sample index
309 v := float64(j) * dt
310 *t = v
311 _, *u = math.Modf(v)
312
313 // calculate a mono sample
314 buf = append(buf, p.Run())
315 }
316 }
317 })
318 }
319 }
320
321 func BenchmarkNativeSoundProgram(b *testing.B) {
322 const tau = 2 * math.Pi
323
324 var soundBenchmarkTests = []struct {
325 Name string
326 Fun func(float64, float64) float64
327 }{
328 {
329 "Sine Wave",
330 func(t, u float64) float64 {
331 return math.Sin(1000 * tau * t)
332 },
333 },
334 {
335 "Laser Pulse",
336 func(t, u float64) float64 {
337 return math.Sin(100 * tau * math.Exp(-40*u))
338 },
339 },
340 {
341 "Club Beat Pulse",
342 func(t, u float64) float64 {
343 return math.Sin(10*tau*math.Exp(-20*t)) * math.Exp(-2*t)
344 },
345 },
346 }
347
348 for _, tc := range soundBenchmarkTests {
349 const seconds = 2
350 const sampleRate = 48_000
351 dt := 1.0 / float64(sampleRate)
352 // buf is the destination buffer for all calculated samples
353 buf := make([]float64, 0, seconds*sampleRate)
354
355 b.Run(tc.Name, func(b *testing.B) {
356 // input parameters for the program
357 t := 0.05
358 u := 0.00
359 fn := tc.Fun
360 b.ResetTimer()
361
362 for i := 0; i < b.N; i++ {
363 // avoid buffer expansions after the first run
364 buf = buf[:0]
365
366 // benchmark 1 second of generated sound
367 for j := 0; j < seconds*sampleRate; j++ {
368 // calculate time in seconds from current sample index
369 v := float64(j) * dt
370 t = v
371 _, u = math.Modf(v)
372
373 // calculate a mono sample
374 buf = append(buf, fn(t, u))
375 }
376 }
377 })
378 }
379 }
380
381 func BenchmarkImageProgram(b *testing.B) {
382 const (
383 // use part of a full HD pic to give more runs for each test, giving
384 // greater statistical-stability/comparability across benchmark runs
385 w = 1920 / 4
386 h = 1080 / 4
387
388 intRipples = "" +
389 "exp(-0.5 * sin(2 * hypot(x - 2, y + 1))) + " +
390 "exp(-0.5 * sin(10 * hypot(x + 2, y - 3.4)))"
391 domainHole = "" +
392 "log1p(sin(x + y) + pow(x - y, 2) - 1.5*x + 2.5*y + 1)"
393 gridPatPow = "" +
394 "sin(sin(x)+cos(y)) + cos(sin(x*y)+cos(pow(y, 2)))"
395 gridPatSquare = "" +
396 "sin(sin(x)+cos(y)) + cos(sin(x*y)+cos(square(y)))"
397 smilingGhostFunc = "" +
398 "log1p((pow(x - 1, 2) + y*y - 4)*" +
399 "(pow(x + 1, 2) + y*y - 4)*" +
400 "(x*x + pow(y - sqrt(3), 2) - 4) - 5)"
401 smilingGhostSyntax = "" +
402 "log1p((*(x - 1) + *y - 4)*" +
403 "(*(x + 1) + *y - 4)*" +
404 "(*x + *(y - sqrt(3)) - 4) - 5)"
405 )
406
407 var imageBenchmarkTests = []struct {
408 Name string
409 Width int
410 Height int
411 Script string
412 }{
413 {"Horizontal Linear", w, h, "x"},
414 {"Multiplication", w, h, "x*y"},
415 {"Star", w, h, "exp(-0.5 * hypot(x, y))"},
416 {"Interfering Ripples", w, h, intRipples},
417 {"Floor Lights", w, h, "abs(sin(x)) / pow(y, 1.4)"},
418 {"Domain Hole", w, h, domainHole},
419 {"Beta Gradient", w, h, "lbeta(x + 5.1, y + 5.1)"},
420 {"Hot Bars", w, h, "abs(x) + sqrt(abs(sin(2*y)))"},
421 {"Grid Pattern (pow)", w, h, gridPatPow},
422 {"Grid Pattern (square)", w, h, gridPatSquare},
423 {"Smiling Ghost (pow)", w, h, smilingGhostFunc},
424 {"Smiling Ghost (square syntax)", w, h, smilingGhostSyntax},
425 }
426
427 for _, tc := range imageBenchmarkTests {
428 var c Compiler
429 defs := map[string]any{
430 "x": 0,
431 "y": 0,
432 }
433
434 // 4k-resolution results buffer
435 buf := make([]float64, 1920*1080*4)
436
437 b.Run(tc.Name, func(b *testing.B) {
438 p, err := c.Compile(tc.Script, defs)
439 if err != nil {
440 const fs = "while compiling %q, got error %q"
441 b.Fatalf(fs, tc.Script, err.Error())
442 return
443 }
444
445 // input parameters for the program
446 x, _ := p.Get("x")
447 y, _ := p.Get("y")
448
449 w := tc.Width
450 h := tc.Height
451 dx := 1.0 / float64(w)
452 dy := 1.0 / float64(h)
453 xmin := -5.2
454 ymax := 23.91
455 b.ResetTimer()
456 for i := 0; i < b.N; i++ {
457 // avoid buffer expansions after the first run
458 buf = buf[:0]
459
460 for j := 0; j < h; j++ {
461 *y = ymax - float64(j)*dy
462 for i := 0; i < w; i++ {
463 *x = float64(i)*dx + xmin
464 buf = append(buf, p.Run())
465 }
466 }
467 }
468 })
469 }
470 }
471
472 func BenchmarkCompiler(b *testing.B) {
473 f := func(name string, src string) {
474 b.Run(name, func(b *testing.B) {
475 for i := 0; i < b.N; i++ {
476 var c Compiler
477 _, err := c.Compile(src, map[string]any{
478 "x": 0.05,
479 "y": 0,
480 "z": 0,
481 })
482
483 if err != nil {
484 const fs = "while compiling %q, got error %q"
485 b.Fatalf(fs, src, err.Error())
486 }
487 }
488 })
489 }
490
491 for _, tc := range mathCorrectnessTests {
492 f(tc.Name, tc.Script)
493 }
494
495 for _, tc := range benchmarks {
496 f(tc.Name, tc.Script)
497 }
498 }
File: ./fmscripts/compilers.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package fmscripts
26
27 import (
28 "fmt"
29 "math"
30 "strconv"
31 )
32
33 // unary2op turns unary operators into their corresponding basic operations;
34 // some entries are only for the optimizer, and aren't accessible directly
35 // from valid source code
36 var unary2op = map[string]opcode{
37 "-": neg,
38 "!": not,
39 "&": abs,
40 "*": square,
41 "^": square,
42 "/": rec,
43 "%": mod1,
44 }
45
46 // binary2op turns binary operators into their corresponding basic operations
47 var binary2op = map[string]opcode{
48 "+": add,
49 "-": sub,
50 "*": mul,
51 "/": div,
52 "%": mod,
53 "&&": and,
54 "&": and,
55 "||": or,
56 "|": or,
57 "==": equal,
58 "!=": notequal,
59 "<>": notequal,
60 "<": less,
61 "<=": lessoreq,
62 ">": more,
63 ">=": moreoreq,
64 "**": pow,
65 "^": pow,
66 }
67
68 // Compiler lets you create Program objects, which you can then run. The whole
69 // point of this is to create quicker-to-run numeric scripts. You can even add
70 // variables with initial values, as well as functions for the script to use.
71 //
72 // Common math funcs and constants are automatically detected, and constant
73 // results are optimized away, unless builtins are redefined. In other words,
74 // the optimizer is effectively disabled for all (sub)expressions containing
75 // redefined built-ins, as there's no way to be sure those values won't change
76 // from one run to the next.
77 //
78 // See the comment for type Program for more details.
79 //
80 // # Example
81 //
82 // var c fmscripts.Compiler
83 //
84 // defs := map[string]any{
85 // "x": 0, // define `x`, and initialize it to 0
86 // "k": 4.25, // define `k`, and initialize it to 4.25
87 // "b": true, // define `b`, and initialize it to 1.0
88 // "n": -23, // define `n`, and initialize it to -23.0
89 // "pi": 3, // define `pi`, overriding the default constant named `pi`
90 //
91 // "f": numericKernel // type is func ([]float64) float64
92 // "g": otherFunc // type is func (float64) float64
93 // }
94 //
95 // prog, err := c.Compile("log10(k) + f(sqrt(k) * exp(-x), 45, -0.23)", defs)
96 // // ...
97 //
98 // x, _ := prog.Get("x") // Get returns (*float64, bool)
99 // y, _ := prog.Get("y") // a useless pointer, since program doesn't use `y`
100 // // ...
101 //
102 // for i := 0; i < n; i++ {
103 // *x = float64(i)*dx + minx // you update inputs in place using pointers
104 // f := prog.Run() // method Run gives you a float64 back
105 // // ...
106 // }
107 type Compiler struct {
108 maxStack int // the exact stack size the resulting program needs
109 ops []numOp // the program's operations
110
111 vaddr map[string]int // address lookup for values
112 faddr map[string]int // address lookup for functions
113 ftype map[string]any // keeps track of func types during compilation
114
115 values []float64 // variables and constants available to programs
116 funcs []any // funcs available to programs
117 }
118
119 // Compile parses the script given and generates a fast float64-only Program
120 // made only of sequential steps: any custom funcs you provide it can use
121 // their own internal looping and/or conditional logic, of course.
122 func (c *Compiler) Compile(src string, defs map[string]any) (Program, error) {
123 // turn source code into an abstract syntax-tree
124 root, err := parse(src)
125 if err != nil {
126 return Program{}, err
127 }
128
129 // generate operations
130 if err := c.reset(defs); err != nil {
131 return Program{}, err
132 }
133 if err = c.compile(root, 0); err != nil {
134 return Program{}, err
135 }
136
137 // create the resulting program
138 var p Program
139 p.stack = make([]float64, c.maxStack)
140 p.values = make([]float64, len(c.values))
141 copy(p.values, c.values)
142 p.ops = make([]numOp, len(c.ops))
143 copy(p.ops, c.ops)
144 p.funcs = make([]any, len(c.ftype))
145 copy(p.funcs, c.funcs)
146 p.names = make(map[string]int, len(c.vaddr))
147
148 // give the program's Get method access only to all allocated variables
149 for k, v := range c.vaddr {
150 // avoid exposing numeric constants on the program's name-lookup
151 // table, which would allow users to change literals across runs
152 if _, err := strconv.ParseFloat(k, 64); err == nil {
153 continue
154 }
155 // expose only actual variable names
156 p.names[k] = v
157 }
158
159 return p, nil
160 }
161
162 // reset prepares a compiler by satisfying internal preconditions func
163 // compileExpr relies on, when given an abstract syntax-tree
164 func (c *Compiler) reset(defs map[string]any) error {
165 // reset the compiler's internal state
166 c.maxStack = 0
167 c.ops = c.ops[:0]
168 c.vaddr = make(map[string]int)
169 c.faddr = make(map[string]int)
170 c.ftype = make(map[string]any)
171 c.values = c.values[:0]
172 c.funcs = c.funcs[:0]
173
174 // allocate vars and funcs
175 for k, v := range defs {
176 if err := c.allocEntry(k, v); err != nil {
177 return err
178 }
179 }
180 return nil
181 }
182
183 // allocEntry simplifies the control-flow of func reset
184 func (c *Compiler) allocEntry(k string, v any) error {
185 const (
186 maxExactRound = 2 << 52
187 rangeErrFmt = "%d is outside range of exact float64 integers"
188 typeErrFmt = "got value of unsupported type %T"
189 )
190
191 switch v := v.(type) {
192 case float64:
193 _, err := c.allocValue(k, v)
194 return err
195
196 case int:
197 if math.Abs(float64(v)) > maxExactRound {
198 return fmt.Errorf(rangeErrFmt, v)
199 }
200 _, err := c.allocValue(k, float64(v))
201 return err
202
203 case bool:
204 _, err := c.allocValue(k, debool(v))
205 return err
206
207 default:
208 if isSupportedFunc(v) {
209 c.ftype[k] = v
210 _, err := c.allocFunc(k, v)
211 return err
212 }
213 return fmt.Errorf(typeErrFmt, v)
214 }
215 }
216
217 // allocValue ensures there's a place to match the name given, returning its
218 // index when successful; only programs using unreasonably many values will
219 // cause this func to fail
220 func (c *Compiler) allocValue(s string, f float64) (int, error) {
221 if len(c.values) >= maxOpIndex {
222 const fs = "programs can only use up to %d distinct float64 values"
223 return -1, fmt.Errorf(fs, maxOpIndex+1)
224 }
225
226 i := len(c.values)
227 c.vaddr[s] = i
228 c.values = append(c.values, f)
229 return i, nil
230 }
231
232 // valueIndex returns the index reserved for an allocated value/variable:
233 // all unallocated values/variables are allocated here on first access
234 func (c *Compiler) valueIndex(s string, f float64) (int, error) {
235 // name is found: return the index of the already-allocated var
236 if i, ok := c.vaddr[s]; ok {
237 return i, nil
238 }
239
240 // name not found, but it's a known math constant
241 if f, ok := mathConst[s]; ok {
242 return c.allocValue(s, f)
243 }
244 // name not found, and it's not of a known math constant
245 return c.allocValue(s, f)
246 }
247
248 // constIndex allocates a constant as a variable named as its own string
249 // representation, which avoids multiple entries for repeated uses of the
250 // same constant value
251 func (c *Compiler) constIndex(f float64) (int, error) {
252 // constants have no name, so use a canonical string representation
253 return c.valueIndex(strconv.FormatFloat(f, 'f', 16, 64), f)
254 }
255
256 // funcIndex returns the index reserved for an allocated function: all
257 // unallocated functions are allocated here on first access
258 func (c *Compiler) funcIndex(name string) (int, error) {
259 // check if func was already allocated
260 if i, ok := c.faddr[name]; ok {
261 return i, nil
262 }
263
264 // if name is reserved allocate an index for its matching func
265 if fn, ok := c.ftype[name]; ok {
266 return c.allocFunc(name, fn)
267 }
268
269 // if name wasn't reserved, see if it's a standard math func name
270 if fn := c.autoFuncLookup(name); fn != nil {
271 return c.allocFunc(name, fn)
272 }
273
274 // name isn't even a standard math func's
275 return -1, fmt.Errorf("function not found")
276 }
277
278 // allocFunc ensures there's a place for the function name given, returning its
279 // index when successful; only funcs of unsupported types will cause failure
280 func (c *Compiler) allocFunc(name string, fn any) (int, error) {
281 if isSupportedFunc(fn) {
282 i := len(c.funcs)
283 c.faddr[name] = i
284 c.ftype[name] = fn
285 c.funcs = append(c.funcs, fn)
286 return i, nil
287 }
288 return -1, fmt.Errorf("can't use a %T as a number-crunching function", fn)
289 }
290
291 // autoFuncLookup checks built-in deterministic funcs for the name given: its
292 // result is nil only if there's no match
293 func (c *Compiler) autoFuncLookup(name string) any {
294 if fn, ok := determFuncs[name]; ok {
295 return fn
296 }
297 return nil
298 }
299
300 // genOp generates/adds a basic operation to the program, while keeping track
301 // of the maximum depth the stack can reach
302 func (c *Compiler) genOp(op numOp, depth int) {
303 // add 2 defensively to ensure stack space for the inputs of binary ops
304 n := depth + 2
305 if c.maxStack < n {
306 c.maxStack = n
307 }
308 c.ops = append(c.ops, op)
309 }
310
311 // compile is a recursive expression evaluator which does the actual compiling
312 // as it goes along
313 func (c *Compiler) compile(expr any, depth int) error {
314 expr = c.optimize(expr)
315
316 switch expr := expr.(type) {
317 case float64:
318 return c.compileLiteral(expr, depth)
319 case string:
320 return c.compileVariable(expr, depth)
321 case []any:
322 return c.compileCombo(expr, depth)
323 case unaryExpr:
324 return c.compileUnary(expr.op, expr.x, depth)
325 case binaryExpr:
326 return c.compileBinary(expr.op, expr.x, expr.y, depth)
327 case callExpr:
328 return c.compileCall(expr, depth)
329 case assignExpr:
330 return c.compileAssign(expr, depth)
331 default:
332 return fmt.Errorf("unsupported expression type %T", expr)
333 }
334 }
335
336 // compileLiteral generates a load operation for the constant value given
337 func (c *Compiler) compileLiteral(f float64, depth int) error {
338 i, err := c.constIndex(f)
339 if err != nil {
340 return err
341 }
342 c.genOp(numOp{What: load, Index: opindex(i)}, depth)
343 return nil
344 }
345
346 // compileVariable generates a load operation for the variable name given
347 func (c *Compiler) compileVariable(name string, depth int) error {
348 // handle names which aren't defined, but are known math constants
349 if _, ok := c.vaddr[name]; !ok {
350 if f, ok := mathConst[name]; ok {
351 return c.compileLiteral(f, depth)
352 }
353 }
354
355 // handle actual variables
356 i, err := c.valueIndex(name, 0)
357 if err != nil {
358 return err
359 }
360 c.genOp(numOp{What: load, Index: opindex(i)}, depth)
361 return nil
362 }
363
364 // compileCombo handles a sequence of expressions
365 func (c *Compiler) compileCombo(exprs []any, depth int) error {
366 for _, v := range exprs {
367 err := c.compile(v, depth)
368 if err != nil {
369 return err
370 }
371 }
372 return nil
373 }
374
375 // compileUnary handles unary expressions
376 func (c *Compiler) compileUnary(op string, x any, depth int) error {
377 err := c.compile(x, depth+1)
378 if err != nil {
379 return err
380 }
381
382 if op, ok := unary2op[op]; ok {
383 c.genOp(numOp{What: op}, depth)
384 return nil
385 }
386 return fmt.Errorf("unary operation %q is unsupported", op)
387 }
388
389 // compileBinary handles binary expressions
390 func (c *Compiler) compileBinary(op string, x, y any, depth int) error {
391 switch op {
392 case "===", "!==":
393 // handle binary expressions with no matching basic operation, by
394 // treating them as aliases for function calls
395 return c.compileCall(callExpr{name: op, args: []any{x, y}}, depth)
396 }
397
398 err := c.compile(x, depth+1)
399 if err != nil {
400 return err
401 }
402 err = c.compile(y, depth+2)
403 if err != nil {
404 return err
405 }
406
407 if op, ok := binary2op[op]; ok {
408 c.genOp(numOp{What: op}, depth)
409 return nil
410 }
411 return fmt.Errorf("binary operation %q is unsupported", op)
412 }
413
414 // compileCall handles function-call expressions
415 func (c *Compiler) compileCall(expr callExpr, depth int) error {
416 // lookup function name
417 index, err := c.funcIndex(expr.name)
418 if err != nil {
419 return fmt.Errorf("%s: %s", expr.name, err.Error())
420 }
421 // get the func value, as its type determines the calling op to emit
422 v, ok := c.ftype[expr.name]
423 if !ok {
424 return fmt.Errorf("%s: function is undefined", expr.name)
425 }
426
427 // figure which type of function operation to use
428 op, ok := func2op(v)
429 if !ok {
430 return fmt.Errorf("%s: unsupported function type %T", expr.name, v)
431 }
432
433 // ensure number of args given to the func makes sense for the func type
434 err = checkArgCount(func2info[op], expr.name, len(expr.args))
435 if err != nil {
436 return err
437 }
438
439 // generate operations to evaluate all args
440 for i, v := range expr.args {
441 err := c.compile(v, depth+i+1)
442 if err != nil {
443 return err
444 }
445 }
446
447 // generate func-call operation
448 given := len(expr.args)
449 next := numOp{What: op, NumArgs: opargs(given), Index: opindex(index)}
450 c.genOp(next, depth)
451 return nil
452 }
453
454 // checkArgCount does what it says, returning informative errors when arg
455 // counts are wrong
456 func checkArgCount(info funcTypeInfo, name string, nargs int) error {
457 if info.AtLeast < 0 && info.AtMost < 0 {
458 return nil
459 }
460
461 if info.AtLeast == info.AtMost && nargs != info.AtMost {
462 const fs = "%s: expected %d args, got %d instead"
463 return fmt.Errorf(fs, name, info.AtMost, nargs)
464 }
465
466 if info.AtLeast >= 0 && info.AtMost >= 0 {
467 const fs = "%s: expected between %d and %d args, got %d instead"
468 if nargs < info.AtLeast || nargs > info.AtMost {
469 return fmt.Errorf(fs, name, info.AtLeast, info.AtMost, nargs)
470 }
471 }
472
473 if info.AtLeast >= 0 && nargs < info.AtLeast {
474 const fs = "%s: expected at least %d args, got %d instead"
475 return fmt.Errorf(fs, name, info.AtLeast, nargs)
476 }
477
478 if info.AtMost >= 0 && nargs > info.AtMost {
479 const fs = "%s: expected at most %d args, got %d instead"
480 return fmt.Errorf(fs, name, info.AtMost, nargs)
481 }
482
483 // all is good
484 return nil
485 }
486
487 // compileAssign generates a store operation for the expression given
488 func (c *Compiler) compileAssign(expr assignExpr, depth int) error {
489 err := c.compile(expr.expr, depth)
490 if err != nil {
491 return err
492 }
493
494 i, err := c.allocValue(expr.name, 0)
495 if err != nil {
496 return err
497 }
498
499 c.genOp(numOp{What: store, Index: opindex(i)}, depth)
500 return nil
501 }
502
503 // func sameFunc(x, y any) bool {
504 // if x == nil || y == nil {
505 // return false
506 // }
507 // return reflect.ValueOf(x).Pointer() == reflect.ValueOf(y).Pointer()
508 // }
509
510 // isSupportedFunc checks if a value's type is a supported func type
511 func isSupportedFunc(fn any) bool {
512 _, ok := func2op(fn)
513 return ok
514 }
515
516 // funcTypeInfo is an entry in the func2info lookup table
517 type funcTypeInfo struct {
518 // AtLeast is the minimum number of inputs the func requires: negative
519 // values are meant to be ignored
520 AtLeast int
521
522 // AtMost is the maximum number of inputs the func requires: negative
523 // values are meant to be ignored
524 AtMost int
525 }
526
527 // func2info is a lookup table to check the number of inputs funcs are given
528 var func2info = map[opcode]funcTypeInfo{
529 call0: {AtLeast: +0, AtMost: +0},
530 call1: {AtLeast: +1, AtMost: +1},
531 call2: {AtLeast: +2, AtMost: +2},
532 call3: {AtLeast: +3, AtMost: +3},
533 call4: {AtLeast: +4, AtMost: +4},
534 call5: {AtLeast: +5, AtMost: +5},
535 callv: {AtLeast: -1, AtMost: -1},
536 call1v: {AtLeast: +1, AtMost: -1},
537 }
538
539 // func2op tries to match a func type into a corresponding opcode
540 func func2op(v any) (op opcode, ok bool) {
541 switch v.(type) {
542 case func() float64:
543 return call0, true
544 case func(float64) float64:
545 return call1, true
546 case func(float64, float64) float64:
547 return call2, true
548 case func(float64, float64, float64) float64:
549 return call3, true
550 case func(float64, float64, float64, float64) float64:
551 return call4, true
552 case func(float64, float64, float64, float64, float64) float64:
553 return call5, true
554 case func(...float64) float64:
555 return callv, true
556 case func(float64, ...float64) float64:
557 return call1v, true
558 default:
559 return 0, false
560 }
561 }
File: ./fmscripts/compilers_test.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package fmscripts
26
27 import (
28 "testing"
29 )
30
31 func TestBuiltinFuncs(t *testing.T) {
32 list := []map[string]any{
33 determFuncs,
34 }
35
36 for _, kv := range list {
37 for k, v := range kv {
38 t.Run(k, func(t *testing.T) {
39 if !isSupportedFunc(v) {
40 t.Fatalf("%s: invalid function type %T", k, v)
41 }
42 })
43 }
44 }
45 }
46
47 var mathCorrectnessTests = []struct {
48 Name string
49 Script string
50 Expected float64
51 Error string
52 }{
53 {
54 Name: "value",
55 Script: "-45.2",
56 Expected: -45.2,
57 Error: "",
58 },
59 {
60 Name: "simple",
61 Script: "1 + 3",
62 Expected: 4,
63 Error: "",
64 },
65 {
66 Name: "fancier",
67 Script: "1*8 + 3*2**3",
68 Expected: 32,
69 Error: "",
70 },
71 {
72 Name: "function call",
73 Script: "log2(1024)",
74 Expected: 10,
75 Error: "",
76 },
77 {
78 Name: "function call (2 args)",
79 Script: "pow(3, 3)",
80 Expected: 27,
81 Error: "",
82 },
83 {
84 Name: "function call (3 args)",
85 Script: "fma(3.5, 56, -1.52)",
86 Expected: 194.48,
87 Error: "",
88 },
89 {
90 Name: "vararg-function call",
91 Script: "min(log10(10_000), log10(1_000_000), log2(4_096), 100 - 130)",
92 Expected: -30,
93 Error: "",
94 },
95 {
96 Name: "square shortcut",
97 Script: "*-3",
98 Expected: 9,
99 Error: "",
100 },
101 {
102 Name: "abs shortcut",
103 Script: "&-3",
104 Expected: 3,
105 Error: "",
106 },
107 {
108 Name: "negative constant",
109 Script: "(-3)",
110 Expected: -3,
111 Error: "",
112 },
113 {
114 Name: "power syntax",
115 Script: "2**4",
116 Expected: 16,
117 Error: "",
118 },
119 {
120 Name: "power syntax order",
121 Script: "3*2**4",
122 Expected: 48,
123 Error: "",
124 },
125 {
126 Script: "2*3**4",
127 Expected: 162,
128 Error: "",
129 },
130 {
131 Script: "2**3*4",
132 Expected: 32,
133 Error: "",
134 },
135 {
136 Script: "(2*3)**4",
137 Expected: 1_296,
138 Error: "",
139 },
140 {
141 Script: "*2*2",
142 Expected: 8,
143 Error: "",
144 },
145 {
146 Script: "2*2*2",
147 Expected: 8,
148 Error: "",
149 },
150 {
151 Script: "3 == 3 ? 10 : -1",
152 Expected: 10,
153 Error: "",
154 },
155 {
156 Script: "3 == 4 ? 10 : -1",
157 Expected: -1,
158 Error: "",
159 },
160 {
161 Script: "log10(-1) ?? 4",
162 Expected: 4,
163 Error: "",
164 },
165 {
166 Script: "log10(10) ?? 4",
167 Expected: 1,
168 Error: "",
169 },
170 {
171 Script: "abc = 123; abc",
172 Expected: 123,
173 Error: "",
174 },
175 {
176 Name: "calling func(float64, ...float64) float64",
177 Script: "horner(2.5, 1, 2, 3)",
178 Expected: 14.25,
179 Error: "",
180 },
181 }
182
183 func TestCompiler(t *testing.T) {
184 for _, tc := range mathCorrectnessTests {
185 name := tc.Name
186 if len(name) == 0 {
187 name = tc.Script
188 }
189
190 t.Run(name, func(t *testing.T) {
191 var c Compiler
192 p, err := c.Compile(tc.Script, map[string]any{"x": 1})
193 if err != nil {
194 t.Fatalf("got compiler error %q", err.Error())
195 }
196
197 f := p.Run()
198 if (err != nil || tc.Error != "") && err.Error() != tc.Error {
199 const fs = "expected error %q, got error %q instead"
200 t.Fatalf(fs, err.Error(), tc.Error)
201 }
202
203 if f != tc.Expected {
204 const fs = "expected result to be %f, got %f instead"
205 t.Fatalf(fs, tc.Expected, f)
206 }
207 })
208 }
209 }
File: ./fmscripts/doc.go
1 /*
2 # Floating-point Math Scripts
3
4 This self-contained package gives you a compiler/interpreter combo to run
5 number-crunching scripts. These are limited to float64 values, but they run
6 surprisingly quickly: various simple benchmarks suggest it's between 1/2 and
7 1/4 the speed of native funcs.
8
9 There are several built-in numeric functions, and the compiler makes it easy
10 to add your custom Go functions to further speed-up any operations specific to
11 your app/domain.
12
13 Finally, notice how float64 data don't really limit you as much as you might
14 think at first, since they can act as booleans (treating 0 as false, and non-0
15 as true), or as exact integers in the extremely-wide range [-2**53, +2**53].
16 */
17
18 package fmscripts
File: ./fmscripts/math.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package fmscripts
26
27 import (
28 "math"
29 )
30
31 const (
32 // the maximum integer a float64 can represent exactly; -maxflint is the
33 // minimum such integer, since floating-point values allow such symmetries
34 maxflint = 2 << 52
35
36 // base-2 versions of size multipliers
37 kilobyte = 1024 * 1.0
38 megabyte = 1024 * kilobyte
39 gigabyte = 1024 * megabyte
40 terabyte = 1024 * gigabyte
41 petabyte = 1024 * terabyte
42
43 // unit-conversion multipliers
44 mi2kmMult = 1 / 0.6213712
45 nm2kmMult = 1.852
46 nmi2kmMult = 1.852
47 yd2mtMult = 1 / 1.093613
48 ft2mtMult = 1 / 3.28084
49 in2cmMult = 2.54
50 lb2kgMult = 0.4535924
51 ga2ltMult = 1 / 0.2199692
52 gal2lMult = 1 / 0.2199692
53 oz2mlMult = 29.5735295625
54 cup2lMult = 0.2365882365
55 mpg2kplMult = 0.2199692 / 0.6213712
56 ton2kgMult = 1 / 907.18474
57 psi2paMult = 1 / 0.00014503773800722
58 deg2radMult = math.Pi / 180
59 rad2degMult = 180 / math.Pi
60 )
61
62 // default math constants
63 var mathConst = map[string]float64{
64 "e": math.E,
65 "pi": math.Pi,
66 "tau": 2 * math.Pi,
67 "phi": math.Phi,
68 "nan": math.NaN(),
69 "inf": math.Inf(+1),
70
71 "minint": -float64(maxflint - 1),
72 "maxint": +float64(maxflint - 1),
73 "minsafeint": -float64(maxflint - 1),
74 "maxsafeint": +float64(maxflint - 1),
75
76 "false": 0.0,
77 "true": 1.0,
78 "f": 0.0,
79 "t": 1.0,
80
81 // conveniently-named multipliers
82 "femto": 1e-15,
83 "pico": 1e-12,
84 "nano": 1e-09,
85 "micro": 1e-06,
86 "milli": 1e-03,
87 "kilo": 1e+03,
88 "mega": 1e+06,
89 "giga": 1e+09,
90 "tera": 1e+12,
91 "peta": 1e+15,
92
93 // unit-conversion multipliers
94 "mi2km": mi2kmMult,
95 "nm2km": nm2kmMult,
96 "nmi2km": nmi2kmMult,
97 "yd2mt": yd2mtMult,
98 "ft2mt": ft2mtMult,
99 "in2cm": in2cmMult,
100 "lb2kg": lb2kgMult,
101 "ga2lt": ga2ltMult,
102 "gal2l": gal2lMult,
103 "oz2ml": oz2mlMult,
104 "cup2l": cup2lMult,
105 "mpg2kpl": mpg2kplMult,
106 "ton2kg": ton2kgMult,
107 "psi2pa": psi2paMult,
108 "deg2rad": deg2radMult,
109 "rad2deg": rad2degMult,
110
111 // base-2 versions of size multipliers
112 "kb": kilobyte,
113 "mb": megabyte,
114 "gb": gigabyte,
115 "tb": terabyte,
116 "pb": petabyte,
117 "binkilo": kilobyte,
118 "binmega": megabyte,
119 "bingiga": gigabyte,
120 "bintera": terabyte,
121 "binpeta": petabyte,
122
123 // physical constants
124 "c": 299_792_458, // speed of light in m/s
125 "g": 6.67430e-11, // gravitational constant in N m2/kg2
126 "h": 6.62607015e-34, // planck constant in J s
127 "ec": 1.602176634e-19, // elementary charge in C
128 "e0": 8.8541878128e-12, // vacuum permittivity in C2/(Nm2)
129 "mu0": 1.25663706212e-6, // vacuum permeability in T m/A
130 "k": 1.380649e-23, // boltzmann constant in J/K
131 "mu": 1.66053906660e-27, // atomic mass constant in kg
132 "me": 9.1093837015e-31, // electron mass in kg
133 "mp": 1.67262192369e-27, // proton mass in kg
134 "mn": 1.67492749804e-27, // neutron mass in kg
135
136 // float64s can only vaguely approx. avogadro's mole (6.02214076e23)
137 }
138
139 // deterministic math functions lookup-table generated using the command
140 //
141 // go doc math | awk '/func/ { gsub(/func |\(.*/, ""); printf("\"%s\": math.%s,\n", tolower($0), $0) }'
142 //
143 // then hand-edited to remove funcs, or to use adapter funcs when needed: removed
144 // funcs either had multiple returns (like SinCos) or dealt with float32 values
145 var determFuncs = map[string]any{
146 "abs": math.Abs,
147 "acos": math.Acos,
148 "acosh": math.Acosh,
149 "asin": math.Asin,
150 "asinh": math.Asinh,
151 "atan": math.Atan,
152 "atan2": math.Atan2,
153 "atanh": math.Atanh,
154 "cbrt": math.Cbrt,
155 "ceil": math.Ceil,
156 "copysign": math.Copysign,
157 "cos": math.Cos,
158 "cosh": math.Cosh,
159 "dim": math.Dim,
160 "erf": math.Erf,
161 "erfc": math.Erfc,
162 "erfcinv": math.Erfcinv,
163 "erfinv": math.Erfinv,
164 "exp": math.Exp,
165 "exp2": math.Exp2,
166 "expm1": math.Expm1,
167 "fma": math.FMA,
168 "floor": math.Floor,
169 "gamma": math.Gamma,
170 "inf": inf,
171 "isinf": isInf,
172 "isnan": isNaN,
173 "j0": math.J0,
174 "j1": math.J1,
175 "jn": jn,
176 "ldexp": ldexp,
177 "lgamma": lgamma,
178 "log10": math.Log10,
179 "log1p": math.Log1p,
180 "log2": math.Log2,
181 "logb": math.Logb,
182 "mod": math.Mod,
183 "nan": math.NaN,
184 "nextafter": math.Nextafter,
185 "pow": math.Pow,
186 "pow10": pow10,
187 "remainder": math.Remainder,
188 "round": math.Round,
189 "roundtoeven": math.RoundToEven,
190 "signbit": signbit,
191 "sin": math.Sin,
192 "sinh": math.Sinh,
193 "sqrt": math.Sqrt,
194 "tan": math.Tan,
195 "tanh": math.Tanh,
196 "trunc": math.Trunc,
197 "y0": math.Y0,
198 "y1": math.Y1,
199 "yn": yn,
200
201 // a few aliases for the standard math funcs: some of the single-letter
202 // aliases are named after the ones in `bc`, the basic calculator tool
203 "a": math.Abs,
204 "c": math.Cos,
205 "ceiling": math.Ceil,
206 "cosine": math.Cos,
207 "e": math.Exp,
208 "isinf0": isAnyInf,
209 "isinfinite": isAnyInf,
210 "l": math.Log,
211 "ln": math.Log,
212 "lg": math.Log2,
213 "modulus": math.Mod,
214 "power": math.Pow,
215 "rem": math.Remainder,
216 "s": math.Sin,
217 "sine": math.Sin,
218 "t": math.Tan,
219 "tangent": math.Tan,
220 "truncate": math.Trunc,
221 "truncated": math.Trunc,
222
223 // not from standard math: these custom funcs were added manually
224 "beta": beta,
225 "bool": num2bool,
226 "clamp": clamp,
227 "cond": cond, // vector-arg if-else chain
228 "cube": cube,
229 "cubed": cube,
230 "degrees": degrees,
231 "deinf": deInf,
232 "denan": deNaN,
233 "factorial": factorial,
234 "fract": fract,
235 "horner": polyval,
236 "hypot": hypot,
237 "if": ifElse,
238 "ifelse": ifElse,
239 "inv": reciprocal,
240 "isanyinf": isAnyInf,
241 "isbad": isBad,
242 "isfin": isFinite,
243 "isfinite": isFinite,
244 "isgood": isGood,
245 "isinteger": isInteger,
246 "lbeta": lnBeta,
247 "len": length,
248 "length": length,
249 "lnbeta": lnBeta,
250 "log": math.Log,
251 "logistic": logistic,
252 "mag": length,
253 "max": max,
254 "min": min,
255 "neg": negate,
256 "negate": negate,
257 "not": notBool,
258 "polyval": polyval,
259 "radians": radians,
260 "range": rangef, // vector-arg max - min
261 "reciprocal": reciprocal,
262 "rev": revalue,
263 "revalue": revalue,
264 "scale": scale,
265 "sgn": sign,
266 "sign": sign,
267 "sinc": sinc,
268 "sq": sqr,
269 "sqmin": solveQuadMin,
270 "sqmax": solveQuadMax,
271 "square": sqr,
272 "squared": sqr,
273 "unwrap": unwrap,
274 "wrap": wrap,
275
276 // a few aliases for the custom funcs
277 "deg": degrees,
278 "isint": isInteger,
279 "rad": radians,
280
281 // a few quicker 2-value versions of vararg funcs: the optimizer depends
282 // on these to rewrite 2-input uses of their vararg counterparts
283 "hypot2": math.Hypot,
284 "max2": math.Max,
285 "min2": math.Min,
286
287 // a few entries to enable custom syntax: the parser depends on these to
288 // rewrite binary expressions into func calls
289 "??": deNaN,
290 "?:": ifElse,
291 "===": same,
292 "!==": notSame,
293 }
294
295 // DefineDetFuncs adds more deterministic funcs to the default set. Such funcs
296 // are considered optimizable, since calling them with the same constant inputs
297 // is supposed to return the same constant outputs, as the name `deterministic`
298 // suggests.
299 //
300 // Only call this before compiling any scripts, and ensure all funcs given are
301 // supported and are deterministic. Random-output funcs certainly won't fit
302 // the bill here.
303 func DefineDetFuncs(funcs map[string]any) {
304 for k, v := range funcs {
305 determFuncs[k] = v
306 }
307 }
308
309 func sqr(x float64) float64 {
310 return x * x
311 }
312
313 func cube(x float64) float64 {
314 return x * x * x
315 }
316
317 func num2bool(x float64) float64 {
318 if x == 0 {
319 return 0
320 }
321 return 1
322 }
323
324 func logistic(x float64) float64 {
325 return 1 / (1 + math.Exp(-x))
326 }
327
328 func sign(x float64) float64 {
329 if math.IsNaN(x) {
330 return x
331 }
332 if x > 0 {
333 return +1
334 }
335 if x < 0 {
336 return -1
337 }
338 return 0
339 }
340
341 func sinc(x float64) float64 {
342 if x == 0 {
343 return 1
344 }
345 return math.Sin(x) / x
346 }
347
348 func isInteger(x float64) float64 {
349 _, frac := math.Modf(x)
350 if frac == 0 {
351 return 1
352 }
353 return 0
354 }
355
356 func inf(sign float64) float64 {
357 return math.Inf(int(sign))
358 }
359
360 func isInf(x float64, sign float64) float64 {
361 if math.IsInf(x, int(sign)) {
362 return 1
363 }
364 return 0
365 }
366
367 func isAnyInf(x float64) float64 {
368 if math.IsInf(x, 0) {
369 return 1
370 }
371 return 0
372 }
373
374 func isFinite(x float64) float64 {
375 if math.IsInf(x, 0) {
376 return 0
377 }
378 return 1
379 }
380
381 func isNaN(x float64) float64 {
382 if math.IsNaN(x) {
383 return 1
384 }
385 return 0
386 }
387
388 func isGood(x float64) float64 {
389 if math.IsNaN(x) || math.IsInf(x, 0) {
390 return 0
391 }
392 return 1
393 }
394
395 func isBad(x float64) float64 {
396 if math.IsNaN(x) || math.IsInf(x, 0) {
397 return 1
398 }
399 return 0
400 }
401
402 func same(x, y float64) float64 {
403 if math.IsNaN(x) && math.IsNaN(y) {
404 return 1
405 }
406 return debool(x == y)
407 }
408
409 func notSame(x, y float64) float64 {
410 if math.IsNaN(x) && math.IsNaN(y) {
411 return 0
412 }
413 return debool(x != y)
414 }
415
416 func deNaN(x float64, instead float64) float64 {
417 if !math.IsNaN(x) {
418 return x
419 }
420 return instead
421 }
422
423 func deInf(x float64, instead float64) float64 {
424 if !math.IsInf(x, 0) {
425 return x
426 }
427 return instead
428 }
429
430 func revalue(x float64, instead float64) float64 {
431 if !math.IsNaN(x) && !math.IsInf(x, 0) {
432 return x
433 }
434 return instead
435 }
436
437 func pow10(n float64) float64 {
438 return math.Pow10(int(n))
439 }
440
441 func jn(n, x float64) float64 {
442 return math.Jn(int(n), x)
443 }
444
445 func ldexp(frac, exp float64) float64 {
446 return math.Ldexp(frac, int(exp))
447 }
448
449 func lgamma(x float64) float64 {
450 y, s := math.Lgamma(x)
451 if s < 0 {
452 return math.NaN()
453 }
454 return y
455 }
456
457 func signbit(x float64) float64 {
458 if math.Signbit(x) {
459 return 1
460 }
461 return 0
462 }
463
464 func yn(n, x float64) float64 {
465 return math.Yn(int(n), x)
466 }
467
468 func negate(x float64) float64 {
469 return -x
470 }
471
472 func reciprocal(x float64) float64 {
473 return 1 / x
474 }
475
476 func rangef(v ...float64) float64 {
477 min := math.Inf(+1)
478 max := math.Inf(-1)
479 for _, f := range v {
480 min = math.Min(min, f)
481 max = math.Max(max, f)
482 }
483 return max - min
484 }
485
486 func cond(v ...float64) float64 {
487 for {
488 switch len(v) {
489 case 0:
490 // either no values are left, or no values were given at all
491 return math.NaN()
492
493 case 1:
494 // either all previous conditions failed, and this last value is
495 // automatically chosen, or only 1 value was given to begin with
496 return v[0]
497
498 default:
499 // check condition: if true (non-0), return the value after it
500 if v[0] != 0 {
501 return v[1]
502 }
503 // condition was false, so skip the leading pair of values
504 v = v[2:]
505 }
506 }
507 }
508
509 func notBool(x float64) float64 {
510 if x == 0 {
511 return 1
512 }
513 return 0
514 }
515
516 func ifElse(cond float64, yes, no float64) float64 {
517 if cond != 0 {
518 return yes
519 }
520 return no
521 }
522
523 func lnGamma(x float64) float64 {
524 y, s := math.Lgamma(x)
525 if s < 0 {
526 return math.NaN()
527 }
528 return y
529 }
530
531 func lnBeta(x float64, y float64) float64 {
532 return lnGamma(x) + lnGamma(y) - lnGamma(x+y)
533 }
534
535 func beta(x float64, y float64) float64 {
536 return math.Exp(lnBeta(x, y))
537 }
538
539 func factorial(n float64) float64 {
540 return math.Round(math.Gamma(n + 1))
541 }
542
543 func degrees(rad float64) float64 {
544 return rad2degMult * rad
545 }
546
547 func radians(deg float64) float64 {
548 return deg2radMult * deg
549 }
550
551 func fract(x float64) float64 {
552 return x - math.Floor(x)
553 }
554
555 func min(v ...float64) float64 {
556 min := +math.Inf(+1)
557 for _, f := range v {
558 min = math.Min(min, f)
559 }
560 return min
561 }
562
563 func max(v ...float64) float64 {
564 max := +math.Inf(-1)
565 for _, f := range v {
566 max = math.Max(max, f)
567 }
568 return max
569 }
570
571 func hypot(v ...float64) float64 {
572 sumsq := 0.0
573 for _, f := range v {
574 sumsq += f * f
575 }
576 return math.Sqrt(sumsq)
577 }
578
579 func length(v ...float64) float64 {
580 ss := 0.0
581 for _, f := range v {
582 ss += f * f
583 }
584 return math.Sqrt(ss)
585 }
586
587 // solveQuadMin finds the lowest solution of a 2nd-degree polynomial, using
588 // a formula which is more accurate than the textbook one
589 func solveQuadMin(a, b, c float64) float64 {
590 disc := math.Sqrt(b*b - 4*a*c)
591 r1 := 2 * c / (-b - disc)
592 return r1
593 }
594
595 // solveQuadMax finds the highest solution of a 2nd-degree polynomial, using
596 // a formula which is more accurate than the textbook one
597 func solveQuadMax(a, b, c float64) float64 {
598 disc := math.Sqrt(b*b - 4*a*c)
599 r2 := 2 * c / (-b + disc)
600 return r2
601 }
602
603 func wrap(x float64, min, max float64) float64 {
604 return (x - min) / (max - min)
605 }
606
607 func unwrap(x float64, min, max float64) float64 {
608 return (max-min)*x + min
609 }
610
611 func clamp(x float64, min, max float64) float64 {
612 return math.Min(math.Max(x, min), max)
613 }
614
615 func scale(x float64, xmin, xmax, ymin, ymax float64) float64 {
616 k := (x - xmin) / (xmax - xmin)
617 return (ymax-ymin)*k + ymin
618 }
619
620 // polyval runs horner's algorithm on a value, with the polynomial coefficients
621 // given after it, higher-order first
622 func polyval(x float64, v ...float64) float64 {
623 if len(v) == 0 {
624 return 0
625 }
626
627 x0 := x
628 x = 1.0
629 y := 0.0
630 for i := len(v) - 1; i >= 0; i-- {
631 y += v[i] * x
632 x *= x0
633 }
634 return y
635 }
File: ./fmscripts/math_test.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package fmscripts
26
27 import "testing"
28
29 func TestTables(t *testing.T) {
30 for k, v := range determFuncs {
31 t.Run(k, func(t *testing.T) {
32 if !isSupportedFunc(v) {
33 t.Fatalf("unsupported func of type %T", v)
34 }
35 })
36 }
37 }
File: ./fmscripts/optimizers.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package fmscripts
26
27 import (
28 "math"
29 )
30
31 // unary2func matches unary operators to funcs the optimizer can use to eval
32 // constant-input unary expressions into their results
33 var unary2func = map[string]func(x float64) float64{
34 // avoid unary +, since it's a no-op and thus there's no instruction for
35 // it, which in turn makes unit-tests for these optimization tables fail
36 // unnecessarily
37 // "+": func(x float64) float64 { return +x },
38
39 "-": func(x float64) float64 { return -x },
40 "!": func(x float64) float64 { return debool(x == 0) },
41 "&": func(x float64) float64 { return math.Abs(x) },
42 "*": func(x float64) float64 { return x * x },
43 "^": func(x float64) float64 { return x * x },
44 "/": func(x float64) float64 { return 1 / x },
45 }
46
47 // binary2func matches binary operators to funcs the optimizer can use to eval
48 // constant-input binary expressions into their results
49 var binary2func = map[string]func(x, y float64) float64{
50 "+": func(x, y float64) float64 { return x + y },
51 "-": func(x, y float64) float64 { return x - y },
52 "*": func(x, y float64) float64 { return x * y },
53 "/": func(x, y float64) float64 { return x / y },
54 "%": func(x, y float64) float64 { return math.Mod(x, y) },
55 "&": func(x, y float64) float64 { return debool(x != 0 && y != 0) },
56 "&&": func(x, y float64) float64 { return debool(x != 0 && y != 0) },
57 "|": func(x, y float64) float64 { return debool(x != 0 || y != 0) },
58 "||": func(x, y float64) float64 { return debool(x != 0 || y != 0) },
59 "==": func(x, y float64) float64 { return debool(x == y) },
60 "!=": func(x, y float64) float64 { return debool(x != y) },
61 "<>": func(x, y float64) float64 { return debool(x != y) },
62 "<": func(x, y float64) float64 { return debool(x < y) },
63 "<=": func(x, y float64) float64 { return debool(x <= y) },
64 ">": func(x, y float64) float64 { return debool(x > y) },
65 ">=": func(x, y float64) float64 { return debool(x >= y) },
66 "**": func(x, y float64) float64 { return math.Pow(x, y) },
67 "^": func(x, y float64) float64 { return math.Pow(x, y) },
68 }
69
70 // func2unary turns built-in func names into built-in unary operators
71 var func2unary = map[string]string{
72 "a": "&",
73 "abs": "&",
74 "inv": "/",
75 "neg": "-",
76 "negate": "-",
77 "reciprocal": "/",
78 "sq": "*",
79 "square": "*",
80 "squared": "*",
81 }
82
83 // func2binary turns built-in func names into built-in binary operators
84 var func2binary = map[string]string{
85 "mod": "%",
86 "modulus": "%",
87 "pow": "**",
88 "power": "**",
89 }
90
91 // vararg2func2 matches variable-argument funcs to their 2-argument versions
92 var vararg2func2 = map[string]string{
93 "hypot": "hypot2",
94 "max": "max2",
95 "min": "min2",
96 }
97
98 // optimize tries to simplify the expression given as much as possible, by
99 // simplifying constants whenever possible, and exploiting known built-in
100 // funcs which are known to behave deterministically
101 func (c *Compiler) optimize(expr any) any {
102 switch expr := expr.(type) {
103 case []any:
104 return c.optimizeCombo(expr)
105
106 case unaryExpr:
107 return c.optimizeUnaryExpr(expr)
108
109 case binaryExpr:
110 return c.optimizeBinaryExpr(expr)
111
112 case callExpr:
113 return c.optimizeCallExpr(expr)
114
115 case assignExpr:
116 expr.expr = c.optimize(expr.expr)
117 return expr
118
119 default:
120 f, ok := c.tryConstant(expr)
121 if ok {
122 return f
123 }
124 return expr
125 }
126 }
127
128 // optimizeCombo handles a sequence of expressions for the optimizer
129 func (c *Compiler) optimizeCombo(exprs []any) any {
130 if len(exprs) == 1 {
131 return c.optimize(exprs[0])
132 }
133
134 // count how many expressions are considered useful: these are
135 // assignments as well as the last expression, since that's what
136 // determines the script's final result
137 useful := 0
138 for i, v := range exprs {
139 _, ok := v.(assignExpr)
140 if ok || i == len(exprs)-1 {
141 useful++
142 }
143 }
144
145 // ignore all expressions which are a waste of time, and optimize
146 // all other expressions
147 res := make([]any, 0, useful)
148 for i, v := range exprs {
149 _, ok := v.(assignExpr)
150 if ok || i == len(exprs)-1 {
151 res = append(res, c.optimize(v))
152 }
153 }
154 return res
155 }
156
157 // optimizeUnaryExpr handles unary expressions for the optimizer
158 func (c *Compiler) optimizeUnaryExpr(expr unaryExpr) any {
159 // recursively optimize input
160 expr.x = c.optimize(expr.x)
161
162 // optimize unary ops on a constant into concrete values
163 if x, ok := expr.x.(float64); ok {
164 if fn, ok := unary2func[expr.op]; ok {
165 return fn(x)
166 }
167 }
168
169 switch expr.op {
170 case "+":
171 // unary plus is an identity operation
172 return expr.x
173
174 default:
175 return expr
176 }
177 }
178
179 // optimizeBinaryExpr handles binary expressions for the optimizer
180 func (c *Compiler) optimizeBinaryExpr(expr binaryExpr) any {
181 // recursively optimize inputs
182 expr.x = c.optimize(expr.x)
183 expr.y = c.optimize(expr.y)
184
185 // optimize binary ops on 2 constants into concrete values
186 if x, ok := expr.x.(float64); ok {
187 if y, ok := expr.y.(float64); ok {
188 if fn, ok := binary2func[expr.op]; ok {
189 return fn(x, y)
190 }
191 }
192 }
193
194 switch expr.op {
195 case "+":
196 if expr.x == 0.0 {
197 // 0+y -> y
198 return expr.y
199 }
200 if expr.y == 0.0 {
201 // x+0 -> x
202 return expr.x
203 }
204
205 case "-":
206 if expr.x == 0.0 {
207 // 0-y -> -y
208 return c.optimizeUnaryExpr(unaryExpr{op: "-", x: expr.y})
209 }
210 if expr.y == 0.0 {
211 // x-0 -> x
212 return expr.x
213 }
214
215 case "*":
216 if expr.x == 0.0 || expr.y == 0.0 {
217 // 0*y -> 0
218 // x*0 -> 0
219 return 0.0
220 }
221 if expr.x == 1.0 {
222 // 1*y -> y
223 return expr.y
224 }
225 if expr.y == 1.0 {
226 // x*1 -> x
227 return expr.x
228 }
229 if expr.x == -1.0 {
230 // -1*y -> -y
231 return c.optimizeUnaryExpr(unaryExpr{op: "-", x: expr.y})
232 }
233 if expr.y == -1.0 {
234 // x*-1 -> -x
235 return c.optimizeUnaryExpr(unaryExpr{op: "-", x: expr.x})
236 }
237
238 case "/":
239 if expr.x == 1.0 {
240 // 1/y -> reciprocal of y
241 return c.optimizeUnaryExpr(unaryExpr{op: "/", x: expr.y})
242 }
243 if expr.y == 1.0 {
244 // x/1 -> x
245 return expr.x
246 }
247 if expr.y == -1.0 {
248 // x/-1 -> -x
249 return c.optimizeUnaryExpr(unaryExpr{op: "-", x: expr.x})
250 }
251
252 case "**":
253 switch expr.y {
254 case -1.0:
255 // x**-1 -> 1/x, reciprocal of x
256 return c.optimizeUnaryExpr(unaryExpr{op: "/", x: expr.x})
257 case 0.0:
258 // x**0 -> 1
259 return 1.0
260 case 1.0:
261 // x**1 -> x
262 return expr.x
263 case 2.0:
264 // x**2 -> *x
265 return c.optimizeUnaryExpr(unaryExpr{op: "*", x: expr.x})
266 case 3.0:
267 // x**3 -> *x*x
268 sq := unaryExpr{op: "*", x: expr.x}
269 return c.optimizeBinaryExpr(binaryExpr{op: "*", x: sq, y: expr.x})
270 }
271
272 case "&", "&&":
273 if expr.x == 0.0 || expr.y == 0.0 {
274 // 0 && y -> 0
275 // x && 0 -> 0
276 return 0.0
277 }
278 }
279
280 // no simplifiable patterns were detected
281 return expr
282 }
283
284 // optimizeCallExpr optimizes special cases of built-in func calls
285 func (c *Compiler) optimizeCallExpr(call callExpr) any {
286 // recursively optimize all inputs, and keep track if they're all
287 // constants in the end
288 numlit := 0
289 for i, v := range call.args {
290 v = c.optimize(v)
291 call.args[i] = v
292 if _, ok := v.(float64); ok {
293 numlit++
294 }
295 }
296
297 // if func is overridden, there's no guarantee the new func works the same
298 if _, ok := determFuncs[call.name]; c.ftype[call.name] != nil || !ok {
299 return call
300 }
301
302 // from this point on, func is guaranteed to be built-in and deterministic
303
304 // handle all-const inputs, by calling func and using its result
305 if numlit == len(call.args) {
306 in := make([]float64, 0, len(call.args))
307 for _, v := range call.args {
308 f, _ := v.(float64)
309 in = append(in, f)
310 }
311
312 if f, ok := tryCall(determFuncs[call.name], in); ok {
313 return f
314 }
315 }
316
317 switch len(call.args) {
318 case 1:
319 if op, ok := func2unary[call.name]; ok {
320 expr := unaryExpr{op: op, x: call.args[0]}
321 return c.optimizeUnaryExpr(expr)
322 }
323 return call
324
325 case 2:
326 if op, ok := func2binary[call.name]; ok {
327 expr := binaryExpr{op: op, x: call.args[0], y: call.args[1]}
328 return c.optimizeBinaryExpr(expr)
329 }
330 if name, ok := vararg2func2[call.name]; ok {
331 call.name = name
332 return call
333 }
334 return call
335
336 default:
337 return call
338 }
339 }
340
341 // tryConstant tries to optimize the expression given into a constant
342 func (c *Compiler) tryConstant(expr any) (value float64, ok bool) {
343 switch expr := expr.(type) {
344 case float64:
345 return expr, true
346
347 case string:
348 if _, ok := c.vaddr[expr]; !ok {
349 // name isn't explicitly defined
350 if f, ok := mathConst[expr]; ok {
351 // and is a known math constant
352 return f, true
353 }
354 }
355 return 0, false
356
357 default:
358 return 0, false
359 }
360 }
361
362 // tryCall tries to simplify the function expression given into a constant
363 func tryCall(fn any, in []float64) (value float64, ok bool) {
364 switch fn := fn.(type) {
365 case func(float64) float64:
366 if len(in) == 1 {
367 return fn(in[0]), true
368 }
369 return 0, false
370
371 case func(float64, float64) float64:
372 if len(in) == 2 {
373 return fn(in[0], in[1]), true
374 }
375 return 0, false
376
377 case func(float64, float64, float64) float64:
378 if len(in) == 3 {
379 return fn(in[0], in[1], in[2]), true
380 }
381 return 0, false
382
383 case func(float64, float64, float64, float64) float64:
384 if len(in) == 4 {
385 return fn(in[0], in[1], in[2], in[3]), true
386 }
387 return 0, false
388
389 case func(float64, float64, float64, float64, float64) float64:
390 if len(in) == 5 {
391 return fn(in[0], in[1], in[2], in[3], in[4]), true
392 }
393 return 0, false
394
395 case func(...float64) float64:
396 return fn(in...), true
397
398 case func(float64, ...float64) float64:
399 if len(in) >= 1 {
400 return fn(in[0], in[1:]...), true
401 }
402 return 0, false
403
404 default:
405 // type isn't a supported func
406 return 0, false
407 }
408 }
File: ./fmscripts/optimizers_test.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package fmscripts
26
27 import (
28 "math"
29 "reflect"
30 "testing"
31 )
32
33 func TestOptimizerTables(t *testing.T) {
34 for k := range unary2func {
35 t.Run(k, func(t *testing.T) {
36 if _, ok := unary2op[k]; !ok {
37 t.Fatalf("missing unary constant optimizer for %q", k)
38 }
39 })
40 }
41
42 for k := range binary2func {
43 t.Run(k, func(t *testing.T) {
44 if _, ok := binary2op[k]; !ok {
45 t.Fatalf("missing binary constant optimizer for %q", k)
46 }
47 })
48 }
49
50 for k, name := range func2unary {
51 t.Run(k, func(t *testing.T) {
52 if _, ok := determFuncs[k]; !ok {
53 const fs = "func(x) optimizer %q has no matching built-in func"
54 t.Fatalf(fs, k)
55 }
56
57 if _, ok := unary2op[name]; !ok {
58 t.Fatalf("missing unary func optimizer for %q", k)
59 }
60 })
61 }
62
63 for k, name := range func2binary {
64 t.Run(k, func(t *testing.T) {
65 if _, ok := determFuncs[k]; !ok {
66 const fs = "func(x, y) optimizer %q has no matching built-in func"
67 t.Fatalf(fs, k)
68 }
69
70 if _, ok := binary2op[name]; !ok {
71 t.Fatalf("missing binary func optimizer for %q", k)
72 }
73 })
74 }
75
76 for k := range vararg2func2 {
77 t.Run(k, func(t *testing.T) {
78 if _, ok := determFuncs[k]; !ok {
79 const fs = "vararg optimizer %q has no matching built-in func"
80 t.Fatalf(fs, k)
81 }
82 })
83 }
84 }
85
86 func TestOptimizer(t *testing.T) {
87 var tests = []struct {
88 Source string
89 Expected any
90 }{
91 {"1", 1.0},
92 {"3+4*5", 23.0},
93
94 {"e", math.E},
95 {"pi", math.Pi},
96 {"phi", math.Phi},
97 {"2*pi", 2 * math.Pi},
98 {"4.51*phi-14.23564", 4.51*math.Phi - 14.23564},
99 {"-e", -math.E},
100
101 {"exp(2*pi)", math.Exp(2 * math.Pi)},
102 {"log(2342.55) / log(43.21)", math.Log(2342.55) / math.Log(43.21)},
103 {"f(3)", callExpr{name: "f", args: []any{3.0}}},
104 {"min(3, 2, -1.5)", -1.5},
105
106 {"hypot(x, 4)", callExpr{name: "hypot2", args: []any{"x", 4.0}}},
107 {"max(x, 4)", callExpr{name: "max2", args: []any{"x", 4.0}}},
108 {"min(x, 4)", callExpr{name: "min2", args: []any{"x", 4.0}}},
109
110 {"rand()", callExpr{name: "rand"}},
111
112 {
113 "sin(2_000 * x * tau * x)",
114 callExpr{
115 name: "sin",
116 args: []any{
117 binaryExpr{
118 "*",
119 binaryExpr{
120 "*",
121 binaryExpr{"*", 2_000.0, "x"},
122 2 * math.Pi,
123 },
124 "x",
125 },
126 },
127 },
128 },
129
130 {
131 "sin(10 * tau * exp(-20 * x)) * exp(-2 * x)",
132 binaryExpr{
133 "*",
134 // sin(...)
135 callExpr{
136 name: "sin",
137 args: []any{
138 // 10 * tau * exp(...)
139 binaryExpr{
140 "*",
141 10 * 2 * math.Pi,
142 // exp(-20 * x)
143 callExpr{
144 name: "exp",
145 args: []any{
146 binaryExpr{"*", -20.0, "x"},
147 },
148 },
149 },
150 },
151 },
152 // exp(-2 * x)
153 callExpr{
154 name: "exp",
155 args: []any{binaryExpr{"*", -2.0, "x"}},
156 },
157 },
158 },
159 }
160
161 defs := map[string]any{
162 "x": 3.5,
163 "f": math.Exp,
164 }
165
166 for _, tc := range tests {
167 t.Run(tc.Source, func(t *testing.T) {
168 var c Compiler
169 root, err := parse(tc.Source)
170 if err != nil {
171 t.Fatal(err)
172 return
173 }
174
175 if err := c.reset(defs); err != nil {
176 t.Fatal(err)
177 return
178 }
179
180 got := c.optimize(root)
181 if !reflect.DeepEqual(got, tc.Expected) {
182 const fs = "expected result to be\n%#v\ninstead of\n%#v"
183 t.Fatalf(fs, tc.Expected, got)
184 return
185 }
186 })
187 }
188 }
File: ./fmscripts/parsing.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package fmscripts
26
27 import (
28 "errors"
29 "fmt"
30 "strconv"
31 "strings"
32 )
33
34 // parse turns source code into an expression type interpreters can use.
35 func parse(src string) (any, error) {
36 tok := newTokenizer(src)
37 par, err := newParser(&tok)
38 if err != nil {
39 return nil, err
40 }
41
42 v, err := par.parse()
43 err = par.improveError(err, src)
44 return v, err
45 }
46
47 // pickLine slices a string, picking one of its lines via a 1-based index:
48 // func improveError uses it to isolate the line of code an error came from
49 func pickLine(src string, linenum int) string {
50 // skip the lines before the target one
51 for i := 0; i < linenum && len(src) > 0; i++ {
52 j := strings.IndexByte(src, '\n')
53 if j < 0 {
54 break
55 }
56 src = src[j+1:]
57 }
58
59 // limit leftover to a single line
60 i := strings.IndexByte(src, '\n')
61 if i >= 0 {
62 return src[:i]
63 }
64 return src
65 }
66
67 // unaryExpr is a unary expression
68 type unaryExpr struct {
69 op string
70 x any
71 }
72
73 // binaryExpr is a binary expression
74 type binaryExpr struct {
75 op string
76 x any
77 y any
78 }
79
80 // callExpr is a function call and its arguments
81 type callExpr struct {
82 name string
83 args []any
84 }
85
86 // assignExpr is a value/variable assignment
87 type assignExpr struct {
88 name string
89 expr any
90 }
91
92 // parser is a parser for JavaScript-like syntax, limited to operations on
93 // 64-bit floating-point numbers
94 type parser struct {
95 tokens []token
96 line int
97 pos int
98 toklen int
99 }
100
101 // newParser is the constructor for type parser
102 func newParser(t *tokenizer) (parser, error) {
103 var p parser
104
105 // get all tokens from the source code
106 for {
107 v, err := t.next()
108 if v.kind != unknownToken {
109 p.tokens = append(p.tokens, v)
110 }
111
112 if err == errEOS {
113 // done scanning/tokenizing
114 return p, nil
115 }
116
117 if err != nil {
118 // handle actual errors
119 return p, err
120 }
121 }
122 }
123
124 // improveError adds more info about where exactly in the source code an error
125 // came from, thus making error messages much more useful
126 func (p *parser) improveError(err error, src string) error {
127 if err == nil {
128 return nil
129 }
130
131 line := pickLine(src, p.line)
132 if len(line) == 0 || p.pos < 1 {
133 const fs = "(line %d: pos %d): %w"
134 return fmt.Errorf(fs, p.line, p.pos, err)
135 }
136
137 ptr := strings.Repeat(" ", p.pos) + "^"
138 const fs = "(line %d: pos %d): %w\n%s\n%s"
139 return fmt.Errorf(fs, p.line, p.pos, err, line, ptr)
140 }
141
142 // parse tries to turn tokens into a compilable abstract syntax tree, and is
143 // the parser's entry point
144 func (p *parser) parse() (any, error) {
145 // source codes with no tokens are always errors
146 if len(p.tokens) == 0 {
147 const msg = "source code is empty, or has no useful expressions"
148 return nil, errors.New(msg)
149 }
150
151 // handle optional excel-like leading equal sign
152 p.acceptSyntax(`=`)
153
154 // ignore trailing semicolons
155 for len(p.tokens) > 0 {
156 t := p.tokens[len(p.tokens)-1]
157 if t.kind != syntaxToken || t.value != `;` {
158 break
159 }
160 p.tokens = p.tokens[:len(p.tokens)-1]
161 }
162
163 // handle single expressions as well as multiple semicolon-separated
164 // expressions: the latter allow value assignments/updates in scripts
165 var res []any
166 for keepGoing := true; keepGoing && len(p.tokens) > 0; {
167 v, err := p.parseExpression()
168 if err != nil && err != errEOS {
169 return v, err
170 }
171
172 res = append(res, v)
173 // handle optional separator/continuation semicolons
174 _, keepGoing = p.acceptSyntax(`;`)
175 }
176
177 // unexpected unparsed trailing tokens are always errors; any trailing
178 // semicolons in the original script are already trimmed
179 if len(p.tokens) > 0 {
180 const fs = "unexpected %s"
181 return res, fmt.Errorf(fs, p.tokens[0].value)
182 }
183
184 // make scripts ending in an assignment also load that value, so they're
185 // useful, as assignments result in no useful value by themselves
186 assign, ok := res[len(res)-1].(assignExpr)
187 if ok {
188 res = append(res, assign.name)
189 }
190
191 // turn 1-item combo expressions into their only expression: some
192 // unit tests may rely on that for convenience
193 if len(res) == 1 {
194 return res[0], nil
195 }
196 return res, nil
197 }
198
199 // acceptSyntax advances the parser on the first syntactic string matched:
200 // notice any number of alternatives/options are allowed, as the syntax
201 // allows/requires at various points
202 func (p *parser) acceptSyntax(syntax ...string) (match string, ok bool) {
203 if len(p.tokens) == 0 {
204 return "", false
205 }
206
207 t := p.tokens[0]
208 if t.kind != syntaxToken {
209 return "", false
210 }
211
212 for _, s := range syntax {
213 if t.value == s {
214 p.advance()
215 return s, true
216 }
217 }
218 return "", false
219 }
220
221 // advance skips the current leading token, if there are still any left
222 func (p *parser) advance() {
223 if len(p.tokens) == 0 {
224 return
225 }
226
227 t := p.tokens[0]
228 p.tokens = p.tokens[1:]
229 p.line = t.line
230 p.pos = t.pos
231 p.toklen = len(t.value)
232 }
233
234 // acceptNumeric advances the parser on a numeric value, but only if it's
235 // the leading token: conversely, any other type of token doesn't advance
236 // the parser; when matches happen the resulting strings need parsing via
237 // func parseNumber
238 func (p *parser) acceptNumeric() (numliteral string, ok bool) {
239 if len(p.tokens) == 0 {
240 return "", false
241 }
242
243 t := p.tokens[0]
244 if t.kind == numberToken {
245 p.advance()
246 return t.value, true
247 }
248 return "", false
249 }
250
251 // demandSyntax imposes a specific syntactic element to follow, or else it's
252 // an error
253 func (p *parser) demandSyntax(syntax string) error {
254 if len(p.tokens) == 0 {
255 return fmt.Errorf("expected %s instead of the end of source", syntax)
256 }
257
258 first := p.tokens[0]
259 if first.kind == syntaxToken && first.value == syntax {
260 p.advance()
261 return nil
262 }
263 return fmt.Errorf("expected %s instead of %s", syntax, first.value)
264 }
265
266 func (p *parser) parseExpression() (any, error) {
267 x, err := p.parseComparison()
268 if err != nil {
269 return x, err
270 }
271
272 // handle assignment statements
273 if _, ok := p.acceptSyntax(`=`, `:=`); ok {
274 varname, ok := x.(string)
275 if !ok {
276 const fs = "expected a variable name, instead of a %T"
277 return nil, fmt.Errorf(fs, x)
278 }
279
280 x, err := p.parseExpression()
281 expr := assignExpr{name: varname, expr: x}
282 return expr, err
283 }
284
285 // handle and/or logical chains
286 for {
287 if op, ok := p.acceptSyntax(`&&`, `||`, `&`, `|`); ok {
288 y, err := p.parseExpression()
289 if err != nil {
290 return y, err
291 }
292 x = binaryExpr{op: op, x: x, y: y}
293 continue
294 }
295 break
296 }
297
298 // handle maybe-properties
299 if _, ok := p.acceptSyntax(`??`); ok {
300 y, err := p.parseExpression()
301 expr := callExpr{name: "??", args: []any{x, y}}
302 return expr, err
303 }
304
305 // handle choice/ternary operator
306 if _, ok := p.acceptSyntax(`?`); ok {
307 y, err := p.parseExpression()
308 if err != nil {
309 expr := callExpr{name: "?:", args: []any{x, nil, nil}}
310 return expr, err
311 }
312
313 if _, ok := p.acceptSyntax(`:`); ok {
314 z, err := p.parseExpression()
315 expr := callExpr{name: "?:", args: []any{x, y, z}}
316 return expr, err
317 }
318
319 if len(p.tokens) == 0 {
320 expr := callExpr{name: "?:", args: []any{x, y, nil}}
321 return expr, errors.New("expected `:`")
322 }
323
324 s := p.tokens[0].value
325 expr := callExpr{name: "?:", args: []any{x, y, nil}}
326 err = fmt.Errorf("expected `:`, but got %q instead", s)
327 return expr, err
328 }
329
330 // expression was just a comparison, or simpler
331 return x, nil
332 }
333
334 func (p *parser) parseComparison() (any, error) {
335 x, err := p.parseTerm()
336 if err != nil {
337 return x, err
338 }
339
340 op, ok := p.acceptSyntax(`==`, `!=`, `<`, `>`, `<=`, `>=`, `<>`, `===`, `!==`)
341 if ok {
342 y, err := p.parseTerm()
343 return binaryExpr{op: op, x: x, y: y}, err
344 }
345 return x, err
346 }
347
348 // parseBinary handles binary operations, by recursing depth-first on the left
349 // side of binary expressions; going tail-recursive on these would reverse the
350 // order of arguments instead, which is obviously wrong
351 func (p *parser) parseBinary(parse func() (any, error), syntax ...string) (any, error) {
352 x, err := parse()
353 if err != nil {
354 return x, err
355 }
356
357 for {
358 op, ok := p.acceptSyntax(syntax...)
359 if !ok {
360 return x, nil
361 }
362
363 y, err := parse()
364 x = binaryExpr{op: op, x: x, y: y}
365 if err != nil {
366 return x, err
367 }
368 }
369 }
370
371 func (p *parser) parseTerm() (any, error) {
372 return p.parseBinary(func() (any, error) {
373 return p.parseProduct()
374 }, `+`, `-`, `^`)
375 }
376
377 func (p *parser) parseProduct() (any, error) {
378 return p.parseBinary(func() (any, error) {
379 return p.parsePower()
380 }, `*`, `/`, `%`)
381 }
382
383 func (p *parser) parsePower() (any, error) {
384 return p.parseBinary(func() (any, error) {
385 return p.parseValue()
386 }, `**`, `^`)
387 }
388
389 func (p *parser) parseValue() (any, error) {
390 // handle unary operators which can also be considered part of numeric
391 // literals, and thus should be simplified away
392 if op, ok := p.acceptSyntax(`+`, `-`); ok {
393 if s, ok := p.acceptNumeric(); ok {
394 x, err := strconv.ParseFloat(s, 64)
395 if err != nil {
396 return nil, err
397 }
398 if simpler, ok := simplifyNumber(op, x); ok {
399 return simpler, nil
400 }
401 return unaryExpr{op: op, x: x}, nil
402 }
403
404 x, err := p.parsePower()
405 return unaryExpr{op: op, x: x}, err
406 }
407
408 // handle all other unary operators
409 if op, ok := p.acceptSyntax(`!`, `&`, `*`, `^`); ok {
410 x, err := p.parsePower()
411 return unaryExpr{op: op, x: x}, err
412 }
413
414 // handle subexpression in parentheses
415 if _, ok := p.acceptSyntax(`(`); ok {
416 x, err := p.parseExpression()
417 if err != nil {
418 return x, err
419 }
420
421 if err := p.demandSyntax(`)`); err != nil {
422 return x, err
423 }
424 return p.parseAccessors(x)
425 }
426
427 // handle subexpression in square brackets: it's just an alternative to
428 // using parentheses for subexpressions
429 if _, ok := p.acceptSyntax(`[`); ok {
430 x, err := p.parseExpression()
431 if err != nil {
432 return x, err
433 }
434
435 if err := p.demandSyntax(`]`); err != nil {
436 return x, err
437 }
438 return p.parseAccessors(x)
439 }
440
441 // handle all other cases
442 x, err := p.parseSimpleValue()
443 if err != nil {
444 return x, err
445 }
446
447 // handle arbitrarily-long chains of accessors
448 return p.parseAccessors(x)
449 }
450
451 // parseSimpleValue handles a numeric literal or a variable/func name, also
452 // known as identifier
453 func (p *parser) parseSimpleValue() (any, error) {
454 if len(p.tokens) == 0 {
455 return nil, errEOS
456 }
457 t := p.tokens[0]
458
459 switch t.kind {
460 case identifierToken:
461 p.advance()
462 // handle func calls, such as f(...)
463 if _, ok := p.acceptSyntax(`(`); ok {
464 args, err := p.parseList(`)`)
465 expr := callExpr{name: t.value, args: args}
466 return expr, err
467 }
468 // handle func calls, such as f[...]
469 if _, ok := p.acceptSyntax(`[`); ok {
470 args, err := p.parseList(`]`)
471 expr := callExpr{name: t.value, args: args}
472 return expr, err
473 }
474 return t.value, nil
475
476 case numberToken:
477 p.advance()
478 return strconv.ParseFloat(t.value, 64)
479
480 default:
481 const fs = "unexpected %s (token type %d)"
482 return nil, fmt.Errorf(fs, t.value, t.kind)
483 }
484 }
485
486 // parseAccessors handles an arbitrarily-long chain of accessors
487 func (p *parser) parseAccessors(x any) (any, error) {
488 for {
489 s, ok := p.acceptSyntax(`.`, `@`)
490 if !ok {
491 // dot-chain is over
492 return x, nil
493 }
494
495 // handle property/method accessors
496 v, err := p.parseDot(s, x)
497 if err != nil {
498 return v, err
499 }
500 x = v
501 }
502 }
503
504 // parseDot handles what follows a syntactic dot, as opposed to a dot which
505 // may be part of a numeric literal
506 func (p *parser) parseDot(after string, x any) (any, error) {
507 if len(p.tokens) == 0 {
508 const fs = "unexpected end of source after a %q"
509 return x, fmt.Errorf(fs, after)
510 }
511
512 t := p.tokens[0]
513 p.advance()
514 if t.kind != identifierToken {
515 const fs = "expected a valid property name, but got %s instead"
516 return x, fmt.Errorf(fs, t.value)
517 }
518
519 if _, ok := p.acceptSyntax(`(`); ok {
520 items, err := p.parseList(`)`)
521 args := append([]any{x}, items...)
522 return callExpr{name: t.value, args: args}, err
523 }
524
525 if _, ok := p.acceptSyntax(`[`); ok {
526 items, err := p.parseList(`]`)
527 args := append([]any{x}, items...)
528 return callExpr{name: t.value, args: args}, err
529 }
530
531 return callExpr{name: t.value, args: []any{x}}, nil
532 }
533
534 // parseList handles the argument-list following a `(` or a `[`
535 func (p *parser) parseList(end string) ([]any, error) {
536 var arr []any
537 for len(p.tokens) > 0 {
538 if _, ok := p.acceptSyntax(`,`); ok {
539 // ensure extra/trailing commas are allowed/ignored
540 continue
541 }
542
543 if _, ok := p.acceptSyntax(end); ok {
544 return arr, nil
545 }
546
547 v, err := p.parseExpression()
548 if err != nil {
549 return arr, err
550 }
551 arr = append(arr, v)
552 }
553
554 // return an appropriate error for the unexpected end of the source
555 return arr, p.demandSyntax(`)`)
556 }
557
558 // simplifyNumber tries to simplify a few trivial unary operations on
559 // numeric constants
560 func simplifyNumber(op string, x any) (v any, ok bool) {
561 if x, ok := x.(float64); ok {
562 switch op {
563 case `+`:
564 return x, true
565 case `-`:
566 return -x, true
567 default:
568 return x, false
569 }
570 }
571 return x, false
572 }
File: ./fmscripts/programs.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package fmscripts
26
27 import (
28 "math"
29 )
30
31 // So far, my apps relying on this package are
32 //
33 // - fh, a Function Heatmapper which color-codes f(x, y), seen from above
34 // - star, a STAtistical Resampler to calculate stats from tabular data
35 // - waveout, an app to emit sample-by-sample wave-audio by formula
36
37 // Quick Notes on Performance
38 //
39 // Note: while these microbenchmarks are far from proper benchmarks, Club Pulse
40 // can still give a general idea of what relative performance to expect.
41 //
42 // While funscripts.Interpreter can do anything a Program can do, and much more,
43 // a fmscripts.Program is way faster for float64-only tasks: typical speed-ups
44 // are between 20-to-50 times. Various simple benchmarks suggest running speed
45 // is between 1/2 and 1/4 of native funcs.
46 //
47 // Reimplementations of the Club Pulse benchmark, when run 50 million times,
48 // suggest this package is starting to measure the relative speed of various
49 // trigonometric func across JIT-ted script runners, running somewhat slower
50 // than NodeJS/V8, faster than PyPy, and much faster than Python.
51 //
52 // Other scripted tests, which aren't as trigonometry/exponential-heavy, hint
53 // at even higher speed-ups compared to Python and PyPy, as well as being
54 // almost as fast as NodeJS/V8.
55 //
56 // Being so close to Node's V8 (0.6x - 0.8x its speed) is a surprisingly good
57 // result for the relatively little effort this package took.
58
59 const (
60 maxArgsLen = 1<<16 - 1 // max length for the []float64 input of callv
61 maxOpIndex = 1<<32 - 1 // max index for values
62 )
63
64 // types to keep compiler code the same, while changing sizes of numOp fields
65 type (
66 opcode uint16
67 opargs uint16 // opcode directly affects max correct value of maxArgsLen
68 opindex uint32 // opcode directly affects max correct value of maxOpIndex
69 )
70
71 // numOp is a numeric operation in a program
72 type numOp struct {
73 What opcode // what to do
74 NumArgs opargs // vector-length only used for callv and call1v
75 Index opindex // index to load a value or call a function
76 }
77
78 // Since the go 1.19 compiler started compiling dense switch statements using
79 // jump tables, adding more basic ops doesn't slow things down anymore when
80 // reaching the next power-of-2 thresholds in the number of cases.
81
82 const (
83 // load float64 value to top of the stack
84 load opcode = iota
85
86 // pop top of stack and store it into value
87 store opcode = iota
88
89 // unary operations
90 neg opcode = iota
91 not opcode = iota
92 abs opcode = iota
93 square opcode = iota
94 // 1/x, the reciprocal/inverse value
95 rec opcode = iota
96 // x%1, but faster than math.Mod(x, 1)
97 mod1 opcode = iota
98
99 // arithmetic and logic operations: 2 float64 inputs, 1 float64 output
100
101 add opcode = iota
102 sub opcode = iota
103 mul opcode = iota
104 div opcode = iota
105 mod opcode = iota
106 pow opcode = iota
107 and opcode = iota
108 or opcode = iota
109
110 // binary comparisons: 2 float64 inputs, 1 booleanized float64 output
111
112 equal opcode = iota
113 notequal opcode = iota
114 less opcode = iota
115 lessoreq opcode = iota
116 more opcode = iota
117 moreoreq opcode = iota
118
119 // function callers with 1..5 float64 inputs and a float64 result
120
121 call0 opcode = iota
122 call1 opcode = iota
123 call2 opcode = iota
124 call3 opcode = iota
125 call4 opcode = iota
126 call5 opcode = iota
127
128 // var-arg input function callers
129
130 callv opcode = iota
131 call1v opcode = iota
132 )
133
134 // Program runs a sequence of float64 operations, with no explicit control-flow:
135 // implicit control-flow is available in the float64-only functions you make
136 // available to such programs. Such custom funcs must all return a float64, and
137 // either take from 0 to 5 float64 arguments, or a single float64 array.
138 //
139 // As the name suggests, don't create such objects directly, but instead use
140 // Compiler.Compile to create them. Only a Compiler lets you register variables
141 // and functions, which then become part of your numeric Program.
142 //
143 // A Program lets you change values before each run, using pointers from method
144 // Program.Get: such pointers are guaranteed never to change before or across
145 // runs. Just ensure you get a variable defined in the Compiler used to make the
146 // Program, or the pointer will be to a dummy place which has no effect on final
147 // results.
148 //
149 // Expressions using literals are automatically optimized into their results:
150 // this also applies to func calls with standard math functions and constants.
151 //
152 // The only way to limit such optimizations is to redefine common math funcs and
153 // const values explicitly when compiling: after doing so, all those value-names
154 // will stand for externally-updatable values which can change from one run to
155 // the next. Similarly, there's no guarantee the (re)defined functions will be
156 // deterministic, like the defaults they replaced.
157 //
158 // # Example
159 //
160 // var c fmscripts.Compiler
161 //
162 // defs := map[string]any{
163 // "x": 0, // define `x`, and initialize it to 0
164 // "k": 4.25, // define `k`, and initialize it to 4.25
165 // "b": true, // define `b`, and initialize it to 1.0
166 // "n": -23, // define `n`, and initialize it to -23.0
167 // "pi": 3, // define `pi`, overriding the automatic constant named `pi`
168 //
169 // "f": numericKernel // type is func ([]float64) float64
170 // "g": otherFunc // type is func (float64) float64
171 // }
172 //
173 // prog, err := c.Compile("log10(k) + f(sqrt(k) * exp(-x), 45, -0.23)", defs)
174 // // ...
175 //
176 // x, _ := prog.Get("x") // Get returns (*float64, bool)
177 // y, _ := prog.Get("y") // a useless pointer, since program doesn't use `y`
178 // // ...
179 //
180 // for i := 0; i < n; i++ {
181 // *x = float64(i)*dx + minx // you update inputs in place using pointers
182 // f := prog.Run() // method Run gives you a float64 back
183 // // ...
184 // }
185 type Program struct {
186 sp int // stack pointer, even though it's an index
187
188 stack []float64 // pre-allocated by compiler to max length needed by program
189 values []float64 // holds all values, whether literals, or variables
190
191 ops []numOp // all sequential operations for each run
192 funcs []any // all funcs used by the program
193
194 names map[string]int // variable-name to index lookup
195 dummy float64 // all pointers for undefined variables point here
196
197 // data []float64
198 }
199
200 // Get lets you change parameters/variables before each time you run a program,
201 // since it doesn't return a value, but a pointer to it, so you can update it
202 // in place.
203 //
204 // If the name given isn't available, the result is a pointer to a dummy place:
205 // this ensures non-nil pointers, which are always safe to use, even though
206 // updates to the dummy destination have no effect on program results.
207 func (p *Program) Get(name string) (ptr *float64, useful bool) {
208 if i, ok := p.names[name]; ok {
209 return &p.values[i], true
210 }
211 return &p.dummy, false
212 }
213
214 // Clone creates an exact copy of a Program with all values in their current
215 // state: this is useful when dispatching embarassingly-parallel tasks to
216 // multiple programs.
217 func (p Program) Clone() Program {
218 // can't share the stack nor the values
219 stack := make([]float64, len(p.stack))
220 values := make([]float64, len(p.values))
221 copy(stack, p.stack)
222 copy(values, p.values)
223 p.stack = stack
224 p.values = values
225
226 // can share everything else as is
227 return p
228 }
229
230 // Memo: the command to show all bound checks is
231 // go test -gcflags="-d=ssa/check_bce/debug=1" fmscripts/programs.go
232
233 // Discussion about go compiler optimizing switch statements into jump tables
234 // https://go-review.googlesource.com/c/go/+/357330/
235
236 // Run executes the program once. Before each run, update input values using
237 // pointers from method Get.
238 func (p *Program) Run() float64 {
239 // Check for empty programs: these happen either when a compilation error
240 // was ignored, or when a program was explicitly declared as a variable.
241 if len(p.ops) == 0 {
242 return math.NaN()
243 }
244
245 p.sp = 0
246 p.runAllOps()
247 return p.stack[p.sp]
248 }
249
250 type func4 = func(float64, float64, float64, float64) float64
251 type func5 = func(float64, float64, float64, float64, float64) float64
252
253 // runAllOps runs all operations in a loop: when done, the program's result is
254 // ready as the only item left in the stack
255 func (p *Program) runAllOps() {
256 for _, op := range p.ops {
257 // shortcut for the current stack pointer
258 sp := p.sp
259
260 // Preceding binary ops and func calls by _ = p.stack[i-n] prevents
261 // an extra bound check for the lhs of assignments, but a quick
262 // statistical summary of benchmarks doesn't show clear speedups,
263 // let alone major ones.
264 //
265 // Separating different func types into different arrays to avoid
266 // type-checking at runtime doesn't seem to be worth it either,
267 // and makes the compiler more complicated.
268
269 switch op.What {
270 case load:
271 // store above top of stack
272 p.stack[sp+1] = p.values[op.Index]
273 p.sp++
274 case store:
275 // store from top of the stack
276 p.values[op.Index] = p.stack[sp]
277 p.sp--
278
279 // unary operations
280 case neg:
281 p.stack[sp] = -p.stack[sp]
282 case not:
283 p.stack[sp] = deboolNot(p.stack[sp])
284 case abs:
285 p.stack[sp] = math.Abs(p.stack[sp])
286 case square:
287 p.stack[sp] *= p.stack[sp]
288 case rec:
289 p.stack[sp] = 1 / p.stack[sp]
290
291 // binary arithmetic ops
292 case add:
293 p.stack[sp-1] += p.stack[sp]
294 p.sp--
295 case sub:
296 p.stack[sp-1] -= p.stack[sp]
297 p.sp--
298 case mul:
299 p.stack[sp-1] *= p.stack[sp]
300 p.sp--
301 case div:
302 p.stack[sp-1] /= p.stack[sp]
303 p.sp--
304 case mod:
305 p.stack[sp-1] = math.Mod(p.stack[sp-1], p.stack[sp])
306 p.sp--
307 case pow:
308 p.stack[sp-1] = math.Pow(p.stack[sp-1], p.stack[sp])
309 p.sp--
310
311 // binary boolean ops / binary comparisons
312 case and:
313 p.stack[sp-1] = deboolAnd(p.stack[sp-1], p.stack[sp])
314 p.sp--
315 case or:
316 p.stack[sp-1] = deboolOr(p.stack[sp-1], p.stack[sp])
317 p.sp--
318 case equal:
319 p.stack[sp-1] = debool(p.stack[sp-1] == p.stack[sp])
320 p.sp--
321 case notequal:
322 p.stack[sp-1] = debool(p.stack[sp-1] != p.stack[sp])
323 p.sp--
324 case less:
325 p.stack[sp-1] = debool(p.stack[sp-1] < p.stack[sp])
326 p.sp--
327 case lessoreq:
328 p.stack[sp-1] = debool(p.stack[sp-1] <= p.stack[sp])
329 p.sp--
330 case more:
331 p.stack[sp-1] = debool(p.stack[sp-1] > p.stack[sp])
332 p.sp--
333 case moreoreq:
334 p.stack[sp-1] = debool(p.stack[sp-1] >= p.stack[sp])
335 p.sp--
336
337 // function calls
338 case call0:
339 f := p.funcs[op.Index].(func() float64)
340 // store above top of stack
341 p.stack[sp+1] = f()
342 p.sp++
343 case call1:
344 f := p.funcs[op.Index].(func(float64) float64)
345 p.stack[sp] = f(p.stack[sp])
346 case call2:
347 f := p.funcs[op.Index].(func(float64, float64) float64)
348 p.stack[sp-1] = f(p.stack[sp-1], p.stack[sp])
349 p.sp--
350 case call3:
351 f := p.funcs[op.Index].(func(float64, float64, float64) float64)
352 p.stack[sp-2] = f(p.stack[sp-2], p.stack[sp-1], p.stack[sp])
353 p.sp -= 2
354 case call4:
355 f := p.funcs[op.Index].(func4)
356 st := p.stack
357 p.stack[sp-3] = f(st[sp-3], st[sp-2], st[sp-1], st[sp])
358 p.sp -= 3
359 case call5:
360 f := p.funcs[op.Index].(func5)
361 st := p.stack
362 p.stack[sp-4] = f(st[sp-4], st[sp-3], st[sp-2], st[sp-1], st[sp])
363 p.sp -= 4
364 case callv:
365 i := sp - int(op.NumArgs) + 1
366 f := p.funcs[op.Index].(func(...float64) float64)
367 p.stack[sp-i+1] = f(p.stack[i : sp+1]...)
368 p.sp = sp - i + 1
369 case call1v:
370 i := sp - int(op.NumArgs) + 1
371 f := p.funcs[op.Index].(func(float64, ...float64) float64)
372 p.stack[sp-i+1] = f(p.stack[i], p.stack[i+1:sp+1]...)
373 p.sp = sp - i + 1
374 }
375 }
376 }
377
378 // debool is only used to turn boolean values used in comparison operations
379 // into float64s, since those are the only type accepted on a program stack
380 func debool(b bool) float64 {
381 if b {
382 return 1
383 }
384 return 0
385 }
386
387 // deboolNot runs the basic `not` operation
388 func deboolNot(x float64) float64 {
389 return debool(x == 0)
390 }
391
392 // deboolAnd runs the basic `and` operation
393 func deboolAnd(x, y float64) float64 {
394 return debool(x != 0 && y != 0)
395 }
396
397 // deboolOr runs the basic `or` operation
398 func deboolOr(x, y float64) float64 {
399 return debool(x != 0 || y != 0)
400 }
File: ./fmscripts/programs_test.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package fmscripts
26
27 import "testing"
28
29 func TestCallVarFuncs(t *testing.T) {
30 tests := []struct {
31 name string
32 src string
33 exp float64
34 }{
35 {"check 1 var arg", "numargs(34)", 1},
36 {"check 2 var arg", "numargs(34, 33)", 2},
37 {"check 3 var arg", "numargs(34, 322, 0.3)", 3},
38 {"check 4 var arg", "numargs(34, -1, 553, 42)", 4},
39 {"check 5 var arg", "numargs(34, 3, 4, 5, 1)", 5},
40 }
41
42 numargs := func(args ...float64) float64 { return float64(len(args)) }
43
44 for _, tc := range tests {
45 t.Run(tc.name, func(t *testing.T) {
46 var c Compiler
47 p, err := c.Compile(tc.src, map[string]any{
48 "numargs": numargs,
49 })
50 if err != nil {
51 t.Fatal(err)
52 return
53 }
54
55 got := p.Run()
56 const fs = "failed check (var-arg): expected %f, got %f instead"
57 if got != tc.exp {
58 t.Fatalf(fs, tc.exp, got)
59 return
60 }
61 })
62 }
63 }
File: ./fmscripts/random.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package fmscripts
26
27 import (
28 "math"
29 "math/rand"
30 )
31
32 // These funcs are kept separate from the larger lookup table so they aren't
33 // mistaken for deterministic funcs which can be optimized away.
34 //
35 // var randomFuncs = map[string]any{
36 // "rand": Random,
37 // "rint": RandomInt,
38 // "runif": RandomUnif,
39 // "rexp": RandomExp,
40 // "rnorm": RandomNorm,
41 // "rgamma": RandomGamma,
42 // "rbeta": RandomBeta,
43 // }
44
45 func Random(r *rand.Rand) float64 {
46 return r.Float64()
47 }
48
49 func RandomInt(r *rand.Rand, min, max float64) float64 {
50 fmin := math.Trunc(min)
51 fmax := math.Trunc(max)
52 if fmin == fmax {
53 return fmin
54 }
55
56 diff := math.Abs(fmax - fmin)
57 return float64(r.Intn(int(diff)+1)) + fmin
58 }
59
60 func RandomUnif(r *rand.Rand, min, max float64) float64 {
61 return (max-min)*r.Float64() + min
62 }
63
64 func RandomExp(r *rand.Rand, scale float64) float64 {
65 return scale * r.ExpFloat64()
66 }
67
68 func RandomNorm(r *rand.Rand, mu, sigma float64) float64 {
69 return sigma*r.NormFloat64() + mu
70 }
71
72 // Gamma generates a gamma-distributed real value, using a scale parameter.
73 //
74 // The algorithm is from Marsaglia and Tsang, as described in
75 //
76 // A simple method for generating gamma variables
77 // https://dl.acm.org/doi/10.1145/358407.358414
78 func RandomGamma(r *rand.Rand, scale float64) float64 {
79 d := scale - 1.0/3.0
80 c := 1 / math.Sqrt(9/d)
81
82 for {
83 // generate candidate value
84 var x, v float64
85 for {
86 x = r.NormFloat64()
87 v = 1 + c*x
88 if v > 0 {
89 break
90 }
91 }
92 v = v * v * v
93
94 // accept or reject candidate value
95 x2 := x * x
96 u := r.Float64()
97 if u < 1-0.0331*x2*x2 {
98 return d * v
99 }
100 if math.Log(u) < 0.5*x2+d*(1-v+math.Log(v)) {
101 return d * v
102 }
103 }
104 }
105
106 // Beta generates a beta-distributed real value.
107 func RandomBeta(r *rand.Rand, a, b float64) float64 {
108 return RandomGamma(r, a) / (RandomGamma(r, a) + RandomGamma(r, b))
109 }
File: ./fmscripts/tokens.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package fmscripts
26
27 import (
28 "errors"
29 "fmt"
30 "strings"
31 "unicode"
32 "unicode/utf8"
33 )
34
35 // tokenType is the specific type of a token; tokens never represent
36 // whitespace-like text between recognized tokens
37 type tokenType int
38
39 const (
40 // default zero-value type for tokens
41 unknownToken tokenType = iota
42
43 // a name
44 identifierToken tokenType = iota
45
46 // a literal numeric value
47 numberToken tokenType = iota
48
49 // any syntactic element made of 1 or more runes
50 syntaxToken tokenType = iota
51 )
52
53 // errEOS signals the end of source code, and is the only token-related error
54 // which the parser should ignore
55 var errEOS = errors.New(`no more source code`)
56
57 // token is either a name, value, or syntactic element coming from a script's
58 // source code
59 type token struct {
60 kind tokenType
61 value string
62 line int
63 pos int
64 }
65
66 // tokenizer splits a string into a stream tokens, via its `next` method
67 type tokenizer struct {
68 cur string
69 linenum int
70 linepos int
71 }
72
73 // newTokenizer is the constructor for type tokenizer
74 func newTokenizer(src string) tokenizer {
75 return tokenizer{
76 cur: src,
77 linenum: 1,
78 linepos: 1,
79 }
80 }
81
82 // next advances the tokenizer, giving back a token, unless it's done
83 func (t *tokenizer) next() (token, error) {
84 // label to allow looping back after skipping comments, and thus avoid
85 // an explicit tail-recursion for each commented line
86 rerun:
87
88 // always ignore any whitespace-like source
89 if err := t.skipWhitespace(); err != nil {
90 return token{}, err
91 }
92
93 if len(t.cur) == 0 {
94 return token{}, errEOS
95 }
96
97 // remember starting position, in case of error
98 line := t.linenum
99 pos := t.linepos
100
101 // use the leading rune to probe what's next
102 r, _ := utf8.DecodeRuneInString(t.cur)
103
104 switch r {
105 case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
106 s, err := t.scanNumber()
107 return token{kind: numberToken, value: s, line: line, pos: pos}, err
108
109 case '(', ')', '[', ']', '{', '}', ',', '+', '-', '%', '^', '~', '@', ';':
110 s := t.cur[:1]
111 t.cur = t.cur[1:]
112 t.linepos++
113 res := token{kind: syntaxToken, value: s, line: line, pos: pos}
114 return res, t.eos()
115
116 case ':':
117 s, err := t.tryPrefixes(`:=`, `:`)
118 return token{kind: syntaxToken, value: s, line: line, pos: pos}, err
119
120 case '*':
121 s, err := t.tryPrefixes(`**`, `*`)
122 return token{kind: syntaxToken, value: s, line: line, pos: pos}, err
123
124 case '/':
125 s, err := t.tryPrefixes(`//`, `/*`, `/`)
126 // double-slash starts a comment until the end of the line
127 if s == `//` {
128 t.skipLine()
129 goto rerun
130 }
131 // handle comments which can span multiple lines
132 if s == `/*` {
133 err := t.skipComment()
134 if err != nil {
135 return token{}, err
136 }
137 goto rerun
138 }
139 // handle division
140 return token{kind: syntaxToken, value: s, line: line, pos: pos}, err
141
142 case '#':
143 // even hash starts a comment until the end of the line, making the
144 // syntax more Python-like
145 t.skipLine()
146 goto rerun
147
148 case '&':
149 s, err := t.tryPrefixes(`&&`, `&`)
150 return token{kind: syntaxToken, value: s, line: line, pos: pos}, err
151
152 case '|':
153 s, err := t.tryPrefixes(`||`, `|`)
154 return token{kind: syntaxToken, value: s, line: line, pos: pos}, err
155
156 case '?':
157 s, err := t.tryPrefixes(`??`, `?.`, `?`)
158 return token{kind: syntaxToken, value: s, line: line, pos: pos}, err
159
160 case '.':
161 r, _ := utf8.DecodeRuneInString(t.cur[1:])
162 if '0' <= r && r <= '9' {
163 s, err := t.scanNumber()
164 res := token{kind: numberToken, value: s, line: line, pos: pos}
165 return res, err
166 }
167 s, err := t.tryPrefixes(`...`, `..`, `.?`, `.`)
168 return token{kind: syntaxToken, value: s, line: line, pos: pos}, err
169
170 case '=':
171 // triple-equal makes the syntax even more JavaScript-like
172 s, err := t.tryPrefixes(`===`, `==`, `=>`, `=`)
173 return token{kind: syntaxToken, value: s, line: line, pos: pos}, err
174
175 case '!':
176 // not-double-equal makes the syntax even more JavaScript-like
177 s, err := t.tryPrefixes(`!==`, `!=`, `!`)
178 return token{kind: syntaxToken, value: s, line: line, pos: pos}, err
179
180 case '<':
181 // the less-more/diamond syntax is a SQL-like way to say not equal
182 s, err := t.tryPrefixes(`<=`, `<>`, `<`)
183 return token{kind: syntaxToken, value: s, line: line, pos: pos}, err
184
185 case '>':
186 s, err := t.tryPrefixes(`>=`, `>`)
187 return token{kind: syntaxToken, value: s, line: line, pos: pos}, err
188
189 default:
190 if isIdentStartRune(r) {
191 s, err := t.scanIdentifier()
192 res := token{kind: identifierToken, value: s, line: line, pos: pos}
193 return res, err
194 }
195 const fs = `line %d: pos %d: unexpected symbol %c`
196 return token{}, fmt.Errorf(fs, t.linenum, t.linepos, r)
197 }
198 }
199
200 // tryPrefixes tries to greedily match any prefix in the order given: when all
201 // candidates fail, an empty string is returned; when successful, the tokenizer
202 // updates its state to account for the matched prefix
203 func (t *tokenizer) tryPrefixes(prefixes ...string) (string, error) {
204 for _, pre := range prefixes {
205 if strings.HasPrefix(t.cur, pre) {
206 t.linepos += len(pre)
207 t.cur = t.cur[len(pre):]
208 return pre, t.eos()
209 }
210 }
211
212 return ``, t.eos()
213 }
214
215 // skipWhitespace updates the tokenizer to ignore runs of consecutive whitespace
216 // symbols: these are the likes of space, tab, newline, carriage return, etc.
217 func (t *tokenizer) skipWhitespace() error {
218 for len(t.cur) > 0 {
219 r, size := utf8.DecodeRuneInString(t.cur)
220 if !unicode.IsSpace(r) {
221 // no more spaces to skip
222 return nil
223 }
224
225 t.cur = t.cur[size:]
226 if r == '\n' {
227 // reached the next line
228 t.linenum++
229 t.linepos = 1
230 continue
231 }
232 // continuing on the same line
233 t.linepos++
234 }
235
236 // source code ended
237 return errEOS
238 }
239
240 // skipLine updates the tokenizer to the end of the current line, or the end of
241 // the source code, if it's the last line
242 func (t *tokenizer) skipLine() error {
243 for len(t.cur) > 0 {
244 r, size := utf8.DecodeRuneInString(t.cur)
245 t.cur = t.cur[size:]
246 if r == '\n' {
247 // reached the next line, as expected
248 t.linenum++
249 t.linepos = 1
250 return nil
251 }
252 }
253
254 // source code ended
255 t.linenum++
256 t.linepos = 1
257 return errEOS
258 }
259
260 // skipComment updates the tokenizer to the end of the comment started with a
261 // `/*` and ending with a `*/`, or to the end of the source code
262 func (t *tokenizer) skipComment() error {
263 var prev rune
264 for len(t.cur) > 0 {
265 r, size := utf8.DecodeRuneInString(t.cur)
266 t.cur = t.cur[size:]
267
268 if r == '\n' {
269 t.linenum++
270 t.linepos = 1
271 } else {
272 t.linepos++
273 }
274
275 if prev == '*' && r == '/' {
276 return nil
277 }
278 prev = r
279 }
280
281 // source code ended
282 const msg = "comment not ended with a `*/` sequence"
283 return errors.New(msg)
284 }
285
286 func (t *tokenizer) scanIdentifier() (string, error) {
287 end := 0
288 for len(t.cur) > 0 {
289 r, size := utf8.DecodeRuneInString(t.cur[end:])
290 if (end == 0 && !isIdentStartRune(r)) || !isIdentRestRune(r) {
291 // identifier ended, and there's more source code after it
292 name := t.cur[:end]
293 t.cur = t.cur[end:]
294 return name, nil
295 }
296 end += size
297 t.linepos++
298 }
299
300 // source code ended with an identifier name
301 name := t.cur
302 t.cur = ``
303 return name, nil
304 }
305
306 func (t *tokenizer) scanNumber() (string, error) {
307 dots := 0
308 end := 0
309 var prev rune
310
311 for len(t.cur) > 0 {
312 r, size := utf8.DecodeRuneInString(t.cur[end:])
313
314 switch r {
315 case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '_', 'e':
316 end += size
317 t.linepos++
318 prev = r
319
320 case '+', '-':
321 if end > 0 && prev != 'e' {
322 // number ended, and there's more source code after it
323 num := t.cur[:end]
324 t.cur = t.cur[end:]
325 return num, nil
326 }
327 end += size
328 t.linepos++
329 prev = r
330
331 case '.':
332 nr, _ := utf8.DecodeRuneInString(t.cur[end+size:])
333 if dots == 1 || isIdentStartRune(nr) || unicode.IsSpace(nr) || nr == '.' {
334 // number ended, and there's more source code after it
335 num := t.cur[:end]
336 t.cur = t.cur[end:]
337 return num, nil
338 }
339 dots++
340 end += size
341 t.linepos++
342 prev = r
343
344 default:
345 // number ended, and there's more source code after it
346 num := t.cur[:end]
347 t.cur = t.cur[end:]
348 return num, nil
349 }
350 }
351
352 // source code ended with a number
353 name := t.cur
354 t.cur = ``
355 return name, nil
356 }
357
358 // eos checks if the source-code is over: if so, it returns an end-of-file error,
359 // or a nil error otherwise
360 func (t *tokenizer) eos() error {
361 if len(t.cur) == 0 {
362 return errEOS
363 }
364 return nil
365 }
366
367 func isIdentStartRune(r rune) bool {
368 return ('A' <= r && r <= 'Z') || ('a' <= r && r <= 'z') || r == '_'
369 }
370
371 func isIdentRestRune(r rune) bool {
372 return isIdentStartRune(r) || ('0' <= r && r <= '9')
373 }
File: ./fmscripts/tokens_test.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package fmscripts
26
27 import (
28 "reflect"
29 "testing"
30 )
31
32 func TestTokenizer(t *testing.T) {
33 var tests = []struct {
34 Script string
35 Tokens []string
36 }{
37 {``, nil},
38 {`3.`, []string{`3.`}},
39 {`3.2`, []string{`3.2`}},
40 {`-3.2`, []string{`-`, `3.2`}},
41 {`-3.2+56`, []string{`-`, `3.2`, `+`, `56`}},
42 }
43
44 for _, tc := range tests {
45 t.Run(tc.Script, func(t *testing.T) {
46 tok := newTokenizer(tc.Script)
47 par, err := newParser(&tok)
48 if err != nil {
49 t.Fatal(err)
50 return
51 }
52
53 var got []string
54 for _, v := range par.tokens {
55 got = append(got, v.value)
56 }
57
58 if !reflect.DeepEqual(got, tc.Tokens) {
59 const fs = "from %s\nexpected\n%#v\nbut got\n%#v\ninstead"
60 t.Fatalf(fs, tc.Script, tc.Tokens, got)
61 return
62 }
63 })
64 }
65 }
File: ./folders/folders.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package folders
26
27 import (
28 "bufio"
29 "io"
30 "io/fs"
31 "os"
32 "path/filepath"
33 "strings"
34 )
35
36 const info = `
37 folders [options...] [folders...]
38
39 Find/list all folders in the folders given, without repetitions.
40
41 All (optional) leading options start with either single or double-dash:
42
43 -h, -help show this help message
44 -t, -top turn off recursive behavior; top-level entries only
45 `
46
47 func Main() {
48 top := false
49 buffered := false
50 args := os.Args[1:]
51
52 for len(args) > 0 {
53 switch args[0] {
54 case `-b`, `--b`, `-buffered`, `--buffered`:
55 buffered = true
56 args = args[1:]
57 continue
58
59 case `-h`, `--h`, `-help`, `--help`:
60 os.Stdout.WriteString(info[1:])
61 return
62
63 case `-t`, `--t`, `-top`, `--top`:
64 top = true
65 args = args[1:]
66 continue
67 }
68
69 break
70 }
71
72 if len(args) > 0 && args[0] == `--` {
73 args = args[1:]
74 }
75
76 liveLines := !buffered
77 if !buffered {
78 if _, err := os.Stdout.Seek(0, io.SeekCurrent); err == nil {
79 liveLines = false
80 }
81 }
82
83 var cfg config
84 cfg.recursive = !top
85 cfg.liveLines = liveLines
86
87 if err := run(os.Stdout, args, cfg); err != nil && err != io.EOF {
88 os.Stderr.WriteString(err.Error())
89 os.Stderr.WriteString("\n")
90 os.Exit(1)
91 }
92 }
93
94 type config struct {
95 recursive bool
96 liveLines bool
97 }
98
99 func run(w io.Writer, paths []string, cfg config) error {
100 bw := bufio.NewWriter(w)
101 defer bw.Flush()
102
103 got := make(map[string]struct{})
104
105 if len(paths) == 0 {
106 paths = []string{`.`}
107 }
108
109 // handle is the callback for func filepath.WalkDir
110 handle := func(path string, e fs.DirEntry, err error) error {
111 if err != nil {
112 return err
113 }
114
115 if _, ok := got[path]; ok {
116 return nil
117 }
118 got[path] = struct{}{}
119
120 if e.IsDir() {
121 if err := handleEntry(bw, path, cfg.liveLines); err != nil {
122 return err
123 }
124 }
125
126 return nil
127 }
128
129 for _, path := range paths {
130 if _, ok := got[path]; ok {
131 continue
132 }
133 got[path] = struct{}{}
134
135 st, err := os.Stat(path)
136 if err != nil {
137 return err
138 }
139
140 if !st.IsDir() {
141 continue
142 }
143
144 if !strings.HasSuffix(path, `/`) {
145 path = path + `/`
146 got[path] = struct{}{}
147 }
148
149 if err := handleEntry(bw, path, cfg.liveLines); err != nil {
150 return err
151 }
152
153 if !cfg.recursive {
154 continue
155 }
156
157 if err := filepath.WalkDir(path, handle); err != nil {
158 return err
159 }
160 }
161
162 return nil
163 }
164
165 func handleEntry(w *bufio.Writer, path string, live bool) error {
166 abs, err := filepath.Abs(path)
167 if err != nil {
168 return err
169 }
170
171 w.WriteString(abs)
172 w.WriteByte('\n')
173
174 if !live {
175 return nil
176 }
177
178 if err := w.Flush(); err != nil {
179 return io.EOF
180 }
181 return nil
182 }
File: ./hima/hima.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package hima
26
27 import (
28 "bufio"
29 "bytes"
30 "io"
31 "os"
32 "regexp"
33 "strings"
34 )
35
36 const info = `
37 hima [options...] [regexes...]
38
39
40 HIlight MAtches ANSI-styles matching regular expressions along lines read
41 from the standard input. The regular-expression mode used is "re2", which
42 is a superset of the commonly-used "extended-mode".
43
44 Regexes always avoid matching any ANSI-style sequences, to avoid messing
45 those up. Also, multiple matches in a line never overlap: at each step
46 along a line, the earliest-starting match among the regexes always wins,
47 as the order regexes are given among the arguments never matters.
48
49 The options are, available both in single and double-dash versions
50
51 -h, -help show this help message
52 -f, -filter filter out (ignore) lines with no matches
53 -i, -ins match regexes case-insensitively
54 `
55
56 const highlightStyle = "\x1b[7m"
57
58 func Main() {
59 filter := false
60 buffered := false
61 insensitive := false
62 args := os.Args[1:]
63
64 for len(args) > 0 {
65 switch args[0] {
66 case `-b`, `--b`, `-buffered`, `--buffered`:
67 buffered = true
68 args = args[1:]
69
70 case `-f`, `--f`, `-filter`, `--filter`:
71 filter = true
72 args = args[1:]
73
74 case `-h`, `--h`, `-help`, `--help`:
75 os.Stdout.WriteString(info[1:])
76 return
77
78 case `-i`, `--i`, `-ins`, `--ins`:
79 insensitive = true
80 args = args[1:]
81 }
82
83 break
84 }
85
86 if len(args) > 0 && args[0] == `--` {
87 args = args[1:]
88 }
89
90 patterns := make([]pattern, 0, len(args))
91
92 for _, s := range args {
93 var err error
94 var pat pattern
95
96 if insensitive {
97 pat, err = compile(`(?i)` + s)
98 } else {
99 pat, err = compile(s)
100 }
101
102 if err != nil {
103 os.Stderr.WriteString(err.Error())
104 os.Stderr.WriteString("\n")
105 continue
106 }
107
108 patterns = append(patterns, pat)
109 }
110
111 // quit right away when given invalid regexes
112 if len(patterns) < len(args) {
113 os.Exit(1)
114 }
115
116 liveLines := !buffered
117 if !buffered {
118 if _, err := os.Stdout.Seek(0, io.SeekCurrent); err == nil {
119 liveLines = false
120 }
121 }
122
123 err := run(os.Stdout, os.Stdin, patterns, filter, liveLines)
124 if err != nil && err != io.EOF {
125 os.Stderr.WriteString(err.Error())
126 os.Stderr.WriteString("\n")
127 os.Exit(1)
128 }
129 }
130
131 // pattern is a regular-expression pattern which distinguishes between the
132 // start/end of a line and those of the chunks it can be used to match
133 type pattern struct {
134 // expr is the regular-expression
135 expr *regexp.Regexp
136
137 // begin is whether the regexp refers to the start of a line
138 begin bool
139
140 // end is whether the regexp refers to the end of a line
141 end bool
142 }
143
144 func compile(src string) (pattern, error) {
145 expr, err := regexp.Compile(src)
146
147 var pat pattern
148 pat.expr = expr
149 pat.begin = strings.HasPrefix(src, `^`) || strings.HasPrefix(src, `(?i)^`)
150 pat.end = strings.HasSuffix(src, `$`) && !strings.HasSuffix(src, `\$`)
151 return pat, err
152 }
153
154 func (p pattern) findIndex(s []byte, i int, last int) (start int, stop int) {
155 if i > 0 && p.begin {
156 return -1, -1
157 }
158 if i != last && p.end {
159 return -1, -1
160 }
161
162 span := p.expr.FindIndex(s)
163 // also ignore empty regex matches to avoid infinite outer loops,
164 // as skipping empty slices isn't advancing at all, leaving the
165 // string stuck to being empty-matched forever by the same regex
166 if len(span) != 2 || span[0] == span[1] {
167 return -1, -1
168 }
169
170 return span[0], span[1]
171 }
172
173 func run(w io.Writer, r io.Reader, pats []pattern, filter, live bool) error {
174 sc := bufio.NewScanner(r)
175 sc.Buffer(nil, 8*1024*1024*1024)
176 bw := bufio.NewWriter(w)
177 defer bw.Flush()
178
179 for i := 0; sc.Scan(); i++ {
180 s := sc.Bytes()
181 if i == 0 && bytes.HasPrefix(s, []byte{0xef, 0xbb, 0xbf}) {
182 s = s[3:]
183 }
184
185 n := 0
186 last := countChunks(s) - 1
187 if last < 0 {
188 last = 0
189 }
190
191 if filter && !matches(s, pats, last) {
192 continue
193 }
194
195 for len(s) > 0 {
196 i, j := indexEscapeSequence(s)
197 if i < 0 {
198 handleChunk(bw, s, pats, n, last)
199 break
200 }
201 if j < 0 {
202 j = len(s)
203 }
204
205 handleChunk(bw, s[:i], pats, n, last)
206 if i > 0 {
207 n++
208 }
209
210 bw.Write(s[i:j])
211
212 s = s[j:]
213 }
214
215 if bw.WriteByte('\n') != nil {
216 return io.EOF
217 }
218
219 if !live {
220 continue
221 }
222
223 if bw.Flush() != nil {
224 return io.EOF
225 }
226 }
227
228 return sc.Err()
229 }
230
231 // matches finds out if any regex matches any substring around ANSI-sequences
232 func matches(s []byte, patterns []pattern, last int) bool {
233 n := 0
234
235 for len(s) > 0 {
236 i, j := indexEscapeSequence(s)
237 if i < 0 {
238 for _, p := range patterns {
239 if begin, _ := p.findIndex(s, n, last); begin >= 0 {
240 return true
241 }
242 }
243 return false
244 }
245
246 if j < 0 {
247 j = len(s)
248 }
249
250 for _, p := range patterns {
251 if begin, _ := p.findIndex(s[:i], n, last); begin >= 0 {
252 return true
253 }
254 }
255
256 if i > 0 {
257 n++
258 }
259
260 s = s[j:]
261 }
262
263 return false
264 }
265
266 func countChunks(s []byte) int {
267 chunks := 0
268
269 for len(s) > 0 {
270 i, j := indexEscapeSequence(s)
271 if i < 0 {
272 break
273 }
274
275 if i > 0 {
276 chunks++
277 }
278
279 if j < 0 {
280 break
281 }
282 s = s[j:]
283 }
284
285 if len(s) > 0 {
286 chunks++
287 }
288 return chunks
289 }
290
291 // indexEscapeSequence finds the first ANSI-style escape-sequence, which is
292 // the multi-byte sequences starting with ESC[; the result is a pair of slice
293 // indices which can be independently negative when either the start/end of
294 // a sequence isn't found; given their fairly-common use, even the hyperlink
295 // ESC]8 sequences are supported
296 func indexEscapeSequence(s []byte) (int, int) {
297 var prev byte
298
299 for i, b := range s {
300 if prev == '\x1b' && b == '[' {
301 j := indexLetter(s[i+1:])
302 if j < 0 {
303 return i, -1
304 }
305 return i - 1, i + 1 + j + 1
306 }
307
308 if prev == '\x1b' && b == ']' && i+1 < len(s) && s[i+1] == '8' {
309 j := indexPair(s[i+1:], '\x1b', '\\')
310 if j < 0 {
311 return i, -1
312 }
313 return i - 1, i + 1 + j + 2
314 }
315
316 prev = b
317 }
318
319 return -1, -1
320 }
321
322 func indexLetter(s []byte) int {
323 for i, b := range s {
324 upper := b &^ 32
325 if 'A' <= upper && upper <= 'Z' {
326 return i
327 }
328 }
329
330 return -1
331 }
332
333 func indexPair(s []byte, x byte, y byte) int {
334 var prev byte
335
336 for i, b := range s {
337 if prev == x && b == y && i > 0 {
338 return i
339 }
340 prev = b
341 }
342
343 return -1
344 }
345
346 // note: looking at the results of restoring ANSI-styles after style-resets
347 // doesn't seem to be worth it, as a previous version used to do
348
349 // handleChunk handles line-slices around any detected ANSI-style sequences,
350 // or even whole lines, when no ANSI-styles are found in them
351 func handleChunk(w *bufio.Writer, s []byte, with []pattern, n int, last int) {
352 for len(s) > 0 {
353 start, end := -1, -1
354 for _, p := range with {
355 i, j := p.findIndex(s, n, last)
356 if i >= 0 && (i < start || start < 0) {
357 start, end = i, j
358 }
359 }
360
361 if start < 0 {
362 w.Write(s)
363 return
364 }
365
366 w.Write(s[:start])
367 w.WriteString(highlightStyle)
368 w.Write(s[start:end])
369 w.WriteString("\x1b[0m")
370
371 s = s[end:]
372 }
373 }
File: ./htmlify/htmlify.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package htmlify
26
27 import (
28 "bufio"
29 "encoding/base64"
30 "errors"
31 "io"
32 "os"
33 "regexp"
34 "strings"
35 )
36
37 const info = `
38 htmlify [options...] [filepaths/URIs...]
39
40
41 Render plain-text prose into self-contained HTML. Lines which are just a
42 valid data-URI are turned into pictures, audio, or even video elements.
43
44 All HTTP(s) URIs are autodetected and rendered as hyperlinks, even when
45 lines have multiple URIs in them.
46
47 If a title isn't given from the cmd-line options, the first line is used
48 as the title.
49
50 All (optional) leading options start with either single or double-dash,
51 and most of them change the style/color used. Some of the options are,
52 shown in their single-dash form:
53
54 -h, -help show this help message
55 -mono, -monospace use a monospace font for text
56 -t, -title use the next argument as the webpage title
57 `
58
59 var links = regexp.MustCompile(`(?i)(https?|ftps?)://[a-zA-Z0-9_%.,?/&=#-]+`)
60
61 type config struct {
62 Title string
63 GotTitle bool
64 Started bool
65 Monospace bool
66 }
67
68 func Main() {
69 var cfg config
70 args := os.Args[1:]
71
72 for len(args) > 0 {
73 switch args[0] {
74 case `-h`, `--h`, `-help`, `--help`:
75 os.Stdout.WriteString(info[1:])
76 return
77
78 case `-mono`, `--mono`, `-monospace`, `--monospace`:
79 cfg.Monospace = true
80 args = args[1:]
81 continue
82
83 case `-t`, `--t`, `-title`, `--title`:
84 if len(args) >= 2 {
85 cfg.Title = args[1]
86 cfg.GotTitle = true
87 args = args[2:]
88 } else {
89 const m = "title option isn't followed by the actual title\n"
90 os.Stderr.WriteString(m)
91 os.Exit(1)
92 }
93 continue
94 }
95
96 break
97 }
98
99 if len(args) > 0 && args[0] == `--` {
100 args = args[1:]
101 }
102
103 if err := run(os.Stdout, args, &cfg); err != nil && err != io.EOF {
104 os.Stderr.WriteString(err.Error())
105 os.Stderr.WriteString("\n")
106 os.Exit(1)
107 }
108 }
109
110 func run(w io.Writer, args []string, cfg *config) error {
111 bw := bufio.NewWriter(w)
112 defer bw.Flush()
113
114 if len(args) == 0 {
115 if err := handleReader(bw, os.Stdin, cfg); err != nil {
116 return err
117 }
118 }
119
120 for _, name := range args {
121 if err := handleFile(bw, name, cfg); err != nil {
122 return err
123 }
124 }
125
126 if cfg.Started {
127 endPage(bw)
128 }
129 return nil
130 }
131
132 func handleFile(w *bufio.Writer, name string, cfg *config) error {
133 if name == `` || name == `-` {
134 return handleReader(w, os.Stdin, cfg)
135 }
136
137 f, err := os.Open(name)
138 if err != nil {
139 return errors.New(`can't read from file named "` + name + `"`)
140 }
141 defer f.Close()
142
143 return handleReader(w, f, cfg)
144 }
145
146 func handleReader(w *bufio.Writer, r io.Reader, cfg *config) error {
147 const gb = 1024 * 1024 * 1024
148 sc := bufio.NewScanner(r)
149 sc.Buffer(nil, 8*gb)
150 lines := 0
151
152 for sc.Scan() {
153 line := sc.Text()
154
155 if lines == 0 && strings.HasPrefix(line, "\xef\xbb\xbf") {
156 line = line[3:]
157 }
158 if lines == 0 && !cfg.Started {
159 title := line
160 if cfg.GotTitle {
161 title = cfg.Title
162 }
163 startPage(w, title, cfg.Monospace)
164 cfg.Started = true
165 }
166 lines++
167
168 if err := handleLine(w, line, cfg); err != nil {
169 return err
170 }
171 }
172
173 if !cfg.Started && lines > 0 {
174 startPage(w, cfg.Title, cfg.Monospace)
175 cfg.Started = true
176 }
177 return sc.Err()
178 }
179
180 const style = `
181 body {
182 margin: 1rem auto 2rem auto;
183 padding: 0.25rem;
184 font-size: 1.1rem;
185 line-height: 1.8rem;
186 font-family: sans-serif;
187
188 max-width: 95vw;
189 /* width: max-content; */
190 width: fit-content;
191
192 box-sizing: border-box;
193 display: block;
194 }
195
196 a {
197 color: steelblue;
198 text-decoration: none;
199 }
200
201 p {
202 display: block;
203 margin: auto;
204 max-width: 80ch;
205 }
206
207 img {
208 margin: none;
209 }
210
211 audio {
212 width: 60ch;
213 }
214
215 table {
216 margin: 2rem auto;
217 border-collapse: collapse;
218 }
219
220 thead>* {
221 position: sticky;
222 top: 0;
223 background-color: white;
224 }
225
226 tfoot th {
227 user-select: none;
228 }
229
230 th, td {
231 padding: 0.1rem 1ch;
232 min-width: 4ch;
233 border-bottom: solid thin transparent;
234 }
235
236 tr:nth-child(5n) td {
237 border-bottom: solid thin #ccc;
238 }
239
240 .monospace {
241 font-family: monospace;
242 }
243 `
244
245 func startPage(w *bufio.Writer, title string, monospace bool) {
246 w.WriteString("<!DOCTYPE html>\n")
247 w.WriteString("<html lang=\"en\">\n")
248 w.WriteString("\n")
249 w.WriteString("<head>\n")
250 w.WriteString(" <meta charset=\"UTF-8\">\n")
251 w.WriteString(" <meta name=\"viewport\" content=\"width=device-width,")
252 w.WriteString(" initial-scale=1.0\">\n")
253 w.WriteString(" <meta http-equiv=\"X-UA-Compatible\"")
254 w.WriteString(" content=\"ie=edge\">\n")
255 w.WriteString("\n")
256 w.WriteString(" <link rel=\"icon\" href=\"data:,\">\n")
257 w.WriteString(` <title>`)
258 w.WriteString(title)
259 w.WriteString("</title>\n")
260 w.WriteString("\n")
261 w.WriteString("\n")
262 w.WriteString(" <style>\n")
263 w.WriteString(style[1:])
264 w.WriteString(" </style>\n")
265 w.WriteString("</head>\n")
266 if monospace {
267 w.WriteString("<body class=\"monospace\">\n")
268 } else {
269 w.WriteString("<body>\n")
270 }
271 }
272
273 func endPage(w *bufio.Writer) {
274 w.WriteString("</body>\n")
275 w.WriteString("</html>\n")
276 }
277
278 func handleLine(w *bufio.Writer, s string, cfg *config) error {
279 if handleDataURI(w, s) {
280 if w.WriteByte('\n') != nil {
281 return io.EOF
282 }
283 return nil
284 }
285
286 for len(s) > 0 {
287 span := links.FindStringIndex(s)
288 if span != nil && len(span) == 2 {
289 i := span[0]
290 j := span[1]
291 href := s[i:j]
292 handleChunk(w, s[:i])
293 w.WriteString(`<a href="`)
294 w.WriteString(href)
295 w.WriteString(`">`)
296 w.WriteString(href)
297 w.WriteString(`</a>`)
298 s = s[j:]
299 continue
300 }
301
302 handleChunk(w, s)
303 break
304 }
305
306 w.WriteString(`<br>`)
307 if w.WriteByte('\n') != nil {
308 return io.EOF
309 }
310 return nil
311 }
312
313 func handleChunk(w *bufio.Writer, s string) {
314 for len(s) > 0 {
315 switch b := s[0]; b {
316 case '&':
317 w.WriteString("&")
318 case '<':
319 w.WriteString("<")
320 case '>':
321 w.WriteString(">")
322 default:
323 w.WriteByte(b)
324 }
325 s = s[1:]
326 }
327 }
328
329 func handleDataURI(w *bufio.Writer, s string) bool {
330 full := s
331
332 if !strings.HasPrefix(s, `data:`) {
333 return false
334 }
335
336 s = strings.TrimPrefix(s, `data:`)
337 i := strings.Index(s, `;base64,`)
338 if i < 0 {
339 return false
340 }
341
342 kind := s[:i]
343 s = s[i+len(`;base64,`):]
344
345 if strings.HasPrefix(kind, `image/`) {
346 if !isBase64(s) {
347 return false
348 }
349
350 w.WriteString(`<img src="`)
351 w.WriteString(full)
352 w.WriteString(`">`)
353 return true
354 }
355
356 if strings.HasPrefix(kind, `audio/`) {
357 if !isBase64(s) {
358 return false
359 }
360
361 w.WriteString(`<audio controls src="`)
362 w.WriteString(full)
363 w.WriteString(`"></audio>`)
364 return true
365 }
366
367 if strings.HasPrefix(kind, `video/`) {
368 if !isBase64(s) {
369 return false
370 }
371
372 w.WriteString(`<video controls src="`)
373 w.WriteString(full)
374 w.WriteString(`"></video>`)
375 return true
376 }
377
378 return false
379 }
380
381 func handleMedia(w *bufio.Writer, begin string, s string, end string) bool {
382 if !isBase64(s) {
383 return false
384 }
385
386 w.WriteString(begin)
387 w.WriteString(s)
388 w.WriteString(end)
389 return true
390 }
391
392 func isBase64(s string) bool {
393 dec := base64.NewDecoder(base64.StdEncoding, strings.NewReader(s))
394 n, err := io.Copy(io.Discard, dec)
395 return n > 0 && err == nil
396 }
File: ./id3pic/id3pic.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package id3pic
26
27 import (
28 "bufio"
29 "encoding/binary"
30 "errors"
31 "io"
32 "os"
33 )
34
35 const info = `
36 id3pic [options...] [file...]
37
38 Extract picture/thumbnail bytes from ID3/MP3 metadata, if available.
39
40 All (optional) leading options start with either single or double-dash:
41
42 -h, -help show this help message
43 `
44
45 // errNoThumb is a generic error to handle lack of thumbnails, in case no
46 // picture-metadata-starters are found at all
47 var errNoThumb = errors.New(`no thumbnail data found`)
48
49 // errInvalidPIC is a generic error for invalid PIC-format pics
50 var errInvalidPIC = errors.New(`invalid PIC-format embedded thumbnail`)
51
52 func Main() {
53 if len(os.Args) > 1 {
54 switch os.Args[1] {
55 case `-h`, `--h`, `-help`, `--help`:
56 os.Stdout.WriteString(info[1:])
57 return
58 }
59 }
60
61 if len(os.Args) > 2 {
62 showError(`can only handle 1 file`)
63 os.Exit(1)
64 }
65
66 name := `-`
67 if len(os.Args) > 1 {
68 name = os.Args[1]
69 }
70
71 if err := run(os.Stdout, name); err != nil && err != io.EOF {
72 showError(err.Error())
73 os.Exit(1)
74 }
75 }
76
77 func showError(msg string) {
78 os.Stderr.WriteString(msg)
79 os.Stderr.WriteString("\n")
80 }
81
82 func run(w io.Writer, name string) error {
83 if name == `-` {
84 return id3pic(w, bufio.NewReader(os.Stdin))
85 }
86
87 f, err := os.Open(name)
88 if err != nil {
89 return errors.New(`can't read from file named "` + name + `"`)
90 }
91 defer f.Close()
92
93 return id3pic(w, bufio.NewReader(f))
94 }
95
96 func match(r *bufio.Reader, what []byte) bool {
97 for _, v := range what {
98 b, err := r.ReadByte()
99 if b != v || err != nil {
100 return false
101 }
102 }
103 return true
104 }
105
106 func id3pic(w io.Writer, r *bufio.Reader) error {
107 // match the ID3 mark
108 for {
109 b, err := r.ReadByte()
110 if err == io.EOF {
111 return errNoThumb
112 }
113 if err != nil {
114 return err
115 }
116
117 if b == 'I' && match(r, []byte{'D', '3'}) {
118 break
119 }
120 }
121
122 for {
123 b, err := r.ReadByte()
124 if err == io.EOF {
125 return errNoThumb
126 }
127 if err != nil {
128 return err
129 }
130
131 // handle APIC-type chunks
132 if b == 'A' && match(r, []byte{'P', 'I', 'C'}) {
133 return handleAPIC(w, r)
134 }
135 }
136 }
137
138 func handleAPIC(w io.Writer, r *bufio.Reader) error {
139 // section-size seems stored as 4 big-endian bytes
140 var size uint32
141 err := binary.Read(r, binary.BigEndian, &size)
142 if err != nil {
143 return err
144 }
145
146 n, err := skipThumbnailTypeAPIC(r)
147 if err != nil {
148 return err
149 }
150
151 _, err = io.Copy(w, io.LimitReader(r, int64(int(size)-n)))
152 return err
153 }
154
155 func skipThumbnailTypeAPIC(r *bufio.Reader) (skipped int, err error) {
156 m, err := r.Discard(2)
157 if err != nil || m != 2 {
158 return -1, errors.New(`failed to sync APIC flags`)
159 }
160 skipped += m
161
162 m, err = r.Discard(1)
163 if err != nil || m != 1 {
164 return -1, errors.New(`failed to sync APIC text-encoding`)
165 }
166 skipped += m
167
168 junk, err := r.ReadSlice(0)
169 if err != nil {
170 return -1, errors.New(`failed to sync to APIC thumbnail MIME-type`)
171 }
172 skipped += len(junk)
173
174 m, err = r.Discard(1)
175 if err != nil || m != 1 {
176 return -1, errors.New(`failed to sync APIC picture type`)
177 }
178 skipped += m
179
180 junk, err = r.ReadSlice(0)
181 if err != nil {
182 return -1, errors.New(`failed to sync to APIC thumbnail description`)
183 }
184 skipped += len(junk)
185
186 return skipped, nil
187 }
File: ./json0/json0.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package json0
26
27 import (
28 "bufio"
29 "errors"
30 "io"
31 "os"
32 "strconv"
33 "unicode"
34 )
35
36 const info = `
37 json0 [options...] [file...]
38
39
40 JSON-0 converts/fixes JSON/pseudo-JSON input into minimal JSON output.
41 Its output is always a single line, which ends with a line-feed.
42
43 Besides minimizing bytes, this tool also adapts almost-JSON input into
44 valid JSON, since it
45
46 - ignores both rest-of-line and multi-line comments
47 - ignores extra/trailing commas in arrays and objects
48 - turns single-quoted strings/keys into double-quoted strings
49 - double-quotes unquoted object keys
50 - changes \x 2-hex-digit into \u 4-hex-digit string-escapes
51
52 All options available can either start with a single or a double-dash
53
54 -h, -help show this help message
55 -jsonl emit JSON Lines, when top-level value is an array
56 `
57
58 const (
59 bufSize = 32 * 1024
60 chunkPeekSize = 16
61 )
62
63 func Main() {
64 args := os.Args[1:]
65 buffered := false
66 handler := json0
67
68 for len(args) > 0 {
69 switch args[0] {
70 case `-b`, `--b`, `-buffered`, `--buffered`:
71 buffered = true
72 args = args[1:]
73 continue
74
75 case `-h`, `--h`, `-help`, `--help`:
76 os.Stdout.WriteString(info[1:])
77 return
78
79 case `-jsonl`, `--jsonl`:
80 handler = jsonl
81 args = args[1:]
82 continue
83 }
84
85 break
86 }
87
88 if len(args) > 0 && args[0] == `--` {
89 args = args[1:]
90 }
91
92 if len(args) > 1 {
93 const msg = "multiple inputs aren't allowed\n"
94 os.Stderr.WriteString(msg)
95 os.Exit(1)
96 }
97
98 liveLines := !buffered
99 if !buffered {
100 if _, err := os.Stdout.Seek(0, io.SeekCurrent); err == nil {
101 liveLines = false
102 }
103 }
104
105 name := `-`
106 if len(args) == 1 {
107 name = args[0]
108 }
109
110 if err := run(os.Stdout, name, handler, liveLines); err != nil && err != io.EOF {
111 os.Stderr.WriteString(err.Error())
112 os.Stderr.WriteString("\n")
113 os.Exit(1)
114 }
115 }
116
117 type handlerFunc func(w *bufio.Writer, r *bufio.Reader, live bool) error
118
119 func run(w io.Writer, name string, handler handlerFunc, live bool) error {
120 // f, _ := os.Create(`json0.prof`)
121 // defer f.Close()
122 // pprof.StartCPUProfile(f)
123 // defer pprof.StopCPUProfile()
124
125 if name == `` || name == `-` {
126 bw := bufio.NewWriterSize(w, bufSize)
127 br := bufio.NewReaderSize(os.Stdin, bufSize)
128 defer bw.Flush()
129 return handler(bw, br, live)
130 }
131
132 f, err := os.Open(name)
133 if err != nil {
134 return errors.New(`can't read from file named "` + name + `"`)
135 }
136 defer f.Close()
137
138 bw := bufio.NewWriterSize(w, bufSize)
139 br := bufio.NewReaderSize(f, bufSize)
140 defer bw.Flush()
141 return handler(bw, br, live)
142 }
143
144 var (
145 errCommentEarlyEnd = errors.New(`unexpected early-end of comment`)
146 errInputEarlyEnd = errors.New(`expected end of input data`)
147 errInvalidComment = errors.New(`expected / or *`)
148 errInvalidHex = errors.New(`expected a base-16 digit`)
149 errInvalidRune = errors.New(`invalid UTF-8 bytes`)
150 errInvalidToken = errors.New(`invalid JSON token`)
151 errNoDigits = errors.New(`expected numeric digits`)
152 errNoStringQuote = errors.New(`expected " or '`)
153 errNoArrayComma = errors.New(`missing comma between array values`)
154 errNoObjectComma = errors.New(`missing comma between key-value pairs`)
155 errStringEarlyEnd = errors.New(`unexpected early-end of string`)
156 errExtraBytes = errors.New(`unexpected extra input bytes`)
157 )
158
159 // linePosError is a more descriptive kind of error, showing the source of
160 // the input-related problem, as 1-based a line/pos number pair in front
161 // of the error message
162 type linePosError struct {
163 // line is the 1-based line count from the input
164 line int
165
166 // pos is the 1-based `horizontal` position in its line
167 pos int
168
169 // err is the error message to `decorate` with the position info
170 err error
171 }
172
173 // Error satisfies the error interface
174 func (lpe linePosError) Error() string {
175 where := strconv.Itoa(lpe.line) + `:` + strconv.Itoa(lpe.pos)
176 return where + `: ` + lpe.err.Error()
177 }
178
179 // isIdentifier improves control-flow of func handleKey, when it handles
180 // unquoted object keys
181 var isIdentifier = [256]bool{
182 '_': true,
183
184 '0': true, '1': true, '2': true, '3': true, '4': true,
185 '5': true, '6': true, '7': true, '8': true, '9': true,
186
187 'A': true, 'B': true, 'C': true, 'D': true, 'E': true, 'F': true,
188 'G': true, 'H': true, 'I': true, 'J': true, 'K': true, 'L': true,
189 'M': true, 'N': true, 'O': true, 'P': true, 'Q': true, 'R': true,
190 'S': true, 'T': true, 'U': true, 'V': true, 'W': true, 'X': true,
191 'Y': true, 'Z': true,
192
193 'a': true, 'b': true, 'c': true, 'd': true, 'e': true, 'f': true,
194 'g': true, 'h': true, 'i': true, 'j': true, 'k': true, 'l': true,
195 'm': true, 'n': true, 'o': true, 'p': true, 'q': true, 'r': true,
196 's': true, 't': true, 'u': true, 'v': true, 'w': true, 'x': true,
197 'y': true, 'z': true,
198 }
199
200 // matchHex both figures out if a byte is a valid ASCII hex-digit, by not
201 // being 0, and normalizes letter-case for the hex letters
202 var matchHex = [256]byte{
203 '0': '0', '1': '1', '2': '2', '3': '3', '4': '4',
204 '5': '5', '6': '6', '7': '7', '8': '8', '9': '9',
205 'A': 'A', 'B': 'B', 'C': 'C', 'D': 'D', 'E': 'E', 'F': 'F',
206 'a': 'A', 'b': 'B', 'c': 'C', 'd': 'D', 'e': 'E', 'f': 'F',
207 }
208
209 // json0 converts JSON/pseudo-JSON into (valid) minimal JSON; final boolean
210 // value isn't used, and is just there to match the signature of func jsonl
211 func json0(w *bufio.Writer, r *bufio.Reader, live bool) error {
212 jr := jsonReader{r, 1, 1}
213 defer w.Flush()
214
215 if err := jr.handleLeadingJunk(); err != nil {
216 return err
217 }
218
219 // handle a single top-level JSON value
220 err := handleValue(w, &jr)
221
222 // end the only output-line with a line-feed; this also avoids showing
223 // error messages on the same line as the main output, since JSON-0
224 // output has no line-feeds before its last byte
225 outputByte(w, '\n')
226
227 if err != nil {
228 return err
229 }
230 return jr.handleTrailingJunk()
231 }
232
233 // jsonl converts JSON/pseudo-JSON into (valid) minimal JSON Lines; this func
234 // avoids writing a trailing line-feed, leaving that up to its caller
235 func jsonl(w *bufio.Writer, r *bufio.Reader, live bool) error {
236 jr := jsonReader{r, 1, 1}
237
238 if err := jr.handleLeadingJunk(); err != nil {
239 return err
240 }
241
242 chunk, err := jr.r.Peek(1)
243 if err == nil && len(chunk) >= 1 {
244 switch b := chunk[0]; b {
245 case '[', '(':
246 return handleArrayJSONL(w, &jr, b, live)
247 }
248 }
249
250 // handle a single top-level JSON value
251 err = handleValue(w, &jr)
252
253 // end the only output-line with a line-feed; this also avoids showing
254 // error messages on the same line as the main output, since JSON-0
255 // output has no line-feeds before its last byte
256 outputByte(w, '\n')
257
258 if err != nil {
259 return err
260 }
261 return jr.handleTrailingJunk()
262 }
263
264 // handleArrayJSONL handles top-level arrays for func jsonl
265 func handleArrayJSONL(w *bufio.Writer, jr *jsonReader, start byte, live bool) error {
266 if err := jr.demandSyntax(start); err != nil {
267 return err
268 }
269
270 var end byte = ']'
271 if start == '(' {
272 end = ')'
273 }
274
275 for n := 0; true; n++ {
276 // there may be whitespace/comments before the next comma
277 if err := jr.seekNext(); err != nil {
278 return err
279 }
280
281 // handle commas between values, as well as trailing ones
282 comma := false
283 b, _ := jr.peekByte()
284 if b == ',' {
285 jr.readByte()
286 comma = true
287
288 // there may be whitespace/comments before an ending ']'
289 if err := jr.seekNext(); err != nil {
290 return err
291 }
292 b, _ = jr.peekByte()
293 }
294
295 // handle end of array
296 if b == end {
297 jr.readByte()
298 if n > 0 {
299 err := outputByte(w, '\n')
300 if live {
301 w.Flush()
302 }
303 return err
304 }
305 return nil
306 }
307
308 // turn commas between adjacent values into line-feeds, as the
309 // output for this custom func is supposed to be JSON Lines
310 if n > 0 {
311 if !comma {
312 return errNoArrayComma
313 }
314 if err := outputByte(w, '\n'); err != nil {
315 return err
316 }
317 if live {
318 w.Flush()
319 }
320 }
321
322 // handle the next value
323 if err := jr.seekNext(); err != nil {
324 return err
325 }
326 if err := handleValue(w, jr); err != nil {
327 return err
328 }
329 }
330
331 // make the compiler happy
332 return nil
333 }
334
335 // jsonReader reads data via a buffer, keeping track of the input position:
336 // this in turn allows showing much more useful errors, when these happen
337 type jsonReader struct {
338 // r is the actual reader
339 r *bufio.Reader
340
341 // line is the 1-based line-counter for input bytes, and gives errors
342 // useful position info
343 line int
344
345 // pos is the 1-based `horizontal` position in its line, and gives
346 // errors useful position info
347 pos int
348 }
349
350 // improveError makes any error more useful, by giving it info about the
351 // current input-position, as a 1-based line/within-line-position pair
352 func (jr jsonReader) improveError(err error) error {
353 if _, ok := err.(linePosError); ok {
354 return err
355 }
356
357 if err == io.EOF {
358 return linePosError{jr.line, jr.pos, errInputEarlyEnd}
359 }
360 if err != nil {
361 return linePosError{jr.line, jr.pos, err}
362 }
363 return nil
364 }
365
366 func (jr *jsonReader) handleLeadingJunk() error {
367 // input is already assumed to be UTF-8: a leading UTF-8 BOM (byte-order
368 // mark) gives no useful info if present, as UTF-8 leaves no ambiguity
369 // about byte-order by design
370 jr.skipUTF8BOM()
371
372 // ignore leading whitespace and/or comments
373 return jr.seekNext()
374 }
375
376 func (jr *jsonReader) handleTrailingJunk() error {
377 // ignore trailing whitespace and/or comments
378 if err := jr.seekNext(); err != nil {
379 return err
380 }
381
382 // ignore trailing semicolons
383 for {
384 if b, ok := jr.peekByte(); !ok || b != ';' {
385 break
386 }
387
388 jr.readByte()
389 // ignore trailing whitespace and/or comments
390 if err := jr.seekNext(); err != nil {
391 return err
392 }
393 }
394
395 // beyond trailing whitespace and/or comments, any more bytes
396 // make the whole input data invalid JSON
397 if _, ok := jr.peekByte(); ok {
398 return jr.improveError(errExtraBytes)
399 }
400 return nil
401 }
402
403 // demandSyntax fails with an error when the next byte isn't the one given;
404 // when it is, the byte is then read/skipped, and a nil error is returned
405 func (jr *jsonReader) demandSyntax(syntax byte) error {
406 chunk, err := jr.r.Peek(1)
407 if err == io.EOF {
408 return jr.improveError(errInputEarlyEnd)
409 }
410 if err != nil {
411 return jr.improveError(err)
412 }
413
414 if len(chunk) < 1 || chunk[0] != syntax {
415 msg := `expected ` + string(rune(syntax))
416 return jr.improveError(errors.New(msg))
417 }
418
419 jr.readByte()
420 return nil
421 }
422
423 // peekByte simplifies control-flow for various other funcs
424 func (jr jsonReader) peekByte() (b byte, ok bool) {
425 chunk, err := jr.r.Peek(1)
426 if err == nil && len(chunk) >= 1 {
427 return chunk[0], true
428 }
429 return 0, false
430 }
431
432 // readByte does what it says, updating the reader's position info
433 func (jr *jsonReader) readByte() (b byte, err error) {
434 b, err = jr.r.ReadByte()
435 if err == nil {
436 if b == '\n' {
437 jr.line += 1
438 jr.pos = 1
439 } else {
440 jr.pos++
441 }
442 return b, nil
443 }
444 return b, jr.improveError(err)
445 }
446
447 // readRune does what it says, updating the reader's position info
448 func (jr *jsonReader) readRune() (r rune, err error) {
449 r, _, err = jr.r.ReadRune()
450 if err == nil {
451 if r == '\n' {
452 jr.line += 1
453 jr.pos = 1
454 } else {
455 jr.pos++
456 }
457 return r, nil
458 }
459 return r, jr.improveError(err)
460 }
461
462 // seekNext skips/seeks the next token, ignoring runs of whitespace symbols
463 // and comments, either single-line (starting with //) or general (starting
464 // with /* and ending with */)
465 func (jr *jsonReader) seekNext() error {
466 for {
467 b, ok := jr.peekByte()
468 if !ok {
469 return nil
470 }
471
472 // case ' ', '\t', '\f', '\v', '\r', '\n':
473 if b <= 32 {
474 // keep skipping whitespace bytes
475 jr.readByte()
476 continue
477 }
478
479 if b == '#' {
480 if err := jr.skipLine(); err != nil {
481 return err
482 }
483 continue
484 }
485
486 if b != '/' {
487 // reached the next token
488 return nil
489 }
490
491 if err := jr.skipComment(); err != nil {
492 return err
493 }
494
495 // after comments, keep looking for more whitespace and/or comments
496 }
497 }
498
499 // skipComment helps func seekNext skip over comments, simplifying the latter
500 // func's control-flow
501 func (jr *jsonReader) skipComment() error {
502 err := jr.demandSyntax('/')
503 if err != nil {
504 return err
505 }
506
507 b, ok := jr.peekByte()
508 if !ok {
509 return nil
510 }
511
512 switch b {
513 case '/':
514 // handle single-line comments
515 return jr.skipLine()
516
517 case '*':
518 // handle (potentially) multi-line comments
519 return jr.skipGeneralComment()
520
521 default:
522 return jr.improveError(errInvalidComment)
523 }
524 }
525
526 // skipLine handles single-line comments for func skipComment
527 func (jr *jsonReader) skipLine() error {
528 for {
529 b, err := jr.readByte()
530 if err == io.EOF {
531 // end of input is fine in this case
532 return nil
533 }
534 if err != nil {
535 return err
536 }
537
538 if b == '\n' {
539 return nil
540 }
541 }
542 }
543
544 // skipGeneralComment handles (potentially) multi-line comments for func
545 // skipComment
546 func (jr *jsonReader) skipGeneralComment() error {
547 var prev byte
548 for {
549 b, err := jr.readByte()
550 if err != nil {
551 return jr.improveError(errCommentEarlyEnd)
552 }
553
554 if prev == '*' && b == '/' {
555 return nil
556 }
557 if b == '\n' {
558 jr.line++
559 }
560 prev = b
561 }
562 }
563
564 // skipUTF8BOM does what it says, if a UTF-8 BOM is present
565 func (jr *jsonReader) skipUTF8BOM() {
566 lead, err := jr.r.Peek(3)
567 if err != nil {
568 return
569 }
570
571 if len(lead) > 2 && lead[0] == 0xef && lead[1] == 0xbb && lead[2] == 0xbf {
572 jr.readByte()
573 jr.readByte()
574 jr.readByte()
575 }
576 }
577
578 // outputByte is a small wrapper on func WriteByte, which adapts any error
579 // into a custom dummy output-error, which is in turn meant to be ignored,
580 // being just an excuse to quit the app immediately and successfully
581 func outputByte(w *bufio.Writer, b byte) error {
582 err := w.WriteByte(b)
583 if err == nil {
584 return nil
585 }
586 return io.EOF
587 }
588
589 // handleArray handles arrays for func handleValue
590 func handleArray(w *bufio.Writer, jr *jsonReader, start byte) error {
591 if err := jr.demandSyntax(start); err != nil {
592 return err
593 }
594
595 var end byte = ']'
596 if start == '(' {
597 end = ')'
598 }
599
600 w.WriteByte('[')
601
602 for n := 0; true; n++ {
603 // there may be whitespace/comments before the next comma
604 if err := jr.seekNext(); err != nil {
605 return err
606 }
607
608 // handle commas between values, as well as trailing ones
609 comma := false
610 b, _ := jr.peekByte()
611 if b == ',' {
612 jr.readByte()
613 comma = true
614
615 // there may be whitespace/comments before an ending ']'
616 if err := jr.seekNext(); err != nil {
617 return err
618 }
619 b, _ = jr.peekByte()
620 }
621
622 // handle end of array
623 if b == end {
624 jr.readByte()
625 w.WriteByte(']')
626 return nil
627 }
628
629 // don't forget commas between adjacent values
630 if n > 0 {
631 if !comma {
632 return errNoArrayComma
633 }
634 if err := outputByte(w, ','); err != nil {
635 return err
636 }
637 }
638
639 // handle the next value
640 if err := jr.seekNext(); err != nil {
641 return err
642 }
643 if err := handleValue(w, jr); err != nil {
644 return err
645 }
646 }
647
648 // make the compiler happy
649 return nil
650 }
651
652 // handleDigits helps various number-handling funcs do their job
653 func handleDigits(w *bufio.Writer, jr *jsonReader) error {
654 if trySimpleDigits(w, jr) {
655 return nil
656 }
657
658 for n := 0; true; n++ {
659 b, _ := jr.peekByte()
660
661 // support `nice` long numbers by ignoring their underscores
662 if b == '_' {
663 jr.readByte()
664 continue
665 }
666
667 if '0' <= b && b <= '9' {
668 jr.readByte()
669 w.WriteByte(b)
670 continue
671 }
672
673 if n == 0 {
674 return errNoDigits
675 }
676 return nil
677 }
678
679 // make the compiler happy
680 return nil
681 }
682
683 // trySimpleDigits tries to handle (more quickly) digit-runs where all bytes
684 // are just digits: this is a very common case for numbers; returns whether
685 // it succeeded, so this func's caller knows knows if it needs to do anything,
686 // the slower way
687 func trySimpleDigits(w *bufio.Writer, jr *jsonReader) (gotIt bool) {
688 chunk, _ := jr.r.Peek(chunkPeekSize)
689
690 for i, b := range chunk {
691 if '0' <= b && b <= '9' {
692 continue
693 }
694
695 if i == 0 || b == '_' {
696 return false
697 }
698
699 // bulk-writing the chunk is this func's whole point
700 w.Write(chunk[:i])
701
702 jr.r.Discard(i)
703 jr.pos += i
704 return true
705 }
706
707 // maybe the digits-run is ok, but it's just longer than the chunk
708 return false
709 }
710
711 // handleDot handles pseudo-JSON numbers which start with a decimal dot
712 func handleDot(w *bufio.Writer, jr *jsonReader) error {
713 if err := jr.demandSyntax('.'); err != nil {
714 return err
715 }
716 w.Write([]byte{'0', '.'})
717 return handleDigits(w, jr)
718 }
719
720 // handleKey is used by func handleObjects and generalizes func handleString,
721 // by allowing unquoted object keys; it's not used anywhere else, as allowing
722 // unquoted string values is ambiguous with actual JSON-keyword values null,
723 // false, and true.
724 func handleKey(w *bufio.Writer, jr *jsonReader) error {
725 quote, ok := jr.peekByte()
726 if !ok {
727 return jr.improveError(errStringEarlyEnd)
728 }
729
730 if quote == '"' || quote == '\'' {
731 return handleString(w, jr, quote)
732 }
733
734 w.WriteByte('"')
735 for {
736 if b, _ := jr.peekByte(); isIdentifier[b] {
737 jr.readByte()
738 w.WriteByte(b)
739 continue
740 }
741
742 w.WriteByte('"')
743 return nil
744 }
745 }
746
747 // trySimpleString tries to handle (more quickly) inner-strings where all bytes
748 // are unescaped ASCII symbols: this is a very common case for strings, and is
749 // almost always the case for object keys; returns whether it succeeded, so
750 // this func's caller knows knows if it needs to do anything, the slower way
751 func trySimpleString(w *bufio.Writer, jr *jsonReader, quote byte) (gotIt bool) {
752 end := -1
753 chunk, _ := jr.r.Peek(chunkPeekSize)
754
755 for i, b := range chunk {
756 if 32 <= b && b <= 127 && b != '\\' && b != '\'' && b != '"' {
757 continue
758 }
759
760 if b == byte(quote) {
761 end = i
762 break
763 }
764 return false
765 }
766
767 if end < 0 {
768 return false
769 }
770
771 // bulk-writing the chunk is this func's whole point
772 w.WriteByte('"')
773 w.Write(chunk[:end])
774 w.WriteByte('"')
775
776 jr.r.Discard(end + 1)
777 jr.pos += end + 1
778 return true
779 }
780
781 // handleKeyword is used by funcs handleFalse, handleNull, and handleTrue
782 func handleKeyword(w *bufio.Writer, jr *jsonReader, kw []byte) error {
783 for rest := kw; len(rest) > 0; rest = rest[1:] {
784 b, err := jr.readByte()
785 if err == nil && b == rest[0] {
786 // keywords given to this func have no line-feeds
787 jr.pos++
788 continue
789 }
790
791 msg := `expected JSON value ` + string(kw)
792 return jr.improveError(errors.New(msg))
793 }
794
795 w.Write(kw)
796 return nil
797 }
798
799 func replaceKeyword(w *bufio.Writer, jr *jsonReader, kw, with []byte) error {
800 for rest := kw; len(rest) > 0; rest = rest[1:] {
801 b, err := jr.readByte()
802 if err == nil && b == rest[0] {
803 // keywords given to this func have no line-feeds
804 jr.pos++
805 continue
806 }
807
808 msg := `expected JSON value ` + string(kw)
809 return jr.improveError(errors.New(msg))
810 }
811
812 w.Write(with)
813 return nil
814 }
815
816 // handleNegative handles numbers starting with a negative sign for func
817 // handleValue
818 func handleNegative(w *bufio.Writer, jr *jsonReader) error {
819 if err := jr.demandSyntax('-'); err != nil {
820 return err
821 }
822
823 w.WriteByte('-')
824 if b, _ := jr.peekByte(); b == '.' {
825 jr.readByte()
826 w.Write([]byte{'0', '.'})
827 return handleDigits(w, jr)
828 }
829 return handleNumber(w, jr)
830 }
831
832 // handleNumber handles numeric values/tokens, including invalid-JSON cases,
833 // such as values starting with a decimal dot
834 func handleNumber(w *bufio.Writer, jr *jsonReader) error {
835 // handle integer digits
836 if err := handleDigits(w, jr); err != nil {
837 return err
838 }
839
840 // handle optional decimal digits, starting with a leading dot
841 if b, _ := jr.peekByte(); b == '.' {
842 jr.readByte()
843 w.WriteByte('.')
844 return handleDigits(w, jr)
845 }
846
847 // handle optional exponent digits
848 if b, _ := jr.peekByte(); b == 'e' || b == 'E' {
849 jr.readByte()
850 w.WriteByte(b)
851 b, _ = jr.peekByte()
852 if b == '+' {
853 jr.readByte()
854 } else if b == '-' {
855 w.WriteByte('-')
856 jr.readByte()
857 }
858 return handleDigits(w, jr)
859 }
860
861 return nil
862 }
863
864 // handleObject handles objects for func handleValue
865 func handleObject(w *bufio.Writer, jr *jsonReader) error {
866 if err := jr.demandSyntax('{'); err != nil {
867 return err
868 }
869 w.WriteByte('{')
870
871 for npairs := 0; true; npairs++ {
872 // there may be whitespace/comments before the next comma
873 if err := jr.seekNext(); err != nil {
874 return err
875 }
876
877 // handle commas between key-value pairs, as well as trailing ones
878 comma := false
879 b, _ := jr.peekByte()
880 if b == ',' {
881 jr.readByte()
882 comma = true
883
884 // there may be whitespace/comments before an ending '}'
885 if err := jr.seekNext(); err != nil {
886 return err
887 }
888 b, _ = jr.peekByte()
889 }
890
891 // handle end of object
892 if b == '}' {
893 jr.readByte()
894 w.WriteByte('}')
895 return nil
896 }
897
898 // don't forget commas between adjacent key-value pairs
899 if npairs > 0 {
900 if !comma {
901 return errNoObjectComma
902 }
903 if err := outputByte(w, ','); err != nil {
904 return err
905 }
906 }
907
908 // handle the next pair's key
909 if err := jr.seekNext(); err != nil {
910 return err
911 }
912 if err := handleKey(w, jr); err != nil {
913 return err
914 }
915
916 // demand a colon right after the key
917 if err := jr.seekNext(); err != nil {
918 return err
919 }
920 if err := jr.demandSyntax(':'); err != nil {
921 return err
922 }
923 w.WriteByte(':')
924
925 // handle the next pair's value
926 if err := jr.seekNext(); err != nil {
927 return err
928 }
929 if err := handleValue(w, jr); err != nil {
930 return err
931 }
932 }
933
934 // make the compiler happy
935 return nil
936 }
937
938 // handlePositive handles numbers starting with a positive sign for func
939 // handleValue
940 func handlePositive(w *bufio.Writer, jr *jsonReader) error {
941 if err := jr.demandSyntax('+'); err != nil {
942 return err
943 }
944
945 // valid JSON isn't supposed to have leading pluses on numbers, so
946 // emit nothing for it, unlike for negative numbers
947
948 if b, _ := jr.peekByte(); b == '.' {
949 jr.readByte()
950 w.Write([]byte{'0', '.'})
951 return handleDigits(w, jr)
952 }
953 return handleNumber(w, jr)
954 }
955
956 // handleString handles strings for funcs handleValue and handleObject, and
957 // supports both single-quotes and double-quotes, always emitting the latter
958 // in the output, of course
959 func handleString(w *bufio.Writer, jr *jsonReader, quote byte) error {
960 if quote != '"' && quote != '\'' {
961 return errNoStringQuote
962 }
963
964 jr.readByte()
965
966 // try the quicker no-escapes ASCII handler
967 if trySimpleString(w, jr, quote) {
968 return nil
969 }
970
971 // it's a non-trivial inner-string, so handle it byte-by-byte
972 w.WriteByte('"')
973 escaped := false
974
975 for quote := rune(quote); true; {
976 r, err := jr.readRune()
977 if r == unicode.ReplacementChar {
978 return jr.improveError(errInvalidRune)
979 }
980 if err != nil {
981 if err == io.EOF {
982 return jr.improveError(errStringEarlyEnd)
983 }
984 return jr.improveError(err)
985 }
986
987 if !escaped {
988 if r == '\\' {
989 escaped = true
990 continue
991 }
992
993 // handle end of string
994 if r == quote {
995 return outputByte(w, '"')
996 }
997
998 if r <= 127 {
999 w.Write(escapedStringBytes[byte(r)])
1000 } else {
1001 w.WriteRune(r)
1002 }
1003 continue
1004 }
1005
1006 // handle escaped items
1007 escaped = false
1008
1009 switch r {
1010 case 'u':
1011 // \u needs exactly 4 hex-digits to follow it
1012 w.Write([]byte{'\\', 'u'})
1013 if err := copyHex(w, 4, jr); err != nil {
1014 return jr.improveError(err)
1015 }
1016
1017 case 'x':
1018 // JSON only supports 4 escaped hex-digits, so pad the 2
1019 // expected hex-digits with 2 zeros
1020 w.Write([]byte{'\\', 'u', '0', '0'})
1021 if err := copyHex(w, 2, jr); err != nil {
1022 return jr.improveError(err)
1023 }
1024
1025 case 't', 'f', 'r', 'n', 'b', '\\', '"':
1026 // handle valid-JSON escaped string sequences
1027 w.WriteByte('\\')
1028 w.WriteByte(byte(r))
1029
1030 case '\'':
1031 // escaped single-quotes aren't standard JSON, but they can
1032 // be handy when the input uses non-standard single-quoted
1033 // strings
1034 w.WriteByte('\'')
1035
1036 default:
1037 if r <= 127 {
1038 w.Write(escapedStringBytes[byte(r)])
1039 } else {
1040 w.WriteRune(r)
1041 }
1042 }
1043 }
1044
1045 return nil
1046 }
1047
1048 // copyHex handles a run of hex-digits for func handleString, starting right
1049 // after the leading `\u` (or `\x`) part; this func doesn't `improve` its
1050 // errors with position info: that's up to the caller
1051 func copyHex(w *bufio.Writer, n int, jr *jsonReader) error {
1052 for i := 0; i < n; i++ {
1053 b, err := jr.readByte()
1054 if err == io.EOF {
1055 return errStringEarlyEnd
1056 }
1057 if err != nil {
1058 return err
1059 }
1060
1061 if b >= 128 {
1062 return errInvalidHex
1063 }
1064
1065 if b := matchHex[b]; b != 0 {
1066 w.WriteByte(b)
1067 continue
1068 }
1069
1070 return errInvalidHex
1071 }
1072
1073 return nil
1074 }
1075
1076 // handleValue is a generic JSON-token handler, which allows the recursive
1077 // behavior to handle any kind of JSON/pseudo-JSON input
1078 func handleValue(w *bufio.Writer, jr *jsonReader) error {
1079 chunk, err := jr.r.Peek(1)
1080 if err == nil && len(chunk) >= 1 {
1081 return handleValueDispatch(w, jr, chunk[0])
1082 }
1083
1084 if err == io.EOF {
1085 return jr.improveError(errInputEarlyEnd)
1086 }
1087 return jr.improveError(errInputEarlyEnd)
1088 }
1089
1090 // handleValueDispatch simplifies control-flow for func handleValue
1091 func handleValueDispatch(w *bufio.Writer, jr *jsonReader, b byte) error {
1092 switch b {
1093 case '#':
1094 return jr.skipLine()
1095 case 'f':
1096 return handleKeyword(w, jr, []byte{'f', 'a', 'l', 's', 'e'})
1097 case 'n':
1098 return handleKeyword(w, jr, []byte{'n', 'u', 'l', 'l'})
1099 case 't':
1100 return handleKeyword(w, jr, []byte{'t', 'r', 'u', 'e'})
1101 case 'F':
1102 return replaceKeyword(w, jr, []byte(`False`), []byte(`false`))
1103 case 'N':
1104 return replaceKeyword(w, jr, []byte(`None`), []byte(`null`))
1105 case 'T':
1106 return replaceKeyword(w, jr, []byte(`True`), []byte(`true`))
1107 case '.':
1108 return handleDot(w, jr)
1109 case '+':
1110 return handlePositive(w, jr)
1111 case '-':
1112 return handleNegative(w, jr)
1113 case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
1114 return handleNumber(w, jr)
1115 case '\'', '"':
1116 return handleString(w, jr, b)
1117 case '[', '(':
1118 return handleArray(w, jr, b)
1119 case '{':
1120 return handleObject(w, jr)
1121 default:
1122 return jr.improveError(errInvalidToken)
1123 }
1124 }
1125
1126 // escapedStringBytes helps func handleString treat all string bytes quickly
1127 // and correctly, using their officially-supported JSON escape sequences
1128 //
1129 // https://www.rfc-editor.org/rfc/rfc8259#section-7
1130 var escapedStringBytes = [256][]byte{
1131 {'\\', 'u', '0', '0', '0', '0'}, {'\\', 'u', '0', '0', '0', '1'},
1132 {'\\', 'u', '0', '0', '0', '2'}, {'\\', 'u', '0', '0', '0', '3'},
1133 {'\\', 'u', '0', '0', '0', '4'}, {'\\', 'u', '0', '0', '0', '5'},
1134 {'\\', 'u', '0', '0', '0', '6'}, {'\\', 'u', '0', '0', '0', '7'},
1135 {'\\', 'b'}, {'\\', 't'},
1136 {'\\', 'n'}, {'\\', 'u', '0', '0', '0', 'b'},
1137 {'\\', 'f'}, {'\\', 'r'},
1138 {'\\', 'u', '0', '0', '0', 'e'}, {'\\', 'u', '0', '0', '0', 'f'},
1139 {'\\', 'u', '0', '0', '1', '0'}, {'\\', 'u', '0', '0', '1', '1'},
1140 {'\\', 'u', '0', '0', '1', '2'}, {'\\', 'u', '0', '0', '1', '3'},
1141 {'\\', 'u', '0', '0', '1', '4'}, {'\\', 'u', '0', '0', '1', '5'},
1142 {'\\', 'u', '0', '0', '1', '6'}, {'\\', 'u', '0', '0', '1', '7'},
1143 {'\\', 'u', '0', '0', '1', '8'}, {'\\', 'u', '0', '0', '1', '9'},
1144 {'\\', 'u', '0', '0', '1', 'a'}, {'\\', 'u', '0', '0', '1', 'b'},
1145 {'\\', 'u', '0', '0', '1', 'c'}, {'\\', 'u', '0', '0', '1', 'd'},
1146 {'\\', 'u', '0', '0', '1', 'e'}, {'\\', 'u', '0', '0', '1', 'f'},
1147 {32}, {33}, {'\\', '"'}, {35}, {36}, {37}, {38}, {39},
1148 {40}, {41}, {42}, {43}, {44}, {45}, {46}, {47},
1149 {48}, {49}, {50}, {51}, {52}, {53}, {54}, {55},
1150 {56}, {57}, {58}, {59}, {60}, {61}, {62}, {63},
1151 {64}, {65}, {66}, {67}, {68}, {69}, {70}, {71},
1152 {72}, {73}, {74}, {75}, {76}, {77}, {78}, {79},
1153 {80}, {81}, {82}, {83}, {84}, {85}, {86}, {87},
1154 {88}, {89}, {90}, {91}, {'\\', '\\'}, {93}, {94}, {95},
1155 {96}, {97}, {98}, {99}, {100}, {101}, {102}, {103},
1156 {104}, {105}, {106}, {107}, {108}, {109}, {110}, {111},
1157 {112}, {113}, {114}, {115}, {116}, {117}, {118}, {119},
1158 {120}, {121}, {122}, {123}, {124}, {125}, {126}, {127},
1159 {128}, {129}, {130}, {131}, {132}, {133}, {134}, {135},
1160 {136}, {137}, {138}, {139}, {140}, {141}, {142}, {143},
1161 {144}, {145}, {146}, {147}, {148}, {149}, {150}, {151},
1162 {152}, {153}, {154}, {155}, {156}, {157}, {158}, {159},
1163 {160}, {161}, {162}, {163}, {164}, {165}, {166}, {167},
1164 {168}, {169}, {170}, {171}, {172}, {173}, {174}, {175},
1165 {176}, {177}, {178}, {179}, {180}, {181}, {182}, {183},
1166 {184}, {185}, {186}, {187}, {188}, {189}, {190}, {191},
1167 {192}, {193}, {194}, {195}, {196}, {197}, {198}, {199},
1168 {200}, {201}, {202}, {203}, {204}, {205}, {206}, {207},
1169 {208}, {209}, {210}, {211}, {212}, {213}, {214}, {215},
1170 {216}, {217}, {218}, {219}, {220}, {221}, {222}, {223},
1171 {224}, {225}, {226}, {227}, {228}, {229}, {230}, {231},
1172 {232}, {233}, {234}, {235}, {236}, {237}, {238}, {239},
1173 {240}, {241}, {242}, {243}, {244}, {245}, {246}, {247},
1174 {248}, {249}, {250}, {251}, {252}, {253}, {254}, {255},
1175 }
File: ./json0/json_test.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package json0
26
27 import (
28 "bufio"
29 "bytes"
30 "io"
31 "strings"
32 "testing"
33 )
34
35 func TestJSON0(t *testing.T) {
36 var tests = []struct {
37 Input string
38 Expected string
39 }{
40 {`false`, `false`},
41 {`null`, `null`},
42 {` true `, `true`},
43
44 {`False`, `false`},
45 {`None`, `null`},
46 {` True `, `true`},
47
48 {`0`, `0`},
49 {`1`, `1`},
50 {`2`, `2`},
51 {`3`, `3`},
52 {`4`, `4`},
53 {`5`, `5`},
54 {`6`, `6`},
55 {`7`, `7`},
56 {`8`, `8`},
57 {`9`, `9`},
58
59 {` .345`, `0.345`},
60 {` -.345`, `-0.345`},
61 {` +.345`, `0.345`},
62 {` +123.345`, `123.345`},
63 {` +.345`, `0.345`},
64 {` 123.34523`, `123.34523`},
65 {` 123.34_523`, `123.34523`},
66 {` 123_456.123`, `123456.123`},
67
68 {`""`, `""`},
69 {`''`, `""`},
70 {`"\""`, `"\""`},
71 {`'\"'`, `"\""`},
72 {`'\''`, `"'"`},
73 {`'abc\u0e9A'`, `"abc\u0E9A"`},
74 {`'abc\x1f[0m'`, `"abc\u001F[0m"`},
75 {`"abc●def"`, `"abc●def"`},
76
77 {`[ ]`, `[]`},
78 {`[ , ]`, `[]`},
79 {`[.345, false,null , ]`, `[0.345,false,null]`},
80
81 {`( )`, `[]`},
82 {`( , )`, `[]`},
83 {`(.345, false,null , )`, `[0.345,false,null]`},
84
85 {`{ }`, `{}`},
86 {`{ , }`, `{}`},
87
88 {
89 `{ 'abc': .345, "def" : false, 'xyz':null , }`,
90 `{"abc":0.345,"def":false,"xyz":null}`,
91 },
92
93 {`{0problems:123,}`, `{"0problems":123}`},
94 {`{0_problems:123}`, `{"0_problems":123}`},
95 }
96
97 for _, tc := range tests {
98 t.Run(tc.Input, func(t *testing.T) {
99 var out strings.Builder
100 w := bufio.NewWriter(&out)
101 r := bufio.NewReader(strings.NewReader(tc.Input))
102 if err := json0(w, r, false); err != nil && err != io.EOF {
103 t.Fatal(err)
104 return
105 }
106 // don't forget to flush the buffer, or output will be empty
107 w.Flush()
108
109 // output may have a final line-feed: get rid of it, or every
110 // single test-case will fail
111 got := out.String()
112 if len(got) > 0 && got[len(got)-1] == '\n' {
113 got = got[:len(got)-1]
114 }
115
116 if got != tc.Expected {
117 t.Fatalf("<got>\n%s\n<expected>\n%s", got, tc.Expected)
118 return
119 }
120 })
121 }
122 }
123
124 func TestEscapedStringBytes(t *testing.T) {
125 var escaped = map[rune][]byte{
126 '\x00': {'\\', 'u', '0', '0', '0', '0'},
127 '\x01': {'\\', 'u', '0', '0', '0', '1'},
128 '\x02': {'\\', 'u', '0', '0', '0', '2'},
129 '\x03': {'\\', 'u', '0', '0', '0', '3'},
130 '\x04': {'\\', 'u', '0', '0', '0', '4'},
131 '\x05': {'\\', 'u', '0', '0', '0', '5'},
132 '\x06': {'\\', 'u', '0', '0', '0', '6'},
133 '\x07': {'\\', 'u', '0', '0', '0', '7'},
134 '\x0b': {'\\', 'u', '0', '0', '0', 'b'},
135 '\x0e': {'\\', 'u', '0', '0', '0', 'e'},
136 '\x0f': {'\\', 'u', '0', '0', '0', 'f'},
137 '\x10': {'\\', 'u', '0', '0', '1', '0'},
138 '\x11': {'\\', 'u', '0', '0', '1', '1'},
139 '\x12': {'\\', 'u', '0', '0', '1', '2'},
140 '\x13': {'\\', 'u', '0', '0', '1', '3'},
141 '\x14': {'\\', 'u', '0', '0', '1', '4'},
142 '\x15': {'\\', 'u', '0', '0', '1', '5'},
143 '\x16': {'\\', 'u', '0', '0', '1', '6'},
144 '\x17': {'\\', 'u', '0', '0', '1', '7'},
145 '\x18': {'\\', 'u', '0', '0', '1', '8'},
146 '\x19': {'\\', 'u', '0', '0', '1', '9'},
147 '\x1a': {'\\', 'u', '0', '0', '1', 'a'},
148 '\x1b': {'\\', 'u', '0', '0', '1', 'b'},
149 '\x1c': {'\\', 'u', '0', '0', '1', 'c'},
150 '\x1d': {'\\', 'u', '0', '0', '1', 'd'},
151 '\x1e': {'\\', 'u', '0', '0', '1', 'e'},
152 '\x1f': {'\\', 'u', '0', '0', '1', 'f'},
153
154 '\t': {'\\', 't'},
155 '\f': {'\\', 'f'},
156 '\b': {'\\', 'b'},
157 '\r': {'\\', 'r'},
158 '\n': {'\\', 'n'},
159 '\\': {'\\', '\\'},
160 '"': {'\\', '"'},
161 }
162
163 if n := len(escapedStringBytes); n != 256 {
164 t.Errorf(`expected 256 entries, instead of %d`, n)
165 }
166
167 for i, v := range escapedStringBytes {
168 exp := []byte{byte(i)}
169 if esc, ok := escaped[rune(i)]; ok {
170 exp = esc
171 }
172
173 if !bytes.Equal(v, exp) {
174 t.Errorf("%d: expected %#v, got %#v", i, exp, v)
175 }
176 }
177 }
File: ./json0/tables_test.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package json0
26
27 import (
28 "bytes"
29 "encoding/json"
30 "testing"
31 )
32
33 func TestTables(t *testing.T) {
34 var buf []byte
35
36 for i := 0; i < 128; i++ {
37 r := rune(i)
38 exp := buf[:0]
39 exp = append(exp, '"')
40 exp = append(exp, escapedStringBytes[i]...)
41 exp = append(exp, '"')
42
43 got, err := json.Marshal(string(r))
44 if err != nil {
45 t.Error(err)
46 continue
47 }
48
49 if bytes.Equal(got, exp) {
50 continue
51 }
52
53 // can't fully rely on the JSON string-encoder from the stdlib
54 switch r {
55 case '&', '<', '>':
56 continue
57 }
58
59 const fs = "escaped-strings entry @ %d: expected %q, got %q"
60 t.Errorf(fs, i, string(exp), string(got))
61 }
62 }
File: ./json2/json2.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package json2
26
27 import (
28 "bufio"
29 "encoding/json"
30 "errors"
31 "io"
32 "os"
33 )
34
35 const info = `
36 json2 [filepath...]
37
38 JSON-2 indents valid JSON input into multi-line JSON which uses 2 spaces for
39 each indentation level.
40 `
41
42 func Main() {
43 args := os.Args[1:]
44
45 if len(args) > 0 {
46 switch args[0] {
47 case `-h`, `--h`, `-help`, `--help`:
48 os.Stdout.WriteString(info[1:])
49 return
50 }
51 }
52
53 if len(args) > 0 && args[0] == `--` {
54 args = args[1:]
55 }
56
57 if len(args) > 1 {
58 const msg = "multiple inputs aren't allowed\n"
59 os.Stderr.WriteString(msg)
60 os.Exit(1)
61 }
62
63 // figure out whether input should come from a named file or from stdin
64 name := `-`
65 if len(args) == 1 {
66 name = args[0]
67 }
68
69 if err := handleInput(os.Stdout, name); err != nil && err != io.EOF {
70 os.Stderr.WriteString(err.Error())
71 os.Stderr.WriteString("\n")
72 os.Exit(1)
73 }
74 }
75
76 // handleInput simplifies control-flow for func main
77 func handleInput(w io.Writer, path string) error {
78 if path == `-` {
79 return convert(w, os.Stdin)
80 }
81
82 f, err := os.Open(path)
83 if err != nil {
84 // on windows, file-not-found error messages may mention `CreateFile`,
85 // even when trying to open files in read-only mode
86 return errors.New(`can't open file named ` + path)
87 }
88 defer f.Close()
89 return convert(w, f)
90 }
91
92 // convert simplifies control-flow for func handleInput
93 func convert(w io.Writer, r io.Reader) error {
94 bw := bufio.NewWriter(w)
95 defer bw.Flush()
96 return json2(bw, r)
97 }
98
99 // escapedStringBytes helps func handleString treat all string bytes quickly
100 // and correctly, using their officially-supported JSON escape sequences
101 //
102 // https://www.rfc-editor.org/rfc/rfc8259#section-7
103 var escapedStringBytes = [256][]byte{
104 {'\\', 'u', '0', '0', '0', '0'}, {'\\', 'u', '0', '0', '0', '1'},
105 {'\\', 'u', '0', '0', '0', '2'}, {'\\', 'u', '0', '0', '0', '3'},
106 {'\\', 'u', '0', '0', '0', '4'}, {'\\', 'u', '0', '0', '0', '5'},
107 {'\\', 'u', '0', '0', '0', '6'}, {'\\', 'u', '0', '0', '0', '7'},
108 {'\\', 'b'}, {'\\', 't'},
109 {'\\', 'n'}, {'\\', 'u', '0', '0', '0', 'b'},
110 {'\\', 'f'}, {'\\', 'r'},
111 {'\\', 'u', '0', '0', '0', 'e'}, {'\\', 'u', '0', '0', '0', 'f'},
112 {'\\', 'u', '0', '0', '1', '0'}, {'\\', 'u', '0', '0', '1', '1'},
113 {'\\', 'u', '0', '0', '1', '2'}, {'\\', 'u', '0', '0', '1', '3'},
114 {'\\', 'u', '0', '0', '1', '4'}, {'\\', 'u', '0', '0', '1', '5'},
115 {'\\', 'u', '0', '0', '1', '6'}, {'\\', 'u', '0', '0', '1', '7'},
116 {'\\', 'u', '0', '0', '1', '8'}, {'\\', 'u', '0', '0', '1', '9'},
117 {'\\', 'u', '0', '0', '1', 'a'}, {'\\', 'u', '0', '0', '1', 'b'},
118 {'\\', 'u', '0', '0', '1', 'c'}, {'\\', 'u', '0', '0', '1', 'd'},
119 {'\\', 'u', '0', '0', '1', 'e'}, {'\\', 'u', '0', '0', '1', 'f'},
120 {32}, {33}, {'\\', '"'}, {35}, {36}, {37}, {38}, {39},
121 {40}, {41}, {42}, {43}, {44}, {45}, {46}, {47},
122 {48}, {49}, {50}, {51}, {52}, {53}, {54}, {55},
123 {56}, {57}, {58}, {59}, {60}, {61}, {62}, {63},
124 {64}, {65}, {66}, {67}, {68}, {69}, {70}, {71},
125 {72}, {73}, {74}, {75}, {76}, {77}, {78}, {79},
126 {80}, {81}, {82}, {83}, {84}, {85}, {86}, {87},
127 {88}, {89}, {90}, {91}, {'\\', '\\'}, {93}, {94}, {95},
128 {96}, {97}, {98}, {99}, {100}, {101}, {102}, {103},
129 {104}, {105}, {106}, {107}, {108}, {109}, {110}, {111},
130 {112}, {113}, {114}, {115}, {116}, {117}, {118}, {119},
131 {120}, {121}, {122}, {123}, {124}, {125}, {126}, {127},
132 {128}, {129}, {130}, {131}, {132}, {133}, {134}, {135},
133 {136}, {137}, {138}, {139}, {140}, {141}, {142}, {143},
134 {144}, {145}, {146}, {147}, {148}, {149}, {150}, {151},
135 {152}, {153}, {154}, {155}, {156}, {157}, {158}, {159},
136 {160}, {161}, {162}, {163}, {164}, {165}, {166}, {167},
137 {168}, {169}, {170}, {171}, {172}, {173}, {174}, {175},
138 {176}, {177}, {178}, {179}, {180}, {181}, {182}, {183},
139 {184}, {185}, {186}, {187}, {188}, {189}, {190}, {191},
140 {192}, {193}, {194}, {195}, {196}, {197}, {198}, {199},
141 {200}, {201}, {202}, {203}, {204}, {205}, {206}, {207},
142 {208}, {209}, {210}, {211}, {212}, {213}, {214}, {215},
143 {216}, {217}, {218}, {219}, {220}, {221}, {222}, {223},
144 {224}, {225}, {226}, {227}, {228}, {229}, {230}, {231},
145 {232}, {233}, {234}, {235}, {236}, {237}, {238}, {239},
146 {240}, {241}, {242}, {243}, {244}, {245}, {246}, {247},
147 {248}, {249}, {250}, {251}, {252}, {253}, {254}, {255},
148 }
149
150 // writeSpaces does what it says, minimizing calls to write-like funcs
151 func writeSpaces(w *bufio.Writer, n int) {
152 const spaces = ` `
153 if n < 1 {
154 return
155 }
156
157 for n >= len(spaces) {
158 w.WriteString(spaces)
159 n -= len(spaces)
160 }
161 w.WriteString(spaces[:n])
162 }
163
164 // json2 does it all, given a reader and a writer
165 func json2(w *bufio.Writer, r io.Reader) error {
166 dec := json.NewDecoder(r)
167 // avoid parsing numbers, so unusually-long numbers are kept verbatim,
168 // even if JSON parsers aren't required to guarantee such input-fidelity
169 // for numbers
170 dec.UseNumber()
171
172 t, err := dec.Token()
173 if err == io.EOF {
174 return errors.New(`input has no JSON values`)
175 }
176
177 if err = handleToken(w, dec, t, 0, 0); err != nil {
178 return err
179 }
180 // don't forget ending the last line for the last value
181 w.WriteByte('\n')
182
183 _, err = dec.Token()
184 if err == io.EOF {
185 // input is over, so it's a success
186 return nil
187 }
188
189 if err == nil {
190 // a successful `read` is a failure, as it means there are
191 // trailing JSON tokens
192 return errors.New(`unexpected trailing data`)
193 }
194
195 // any other error, perhaps some invalid-JSON-syntax-type error
196 return err
197 }
198
199 // handleToken handles recursion for func json2
200 func handleToken(w *bufio.Writer, dec *json.Decoder, t json.Token, pre, level int) error {
201 switch t := t.(type) {
202 case json.Delim:
203 switch t {
204 case json.Delim('['):
205 return handleArray(w, dec, pre, level)
206 case json.Delim('{'):
207 return handleObject(w, dec, pre, level)
208 default:
209 return errors.New(`unsupported JSON syntax ` + string(t))
210 }
211
212 case nil:
213 writeSpaces(w, 2*pre)
214 w.WriteString(`null`)
215 return nil
216
217 case bool:
218 writeSpaces(w, 2*pre)
219 if t {
220 w.WriteString(`true`)
221 } else {
222 w.WriteString(`false`)
223 }
224 return nil
225
226 case json.Number:
227 writeSpaces(w, 2*pre)
228 w.WriteString(t.String())
229 return nil
230
231 case string:
232 return handleString(w, t, pre)
233
234 default:
235 // return fmt.Errorf(`unsupported token type %T`, t)
236 return errors.New(`invalid JSON token`)
237 }
238 }
239
240 // handleArray handles arrays for func handleToken
241 func handleArray(w *bufio.Writer, dec *json.Decoder, pre, level int) error {
242 for i := 0; true; i++ {
243 t, err := dec.Token()
244 if err != nil {
245 return err
246 }
247
248 if t == json.Delim(']') {
249 if i == 0 {
250 writeSpaces(w, 2*pre)
251 w.WriteByte('[')
252 w.WriteByte(']')
253 } else {
254 w.WriteByte('\n')
255 writeSpaces(w, 2*level)
256 w.WriteByte(']')
257 }
258 return nil
259 }
260
261 if i == 0 {
262 writeSpaces(w, 2*pre)
263 w.WriteByte('[')
264 w.WriteByte('\n')
265 } else {
266 w.WriteByte(',')
267 w.WriteByte('\n')
268 if err := w.Flush(); err != nil {
269 // a write error may be the consequence of stdout being closed,
270 // perhaps by another app along a pipe
271 return io.EOF
272 }
273 }
274
275 err = handleToken(w, dec, t, level+1, level+1)
276 if err != nil {
277 return err
278 }
279 }
280
281 // make the compiler happy
282 return nil
283 }
284
285 // handleObject handles objects for func handleToken
286 func handleObject(w *bufio.Writer, dec *json.Decoder, pre, level int) error {
287 for i := 0; true; i++ {
288 t, err := dec.Token()
289 if err != nil {
290 return err
291 }
292
293 if t == json.Delim('}') {
294 if i == 0 {
295 writeSpaces(w, 2*pre)
296 w.WriteByte('{')
297 w.WriteByte('}')
298 } else {
299 w.WriteByte('\n')
300 writeSpaces(w, 2*level)
301 w.WriteByte('}')
302 }
303 return nil
304 }
305
306 if i == 0 {
307 writeSpaces(w, 2*pre)
308 w.WriteByte('{')
309 w.WriteByte('\n')
310 } else {
311 w.WriteByte(',')
312 w.WriteByte('\n')
313 }
314
315 k, ok := t.(string)
316 if !ok {
317 return errors.New(`expected a string for a key-value pair`)
318 }
319
320 err = handleString(w, k, level+1)
321 if err != nil {
322 return err
323 }
324
325 w.WriteString(": ")
326
327 t, err = dec.Token()
328 if err == io.EOF {
329 return errors.New(`expected a value for a key-value pair`)
330 }
331
332 err = handleToken(w, dec, t, 0, level+1)
333 if err != nil {
334 return err
335 }
336 }
337
338 // make the compiler happy
339 return nil
340 }
341
342 // handleString handles strings for func handleToken, and keys for func
343 // handleObject
344 func handleString(w *bufio.Writer, s string, level int) error {
345 writeSpaces(w, 2*level)
346 w.WriteByte('"')
347 for i := range s {
348 w.Write(escapedStringBytes[s[i]])
349 }
350 w.WriteByte('"')
351 return nil
352 }
File: ./jsonl/jsonl.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package jsonl
26
27 import (
28 "bufio"
29 "encoding/json"
30 "errors"
31 "io"
32 "os"
33 )
34
35 const info = `
36 jsonl [options...] [filepaths...]
37
38 JSON Lines turns valid JSON-input arrays into separate JSON lines, one for
39 each top-level item. Non-arrays result in a single JSON-line.
40
41 When not given a filepath to load, standard input is used instead. Every
42 output line is always a single top-level item from the input.
43 `
44
45 func Main() {
46 args := os.Args[1:]
47 buffered := false
48
49 for len(args) > 0 {
50 switch args[0] {
51 case `-b`, `--b`, `-buffered`, `--buffered`:
52 buffered = true
53 args = args[1:]
54 continue
55
56 case `-h`, `--h`, `-help`, `--help`:
57 os.Stdout.WriteString(info[1:])
58 return
59 }
60
61 break
62 }
63
64 if len(args) > 0 && args[0] == `--` {
65 args = args[1:]
66 }
67
68 liveLines := !buffered
69 if !buffered {
70 if _, err := os.Stdout.Seek(0, io.SeekCurrent); err == nil {
71 liveLines = false
72 }
73 }
74
75 if err := run(os.Stdout, args, liveLines); err != nil && err != io.EOF {
76 os.Stderr.WriteString(err.Error())
77 os.Stderr.WriteString("\n")
78 os.Exit(1)
79 }
80 }
81
82 func run(w io.Writer, args []string, liveLines bool) error {
83 dashes := 0
84 for _, path := range args {
85 if path == `-` {
86 dashes++
87 }
88 if dashes > 1 {
89 return errors.New(`can't use stdin (dash) more than once`)
90 }
91 }
92
93 bw := bufio.NewWriter(w)
94 defer bw.Flush()
95
96 if len(args) == 0 {
97 return handleInput(bw, `-`, liveLines)
98 }
99
100 for _, path := range args {
101 if err := handleInput(bw, path, liveLines); err != nil {
102 return err
103 }
104 }
105 return nil
106 }
107
108 // handleInput simplifies control-flow for func main
109 func handleInput(w *bufio.Writer, path string, liveLines bool) error {
110 if path == `-` {
111 return jsonl(w, os.Stdin, liveLines)
112 }
113
114 f, err := os.Open(path)
115 if err != nil {
116 // on windows, file-not-found error messages may mention `CreateFile`,
117 // even when trying to open files in read-only mode
118 return errors.New(`can't open file named ` + path)
119 }
120 defer f.Close()
121 return jsonl(w, f, liveLines)
122 }
123
124 // escapedStringBytes helps func handleString treat all string bytes quickly
125 // and correctly, using their officially-supported JSON escape sequences
126 //
127 // https://www.rfc-editor.org/rfc/rfc8259#section-7
128 var escapedStringBytes = [256][]byte{
129 {'\\', 'u', '0', '0', '0', '0'}, {'\\', 'u', '0', '0', '0', '1'},
130 {'\\', 'u', '0', '0', '0', '2'}, {'\\', 'u', '0', '0', '0', '3'},
131 {'\\', 'u', '0', '0', '0', '4'}, {'\\', 'u', '0', '0', '0', '5'},
132 {'\\', 'u', '0', '0', '0', '6'}, {'\\', 'u', '0', '0', '0', '7'},
133 {'\\', 'b'}, {'\\', 't'},
134 {'\\', 'n'}, {'\\', 'u', '0', '0', '0', 'b'},
135 {'\\', 'f'}, {'\\', 'r'},
136 {'\\', 'u', '0', '0', '0', 'e'}, {'\\', 'u', '0', '0', '0', 'f'},
137 {'\\', 'u', '0', '0', '1', '0'}, {'\\', 'u', '0', '0', '1', '1'},
138 {'\\', 'u', '0', '0', '1', '2'}, {'\\', 'u', '0', '0', '1', '3'},
139 {'\\', 'u', '0', '0', '1', '4'}, {'\\', 'u', '0', '0', '1', '5'},
140 {'\\', 'u', '0', '0', '1', '6'}, {'\\', 'u', '0', '0', '1', '7'},
141 {'\\', 'u', '0', '0', '1', '8'}, {'\\', 'u', '0', '0', '1', '9'},
142 {'\\', 'u', '0', '0', '1', 'a'}, {'\\', 'u', '0', '0', '1', 'b'},
143 {'\\', 'u', '0', '0', '1', 'c'}, {'\\', 'u', '0', '0', '1', 'd'},
144 {'\\', 'u', '0', '0', '1', 'e'}, {'\\', 'u', '0', '0', '1', 'f'},
145 {32}, {33}, {'\\', '"'}, {35}, {36}, {37}, {38}, {39},
146 {40}, {41}, {42}, {43}, {44}, {45}, {46}, {47},
147 {48}, {49}, {50}, {51}, {52}, {53}, {54}, {55},
148 {56}, {57}, {58}, {59}, {60}, {61}, {62}, {63},
149 {64}, {65}, {66}, {67}, {68}, {69}, {70}, {71},
150 {72}, {73}, {74}, {75}, {76}, {77}, {78}, {79},
151 {80}, {81}, {82}, {83}, {84}, {85}, {86}, {87},
152 {88}, {89}, {90}, {91}, {'\\', '\\'}, {93}, {94}, {95},
153 {96}, {97}, {98}, {99}, {100}, {101}, {102}, {103},
154 {104}, {105}, {106}, {107}, {108}, {109}, {110}, {111},
155 {112}, {113}, {114}, {115}, {116}, {117}, {118}, {119},
156 {120}, {121}, {122}, {123}, {124}, {125}, {126}, {127},
157 {128}, {129}, {130}, {131}, {132}, {133}, {134}, {135},
158 {136}, {137}, {138}, {139}, {140}, {141}, {142}, {143},
159 {144}, {145}, {146}, {147}, {148}, {149}, {150}, {151},
160 {152}, {153}, {154}, {155}, {156}, {157}, {158}, {159},
161 {160}, {161}, {162}, {163}, {164}, {165}, {166}, {167},
162 {168}, {169}, {170}, {171}, {172}, {173}, {174}, {175},
163 {176}, {177}, {178}, {179}, {180}, {181}, {182}, {183},
164 {184}, {185}, {186}, {187}, {188}, {189}, {190}, {191},
165 {192}, {193}, {194}, {195}, {196}, {197}, {198}, {199},
166 {200}, {201}, {202}, {203}, {204}, {205}, {206}, {207},
167 {208}, {209}, {210}, {211}, {212}, {213}, {214}, {215},
168 {216}, {217}, {218}, {219}, {220}, {221}, {222}, {223},
169 {224}, {225}, {226}, {227}, {228}, {229}, {230}, {231},
170 {232}, {233}, {234}, {235}, {236}, {237}, {238}, {239},
171 {240}, {241}, {242}, {243}, {244}, {245}, {246}, {247},
172 {248}, {249}, {250}, {251}, {252}, {253}, {254}, {255},
173 }
174
175 // jsonl does it all, given a reader and a writer
176 func jsonl(w *bufio.Writer, r io.Reader, live bool) error {
177 dec := json.NewDecoder(r)
178 // avoid parsing numbers, so unusually-long numbers are kept verbatim,
179 // even if JSON parsers aren't required to guarantee such input-fidelity
180 // for numbers
181 dec.UseNumber()
182
183 t, err := dec.Token()
184 if err == io.EOF {
185 // return errors.New(`input has no JSON values`)
186 return nil
187 }
188
189 if t == json.Delim('[') {
190 if err := handleTopLevelArray(w, dec, live); err != nil {
191 return err
192 }
193 } else {
194 if err := handleToken(w, dec, t); err != nil {
195 return err
196 }
197 w.WriteByte('\n')
198 }
199
200 _, err = dec.Token()
201 if err == io.EOF {
202 // input is over, so it's a success
203 return nil
204 }
205
206 if err == nil {
207 // a successful `read` is a failure, as it means there are
208 // trailing JSON tokens
209 return errors.New(`unexpected trailing data`)
210 }
211
212 // any other error, perhaps some invalid-JSON-syntax-type error
213 return err
214 }
215
216 // handleToken handles recursion for func json2
217 func handleToken(w *bufio.Writer, dec *json.Decoder, t json.Token) error {
218 switch t := t.(type) {
219 case json.Delim:
220 switch t {
221 case json.Delim('['):
222 return handleArray(w, dec)
223 case json.Delim('{'):
224 return handleObject(w, dec)
225 default:
226 return errors.New(`unsupported JSON syntax ` + string(t))
227 }
228
229 case nil:
230 w.WriteString(`null`)
231 return nil
232
233 case bool:
234 if t {
235 w.WriteString(`true`)
236 } else {
237 w.WriteString(`false`)
238 }
239 return nil
240
241 case json.Number:
242 w.WriteString(t.String())
243 return nil
244
245 case string:
246 return handleString(w, t)
247
248 default:
249 // return fmt.Errorf(`unsupported token type %T`, t)
250 return errors.New(`invalid JSON token`)
251 }
252 }
253
254 func handleTopLevelArray(w *bufio.Writer, dec *json.Decoder, live bool) error {
255 for i := 0; true; i++ {
256 t, err := dec.Token()
257 if err == io.EOF {
258 return nil
259 }
260
261 if err != nil {
262 return err
263 }
264
265 if t == json.Delim(']') {
266 return nil
267 }
268
269 err = handleToken(w, dec, t)
270 if err != nil {
271 return err
272 }
273
274 if w.WriteByte('\n') != nil {
275 return io.EOF
276 }
277
278 if !live {
279 continue
280 }
281
282 if w.Flush() != nil {
283 return io.EOF
284 }
285 }
286
287 // make the compiler happy
288 return nil
289 }
290
291 // handleArray handles arrays for func handleToken
292 func handleArray(w *bufio.Writer, dec *json.Decoder) error {
293 w.WriteByte('[')
294
295 for i := 0; true; i++ {
296 t, err := dec.Token()
297 if err == io.EOF {
298 w.WriteByte(']')
299 return nil
300 }
301
302 if err != nil {
303 return err
304 }
305
306 if t == json.Delim(']') {
307 w.WriteByte(']')
308 return nil
309 }
310
311 if i > 0 {
312 _, err := w.WriteString(", ")
313 if err != nil {
314 return io.EOF
315 }
316 }
317
318 err = handleToken(w, dec, t)
319 if err != nil {
320 return err
321 }
322 }
323
324 // make the compiler happy
325 return nil
326 }
327
328 // handleObject handles objects for func handleToken
329 func handleObject(w *bufio.Writer, dec *json.Decoder) error {
330 w.WriteByte('{')
331
332 for i := 0; true; i++ {
333 t, err := dec.Token()
334 if err == io.EOF {
335 w.WriteByte('}')
336 return nil
337 }
338
339 if err != nil {
340 return err
341 }
342
343 if t == json.Delim('}') {
344 w.WriteByte('}')
345 return nil
346 }
347
348 if i > 0 {
349 _, err := w.WriteString(", ")
350 if err != nil {
351 return io.EOF
352 }
353 }
354
355 k, ok := t.(string)
356 if !ok {
357 return errors.New(`expected a string for a key-value pair`)
358 }
359
360 err = handleString(w, k)
361 if err != nil {
362 return err
363 }
364
365 w.WriteString(": ")
366
367 t, err = dec.Token()
368 if err == io.EOF {
369 return errors.New(`expected a value for a key-value pair`)
370 }
371
372 err = handleToken(w, dec, t)
373 if err != nil {
374 return err
375 }
376 }
377
378 // make the compiler happy
379 return nil
380 }
381
382 // handleString handles strings for func handleToken, and keys for func
383 // handleObject
384 func handleString(w *bufio.Writer, s string) error {
385 w.WriteByte('"')
386 for i := range s {
387 w.Write(escapedStringBytes[s[i]])
388 }
389 w.WriteByte('"')
390 return nil
391 }
File: ./jsons/jsons.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package jsons
26
27 import (
28 "bufio"
29 "io"
30 "os"
31 "strings"
32 )
33
34 const info = `
35 jsons [options...] [filenames...]
36
37 JSON Strings turns TSV (tab-separated values) data into a JSON array of
38 objects whose values are strings or nulls, the latter being used for
39 missing trailing values.
40 `
41
42 func Main() {
43 if len(os.Args) > 1 {
44 switch os.Args[1] {
45 case `-h`, `--h`, `-help`, `--help`:
46 os.Stdout.WriteString(info[1:])
47 return
48 }
49 }
50
51 err := run(os.Args[1:])
52 if err == io.EOF {
53 err = nil
54 }
55
56 if err != nil {
57 os.Stderr.WriteString(err.Error())
58 os.Stderr.WriteString("\n")
59 os.Exit(1)
60 }
61 }
62
63 type runConfig struct {
64 lines int
65 keys []string
66 }
67
68 func run(paths []string) error {
69 bw := bufio.NewWriter(os.Stdout)
70 defer bw.Flush()
71
72 dashes := 0
73 var cfg runConfig
74
75 for _, path := range paths {
76 if path == `-` {
77 dashes++
78 if dashes > 1 {
79 continue
80 }
81
82 if err := handleInput(bw, os.Stdin, &cfg); err != nil {
83 return err
84 }
85
86 continue
87 }
88
89 if err := handleFile(bw, path, &cfg); err != nil {
90 return err
91 }
92 }
93
94 if len(paths) == 0 {
95 if err := handleInput(bw, os.Stdin, &cfg); err != nil {
96 return err
97 }
98 }
99
100 if cfg.lines > 1 {
101 bw.WriteString("\n]\n")
102 } else {
103 bw.WriteString("[]\n")
104 }
105 return nil
106 }
107
108 func handleFile(w *bufio.Writer, path string, cfg *runConfig) error {
109 f, err := os.Open(path)
110 if err != nil {
111 return err
112 }
113 defer f.Close()
114 return handleInput(w, f, cfg)
115 }
116
117 func escapeKeys(line string) []string {
118 var keys []string
119 var sb strings.Builder
120
121 loopTSV(line, func(i int, s string) {
122 sb.WriteByte('"')
123 for _, r := range s {
124 if r == '\\' || r == '"' {
125 sb.WriteByte('\\')
126 }
127 sb.WriteRune(r)
128 }
129 sb.WriteByte('"')
130
131 keys = append(keys, sb.String())
132 sb.Reset()
133 })
134
135 return keys
136 }
137
138 func emitRow(w *bufio.Writer, line string, keys []string) {
139 j := 0
140 w.WriteByte('{')
141
142 loopTSV(line, func(i int, s string) {
143 j = i
144 if i > 0 {
145 w.WriteString(", ")
146 }
147
148 w.WriteString(keys[i])
149 w.WriteString(": \"")
150
151 for _, r := range s {
152 if r == '\\' || r == '"' {
153 w.WriteByte('\\')
154 }
155 w.WriteRune(r)
156 }
157 w.WriteByte('"')
158 })
159
160 for i := j + 1; i < len(keys); i++ {
161 if i > 0 {
162 w.WriteString(", ")
163 }
164 w.WriteString(keys[i])
165 w.WriteString(": null")
166 }
167 w.WriteByte('}')
168 }
169
170 func loopTSV(line string, f func(i int, s string)) {
171 for i := 0; len(line) > 0; i++ {
172 pos := strings.IndexByte(line, '\t')
173 if pos < 0 {
174 f(i, line)
175 return
176 }
177
178 f(i, line[:pos])
179 line = line[pos+1:]
180 }
181 }
182
183 func handleInput(w *bufio.Writer, r io.Reader, cfg *runConfig) error {
184 const gb = 1024 * 1024 * 1024
185 sc := bufio.NewScanner(r)
186 sc.Buffer(nil, 8*gb)
187
188 for i := 0; sc.Scan(); i++ {
189 s := sc.Text()
190 if i == 0 && strings.HasPrefix(s, "\xef\xbb\xbf") {
191 s = s[3:]
192 }
193
194 if cfg.lines == 0 {
195 cfg.keys = escapeKeys(s)
196 w.WriteByte('[')
197 cfg.lines++
198 continue
199 }
200
201 if cfg.lines == 1 {
202 w.WriteString("\n ")
203 } else {
204 if _, err := w.WriteString(",\n "); err != nil {
205 return io.EOF
206 }
207 }
208
209 emitRow(w, s, cfg.keys)
210 cfg.lines++
211 }
212
213 return sc.Err()
214 }
File: ./main.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 /*
26 To compile a smaller-sized command-line app, you can use the `go` command as
27 follows:
28
29 go build -ldflags "-s -w" -trimpath
30
31 If you have `tinygo`, you can do even better by instead using:
32
33 tinygo build -no-debug -opt=2 easybox
34 */
35
36 package main
37
38 import (
39 "fmt"
40 "io"
41 "os"
42 "path"
43 "sort"
44 "strings"
45 "unicode/utf8"
46
47 "./avoid"
48 "./bytedump"
49 "./calc"
50 "./catl"
51 "./coby"
52 "./countdown"
53 "./datauri"
54 "./debase64"
55 "./dedup"
56 "./dejsonl"
57 "./dessv"
58 "./ecoli"
59 "./erase"
60 "./files"
61 "./filesizes"
62 "./fixlines"
63 "./folders"
64 "./hima"
65 "./htmlify"
66 "./id3pic"
67 "./json0"
68 "./json2"
69 "./jsonl"
70 "./jsons"
71 "./match"
72 "./ncol"
73 "./ngron"
74 "./nhex"
75 "./njson"
76 "./nn"
77 "./plain"
78 "./primes"
79 "./realign"
80 "./squeeze"
81 "./tcatl"
82 "./teletype"
83 "./utfate"
84 "./waveout"
85 )
86
87 const info = `
88 easybox [options...] [tool] [arguments...]
89
90 This is a collection of many specialized app-like tools, similar to "busybox".
91
92 You can either run it with the tool name as its first argument, or run a link
93 to it whose name is one of those same tools, avoiding the tool-name argument
94 in that case.
95
96 Tool "help" shows you all tools available, as well as all their aliases, and
97 tool "tools" merely lists all main tool-names.
98 `
99
100 // mains has some entries starting as nil to avoid circular-dependency errors
101 var mains = map[string]func(){
102 `avoid`: avoid.Main,
103 `bytedump`: bytedump.Main,
104 `calc`: calc.Main,
105 `catl`: catl.Main,
106 `coby`: coby.Main,
107 `countdown`: countdown.Main,
108 `datauri`: datauri.Main,
109 `debase64`: debase64.Main,
110 `dedup`: dedup.Main,
111 `dejsonl`: dejsonl.Main,
112 `dessv`: dessv.Main,
113 `ecoli`: ecoli.Main,
114 `erase`: erase.Main,
115 `files`: files.Main,
116 `filesizes`: filesizes.Main,
117 `fixlines`: fixlines.Main,
118 `folders`: folders.Main,
119 `help`: nil,
120 `hima`: hima.Main,
121 `htmlify`: htmlify.Main,
122 `id3pic`: id3pic.Main,
123 `json0`: json0.Main,
124 `json2`: json2.Main,
125 `jsonl`: jsonl.Main,
126 `jsons`: jsons.Main,
127 `match`: match.Main,
128 `ncol`: ncol.Main,
129 `ngron`: ngron.Main,
130 `nhex`: nhex.Main,
131 `njson`: njson.Main,
132 `nn`: nn.Main,
133 `plain`: plain.Main,
134 `primes`: primes.Main,
135 `realign`: realign.Main,
136 `squeeze`: squeeze.Main,
137 `tcatl`: tcatl.Main,
138 `teletype`: teletype.Main,
139 `tools`: nil,
140 `utfate`: utfate.Main,
141 `waveout`: waveout.Main,
142 }
143
144 var extras = map[string]func(){
145 `help`: help,
146 `tools`: tools,
147 }
148
149 var aliases = map[string]string{
150 `bytedump`: `bytedump`,
151 `ca`: `calc`,
152 `calculate`: `calc`,
153 `calculator`: `calc`,
154 `fc`: `calc`,
155 `frac`: `calc`,
156 `fraca`: `calc`,
157 `fracalc`: `calc`,
158 `datauri`: `datauri`,
159 `deduplicate`: `dedup`,
160 `unique`: `dedup`,
161 `detrail`: `fixlines`,
162 `id3pic`: `id3pic`,
163 `mp3pic`: `id3pic`,
164 `ncols`: `ncol`,
165 `nicecols`: `ncol`,
166 `nh`: `nhex`,
167 `nj`: `njson`,
168 `nicedigits`: `nn`,
169 `nicenums`: `nn`,
170 `j0`: `json0`,
171 `j2`: `json2`,
172 `jl`: `jsonl`,
173 `detsv`: `jsons`,
174 `tty`: `teletype`,
175 `utf8`: `utfate`,
176 }
177
178 var blurbs = map[string]string{
179 `avoid`: `ignore lines matching any of the regexes given`,
180 `bytedump`: `show bytes as hex values, with a wide ASCII panel`,
181 `calc`: `fractional calculator, with floating-point powers`,
182 `catl`: `conCATenate Lines, ensures text ends with a line-feed`,
183 `coby`: `COunt BYtes, and many other byte/text-related stats`,
184 `countdown`: `countdown the seconds/minutes/hours given`,
185 `datauri`: `turn bytes into data-URIs, auto-detecting MIME types`,
186 `debase64`: `decode base64 text and data-URIs`,
187 `dedup`: `deduplicate lines, emitting each unique line only once`,
188 `dejsonl`: `turn JSON Lines into proper JSON`,
189 `dessv`: `turn tables of space-separated values into TSV tables`,
190 `ecoli`: `expressions coloring lines color-codes matching lines`,
191 `erase`: `ignore/erase all matching regexes away from each line`,
192 `files`: `list all files in the folder(s) given`,
193 `filesizes`: `show sizes of files and block-counts (4K by default)`,
194 `fixlines`: `ignore carriage-returns, or even trailing spaces`,
195 `folders`: `list all folders in the folder(s) given`,
196 `help`: `show the help message for "easybox"`,
197 `hima`: `HIlight MAtches using the regexes given`,
198 `htmlify`: `turn plain-text lines into HTML documents`,
199 `id3pic`: `get the encoded picture out of audio files, if present`,
200 `json0`: `minimize/fix JSON into the smallest-possible size`,
201 `json2`: `indent JSON into multiple lines, using 2 spaces per level`,
202 `jsonl`: `turn items from top-level JSON arrays into JSON Lines`,
203 `jsons`: `JSON Strings turns TSV into arrays of objects of strings`,
204 `match`: `only keep lines matching any of the regexes given`,
205 `ncol`: `Nice COLumns realigns tables, color-coding their values`,
206 `ngron`: `Nice GRON mimics a subset of "gron", using better colors`,
207 `nhex`: `Nice HEXadecimal shows bytes as hex values and ASCII`,
208 `njson`: `Nice JSON indents and color-codes JSON data`,
209 `nn`: `Nice Numbers color-codes groups of digits for legibility`,
210 `plain`: `ignore all ANSI-sequences, leaving unstyled text`,
211 `primes`: `find prime numbers, up to the first million by default`,
212 `realign`: `realign items from the SSV/TSV tables given`,
213 `squeeze`: `aggressively ignore spaces, especially runs of spaces`,
214 `tcatl`: `Titled conCATenate Lines, is like "catl" but with names`,
215 `teletype`: `mimic old-fashioned teletype devices, by delaying output`,
216 `tools`: `list all tools available`,
217 `utfate`: `decode all other types of UTF text into UTF-8`,
218 `waveout`: `emit/calculate WAV-format sounds by formula`,
219 }
220
221 func main() {
222 // add the deliberately-missing lookup entries
223 for k, v := range extras {
224 mains[k] = v
225 }
226
227 // try to use the app's `name`, in case it's being called from a file-link
228 // named after one of the tools
229 if tool, ok := lookupTool(path.Base(os.Args[0])); ok {
230 tool()
231 return
232 }
233
234 // try normal tool-lookup using the first command-line argument
235 if len(os.Args) >= 2 {
236 name := os.Args[1]
237
238 if tool, ok := lookupTool(name); ok {
239 os.Args = os.Args[1:]
240 tool()
241 return
242 }
243
244 switch name {
245 case `-h`, `--h`, `-help`, `--help`, `help`:
246 showHelp(os.Stdout)
247 return
248
249 case `-l`, `--l`, `-list`, `--list`:
250 tools()
251 return
252
253 case `-links`, `--links`:
254 showLinks(os.Stdout)
255 return
256
257 case `-t`, `--t`, `-tools`, `--tools`, `tools`:
258 tools()
259 return
260 }
261
262 const fs = "easybox: tool/alias named %q not found\n"
263 fmt.Fprintf(os.Stderr, fs, name)
264 os.Exit(1)
265 }
266
267 showHelp(os.Stderr)
268 fmt.Fprintln(os.Stderr, ``)
269 fmt.Fprintln(os.Stderr, `easybox: no tool name given`)
270 os.Exit(1)
271 }
272
273 // dealias tries to lookup a string to the aliases given, returning the name
274 // given if the lookup fails
275 func dealias(aliases map[string]string, name string) string {
276 if s, ok := aliases[name]; ok {
277 return s
278 }
279 return name
280 }
281
282 func help() {
283 showHelp(os.Stdout)
284 }
285
286 func lookupTool(name string) (tool func(), ok bool) {
287 name = strings.ReplaceAll(name, `-`, ``)
288 tool, ok = mains[dealias(aliases, name)]
289 return tool, ok
290 }
291
292 // showHelp has a parameter to write either to stdout or stderr
293 func showHelp(w io.Writer) {
294 fmt.Fprintln(w, info[1:])
295 fmt.Fprintln(w, ``)
296 fmt.Fprintln(w, `Tools Available`)
297
298 maxlen := 0
299 names := make([]string, 0, max(len(mains), len(aliases)))
300 for k := range mains {
301 names = append(names, k)
302 maxlen = max(maxlen, utf8.RuneCountInString(k))
303 }
304
305 sort.Strings(names)
306
307 for _, s := range names {
308 fmt.Fprintf(w, " - %-*s: %s\n", maxlen, s, blurbs[s])
309 }
310
311 fmt.Fprintln(w, ``)
312 fmt.Fprintln(w, `Aliases Available`)
313
314 maxlen = 0
315 names = names[:0]
316 for k := range aliases {
317 names = append(names, k)
318 maxlen = max(maxlen, utf8.RuneCountInString(k))
319 }
320
321 sort.Strings(names)
322
323 for _, k := range names {
324 fmt.Fprintf(w, " - %-*s -> %s\n", maxlen, k, aliases[k])
325 }
326 }
327
328 // showLinks has a parameter to write either to stdout or stderr
329 func showLinks(w io.Writer) {
330 names := make([]string, 0, len(mains)-len(extras))
331 for k := range mains {
332 if _, ok := extras[k]; ok {
333 continue
334 }
335 names = append(names, k)
336 }
337
338 sort.Strings(names)
339
340 for _, s := range names {
341 fmt.Fprintf(w, "ln -s \"$(which easybox)\" ./%s\n", s)
342 }
343 }
344
345 func tools() {
346 names := make([]string, 0, len(mains))
347 for k := range mains {
348 names = append(names, k)
349 }
350
351 sort.Strings(names)
352
353 for _, s := range names {
354 fmt.Fprintln(os.Stdout, s)
355 }
356 }
File: ./main_test.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package main
26
27 import "testing"
28
29 func TestAliases(t *testing.T) {
30 for alias, name := range aliases {
31 if _, ok := mains[name]; ok {
32 continue
33 }
34
35 t.Errorf("alias %q leads nowhere", alias)
36 }
37 }
38
39 func TestBlurbs(t *testing.T) {
40 for name := range mains {
41 if blurbs[name] != `` {
42 continue
43 }
44 t.Errorf("no description/blurb for tool %q", name)
45 }
46 }
47
48 func TestFillers(t *testing.T) {
49 for name, v := range mains {
50 if v != nil {
51 continue
52 }
53
54 if _, ok := extras[name]; ok {
55 continue
56 }
57
58 t.Errorf("tool %q has no filler for invalid entry", name)
59 }
60
61 for name := range extras {
62 if v, ok := mains[name]; !ok || v != nil {
63 t.Errorf("filling for missing entry %q", name)
64 }
65 }
66 }
File: ./match/match.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package match
26
27 import (
28 "bufio"
29 "bytes"
30 "io"
31 "os"
32 "regexp"
33 )
34
35 const info = `
36 match [options...] [regular expressions...]
37
38 Only keep lines which match any of the extended-mode regular expressions
39 given. When not given any regex, match non-empty lines by default.
40
41 The options are, available both in single and double-dash versions
42
43 -h, -help show this help message
44 -i, -ins match regexes case-insensitively
45 -l, -links add a regex to match HTTP/HTTPS links case-insensitively
46 `
47
48 const linkRegexp = `(?i)https?://[a-z0-9+_.:%-]+(/[a-z0-9+_.%/,#?&=-]*)*`
49
50 func Main() {
51 nerr := 0
52 links := false
53 buffered := false
54 sensitive := true
55 args := os.Args[1:]
56
57 for len(args) > 0 {
58 switch args[0] {
59 case `-b`, `--b`, `-buffered`, `--buffered`:
60 buffered = true
61 args = args[1:]
62 continue
63
64 case `-h`, `--h`, `-help`, `--help`:
65 os.Stdout.WriteString(info[1:])
66 return
67
68 case `-i`, `--i`, `-ins`, `--ins`:
69 sensitive = false
70 args = args[1:]
71 continue
72
73 case `-l`, `--l`, `-links`, `--links`:
74 links = true
75 args = args[1:]
76 continue
77 }
78
79 break
80 }
81
82 if len(args) > 0 && args[0] == `--` {
83 args = args[1:]
84 }
85
86 liveLines := !buffered
87 if !buffered {
88 if _, err := os.Stdout.Seek(0, io.SeekCurrent); err == nil {
89 liveLines = false
90 }
91 }
92
93 if len(args) == 0 {
94 args = []string{`.`}
95 }
96
97 var exprs []*regexp.Regexp
98 if links {
99 exprs = make([]*regexp.Regexp, 0, len(args)+1)
100 exprs = append(exprs, regexp.MustCompile(linkRegexp))
101 } else {
102 exprs = make([]*regexp.Regexp, 0, len(args))
103 }
104
105 for _, src := range args {
106 var err error
107 var exp *regexp.Regexp
108 if !sensitive {
109 exp, err = regexp.Compile(`(?i)` + src)
110 } else {
111 exp, err = regexp.Compile(src)
112 }
113
114 if err != nil {
115 os.Stderr.WriteString(err.Error())
116 os.Stderr.WriteString("\n")
117 nerr++
118 }
119
120 exprs = append(exprs, exp)
121 }
122
123 if nerr > 0 {
124 os.Exit(1)
125 }
126
127 var buf []byte
128 sc := bufio.NewScanner(os.Stdin)
129 sc.Buffer(nil, 8*1024*1024*1024)
130 bw := bufio.NewWriter(os.Stdout)
131
132 for i := 0; sc.Scan(); i++ {
133 line := sc.Bytes()
134 if i == 0 && bytes.HasPrefix(line, []byte{0xef, 0xbb, 0xbf}) {
135 line = line[3:]
136 }
137
138 s := line
139 if bytes.IndexByte(s, '\x1b') >= 0 {
140 buf = plain(buf[:0], s)
141 s = buf
142 }
143
144 if match(s, exprs) {
145 bw.Write(line)
146 bw.WriteByte('\n')
147
148 if !liveLines {
149 continue
150 }
151
152 if err := bw.Flush(); err != nil {
153 return
154 }
155 }
156 }
157 }
158
159 func match(what []byte, with []*regexp.Regexp) bool {
160 for _, e := range with {
161 if e.Match(what) {
162 return true
163 }
164 }
165 return false
166 }
167
168 func plain(dst []byte, src []byte) []byte {
169 for len(src) > 0 {
170 i, j := indexEscapeSequence(src)
171 if i < 0 {
172 dst = append(dst, src...)
173 break
174 }
175 if j < 0 {
176 j = len(src)
177 }
178
179 if i > 0 {
180 dst = append(dst, src[:i]...)
181 }
182
183 src = src[j:]
184 }
185
186 return dst
187 }
188
189 // indexEscapeSequence finds the first ANSI-style escape-sequence, which is
190 // the multi-byte sequences starting with ESC[; the result is a pair of slice
191 // indices which can be independently negative when either the start/end of
192 // a sequence isn't found; given their fairly-common use, even the hyperlink
193 // ESC]8 sequences are supported
194 func indexEscapeSequence(s []byte) (int, int) {
195 var prev byte
196
197 for i, b := range s {
198 if prev == '\x1b' && b == '[' {
199 j := indexLetter(s[i+1:])
200 if j < 0 {
201 return i, -1
202 }
203 return i - 1, i + 1 + j + 1
204 }
205
206 if prev == '\x1b' && b == ']' && i+1 < len(s) && s[i+1] == '8' {
207 j := indexPair(s[i+1:], '\x1b', '\\')
208 if j < 0 {
209 return i, -1
210 }
211 return i - 1, i + 1 + j + 2
212 }
213
214 prev = b
215 }
216
217 return -1, -1
218 }
219
220 func indexLetter(s []byte) int {
221 for i, b := range s {
222 upper := b &^ 32
223 if 'A' <= upper && upper <= 'Z' {
224 return i
225 }
226 }
227
228 return -1
229 }
230
231 func indexPair(s []byte, x byte, y byte) int {
232 var prev byte
233
234 for i, b := range s {
235 if prev == x && b == y && i > 0 {
236 return i
237 }
238 prev = b
239 }
240
241 return -1
242 }
File: ./mathplus/doc.go
1 /*
2 # mathplus
3
4 This is an add-on to the stdlib package `math`, with statistics-gathering
5 functionality and plenty more math-related functions.
6
7 This package also wraps types/methods from math/rand with nil-safe ones, which
8 act on the default value-generator when nil pointers are used.
9 */
10
11 package mathplus
File: ./mathplus/functions.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package mathplus
26
27 import "math"
28
29 func Decade(x float64) float64 { return 10 * math.Floor(0.1*x) }
30 func Century(x float64) float64 { return 100 * math.Floor(0.01*x) }
31
32 func Round1(x float64) float64 { return 1e-1 * math.Round(1e+1*x) }
33 func Round2(x float64) float64 { return 1e-2 * math.Round(1e+2*x) }
34 func Round3(x float64) float64 { return 1e-3 * math.Round(1e+3*x) }
35 func Round4(x float64) float64 { return 1e-4 * math.Round(1e+4*x) }
36 func Round5(x float64) float64 { return 1e-5 * math.Round(1e+5*x) }
37 func Round6(x float64) float64 { return 1e-6 * math.Round(1e+6*x) }
38 func Round7(x float64) float64 { return 1e-7 * math.Round(1e+7*x) }
39 func Round8(x float64) float64 { return 1e-8 * math.Round(1e+8*x) }
40 func Round9(x float64) float64 { return 1e-9 * math.Round(1e+9*x) }
41
42 // Ln calculates the natural logarithm.
43 func Ln(x float64) float64 {
44 return math.Log(x)
45 }
46
47 // Log calculates logarithms using the base given.
48 // func Log(base float64, x float64) float64 {
49 // return math.Log(x) / math.Log(base)
50 // }
51
52 // Round rounds the number given to the number of decimal places given.
53 func Round(x float64, decimals int) float64 {
54 k := math.Pow(10, float64(decimals))
55 return math.Round(k*x) / k
56 }
57
58 // RoundBy rounds the number given to the unit-size given
59 // func RoundBy(x float64, by float64) float64 {
60 // return math.Round(by*x) / by
61 // }
62
63 // FloorBy quantizes a number downward by the unit-size given
64 // func FloorBy(x float64, by float64) float64 {
65 // mod := math.Mod(x, by)
66 // if x < 0 {
67 // if mod == 0 {
68 // return x - mod
69 // }
70 // return x - mod - by
71 // }
72 // return x - mod
73 // }
74
75 // Wrap linearly interpolates the number given to the range [0...1],
76 // according to its continuous domain, specified by the limits given
77 func Wrap(x float64, min, max float64) float64 {
78 return (x - min) / (max - min)
79 }
80
81 // AnchoredWrap works like Wrap, except when both domain boundaries are positive,
82 // the minimum becomes 0, and when both domain boundaries are negative, the
83 // maximum becomes 0.
84 func AnchoredWrap(x float64, min, max float64) float64 {
85 if min > 0 && max > 0 {
86 min = 0
87 } else if max < 0 && min < 0 {
88 max = 0
89 }
90 return (x - min) / (max - min)
91 }
92
93 // Unwrap undoes Wrap, by turning the [0...1] number given into its equivalent
94 // in the new domain given.
95 func Unwrap(x float64, min, max float64) float64 {
96 return (max-min)*x + min
97 }
98
99 // Clamp constrains the domain of the value given
100 func Clamp(x float64, min, max float64) float64 {
101 return math.Min(math.Max(x, min), max)
102 }
103
104 // Scale transforms a number according to its position in [xMin, xMax] into its
105 // correspondingly-positioned counterpart in [yMin, yMax]: if value isn't in its
106 // assumed domain, its result will be extrapolated accordingly
107 func Scale(x float64, xmin, xmax, ymin, ymax float64) float64 {
108 k := (x - xmin) / (xmax - xmin)
109 return (ymax-ymin)*k + ymin
110 }
111
112 // AnchoredScale works like Scale, except when both domain boundaries are positive,
113 // the minimum becomes 0, and when both domain boundaries are negative, the maximum
114 // becomes 0. This allows for proportionally-correct scaling of quantities, such
115 // as when showing data visually.
116 func AnchoredScale(x float64, xmin, xmax, ymin, ymax float64) float64 {
117 if xmin > 0 && xmax > 0 {
118 xmin = 0
119 } else if xmax < 0 && xmin < 0 {
120 xmax = 0
121 }
122 k := (x - xmin) / (xmax - xmin)
123 return (ymax-ymin)*k + ymin
124 }
125
126 // ForceRange is handy when trying to mold floating-point values into numbers
127 // valid for JSON, since NaN and Infinity are replaced by the values given;
128 // the infinity-replacement value is negated for negative infinity.
129 func ForceRange(x float64, nan, inf float64) float64 {
130 if math.IsNaN(x) {
131 return nan
132 }
133 if math.IsInf(x, -1) {
134 return -inf
135 }
136 if math.IsInf(x, +1) {
137 return inf
138 }
139 return x
140 }
141
142 // Sign returns the standardized sign of a value, either as -1, 0, or +1: NaN
143 // values stay as NaN, as is expected when using floating-point values.
144 func Sign(x float64) float64 {
145 if x > 0 {
146 return +1
147 }
148 if x < 0 {
149 return -1
150 }
151 if x == 0 {
152 return 0
153 }
154 return math.NaN()
155 }
156
157 // IsInteger checks if a floating-point value is already rounded to an integer
158 // value.
159 func IsInteger(x float64) bool {
160 _, frac := math.Modf(x)
161 return frac == 0
162 }
163
164 func Square(x float64) float64 {
165 return x * x
166 }
167
168 func Cube(x float64) float64 {
169 return x * x * x
170 }
171
172 func RoundTo(x float64, decimals float64) float64 {
173 k := math.Pow10(int(decimals))
174 return math.Round(k*x) / k
175 }
176
177 func RelDiff(x, y float64) float64 {
178 return (x - y) / y
179 }
180
181 // Bool booleanizes a number: 0 stays 0, and anything else becomes 1.
182 func Bool(x float64) float64 {
183 if x == 0 {
184 return 0
185 }
186 return 1
187 }
188
189 // DeNaN replaces NaN with the alternative value given: regular values are
190 // returned as given.
191 func DeNaN(x float64, instead float64) float64 {
192 if !math.IsNaN(x) {
193 return x
194 }
195 return instead
196 }
197
198 // DeInf replaces either infinity with the alternative value given: regular
199 // values are returned as given.
200 func DeInf(x float64, instead float64) float64 {
201 if !math.IsInf(x, 0) {
202 return x
203 }
204 return instead
205 }
206
207 // Revalue replaces NaN and either infinity with the alternative value given:
208 // regular values are returned as given.
209 func Revalue(x float64, instead float64) float64 {
210 if !math.IsNaN(x) && !math.IsInf(x, 0) {
211 return x
212 }
213 return instead
214 }
215
216 // Linear evaluates a linear polynomial, the first arg being the main input,
217 // followed by the polynomial coefficients in decreasing-power order.
218 func Linear(x float64, a, b float64) float64 {
219 return a*x + b
220 }
221
222 // Quadratic evaluates a 2nd-degree polynomial, the first arg being the main
223 // input, followed by the polynomial coefficients in decreasing-power order.
224 func Quadratic(x float64, a, b, c float64) float64 {
225 return (a*x+b)*x + c
226 }
227
228 // Cubic evaluates a cubic polynomial, the first arg being the main input,
229 // followed by the polynomial coefficients in decreasing-power order.
230 func Cubic(x float64, a, b, c, d float64) float64 {
231 return ((a*x+b)*x+c)*x + d
232 }
233
234 func LinearFMA(x float64, a, b float64) float64 {
235 return math.FMA(x, a, b)
236 }
237
238 func QuadraticFMA(x float64, a, b, c float64) float64 {
239 lin := math.FMA(x, a, b)
240 return math.FMA(lin, x, c)
241 }
242
243 func CubicFMA(x float64, a, b, c, d float64) float64 {
244 lin := math.FMA(x, a, b)
245 quad := math.FMA(lin, x, c)
246 return math.FMA(quad, x, d)
247 }
248
249 // Radians converts angular degrees into angular radians: 180 degrees are pi
250 // pi radians.
251 func Radians(deg float64) float64 {
252 const k = math.Pi / 180
253 return k * deg
254 }
255
256 // Degrees converts angular radians into angular degrees: pi radians are 180
257 // degrees.
258 func Degrees(rad float64) float64 {
259 const k = 180 / math.Pi
260 return k * rad
261 }
262
263 // Fract calculates the non-integer/fractional part of a number.
264 func Fract(x float64) float64 {
265 return x - math.Floor(x)
266 }
267
268 // Mix interpolates 2 numbers using a third number, used as an interpolation
269 // coefficient. This parameter naturally falls in the range [0, 1], but doesn't
270 // have to be: when given outside that range, the parameter can extrapolate in
271 // either direction instead.
272 func Mix(x, y float64, k float64) float64 {
273 return (1-k)*(y-x) + x
274 }
275
276 // Step implements a step function with a parametric threshold.
277 func Step(edge, x float64) float64 {
278 if x < edge {
279 return 0
280 }
281 return 1
282 }
283
284 // SmoothStep is like the `smoothstep` func found in GLSL, using a cubic
285 // interpolator in the transition region.
286 func SmoothStep(edge0, edge1, x float64) float64 {
287 if x <= edge0 {
288 return 0
289 }
290 if x >= edge1 {
291 return 1
292 }
293
294 // use the cubic hermite interpolator 3x^2 - 2x^3 in the transition band
295 return x * x * (3 - 2*x)
296 }
297
298 // Logistic approximates the math func of the same name.
299 func Logistic(x float64) float64 {
300 return 1 / (1 + math.Exp(-x))
301 }
302
303 // Sinc approximates the math func of the same name.
304 func Sinc(x float64) float64 {
305 if x != 0 {
306 return math.Sin(x) / x
307 }
308 return 1
309 }
310
311 // Sum adds all the numbers in an array.
312 func Sum(v ...float64) float64 {
313 s := 0.0
314 for _, f := range v {
315 s += f
316 }
317 return s
318 }
319
320 // Product multiplies all the numbers in an array.
321 func Product(v ...float64) float64 {
322 p := 1.0
323 for _, f := range v {
324 p *= f
325 }
326 return p
327 }
328
329 // Length calculates the Euclidean length of the vector given.
330 func Length(v ...float64) float64 {
331 ss := 0.0
332 for _, f := range v {
333 ss += f * f
334 }
335 return math.Sqrt(ss)
336 }
337
338 // Dot calculates the dot product of 2 vectors
339 func Dot(x []float64, y []float64) float64 {
340 l := len(x)
341 if len(y) < l {
342 l = len(y)
343 }
344
345 dot := 0.0
346 for i := 0; i < l; i++ {
347 dot += x[i] * y[i]
348 }
349 return dot
350 }
351
352 // Min finds the minimum value from the numbers given.
353 func Min(v ...float64) float64 {
354 min := +math.Inf(+1)
355 for _, f := range v {
356 min = math.Min(min, f)
357 }
358 return min
359 }
360
361 // Max finds the maximum value from the numbers given.
362 func Max(v ...float64) float64 {
363 max := +math.Inf(-1)
364 for _, f := range v {
365 max = math.Max(max, f)
366 }
367 return max
368 }
369
370 // Hypot calculates the Euclidean n-dimensional hypothenuse from the numbers
371 // given: all numbers can be lengths, or simply positional coordinates.
372 func Hypot(v ...float64) float64 {
373 sumsq := 0.0
374 for _, f := range v {
375 sumsq += f * f
376 }
377 return math.Sqrt(sumsq)
378 }
379
380 // Polyval evaluates a polynomial using Horner's algorithm. The array has all
381 // the coefficients in textbook order, from the highest power down to the final
382 // constant.
383 func Polyval(x float64, v ...float64) float64 {
384 if len(v) == 0 {
385 return 0
386 }
387
388 x0 := x
389 x = 1.0
390 y := 0.0
391 for i := len(v) - 1; i >= 0; i-- {
392 y += v[i] * x
393 x *= x0
394 }
395 return y
396 }
397
398 // LnGamma is a 1-input 1-output version of math.Lgamma from the stdlib.
399 func LnGamma(x float64) float64 {
400 y, s := math.Lgamma(x)
401 if s < 0 {
402 return math.NaN()
403 }
404 return y
405 }
406
407 // LnBeta calculates the natural-logarithm of the beta function.
408 func LnBeta(x float64, y float64) float64 {
409 return LnGamma(x) + LnGamma(y) - LnGamma(x+y)
410 }
411
412 // Beta calculates the beta function.
413 func Beta(x float64, y float64) float64 {
414 return math.Exp(LnBeta(x, y))
415 }
416
417 // Factorial calculates the product of all integers in [1, n]
418 func Factorial(n int) int64 {
419 return int64(math.Round(math.Gamma(float64(n + 1))))
420 }
421
422 // IsPrime checks whether an integer is bigger than 1 and can only be fully
423 // divided by 1 and itself, which is the definition of a prime number.
424 func IsPrime(n int64) bool {
425 // prime numbers start at 2
426 if n < 2 {
427 return false
428 }
429
430 // 2 is the only even prime
431 if n%2 == 0 {
432 return n == 2
433 }
434
435 // no divisor can be more than the square root of the target number:
436 // this limit makes the loop an O(sqrt(n)) one, instead of O(n); this
437 // is a major algorithmic speedup both in theory and in practice
438 max := int64(math.Floor(math.Sqrt(float64(n))))
439
440 // the only possible full-divisors are odd integers 3..sqrt(n),
441 // since reaching this point guarantees n is odd and n > 2
442 for d := int64(3); d <= max; d += 2 {
443 if n%d == 0 {
444 return false
445 }
446 }
447 return true
448 }
449
450 // LCM finds the least common-multiple of 2 positive integers; when one or
451 // both inputs aren't positive, this func returns 0.
452 func LCM(x, y int64) int64 {
453 if gcd := GCD(x, y); gcd > 0 {
454 return x * y / gcd
455 }
456 return 0
457 }
458
459 // GCD finds the greatest common-divisor of 2 positive integers; when one or
460 // both inputs aren't positive, this func returns 0.
461 func GCD(x, y int64) int64 {
462 if x < 1 || y < 1 {
463 return 0
464 }
465
466 // the loop below requires a >= b
467 a, b := x, y
468 if a < b {
469 a, b = y, x
470 }
471
472 for b > 0 {
473 a, b = b, a%b
474 }
475 return a
476 }
477
478 // Perm counts the number of all possible permutations from n objects when
479 // picking k times. When one or both inputs aren't positive, the result is 0.
480 func Perm(n, k int) int64 {
481 if n < k || n < 0 || k < 0 {
482 return 0
483 }
484
485 perm := int64(1)
486 for i := n - k + 1; i <= n; i++ {
487 perm *= int64(i)
488 }
489 return perm
490 }
491
492 // Choose counts the number of all possible combinations from n objects when
493 // picking k times. When one or both inputs aren't positive, the result is 0.
494 func Choose(n, k int) int64 {
495 if n < k || n < 0 || k < 0 {
496 return 0
497 }
498
499 // the log trick isn't always more accurate when there's no overflow:
500 // for those cases calculate using the textbook definition
501 f := math.Round(float64(Perm(n, k) / Factorial(k)))
502 if !math.IsInf(f, 0) {
503 return int64(f)
504 }
505
506 // calculate using the log-factorial of n, k, and n - k
507 a, _ := math.Lgamma(float64(n + 1))
508 b, _ := math.Lgamma(float64(k + 1))
509 c, _ := math.Lgamma(float64(n - k + 1))
510 return int64(math.Round(math.Exp(a - b - c)))
511 }
512
513 // BinomialMass calculates the probability mass of the binomial random process
514 // given. When the probability given isn't between 0 and 1, the result is NaN.
515 func BinomialMass(x, n int, p float64) float64 {
516 // invalid probability input
517 if p < 0 || p > 1 {
518 return math.NaN()
519 }
520 // events outside the support are impossible by definition
521 if x < 0 || x > n {
522 return 0
523 }
524
525 q := 1 - p
526 m := n - x
527 ncx := float64(Choose(n, x))
528 return ncx * math.Pow(p, float64(x)) * math.Pow(q, float64(m))
529 }
530
531 // CumulativeBinomialDensity calculates cumulative probabilities/masses up to
532 // the value given for the binomial random process given. When the probability
533 // given isn't between 0 and 1, the result is NaN.
534 func CumulativeBinomialDensity(x, n int, p float64) float64 {
535 // invalid probability input
536 if p < 0 || p > 1 {
537 return math.NaN()
538 }
539 if x < 0 {
540 return 0
541 }
542 if x >= n {
543 return 1
544 }
545
546 p0 := p
547 q0 := 1 - p0
548 q := math.Pow(q0, float64(n))
549
550 pbinom := 0.0
551 np1 := float64(n + 1)
552 for k := 0; k < x; k++ {
553 a, _ := math.Lgamma(np1)
554 b, _ := math.Lgamma(float64(k + 1))
555 c, _ := math.Lgamma(float64(n - k + 1))
556 // count all possible combinations for this event
557 ncomb := math.Round(math.Exp(a - b - c))
558 pbinom += ncomb * p * q
559 p *= p0
560 q /= q0
561 }
562 return pbinom
563 }
564
565 // NormalDensity calculates the density at a point along the normal distribution
566 // given.
567 func NormalDensity(x float64, mu, sigma float64) float64 {
568 z := (x - mu) / sigma
569 return math.Sqrt(0.5/sigma) * math.Exp(-(z*z)/sigma)
570 }
571
572 // CumulativeNormalDensity calculates the probability of a normal variate of
573 // being up to the value given.
574 func CumulativeNormalDensity(x float64, mu, sigma float64) float64 {
575 z := (x - mu) / sigma
576 return 0.5 + 0.5*math.Erf(z/math.Sqrt2)
577 }
578
579 // Epanechnikov is a commonly-used kernel function.
580 func Epanechnikov(x float64) float64 {
581 if math.Abs(x) > 1 {
582 // func is 0 ouside -1..+1
583 return 0
584 }
585 return 0.75 * (1 - x*x)
586 }
587
588 // Gauss is the commonly-used Gaussian kernel function.
589 func Gauss(x float64) float64 {
590 return math.Exp(-(x * x))
591 }
592
593 // Tricube is a commonly-used kernel function.
594 func Tricube(x float64) float64 {
595 a := math.Abs(x)
596 if a > 1 {
597 // func is 0 ouside -1..+1
598 return 0
599 }
600
601 b := a * a * a
602 c := 1 - b
603 return 70.0 / 81.0 * c * c * c
604 }
605
606 // SolveQuad finds the solutions of a 2nd-degree polynomial, using a formula
607 // which is more accurate than the textbook one.
608 func SolveQuad(a, b, c float64) (x1 float64, x2 float64) {
609 div := 2 * c
610 disc := math.Sqrt(b*b - 4*a*c)
611 x1 = div / (-b - disc)
612 x2 = div / (-b + disc)
613 return x1, x2
614 }
File: ./mathplus/functions_test.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package mathplus
26
27 import (
28 "math"
29 "testing"
30 )
31
32 func TestRounding(t *testing.T) {
33 var decadeTests = []struct {
34 Number float64
35 Expected float64
36 }{
37 {0, 0},
38 {-1, -10},
39 {-0.75, -10},
40 {-0.725, -10},
41 {123.532123143, 120},
42 {1932.532123143, 1930},
43 {2023.4, 2020},
44 }
45
46 for _, tc := range decadeTests {
47 y := Decade(tc.Number)
48 if y != tc.Expected {
49 const fs = `decade(%f): expected %f, got %f`
50 t.Fatalf(fs, tc.Number, tc.Expected, y)
51 }
52 }
53
54 var centuryTests = []struct {
55 Number float64
56 Expected float64
57 }{
58 {0, 0},
59 {-1, -100},
60 {-0.75, -100},
61 {-0.725, -100},
62 {123.532123143, 100},
63 {1932.532123143, 1900},
64 {2023.4, 2000},
65 }
66
67 for _, tc := range centuryTests {
68 y := Century(tc.Number)
69 if y != tc.Expected {
70 const fs = `century(%f): expected %f, got %f`
71 t.Fatalf(fs, tc.Number, tc.Expected, y)
72 }
73 }
74
75 var roundingTests = []struct {
76 Number float64
77 Expected [6]float64
78 }{
79 {0, [6]float64{0, 0, 0, 0, 0, 0}},
80 {-1, [6]float64{-1, -1, -1, -1, -1, -1}},
81 {-0.75, [6]float64{-0.8, -0.75, -0.75, -0.75, -0.75, -0.75}},
82 {-0.725, [6]float64{-0.7, -0.73, -0.725, -0.725, -0.725, -0.725}},
83 {123.532123143, [6]float64{123.5, 123.53, 123.532, 123.5321, 123.53212, 123.532123}},
84 {1932.532123143, [6]float64{1932.5, 1932.53, 1932.532, 1932.5321, 1932.53212, 1932.532123}},
85 {2023.4, [6]float64{2023.4, 2023.4, 2023.4, 2023.4, 2023.4, 2023.4}},
86 }
87
88 for _, tc := range roundingTests {
89 x := tc.Number
90 y := []float64{
91 Round1(x), Round2(x), Round3(x), Round4(x), Round5(x), Round6(x),
92 }
93
94 for i, f := range y {
95 exp := tc.Expected[i]
96 if math.Abs(exp-f) > 1e-12 {
97 const fs = `r%d(%f): expected %f, got %f`
98 t.Fatalf(fs, i+1, tc.Number, exp, f)
99 }
100 }
101 }
102 }
103
104 var scaleTests = []struct {
105 Input float64
106 InMin float64
107 InMax float64
108 OutMin float64
109 OutMax float64
110 Expected float64
111 }{
112 {-2, -5, 4, 0, 1, 1.0 / 3},
113 {0.1, 0, 0.5, -3, 5, -1.4},
114 }
115
116 func TestScale(t *testing.T) {
117 for _, tc := range scaleTests {
118 in := tc.Input
119 exp := tc.Expected
120 got := Scale(in, tc.InMin, tc.InMax, tc.OutMin, tc.OutMax)
121 if got != exp {
122 const fs = `Scale(%f, %f, %f, %f, %f): expected %f, got %f`
123 t.Fatalf(fs, in, tc.InMin, tc.InMax, tc.OutMin, tc.OutMax, exp, got)
124 }
125 }
126 }
127
128 func TestIsPrime(t *testing.T) {
129 var tests = []struct {
130 Input int64
131 Expected bool
132 }{
133 {-3, false},
134 {0, false},
135 {1, false},
136 {4, false},
137 {9, false},
138 {21, false},
139
140 {2, true},
141 {3, true},
142 {5, true},
143 {19, true},
144 // 15,485,863 is the millionth prime
145 {15_485_863, true},
146 }
147
148 for _, tc := range tests {
149 if v := IsPrime(tc.Input); v != tc.Expected {
150 const fs = `isprime(%d) wrongly returned %v`
151 t.Fatalf(fs, tc.Input, v)
152 }
153 }
154 }
155
156 func TestHorner(t *testing.T) {
157 var tests = []struct {
158 X float64
159 C []float64
160 Expected float64
161 }{
162 {2, []float64{1, 2, 3}, 11},
163 {3, []float64{3, 5, -1}, 41},
164 }
165
166 for _, tc := range tests {
167 got := Polyval(tc.X, tc.C...)
168 if got != tc.Expected {
169 const fs = `horner(%f, %#v) gave %f, instead of %f`
170 t.Fatalf(fs, tc.X, tc.C, got, tc.Expected)
171 return
172 }
173 }
174 }
175
176 func TestGCD(t *testing.T) {
177 var tests = []struct {
178 X int64
179 Y int64
180 Expected int64
181 }{
182 {0, 0, 0},
183 {-1, 10, 0},
184 {1, -10, 0},
185 {1, 1, 1},
186 {1, 7, 1},
187 {3 * 12, 12, 12},
188 {1280, 1920, 640},
189 }
190
191 for _, tc := range tests {
192 got := GCD(tc.X, tc.Y)
193 if got != tc.Expected {
194 const fs = `gcd(%d, %d) gave %d, instead of %d`
195 t.Fatalf(fs, tc.X, tc.Y, got, tc.Expected)
196 return
197 }
198 }
199 }
200
201 func TestPerm(t *testing.T) {
202 var tests = []struct {
203 X int
204 Y int
205 Expected int64
206 }{
207 {10, 4, 5_040},
208 {5, 0, 1},
209 {5, 5, 120},
210 }
211
212 for _, tc := range tests {
213 got := Perm(tc.X, tc.Y)
214 if got != tc.Expected {
215 const fs = `perm(%d, %d) gave %d, instead of %d`
216 t.Fatalf(fs, tc.X, tc.Y, got, tc.Expected)
217 return
218 }
219 }
220 }
221
222 func TestChoose(t *testing.T) {
223 var tests = []struct {
224 X int
225 Y int
226 Expected int64
227 }{
228 {10, 4, 210},
229 {10, 0, 1},
230 {10, 10, 1},
231 }
232
233 for _, tc := range tests {
234 got := Choose(tc.X, tc.Y)
235 if got != tc.Expected {
236 const fs = `comb(%d, %d) gave %d, instead of %d`
237 t.Fatalf(fs, tc.X, tc.Y, got, tc.Expected)
238 return
239 }
240 }
241 }
File: ./mathplus/integers.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package mathplus
26
27 import "math"
28
29 const (
30 MinInt16 = -(1 << 15)
31 MinInt32 = -(1 << 31)
32 MinInt64 = -(1 << 63)
33
34 MaxInt16 = 1<<15 - 1
35 MaxInt32 = 1<<31 - 1
36 MaxInt64 = 1<<63 - 1
37
38 MaxUint16 = 1<<16 - 1
39 MaxUint32 = 1<<32 - 1
40 MaxUint64 = 1<<64 - 1
41 )
42
43 func CountIntegerDigits(n int64) int {
44 if n < 0 {
45 n = -n
46 }
47
48 // 0 doesn't have a log10
49 if n == 0 {
50 return 1
51 }
52 // add 1 to the floored logarithm, since digits are always full and
53 // with ceiling-like behavior, to use an analogy for this formula
54 return int(math.Floor(math.Log10(float64(n)))) + 1
55 }
56
57 func LoopThousandsGroups(n int64, fn func(i, n int)) {
58 // 0 doesn't have a log10
59 if n == 0 {
60 fn(0, 0)
61 return
62 }
63
64 sign := +1
65 if n < 0 {
66 n = -n
67 sign = -1
68 }
69
70 intLog1000 := int(math.Log10(float64(n)) / 3)
71 remBase := int64(math.Pow10(3 * intLog1000))
72
73 for i := 0; remBase > 0; i++ {
74 group := (1000 * n) / remBase / 1000
75 fn(i, sign*int(group))
76 // if original number was negative, ensure only first
77 // group gives a negative input to the callback
78 sign = +1
79
80 n %= remBase
81 remBase /= 1000
82 }
83 }
84
85 var pow10 = []int64{
86 1,
87 10,
88 100,
89 1000,
90 10000,
91 100000,
92 1000000,
93 10000000,
94 100000000,
95 1000000000, // last entry for int32
96 10000000000,
97 100000000000,
98 1000000000000,
99 10000000000000,
100 100000000000000,
101 1000000000000000,
102 10000000000000000,
103 100000000000000000,
104 1000000000000000000,
105 // 10000000000000000000,
106 }
107
108 var pow2 = []int64{
109 1,
110 2,
111 4,
112 8,
113 16,
114 32,
115 64,
116 128,
117 256,
118 512,
119 1024,
120 2048,
121 4096,
122 8192,
123 16384,
124 32768,
125 65536,
126 131072,
127 262144,
128 524288,
129 1048576,
130 2097152,
131 4194304,
132 8388608,
133 16777216,
134 33554432,
135 67108864,
136 134217728,
137 268435456,
138 536870912,
139 1073741824,
140 2147483648, // last entry for int32
141 4294967296,
142 8589934592,
143 17179869184,
144 34359738368,
145 68719476736,
146 137438953472,
147 274877906944,
148 549755813888,
149 1099511627776,
150 2199023255552,
151 4398046511104,
152 8796093022208,
153 17592186044416,
154 35184372088832,
155 70368744177664,
156 140737488355328,
157 281474976710656,
158 562949953421312,
159 1125899906842624,
160 2251799813685248,
161 4503599627370496,
162 9007199254740992,
163 18014398509481984,
164 36028797018963968,
165 72057594037927936,
166 144115188075855872,
167 288230376151711744,
168 576460752303423488,
169 1152921504606846976,
170 2305843009213693952,
171 4611686018427387904,
172 // 9223372036854775808,
173 }
174
175 // Log2Int gives you the floor of the base-2 logarithm, or negative results
176 // for invalid inputs. Values less than 1 aren't supported, since they don't
177 // have logarithms of any valid base, let alone base-2 logarithms.
178 func Log2Int(n int64) (log2 int, ok bool) {
179 if n < 1 {
180 return -1, false
181 }
182
183 v := uint64(n)
184 mask := uint64(1 << 63) // 2**63
185 for i := int64(63); i > 0; i-- {
186 if v&mask != 0 {
187 return int(i), true
188 }
189 mask >>= 1
190 }
191 return 0, true
192 }
193
194 // Log10Int gives you the floor of the base-10 logarithm, or negative results
195 // for invalid inputs. Values less than 1 aren't supported, since they don't
196 // have logarithms of any valid base, let alone base-10 logarithms.
197 func Log10Int(n int64) (log10 int, ok bool) {
198 if n < 1 {
199 return -1, false
200 }
201
202 for i := int64(0); i < 19; i++ {
203 n /= 10
204 if n == 0 {
205 return int(i), true
206 }
207 }
208 return 0, true
209 }
210
211 // Pow10Int gives you the integers powers of 10 as far as int64 allows: negative
212 // inputs aren't supported, since negative exponents don't give integer results.
213 func Pow10Int(n int) (power10 int64, ok bool) {
214 if 0 <= n && n < len(pow10) {
215 return pow10[n], true
216 }
217 return -1, false
218 }
219
220 // Pow2Int gives you the integers powers of 2 as far as int64 allows: negative
221 // inputs aren't supported, since negative exponents don't give integer results.
222 func Pow2Int(n int) (power2 int64, ok bool) {
223 if 0 <= n && n < len(pow2) {
224 return pow2[n], true
225 }
226 return -1, false
227 }
File: ./mathplus/integers_test.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package mathplus
26
27 import "testing"
28
29 func TestCountIntegerDigits(t *testing.T) {
30 var tests = []struct {
31 Input int64
32 Expected int
33 }{
34 {0, 1},
35 {-32, 2},
36 {999, 3},
37 {3_490, 4},
38 {12_332, 5},
39 {999_999, 6},
40 {1_000_000, 7},
41 {1_000_001, 7},
42 {12_345_678, 8},
43 }
44
45 for _, tc := range tests {
46 if n := CountIntegerDigits(tc.Input); n != tc.Expected {
47 const fs = `integer digits in %d: got %d instead of %d`
48 t.Errorf(fs, tc.Input, n, tc.Expected)
49 }
50 }
51 }
52
53 func TestLoopThousandsGroups(t *testing.T) {
54 var tests = []struct {
55 Input int64
56 Expected []int
57 }{
58 {0, []int{0}},
59 {-32, []int{-32}}, // negatives not supported yet
60 {999, []int{999}},
61 {1_670, []int{1, 670}},
62 {3_490, []int{3, 490}},
63 {12_332, []int{12, 332}},
64 {999_999, []int{999, 999}},
65 {1_000_000, []int{1, 0, 0}},
66 {1_000_001, []int{1, 0, 1}},
67 {1_234_567, []int{1, 234, 567}},
68 }
69
70 // return
71 for _, tc := range tests {
72 count := 0
73 LoopThousandsGroups(tc.Input, func(i, n int) {
74 // t.Log(tc.Input, i, n)
75 if n != tc.Expected[i] {
76 const fs = `group %d in %d: got %d instead of %d`
77 t.Errorf(fs, i, tc.Input, n, tc.Expected[i])
78 }
79 count++
80 })
81
82 if count != len(tc.Expected) {
83 const fs = `thousands-groups from %d: got %d instead of %d`
84 t.Errorf(fs, tc.Input, count, len(tc.Expected))
85 }
86 }
87 }
88
89 func TestLog2Int(t *testing.T) {
90 var tests = []struct {
91 Value int64
92 Expected int
93 }{
94 {-3, -1},
95 {1, 0},
96 {2, 1},
97 {3, 1},
98 {4, 2},
99 {1024, 10},
100 {1_025, 10},
101 {2*1024 - 1, 10},
102 }
103
104 for _, tc := range tests {
105 got, ok := Log2Int(tc.Value)
106 if got != tc.Expected || (ok && tc.Value < 1) {
107 const fs = `log2int(%d) = %d, but got %d instead`
108 t.Fatalf(fs, tc.Value, tc.Expected, got)
109 }
110 }
111 }
112
113 func TestLog10Int(t *testing.T) {
114 var tests = []struct {
115 Value int64
116 Expected int
117 }{
118 {-3, -1},
119 {1, 0},
120 {10, 1},
121 {100, 2},
122 {101, 2},
123 {199, 2},
124 {1_000_000, 6},
125 }
126
127 for _, tc := range tests {
128 got, ok := Log10Int(tc.Value)
129 if got != tc.Expected || (ok && tc.Value < 1) {
130 const fs = `log10int(%d) = %d, but got %d instead`
131 t.Fatalf(fs, tc.Value, tc.Expected, got)
132 }
133 }
134 }
File: ./mathplus/numbers.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package mathplus
26
27 import (
28 "math"
29 )
30
31 const (
32 // the maximum integer a float64 can represent exactly; since float64
33 // uses a sign bit, instead of a 2-complement mantissa, -maxflint is
34 // the minimum integer
35 maxflint = 2 << 52
36 )
37
38 // StringWidth counts how many runes it takes to represent the value given as a
39 // string both as a plain no-commas string and as a string with digit-grouping
40 // commas, if needed. Each group is 3 digits. Showing numbers with commas makes
41 // long numbers easier to read.
42 func StringWidth(f float64, decimals int) (plain, nice int) {
43 if math.IsNaN(f) || math.IsInf(f, 0) {
44 return 0, 0
45 }
46
47 // avoid wrong results, since decimals will be added at the end
48 if decimals < 0 {
49 decimals = 0
50 }
51
52 extras := 0 // count non-digits, such as negatives and dots
53 if f < 0 {
54 f = -f // fix value for uintWidth
55 extras++ // count the leading negative sign
56 }
57 if decimals > 0 {
58 extras++ // count the decimal dot
59 }
60
61 // at this point, f >= 0 for sure
62 plain, nice = uintWidth(f)
63 plain += extras + decimals
64 nice += extras + decimals
65 return plain, nice
66 }
67
68 // uintWidth can't handle negatives correctly, as its name suggests
69 func uintWidth(f float64) (plain, nice int) {
70 // only 1 digit and 0 commas for 0 <= x < 10
71 if f < 10 {
72 return 1, 1
73 }
74
75 mag := math.Log10(math.Floor(f)) // order of magnitude
76 digits := int(mag) + 1 // integer digits
77 commas := int(mag) / 3 // commas separating digits in groups of 3
78 return digits, digits + commas
79 }
80
81 // Equals allows easily comparing numbers, including NaN values, which otherwise
82 // never equal anything they're compared to, including themselves.
83 func Equals(x, y float64) bool {
84 if x == y {
85 return true
86 }
87 return math.IsNaN(x) && math.IsNaN(y)
88 }
89
90 // ApproxEqual allows approximate comparisons, as well as checking if both values
91 // are NaN, which otherwise never equal themselves when compared directly. The
92 // last parameter must be positive for approx. comparisons, or zero for exact
93 // comparisons.
94 func ApproxEqual(x, y float64, maxdiff float64) bool {
95 if math.Abs(x-y) <= maxdiff {
96 return true
97 }
98 return math.IsNaN(x) && math.IsNaN(y)
99 }
100
101 // GuessDecimalsCount tries to guess the number of decimal digits of the number
102 // given.
103 func GuessDecimalsCount(x float64, max int) int {
104 if x < 0 {
105 x = -x
106 }
107
108 // only up to 16, because log10(2**-53) ~= -15.9546
109 if !(0 <= max && max <= 16) {
110 max = 16
111 }
112
113 const tol = 5e-13 // 1e-11, 1e-12, 5e-13
114 for digits := 0; digits <= max; digits++ {
115 _, frac := math.Modf(x)
116 frac = math.Abs(frac)
117 // when it's time to stop, the absolute value of the fraction part is
118 // either extremely close to 0 or extremely close to 1
119 if frac < tol || (1-frac) < tol {
120 return digits
121 }
122 x *= 10
123 }
124 return max
125 }
126
127 // Default returns the first non-NaN value among those given: failing that,
128 // the result will be NaN.
129 func Default(args ...float64) float64 {
130 for _, x := range args {
131 if !math.IsNaN(x) {
132 return x
133 }
134 }
135 return math.NaN()
136 }
137
138 // TrimSlice ignores all leading/trailing NaN values from the slice given: its
139 // main use-case is after sorting via sort.Float64s, since all NaN values are
140 // moved in place to the start/end of the now-sorted slice.
141 //
142 // # Example
143 //
144 // sort.Float64(values)
145 // res := mathplus.TrimSlice(values)
146 func TrimSlice(x []float64) []float64 {
147 // ignore all leading NaNs
148 for len(x) > 0 && math.IsNaN(x[0]) {
149 x = x[1:]
150 }
151 // ignore all trailing NaNs
152 for len(x) > 0 && math.IsNaN(x[len(x)-1]) {
153 x = x[:len(x)-1]
154 }
155 return x
156 }
157
158 // Linspace works like the Matlab function of the same name, except it takes a
159 // callback, generalizing its behavior.
160 func Linspace(a, incr, b float64, f func(x float64)) {
161 if incr <= 0 {
162 return
163 }
164
165 if a < b {
166 forwardLinspace(a, incr, b, f)
167 } else if a > b {
168 backwardLinspace(a, incr, b, f)
169 } else {
170 f(a)
171 }
172 }
173
174 func forwardLinspace(a, incr, b float64, f func(x float64)) {
175 for i := 0; true; i++ {
176 x := float64(i)*incr + a
177 if x <= b {
178 f(x)
179 } else {
180 return
181 }
182 }
183 }
184
185 func backwardLinspace(a, incr, b float64, f func(x float64)) {
186 for i := 0; true; i++ {
187 x := b - float64(i)*incr
188 if x >= a {
189 f(x)
190 } else {
191 return
192 }
193 }
194 }
195
196 // Seq is a special case of Linspace, where the increment is +/-1.
197 func Seq(a, b float64, f func(x float64)) {
198 Linspace(a, 1, b, f)
199 }
200
201 // Increment increments the number given by adding 1, when it's in the int-safe
202 // range of float64s; when outside that range, the smallest available delta is
203 // used instead.
204 func Increment(x float64) float64 {
205 if math.IsNaN(x) || math.IsInf(x, 0) {
206 return x
207 }
208 if incr := x + 1; incr != x {
209 return incr
210 }
211 return math.Float64frombits(math.Float64bits(x) + 1)
212 }
213
214 // Decrement decrements the number given by subtracting 1, when it's in the
215 // int-safe range of float64s: when outside that range, the smallest available
216 // delta is used instead.
217 func Decrement(x float64) float64 {
218 if math.IsNaN(x) || math.IsInf(x, 0) {
219 return x
220 }
221 if decr := x - 1; decr != x {
222 return decr
223 }
224 // subtracting 1 from the value's uint64 counterpart doesn't work for values
225 // less than the minimum exact-integer: uint64s use 2-complement arithmetic
226 return -math.Float64frombits(math.Float64bits(-x) + 1)
227 }
228
229 func Deapproximate(x float64) (min float64, max float64) {
230 if math.IsNaN(x) || math.IsInf(x, 0) {
231 return x, x
232 }
233
234 if x == 0 {
235 return -0.5, +0.5
236 }
237
238 if math.Remainder(x, 1) == 0 {
239 if math.Remainder(x, 10) != 0 {
240 return x - 0.5, x + 0.5
241 }
242
243 sign := Sign(x)
244 abs := math.Abs(x)
245 delta := float64(maxExactPow10(int(abs))) / 2
246 return sign*abs - delta, sign*abs + delta
247 }
248
249 // return surrounding integers when given non-integers
250 return math.Floor(x), math.Ceil(x)
251 }
252
253 func maxExactPow10(x int) int {
254 pow10 := 1
255 for {
256 if x%pow10 != 0 {
257 return pow10 / 10
258 }
259 pow10 *= 10
260 }
261 }
File: ./mathplus/numbers_test.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package mathplus
26
27 import (
28 "math"
29 "strconv"
30 "testing"
31 )
32
33 func TestNumberWidth(t *testing.T) {
34 var tests = []struct {
35 Input float64
36 Decimals int
37 PlainWidth int
38 PrettyWidth int
39 }{
40 {0, 0, len(`0`), len(`0`)},
41 {0.923123, 0, len(`0`), len(`0`)},
42 {0.923123, 2, len(`0.92`), len(`0.92`)},
43 {-3, 0, len(`-3`), len(`-3`)},
44 {-300, 0, len(`-300`), len(`-300`)},
45 {+2_300, 0, len(`2300`), len(`2,300`)},
46 {-1_000_000, 0, len(`-1000000`), len(`-1,000,000`)},
47 {+1_000_000, 0, len(`1000000`), len(`1,000,000`)},
48 {+1_610_058.23423, 4, len(`1610058.2342`), len(`1_610_058.2342`)},
49 {-317_289, 4, len(`-317289.0000`), len(`-317_289.0000`)},
50 {5_457_013.0000, 4, len(`5457013.0000`), len(`5_457_013.0000`)},
51 {-118.406800000000004, 15, 20, 20},
52 }
53
54 for _, tc := range tests {
55 pl, pr := StringWidth(tc.Input, tc.Decimals)
56 if pl != tc.PlainWidth {
57 const fs = `PlainWidth(%f, %d): expected %d, got %d`
58 t.Fatalf(fs, tc.Input, tc.Decimals, tc.PlainWidth, pl)
59 }
60 if pr != tc.PrettyWidth {
61 const fs = `PrettyWidth(%f, %d): expected %d, got %d`
62 t.Fatalf(fs, tc.Input, tc.Decimals, tc.PrettyWidth, pr)
63 }
64 }
65 }
66
67 func TestNumberWidthInternal(t *testing.T) {
68 var tests = []struct {
69 Number float64
70 Decimals int
71 IntDigits int
72 Commas int
73 }{
74 {0, 0, 1, 0},
75 {-3, 0, 1, 0},
76 {-300, 0, 3, 0},
77 {+2_300, 0, 4, 1},
78 {-1_000_000, 0, 7, 2},
79 {+1_000_000, 0, 7, 2},
80 {+1_610_058.23423, 4, 7, 2},
81 {-317_289, 4, 6, 1},
82 {5_457_013.0000, 4, 7, 2},
83 }
84
85 for _, tc := range tests {
86 // remember that uintWidth can't handle negatives
87 digits, pretty := uintWidth(math.Abs(tc.Number))
88
89 commas := pretty - digits
90 if digits != tc.IntDigits || commas != tc.Commas {
91 const fs = `%f: expected %d and %d, but got %d and %d instead`
92 t.Fatalf(fs, tc.Number, tc.IntDigits, tc.Commas, digits, commas)
93 }
94 }
95 }
96
97 func TestGuessDecimalsCount(t *testing.T) {
98 var tests = []struct {
99 Input float64
100 Expected int
101 }{
102 {-1, 0},
103 {-1.3, 1},
104 {-1.1, 1},
105 {10.103, 3},
106 {3.9500, 2},
107 {1.789000, 3},
108 {5.0409999000000000, 7},
109 }
110
111 for _, tc := range tests {
112 got := GuessDecimalsCount(tc.Input, -1)
113 if got != tc.Expected {
114 const fs = `guess(%f, %d) gave %d, but should have been %d`
115 t.Fatalf(fs, tc.Input, -1, got, tc.Expected)
116 }
117 }
118
119 var v = []float64{
120 1.773, 1.789, 1.773, 1.779, 1.817, 1.797, 1.780, 1.737,
121 1.723, 1.725, 1.733, 1.742, 1.746, 1.728, 1.726, 1.701,
122 1.690, 1.675, 1.680, 1.672, 1.685, 1.679, 1.674, 1.653,
123 1.668, 1.669, 1.680, 1.693, 1.713, 1.712, 1.733, 1.744,
124 1.739, 1.708, 1.695, 1.691, 1.715, 1.733, 1.730, 1.722,
125 1.737, 1.749, 1.774, 1.773, 1.789, 1.804, 1.795, 1.752,
126 1.770, 1.736, 1.717, 1.690, 1.696, 1.703, 1.679, 1.673,
127 1.694, 1.710, 1.711, 1.739, 1.725, 1.748, 1.764, 1.781,
128 1.766, 1.741, 1.739, 1.750, 1.752, 1.731, 1.717, 1.699,
129 1.689, 1.701, 1.713, 1.715, 1.713, 1.780, 1.795, 1.822,
130 1.832, 1.835, 1.846, 1.857, 1.869, 1.847, 1.831, 1.874,
131 1.928, 1.900, 1.925, 1.888, 1.842, 1.836, 1.825, 1.804,
132 1.745, 1.753, 1.779, 1.797, 1.812, 1.820, 1.829, 1.818,
133 1.799, 1.795, 1.795, 1.792, 1.792, 1.760, 1.741, 1.721,
134 1.718, 1.734, 1.751, 1.776, 1.768, 1.774, 1.790, 1.813,
135 1.840, 1.861, 1.853, 1.834, 1.848, 1.859, 1.856, 1.839,
136 1.826, 1.833, 1.850, 1.861, 1.874, 1.905, 1.931, 1.951,
137 1.971, 1.985, 1.997, 2.023, 2.047, 2.080, 2.103, 2.139,
138 2.155, 2.151, 2.142, 2.147, 2.147, 2.165, 2.171, 2.176,
139 2.174, 2.190, 2.192, 2.184, 2.170, 2.155, 2.155, 2.140,
140 2.129, 2.124, 2.128, 2.146, 2.135, 2.151, 2.141, 2.134,
141 2.086, 2.059, 2.014, 1.980, 1.969, 1.972, 1.953, 1.927,
142 1.915, 1.901, 1.905, 1.941, 1.951, 1.940, 1.949, 1.959,
143 1.985, 1.995, 2.001, 2.002, 2.017, 2.018, 2.006, 1.997,
144 1.994, 1.987, 1.978, 1.977, 1.940, 1.928, 1.909, 1.813,
145 1.736, 1.710, 1.712, 1.726, 1.740, 1.747, 1.748, 1.745,
146 1.728, 1.714, 1.656, 1.649, 1.642, 1.630, 1.612, 1.588,
147 1.583, 1.570, 1.568, 1.561, 1.537, 1.542, 1.548, 1.538,
148 1.520, 1.521, 1.513, 1.494, 1.469, 1.458, 1.449, 1.441,
149 1.434, 1.428, 1.431, 1.440, 1.447, 1.457, 1.456, 1.457,
150 1.450, 1.451, 1.439, 1.423, 1.374, 1.100, 1.147, 1.134,
151 1.121, 1.119, 1.115,
152 }
153
154 for i, x := range v {
155 guess := gdc(t, x)
156 // guess := GuessDecimalsCount(v, -1)
157 if guess > 3 {
158 _, diff := math.Modf(1000 * x)
159 t.Logf("len: %d\n", len(v))
160 const fs = `(item %2d) %f doesn't have %d decimals; diff: %.20f`
161 t.Fatalf(fs, i+1, x, guess, diff)
162 }
163 }
164
165 v = []float64{
166 6000.00, 2300.00, 2000.00, 1700.00, 1500.00, 1500.00, 1400.00, 1300.00,
167 900.00, 800.00, 800.00, 700.00, 600.00, 550.00, 550.00, 400.00,
168 320.39, 300.00, 200.00, 198.56, 155.94, 155.73, 98.15, 80.00,
169 80.00, 74.50, 70.00, 70.00, 70.00, 70.00, 70.00, 65.00,
170 60.00, 55.45, 49.00, 35.00, 35.00, 25.00, 25.00, 25.00,
171 20.00, 15.00, 15.00, 12.00, 10.00, 10.00, 10.00, 10.00,
172 7.00, 6.00, 6.00, 5.00, 3.00, 2.00, 1.00,
173 }
174 for i, x := range v {
175 guess := gdc(t, x)
176 // guess := GuessDecimalsCount(v, -1)
177 if guess > 2 {
178 _, diff := math.Modf(100 * x)
179 t.Logf("len: %d\n", len(v))
180 const fs = `(item %2d) %f doesn't have %d decimals; diff: %.20f`
181 t.Fatalf(fs, i+1, x, guess, diff)
182 }
183 }
184 }
185
186 // gdc logs each step of the loop inside GuessDecimalsCount
187 func gdc(t *testing.T, x float64) int {
188 const max = 16
189 const tol = 5e-11 // 5e-13
190 for digits := 0; digits <= max; digits++ {
191 _, frac := math.Modf(x)
192 const fs = "x: %f\tdigits: %d\tdiff: %.20f\tcdiff: %.20f\n"
193 t.Logf(fs, x, digits, frac, 1-frac)
194 ar := math.Abs(frac)
195 if ar < tol || (1-ar) < tol {
196 return digits
197 }
198 x *= 10
199 }
200 return max
201 }
202
203 func TestIncrement(t *testing.T) {
204 var tests = []struct {
205 Input float64
206 Expected float64
207 }{
208 {math.NaN(), math.NaN()},
209 {math.Inf(-1), math.Inf(-1)},
210 {math.Inf(+1), math.Inf(+1)},
211
212 {-maxflint - 2, -maxflint},
213 {-maxflint, -maxflint + 1},
214 {-1, 0},
215 {0, 1},
216 {10.25, 11.25},
217 {maxflint - 1, maxflint},
218 {maxflint, maxflint + 2},
219 }
220
221 for _, tc := range tests {
222 name := strconv.FormatFloat(tc.Input, 'f', 2, 64)
223 t.Run(name, func(t *testing.T) {
224 got := Increment(tc.Input)
225 if !Equals(got, tc.Expected) {
226 const fs = `got %v, instead of %v`
227 t.Fatalf(fs, got, tc.Expected)
228 }
229 })
230 }
231 }
232
233 func TestDecrement(t *testing.T) {
234 var tests = []struct {
235 Input float64
236 Expected float64
237 }{
238 {math.NaN(), math.NaN()},
239 {math.Inf(-1), math.Inf(-1)},
240 {math.Inf(+1), math.Inf(+1)},
241
242 {-maxflint, -maxflint - 2},
243 {-maxflint + 1, -maxflint},
244 {-1, -2},
245 {0, -1},
246 {11.25, 10.25},
247 {maxflint, maxflint - 1},
248 {maxflint + 2, maxflint},
249 }
250
251 for _, tc := range tests {
252 name := strconv.FormatFloat(tc.Input, 'f', 2, 64)
253 t.Run(name, func(t *testing.T) {
254 got := Decrement(tc.Input)
255 if !Equals(got, tc.Expected) {
256 const fs = `got %v, instead of %v`
257 t.Fatalf(fs, got, tc.Expected)
258 }
259 })
260 }
261 }
262
263 func TestDeapproximate(t *testing.T) {
264 var tests = []struct {
265 Input float64
266 ExpectedMin float64
267 ExpectedMax float64
268 }{
269 {0, -0.5, +0.5},
270 {1, 0.5, 1.5},
271 {10, 5, 15},
272 {100, 50, 150},
273 {101, 100.5, 101.5},
274 {102, 101.5, 102.5},
275 {120, 115, 125},
276 }
277
278 for _, tc := range tests {
279 name := strconv.FormatFloat(tc.Input, 'f', 6, 64)
280 t.Run(name, func(t *testing.T) {
281 min, max := Deapproximate(tc.Input)
282 if !Equals(min, tc.ExpectedMin) || !Equals(max, tc.ExpectedMax) {
283 const fs = `got %f and %f, instead of %f and %f`
284 t.Fatalf(fs, min, max, tc.ExpectedMin, tc.ExpectedMax)
285 }
286 })
287 }
288 }
289
290 func TestMaxExactPow10(t *testing.T) {
291 var tests = []struct {
292 Input int
293 Expected int
294 }{
295 // {0, 1},
296 {1, 1},
297 {4, 1},
298 {10, 10},
299 {100, 100},
300 {101, 1},
301 {102, 1},
302 {120, 10},
303 }
304
305 for _, tc := range tests {
306 name := strconv.Itoa(tc.Input)
307 t.Run(name, func(t *testing.T) {
308 got := maxExactPow10(int(tc.Input))
309 if got != int(tc.Expected) {
310 const fs = `got %d, instead of %d`
311 t.Fatalf(fs, got, tc.Expected)
312 }
313 })
314 }
315 }
File: ./mathplus/statistics.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package mathplus
26
27 import (
28 "math"
29 )
30
31 // Quantile calculates a sorted array's quantile, parameterized by a number in
32 // [0, 1]; the sorted array must be in increasing order and can't contain any
33 // NaN value.
34 func Quantile(x []float64, q float64) float64 {
35 l := len(x)
36 // quantiles aren't defined for empty arrays/samples
37 if l == 0 {
38 return math.NaN()
39 }
40
41 // calculate indices of surrounding values, which match when index isn't
42 // fractional
43 mid := float64(l-1) * q
44 low := math.Floor(mid)
45 high := math.Ceil(mid)
46
47 // for fractional indices, interpolate their 2 surrounding values
48 a := x[int(low)]
49 b := x[int(high)]
50 return a + (mid-low)*(b-a)
51 }
52
53 // welford has almost everything needed to implement Welford's running-stats
54 // algorithm for the arithmetic mean and the standard deviation: the only
55 // thing missing is the count of values so far, which you must provide every
56 // time you call its methods.
57 type welford struct {
58 Mean float64
59 meanOfSquares float64
60 }
61
62 // Update advances Welford's algorithm, using the value and item-count given.
63 func (w *welford) Update(x float64, n int) {
64 d1 := x - w.Mean
65 w.Mean += d1 / float64(n)
66 d2 := x - w.Mean
67 w.meanOfSquares += d1 * d2
68 }
69
70 // SD calculates the current standard-deviation. The first parameter is the
71 // bias-correction to use: 0 gives you the `population` SD, 1 gives you the
72 // `sample` SD; 1.5 is very-rarely used and only in special circumstances.
73 func (w welford) SD(bias float64, n int) float64 {
74 denom := float64(n) - bias
75 return math.Sqrt(w.meanOfSquares / denom)
76 }
77
78 // RMS calculates the current root-mean-square statistic.
79 func (w welford) RMS() float64 {
80 return math.Sqrt(w.meanOfSquares)
81 }
82
83 // NumberSummary has/updates numeric constant-space running stats.
84 type NumberSummary struct {
85 // struct welford has the Mean public field
86 welford
87
88 // Count is how many values there are so far, including NaNs
89 Count int
90
91 // NaN counts NaN values so far
92 NaN int
93
94 // Integers counts all integers so far
95 Integers int
96
97 // Negatives counts all negative number so far
98 Negatives int
99
100 // Zeros counts all zeros so far
101 Zeros int
102
103 // Positives counts all positive numbers so far
104 Positives int
105
106 // Min is the least number so far, ignoring NaNs
107 Min float64
108
109 // Max is the highest number so far, ignoring NaNs
110 Max float64
111
112 // Sum is the sum of all numbers so far, ignoring NaNs
113 Sum float64
114
115 // sumOfLogs is used to calculate the geometric mean
116 sumOfLogs float64
117 }
118
119 // Update does exactly what it says.
120 func (ns *NumberSummary) Update(f float64) {
121 if ns.Count == 0 {
122 ns.Min = math.Inf(+1)
123 ns.Max = math.Inf(-1)
124 }
125
126 ns.Count++
127 if math.IsNaN(f) {
128 ns.NaN++
129 return
130 }
131
132 if _, r := math.Modf(f); r == 0 {
133 ns.Integers++
134 }
135
136 if f > 0 {
137 ns.Positives++
138 } else if f == 0 {
139 ns.Zeros++
140 } else if f < 0 {
141 ns.Negatives++
142 }
143
144 ns.Sum += f
145 ns.sumOfLogs += math.Log(f)
146 ns.Min = math.Min(ns.Min, f)
147 ns.Max = math.Max(ns.Max, f)
148 ns.welford.Update(f, ns.Valid())
149 }
150
151 // Valid finds how many numbers are valid so far
152 func (ns NumberSummary) Valid() int {
153 return ns.Count - ns.NaN
154 }
155
156 // Invalid finds how many numbers are invalid so far
157 func (ns NumberSummary) Invalid() int {
158 return ns.NaN
159 }
160
161 // Geomean calculates the current geometric mean
162 func (ns NumberSummary) Geomean() float64 {
163 if ns.Negatives > 0 || ns.Zeros > 0 {
164 return math.NaN()
165 }
166 return math.Exp(ns.sumOfLogs / float64(ns.Valid()))
167 }
168
169 // SD calculates the current standard-deviation. The only parameter is the
170 // bias-correction to use:
171 //
172 // 0 means calculate the current population standard-deviation
173 // 1 means calculate the current sample standard-deviation
174 // 1.5 is very-rarely used and only in special circumstances
175 func (ns NumberSummary) SD(bias float64) float64 {
176 return ns.welford.SD(bias, ns.Valid())
177 }
178
179 // CommonQuantiles groups all the most commonly-used quantiles in practice.
180 //
181 // Funcs AppendAllDeciles and AppendAllPercentiles give you other sets of
182 // commonly-used ranking stats.
183 type CommonQuantiles struct {
184 Min float64
185 P01 float64 // 1st percentile
186 P05 float64 // 5th percentile
187 P10 float64 // 10th percentile, also the 1st decile
188 P25 float64 // 1st quartile, also the 25th percentile
189 P50 float64 // 2nd quartile, also the 50th percentile
190 P75 float64 // 3rd quartile, also the 75th percentile
191 P90 float64
192 P95 float64
193 P99 float64
194 Max float64
195 }
196
197 // NewCommonQuantiles is a convenience constructor for struct CommonQuantiles.
198 func NewCommonQuantiles(x []float64) CommonQuantiles {
199 return CommonQuantiles{
200 Min: Quantile(x, 0.00),
201 P01: Quantile(x, 0.01),
202 P05: Quantile(x, 0.05),
203 P10: Quantile(x, 0.10),
204 P25: Quantile(x, 0.25),
205 P50: Quantile(x, 0.50),
206 P75: Quantile(x, 0.75),
207 P90: Quantile(x, 0.90),
208 P95: Quantile(x, 0.95),
209 P99: Quantile(x, 0.99),
210 Max: Quantile(x, 1.00),
211 }
212 }
213
214 // AppendAllDeciles appends 11 items to the slice given, which ultimately
215 // lets you reuse slices for multiple such calculations, thus avoiding extra
216 // allocations.
217 //
218 // The 1st item returned is the minimum, and the last one is the maximum.
219 func AppendAllDeciles(dest []float64, x []float64) []float64 {
220 for i := 0; i <= 10; i++ {
221 dest = append(dest, Quantile(x, float64(i)/10))
222 }
223 return dest
224 }
225
226 // AppendAllPercentiles appends 101 items to the slice given, which ultimately
227 // lets you reuse slices for multiple such calculations, thus avoiding extra
228 // allocations.
229 //
230 // The 1st item returned is the minimum, while the last one is the maximum.
231 func AppendAllPercentiles(dest []float64, x []float64) []float64 {
232 for i := 0; i <= 100; i++ {
233 dest = append(dest, Quantile(x, float64(i)/100))
234 }
235 return dest
236 }
File: ./mathplus/statistics_test.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package mathplus
26
27 import (
28 "math"
29 "testing"
30 )
31
32 type NumericTestResult struct {
33 Count int
34 Valid int
35 Integers int
36 Negatives int
37 Zeros int
38 Positives int
39
40 Min float64
41 Max float64
42 Sum float64
43 Mean float64
44 Geomean float64
45 SD float64
46 RMS float64
47 }
48
49 func (tr NumericTestResult) Match(ns NumberSummary) bool {
50 return true &&
51 tr.Count == ns.Count &&
52 tr.Valid == ns.Valid() &&
53 tr.Integers == ns.Integers &&
54 tr.Negatives == ns.Negatives &&
55 tr.Zeros == ns.Zeros &&
56 tr.Positives == ns.Positives &&
57 equals(tr.Min, ns.Min) &&
58 equals(tr.Max, ns.Max) &&
59 equals(tr.Sum, ns.Sum) &&
60 equals(tr.Mean, ns.Mean) &&
61 equals(tr.Geomean, ns.Geomean()) &&
62 equals(tr.SD, ns.SD(0)) &&
63 equals(tr.RMS, ns.RMS()) &&
64 true
65 }
66
67 func (tr NumericTestResult) Test(t *testing.T, ns NumberSummary) {
68 var checks = []struct {
69 X float64
70 Y float64
71 Message string
72 }{
73 {float64(tr.Count), float64(ns.Count), `count`},
74 {float64(tr.Valid), float64(ns.Valid()), `valid`},
75 {float64(tr.Integers), float64(ns.Integers), `integers`},
76 {float64(tr.Negatives), float64(ns.Negatives), `negatives`},
77 {float64(tr.Zeros), float64(ns.Zeros), `zeros`},
78 {float64(tr.Positives), float64(ns.Positives), `positives`},
79 {float64(tr.Min), float64(ns.Min), `min`},
80 {float64(tr.Max), float64(ns.Max), `max`},
81 {float64(tr.Sum), float64(ns.Sum), `sum`},
82 {float64(tr.Mean), float64(ns.Mean), `mean`},
83 {float64(tr.Geomean), float64(ns.Geomean()), `geomean`},
84 {float64(tr.SD), float64(ns.SD(0)), `sd`},
85 {float64(tr.RMS), float64(ns.RMS()), `rms`},
86 }
87
88 const fs = "field %q failed: %f and %f differ\nexpected %#v\ngot %#v"
89 for _, tc := range checks {
90 if !equals(tc.X, tc.Y) {
91 t.Fatalf(fs, tc.Message, tc.X, tc.Y, tr, ns)
92 return
93 }
94 }
95 }
96
97 var numericTests = []struct {
98 Description string
99 Input []float64
100 Expected NumericTestResult
101 }{
102 {
103 Description: `no values`,
104 Input: []float64{},
105 Expected: NumericTestResult{
106 Count: 0,
107 Valid: 0,
108 Integers: 0,
109 // Min: math.Inf(+1),
110 // Max: math.Inf(-1),
111 Min: 0.0,
112 Max: 0.0,
113 Sum: 0.0,
114 Mean: 0.0,
115 Geomean: math.NaN(),
116 SD: math.NaN(),
117 RMS: 0.0,
118 },
119 },
120 {
121 Description: `just a value`,
122 Input: []float64{-3.5},
123 Expected: NumericTestResult{
124 Count: 1,
125 Valid: 1,
126 Integers: 0,
127 Negatives: 1,
128 Zeros: 0,
129 Positives: 0,
130 Min: -3.5,
131 Max: -3.5,
132 Sum: -3.5,
133 Mean: -3.5,
134 Geomean: math.NaN(),
135 SD: 0.0,
136 RMS: 0.0,
137 },
138 },
139 {
140 Description: `integers 1..10`,
141 Input: []float64{math.NaN(), 1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
142 Expected: NumericTestResult{
143 Count: 11,
144 Valid: 10,
145 Integers: 10,
146 Negatives: 0,
147 Zeros: 0,
148 Positives: 10,
149 Min: 1,
150 Max: 10,
151 Sum: 55,
152 Mean: 5.5,
153 Geomean: 4.528728688116766,
154 RMS: 9.082951062292475,
155 SD: 2.8722813232690143,
156 },
157 },
158 {
159 Description: `nothing valid`,
160 Input: []float64{
161 math.NaN(), math.NaN(), math.NaN(), math.NaN(), math.NaN(),
162 },
163 Expected: NumericTestResult{
164 Count: 5,
165 Valid: 0,
166 Integers: 0,
167 Min: math.Inf(+1),
168 Max: math.Inf(-1),
169 Sum: 0.0,
170 Mean: 0.0,
171 Geomean: math.NaN(),
172 SD: math.NaN(),
173 RMS: 0.0,
174 },
175 },
176 }
177
178 func TestNumberSummary(t *testing.T) {
179 for _, tc := range numericTests {
180 var s NumberSummary
181 for _, x := range tc.Input {
182 s.Update(x)
183 }
184 tc.Expected.Test(t, s)
185 }
186 }
187
188 func equals(x, y float64) bool {
189 if x == y {
190 return true
191 }
192 return math.IsNaN(x) && math.IsNaN(y)
193 }
File: ./mit-license.txt
1 The MIT License (MIT)
2
3 Copyright (c) 2026 pacman64
4
5 Permission is hereby granted, free of charge, to any person obtaining a copy of
6 this software and associated documentation files (the "Software"), to deal
7 in the Software without restriction, including without limitation the rights to
8 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
9 of the Software, and to permit persons to whom the Software is furnished to do
10 so, subject to the following conditions:
11
12 The above copyright notice and this permission notice shall be included in all
13 copies or substantial portions of the Software.
14
15 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 SOFTWARE.
File: ./ncol/ncol.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package ncol
26
27 import (
28 "bufio"
29 "errors"
30 "io"
31 "os"
32 "strconv"
33 "strings"
34 "unicode/utf8"
35 )
36
37 const info = `
38 ncol [options...] [filenames...]
39
40
41 Nice COLumns realigns and styles data tables using ANSI color sequences. In
42 particular, all auto-detected numbers are styled so they're easier to read
43 at a glance. Input tables can be either lines of space-separated values or
44 tab-separated values, and are auto-detected using the first non-empty line.
45
46 When not given filepaths to read data from, this tool reads from standard
47 input by default.
48
49 The options are, available both in single and double-dash versions
50
51 -h, -help show this help message
52 -m, -max-columns use the row with the most items for the item-count
53 -no-sums, -unsummed avoid showing a final row with column sums
54 -no-tiles, -untiled avoid showing color-coded tiles
55 -s, -simple avoid showing color-coded tiles and sums
56 `
57
58 const columnGap = 2
59
60 // altDigitStyle is used to make 4+ digit-runs easier to read
61 const altDigitStyle = "\x1b[38;2;168;168;168m"
62
63 func Main() {
64 sums := true
65 tiles := true
66 maxCols := false
67 args := os.Args[1:]
68
69 for len(args) > 0 {
70 switch args[0] {
71 case `-h`, `--h`, `-help`, `--help`:
72 os.Stdout.WriteString(info[1:])
73 return
74
75 case
76 `-m`, `--m`,
77 `-maxcols`, `--maxcols`,
78 `-max-columns`, `--max-columns`:
79 maxCols = true
80 args = args[1:]
81 continue
82
83 case
84 `-no-sums`, `--no-sums`, `-no-totals`, `--no-totals`,
85 `-unsummed`, `--unsummed`, `-untotaled`, `--untotaled`,
86 `-untotalled`, `--untotalled`:
87 sums = false
88 args = args[1:]
89 continue
90
91 case `-no-tiles`, `--no-tiles`, `-untiled`, `--untiled`:
92 tiles = false
93 args = args[1:]
94 continue
95
96 case `-s`, `--s`, `-simple`, `--simple`:
97 sums = false
98 tiles = false
99 args = args[1:]
100 continue
101 }
102
103 break
104 }
105
106 if len(args) > 0 && args[0] == `--` {
107 args = args[1:]
108 }
109
110 var res table
111 res.MaxColumns = maxCols
112 res.ShowTiles = tiles
113 res.ShowSums = sums
114
115 if err := run(args, &res); err != nil {
116 os.Stderr.WriteString(err.Error())
117 os.Stderr.WriteString("\n")
118 os.Exit(1)
119 }
120 }
121
122 // table has all summary info gathered from the data, along with the row
123 // themselves, stored as lines/strings
124 type table struct {
125 Columns int
126
127 Rows []string
128
129 MaxWidth []int
130
131 MaxDotDecimals []int
132
133 Numeric []int
134
135 Sums []float64
136
137 LoopItems func(line string, items int, t *table, f itemFunc) int
138
139 sb strings.Builder
140
141 MaxColumns bool
142
143 ShowTiles bool
144
145 ShowSums bool
146 }
147
148 type itemFunc func(i int, s string, t *table)
149
150 func run(paths []string, res *table) error {
151 for _, p := range paths {
152 if err := handleFile(res, p); err != nil {
153 return err
154 }
155 }
156
157 if len(paths) == 0 {
158 if err := handleReader(res, os.Stdin); err != nil {
159 return err
160 }
161 }
162
163 bw := bufio.NewWriterSize(os.Stdout, 32*1024)
164 defer bw.Flush()
165 realign(bw, res)
166 return nil
167 }
168
169 func handleFile(res *table, path string) error {
170 f, err := os.Open(path)
171 if err != nil {
172 // on windows, file-not-found error messages may mention `CreateFile`,
173 // even when trying to open files in read-only mode
174 return errors.New(`can't open file named ` + path)
175 }
176 defer f.Close()
177 return handleReader(res, f)
178 }
179
180 func handleReader(t *table, r io.Reader) error {
181 const gb = 1024 * 1024 * 1024
182 sc := bufio.NewScanner(r)
183 sc.Buffer(nil, 8*gb)
184
185 const maxInt = int(^uint(0) >> 1)
186 maxCols := maxInt
187
188 for i := 0; sc.Scan(); i++ {
189 s := sc.Text()
190 if i == 0 && strings.HasPrefix(s, "\xef\xbb\xbf") {
191 s = s[3:]
192 }
193
194 if len(s) == 0 {
195 continue
196 }
197
198 t.Rows = append(t.Rows, s)
199
200 if t.Columns == 0 {
201 if t.LoopItems == nil {
202 if strings.IndexByte(s, '\t') >= 0 {
203 t.LoopItems = loopItemsTSV
204 } else {
205 t.LoopItems = loopItemsSSV
206 }
207 }
208
209 if !t.MaxColumns {
210 t.Columns = t.LoopItems(s, maxCols, t, doNothing)
211 maxCols = t.Columns
212 }
213 }
214
215 t.LoopItems(s, maxCols, t, updateItem)
216 }
217
218 return sc.Err()
219 }
220
221 // doNothing is given to LoopItems to count items, while doing nothing else
222 func doNothing(i int, s string, t *table) {}
223
224 func updateItem(i int, s string, t *table) {
225 // ensure column-info-slices have enough room
226 if i >= len(t.MaxWidth) {
227 // update column-count if in max-columns mode
228 if t.MaxColumns {
229 t.Columns = i + 1
230 }
231 t.MaxWidth = append(t.MaxWidth, 0)
232 t.MaxDotDecimals = append(t.MaxDotDecimals, 0)
233 t.Numeric = append(t.Numeric, 0)
234 t.Sums = append(t.Sums, 0)
235 }
236
237 // keep track of widest rune-counts for each column
238 w := countWidth(s)
239 if t.MaxWidth[i] < w {
240 t.MaxWidth[i] = w
241 }
242
243 // update stats for numeric items
244 if isNumeric(s, &(t.sb)) {
245 dd := countDotDecimals(s)
246 if t.MaxDotDecimals[i] < dd {
247 t.MaxDotDecimals[i] = dd
248 }
249
250 t.Numeric[i]++
251 f, _ := strconv.ParseFloat(t.sb.String(), 64)
252 t.Sums[i] += f
253 }
254 }
255
256 // loopItemsSSV loops over a line's items, allocation-free style; when given
257 // empty strings, the callback func is never called
258 func loopItemsSSV(s string, max int, t *table, f itemFunc) int {
259 i := 0
260 s = trimTrailingSpaces(s)
261
262 for {
263 s = trimLeadingSpaces(s)
264 if len(s) == 0 {
265 return i
266 }
267
268 if i+1 == max {
269 f(i, s, t)
270 return i + 1
271 }
272
273 j := strings.IndexByte(s, ' ')
274 if j < 0 {
275 f(i, s, t)
276 return i + 1
277 }
278
279 f(i, s[:j], t)
280 s = s[j+1:]
281 i++
282 }
283 }
284
285 func trimLeadingSpaces(s string) string {
286 for len(s) > 0 && s[0] == ' ' {
287 s = s[1:]
288 }
289 return s
290 }
291
292 func trimTrailingSpaces(s string) string {
293 for len(s) > 0 && s[len(s)-1] == ' ' {
294 s = s[:len(s)-1]
295 }
296 return s
297 }
298
299 // loopItemsTSV loops over a line's tab-separated items, allocation-free style;
300 // when given empty strings, the callback func is never called
301 func loopItemsTSV(s string, max int, t *table, f itemFunc) int {
302 if len(s) == 0 {
303 return 0
304 }
305
306 i := 0
307
308 for {
309 if i+1 == max {
310 f(i, s, t)
311 return i + 1
312 }
313
314 j := strings.IndexByte(s, '\t')
315 if j < 0 {
316 f(i, s, t)
317 return i + 1
318 }
319
320 f(i, s[:j], t)
321 s = s[j+1:]
322 i++
323 }
324 }
325
326 func skipLeadingEscapeSequences(s string) string {
327 for len(s) >= 2 {
328 if s[0] != '\x1b' {
329 return s
330 }
331
332 switch s[1] {
333 case '[':
334 s = skipSingleLeadingANSI(s[2:])
335
336 case ']':
337 if len(s) < 3 || s[2] != '8' {
338 return s
339 }
340 s = skipSingleLeadingOSC(s[3:])
341
342 default:
343 return s
344 }
345 }
346
347 return s
348 }
349
350 func skipSingleLeadingANSI(s string) string {
351 for len(s) > 0 {
352 upper := s[0] &^ 32
353 s = s[1:]
354 if 'A' <= upper && upper <= 'Z' {
355 break
356 }
357 }
358
359 return s
360 }
361
362 func skipSingleLeadingOSC(s string) string {
363 var prev byte
364
365 for len(s) > 0 {
366 b := s[0]
367 s = s[1:]
368 if prev == '\x1b' && b == '\\' {
369 break
370 }
371 prev = b
372 }
373
374 return s
375 }
376
377 // isNumeric checks if a string is valid/useable as a number
378 func isNumeric(s string, sb *strings.Builder) bool {
379 if len(s) == 0 {
380 return false
381 }
382
383 sb.Reset()
384
385 s = skipLeadingEscapeSequences(s)
386 if len(s) > 0 && (s[0] == '+' || s[0] == '-') {
387 sb.WriteByte(s[0])
388 s = s[1:]
389 }
390
391 s = skipLeadingEscapeSequences(s)
392 if len(s) == 0 {
393 return false
394 }
395 if b := s[0]; b == '.' {
396 sb.WriteByte(b)
397 return isDigits(s[1:], sb)
398 }
399
400 digits := 0
401
402 for {
403 s = skipLeadingEscapeSequences(s)
404 if len(s) == 0 {
405 break
406 }
407
408 b := s[0]
409 sb.WriteByte(b)
410
411 if b == '.' {
412 return isDigits(s[1:], sb)
413 }
414
415 if !('0' <= b && b <= '9') {
416 return false
417 }
418
419 digits++
420 s = s[1:]
421 }
422
423 s = skipLeadingEscapeSequences(s)
424 return len(s) == 0 && digits > 0
425 }
426
427 func isDigits(s string, sb *strings.Builder) bool {
428 if len(s) == 0 {
429 return false
430 }
431
432 digits := 0
433
434 for {
435 s = skipLeadingEscapeSequences(s)
436 if len(s) == 0 {
437 break
438 }
439
440 if b := s[0]; '0' <= b && b <= '9' {
441 sb.WriteByte(b)
442 s = s[1:]
443 digits++
444 } else {
445 return false
446 }
447 }
448
449 s = skipLeadingEscapeSequences(s)
450 return len(s) == 0 && digits > 0
451 }
452
453 // countDecimals counts decimal digits from the string given, assuming it
454 // represents a valid/useable float64, when parsed
455 func countDecimals(s string) int {
456 dot := strings.IndexByte(s, '.')
457 if dot < 0 {
458 return 0
459 }
460
461 decs := 0
462 s = s[dot+1:]
463
464 for len(s) > 0 {
465 s = skipLeadingEscapeSequences(s)
466 if len(s) == 0 {
467 break
468 }
469 if '0' <= s[0] && s[0] <= '9' {
470 decs++
471 }
472 s = s[1:]
473 }
474
475 return decs
476 }
477
478 // countDotDecimals is like func countDecimals, but this one also includes
479 // the dot, when any decimals are present, else the count stays at 0
480 func countDotDecimals(s string) int {
481 decs := countDecimals(s)
482 if decs > 0 {
483 return decs + 1
484 }
485 return decs
486 }
487
488 func countWidth(s string) int {
489 width := 0
490
491 for len(s) > 0 {
492 i, j := indexEscapeSequence(s)
493 if i < 0 {
494 break
495 }
496 if j < 0 {
497 j = len(s)
498 }
499
500 width += utf8.RuneCountInString(s[:i])
501 s = s[j:]
502 }
503
504 // count trailing/all runes in strings which don't end with ANSI-sequences
505 width += utf8.RuneCountInString(s)
506 return width
507 }
508
509 // indexEscapeSequence finds the first ANSI-style escape-sequence, which is
510 // the multi-byte sequences starting with ESC[; the result is a pair of slice
511 // indices which can be independently negative when either the start/end of
512 // a sequence isn't found; given their fairly-common use, even the hyperlink
513 // ESC]8 sequences are supported
514 func indexEscapeSequence(s string) (int, int) {
515 var prev byte
516
517 for i := range s {
518 b := s[i]
519
520 if prev == '\x1b' && b == '[' {
521 j := indexLetter(s[i+1:])
522 if j < 0 {
523 return i, -1
524 }
525 return i - 1, i + 1 + j + 1
526 }
527
528 if prev == '\x1b' && b == ']' && i+1 < len(s) && s[i+1] == '8' {
529 j := indexPair(s[i+1:], '\x1b', '\\')
530 if j < 0 {
531 return i, -1
532 }
533 return i - 1, i + 1 + j + 2
534 }
535
536 prev = b
537 }
538
539 return -1, -1
540 }
541
542 func indexLetter(s string) int {
543 for i, b := range s {
544 upper := b &^ 32
545 if 'A' <= upper && upper <= 'Z' {
546 return i
547 }
548 }
549
550 return -1
551 }
552
553 func indexPair(s string, x byte, y byte) int {
554 var prev byte
555
556 for i := range s {
557 b := s[i]
558 if prev == x && b == y && i > 0 {
559 return i
560 }
561 prev = b
562 }
563
564 return -1
565 }
566
567 func realign(w *bufio.Writer, t *table) {
568 // make sums row first, as final alignments are usually affected by these
569 var sums []string
570 if t.ShowSums {
571 sums = make([]string, 0, t.Columns)
572
573 for i := 0; i < t.Columns; i++ {
574 if t.Numeric[i] == 0 {
575 sums = append(sums, `-`)
576 if t.MaxWidth[i] < 1 {
577 t.MaxWidth[i] = 1
578 }
579 continue
580 }
581
582 decs := t.MaxDotDecimals[i]
583 if decs > 0 {
584 decs--
585 }
586
587 var buf [64]byte
588 s := strconv.AppendFloat(buf[:0], t.Sums[i], 'f', decs, 64)
589 sums = append(sums, string(s))
590 if t.MaxWidth[i] < len(s) {
591 t.MaxWidth[i] = len(s)
592 }
593 }
594 }
595
596 // due keeps track of how many spaces are due, when separating realigned
597 // items from their immediate predecessor on the same row; this counter
598 // is also used to right-pad numbers with decimals, as such items can be
599 // padded with spaces from either side
600 due := 0
601
602 showItem := func(i int, s string, t *table) {
603 if i > 0 {
604 due += columnGap
605 }
606
607 if isNumeric(s, &(t.sb)) {
608 dd := countDotDecimals(s)
609 rpad := t.MaxDotDecimals[i] - dd
610 width := countWidth(s)
611 lpad := t.MaxWidth[i] - (width + rpad) + due
612 writeSpaces(w, lpad)
613 f, _ := strconv.ParseFloat(t.sb.String(), 64)
614 writeNumericItem(w, s, numericStyle(f))
615 due = rpad
616 return
617 }
618
619 writeSpaces(w, due)
620 w.WriteString(s)
621 due = t.MaxWidth[i] - countWidth(s)
622 }
623
624 writeTile := func(i int, s string, t *table) {
625 // make empty items stand out
626 if len(s) == 0 {
627 w.WriteString("\x1b[0m○")
628 return
629 }
630
631 if isNumeric(s, &(t.sb)) {
632 f, _ := strconv.ParseFloat(t.sb.String(), 64)
633 w.WriteString(numericStyle(f))
634 w.WriteString("■")
635 return
636 }
637
638 // make padded items stand out: these items have spaces at either end
639 if s[0] == ' ' || s[len(s)-1] == ' ' {
640 w.WriteString("\x1b[38;2;196;160;0m■")
641 return
642 }
643
644 w.WriteString("\x1b[38;2;128;128;128m■")
645 }
646
647 // show realigned rows
648
649 for _, line := range t.Rows {
650 due = 0
651
652 if t.ShowTiles {
653 end := t.LoopItems(line, t.Columns, t, writeTile)
654 if end < len(t.MaxWidth)-1 {
655 w.WriteString("\x1b[0m")
656 }
657 // make rows with missing trailing items stand out
658 for i := end; i < len(t.MaxWidth); i++ {
659 w.WriteString("×")
660 }
661 w.WriteString("\x1b[0m")
662 due += columnGap
663 }
664
665 t.LoopItems(line, t.Columns, t, showItem)
666 if w.WriteByte('\n') != nil {
667 return
668 }
669 }
670
671 if t.Columns > 0 && t.ShowSums {
672 realignSums(w, t, sums)
673 }
674 }
675
676 func realignSums(w *bufio.Writer, t *table, sums []string) {
677 due := 0
678 if t.ShowTiles {
679 due += t.Columns + columnGap
680 }
681
682 for i, s := range sums {
683 if i > 0 {
684 due += columnGap
685 }
686
687 if t.Numeric[i] == 0 {
688 writeSpaces(w, due)
689 w.WriteString(s)
690 due = t.MaxWidth[i] - countWidth(s)
691 continue
692 }
693
694 lpad := t.MaxWidth[i] - len(s) + due
695 writeSpaces(w, lpad)
696 writeNumericItem(w, s, numericStyle(t.Sums[i]))
697 due = 0
698 }
699
700 w.WriteByte('\n')
701 }
702
703 // writeSpaces does what it says, minimizing calls to write-like funcs
704 func writeSpaces(w *bufio.Writer, n int) {
705 const spaces = ` `
706 if n < 1 {
707 return
708 }
709
710 for n >= len(spaces) {
711 w.WriteString(spaces)
712 n -= len(spaces)
713 }
714 w.WriteString(spaces[:n])
715 }
716
717 func writeRowTiles(w *bufio.Writer, s string, t *table, writeTile itemFunc) {
718 end := t.LoopItems(s, t.Columns, t, writeTile)
719
720 if end < len(t.MaxWidth)-1 {
721 w.WriteString("\x1b[0m")
722 }
723 for i := end + 1; i < len(t.MaxWidth); i++ {
724 w.WriteString("×")
725 }
726 w.WriteString("\x1b[0m")
727 }
728
729 func numericStyle(f float64) string {
730 if f > 0 {
731 if float64(int64(f)) == f {
732 return "\x1b[38;2;0;135;0m"
733 }
734 return "\x1b[38;2;0;155;95m"
735 }
736 if f < 0 {
737 if float64(int64(f)) == f {
738 return "\x1b[38;2;204;0;0m"
739 }
740 return "\x1b[38;2;215;95;95m"
741 }
742 if f == 0 {
743 return "\x1b[38;2;0;95;215m"
744 }
745 return "\x1b[38;2;128;128;128m"
746 }
747
748 func writeNumericItem(w *bufio.Writer, s string, startStyle string) {
749 w.WriteString(startStyle)
750 if len(s) > 0 && (s[0] == '-' || s[0] == '+') {
751 w.WriteByte(s[0])
752 s = s[1:]
753 }
754
755 dot := strings.IndexByte(s, '.')
756 if dot < 0 {
757 restyleDigits(w, s, altDigitStyle)
758 w.WriteString("\x1b[0m")
759 return
760 }
761
762 if len(s[:dot]) > 3 {
763 restyleDigits(w, s[:dot], altDigitStyle)
764 w.WriteString("\x1b[0m")
765 w.WriteString(startStyle)
766 w.WriteByte('.')
767 } else {
768 w.WriteString(s[:dot])
769 w.WriteByte('.')
770 }
771
772 rest := s[dot+1:]
773 restyleDigits(w, rest, altDigitStyle)
774 if len(rest) < 4 {
775 w.WriteString("\x1b[0m")
776 }
777 }
778
779 // restyleDigits renders a run of digits as alternating styled/unstyled runs
780 // of 3 digits, which greatly improves readability, and is the only purpose
781 // of this app; string is assumed to be all decimal digits
782 func restyleDigits(w *bufio.Writer, digits string, altStyle string) {
783 if len(digits) < 4 {
784 // digit sequence is short, so emit it as is
785 w.WriteString(digits)
786 return
787 }
788
789 // separate leading 0..2 digits which don't align with the 3-digit groups
790 i := len(digits) % 3
791 // emit leading digits unstyled, if there are any
792 w.WriteString(digits[:i])
793 // the rest is guaranteed to have a length which is a multiple of 3
794 digits = digits[i:]
795
796 // start by styling, unless there were no leading digits
797 style := i != 0
798
799 for len(digits) > 0 {
800 if style {
801 w.WriteString(altStyle)
802 w.WriteString(digits[:3])
803 w.WriteString("\x1b[0m")
804 } else {
805 w.WriteString(digits[:3])
806 }
807
808 // advance to the next triple: the start of this func is supposed
809 // to guarantee this step always works
810 digits = digits[3:]
811
812 // alternate between styled and unstyled 3-digit groups
813 style = !style
814 }
815 }
File: ./ncol/ncol_test.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package ncol
26
27 import "testing"
28
29 func TestCountWidth(t *testing.T) {
30 var tests = []struct {
31 name string
32 input string
33 expected int
34 }{
35 {`empty`, ``, 0},
36 {`empty ANSI`, "\x1b[38;5;0;0;0m\x1b[0m", 0},
37 {`simple plain`, `abc def`, 7},
38 {`unicode plain`, `abc●def`, 7},
39 {`simple ANSI`, "abc \x1b[7mde\x1b[0mf", 7},
40 {`unicode ANSI`, "abc●\x1b[7mde\x1b[0mf", 7},
41 }
42
43 for _, tc := range tests {
44 t.Run(tc.name, func(t *testing.T) {
45 got := countWidth(tc.input)
46 if got != tc.expected {
47 t.Errorf("expected width %d, got %d instead", tc.expected, got)
48 }
49 })
50 }
51 }
File: ./ngron/ngron.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package ngron
26
27 import (
28 "bufio"
29 "encoding/json"
30 "errors"
31 "io"
32 "os"
33 "strconv"
34 )
35
36 const info = `
37 ngron [options...] [filepath/URI...]
38
39
40 Nice GRON converts JSON data into 'grep'-friendly lines, similar to what
41 tool 'gron' (GRep jsON; https://github.com/tomnomnom/gron) does.
42
43 This tool uses nicer ANSI styles than the original, hence its name, but
44 can't convert its output back into JSON, unlike the latter.
45
46 Unlike the original 'gron', there's no sort-mode. When not given a named
47 source (filepath/URI) to read from, data are read from standard input.
48
49 Options, where leading double-dashes are also allowed:
50
51 -h show this help message
52 -help show this help message
53
54 -m monochrome (default), enables unstyled output-mode
55 -c color, enables ANSI-styled output-mode
56 -color enables ANSI-styled output-mode
57 `
58
59 type emitConfig struct {
60 path func(w *bufio.Writer, path []any) error
61 null func(w *bufio.Writer) error
62 boolean func(w *bufio.Writer, b bool) error
63 number func(w *bufio.Writer, n json.Number) error
64 key func(w *bufio.Writer, k string) error
65 text func(w *bufio.Writer, s string) error
66
67 arrayDecl string
68 objectDecl string
69 }
70
71 var monochrome = emitConfig{
72 path: monoPath,
73 null: monoNull,
74 boolean: monoBool,
75 number: monoNumber,
76 key: monoString,
77 text: monoString,
78
79 arrayDecl: `[]`,
80 objectDecl: `{}`,
81 }
82
83 var styled = emitConfig{
84 path: styledPath,
85 null: styledNull,
86 boolean: styledBool,
87 number: styledNumber,
88 key: styledString,
89 text: styledString,
90
91 arrayDecl: "\x1b[38;2;168;168;168m[]\x1b[0m",
92 objectDecl: "\x1b[38;2;168;168;168m{}\x1b[0m",
93 }
94
95 var config = monochrome
96
97 func Main() {
98 args := os.Args[1:]
99
100 for len(args) > 0 {
101 switch args[0] {
102 case `-c`, `--c`, `-color`, `--color`:
103 config = styled
104 args = args[1:]
105 continue
106
107 case `-h`, `--h`, `-help`, `--help`:
108 os.Stdout.WriteString(info[1:])
109 return
110
111 case `-m`, `--m`:
112 config = monochrome
113 args = args[1:]
114 continue
115 }
116
117 break
118 }
119
120 if len(args) > 0 && args[0] == `--` {
121 args = args[1:]
122 }
123
124 if len(args) > 2 {
125 os.Stderr.WriteString("multiple inputs not allowed\n")
126 os.Exit(1)
127 }
128
129 // figure out whether input should come from a named file or from stdin
130 path := `-`
131 if len(args) > 0 {
132 path = args[0]
133 }
134
135 err := handleInput(os.Stdout, path)
136 if err != nil && err != io.EOF {
137 os.Stderr.WriteString(err.Error())
138 os.Stderr.WriteString("\n")
139 os.Exit(1)
140 }
141 }
142
143 type handlerFunc func(*bufio.Writer, *json.Decoder, json.Token, []any) error
144
145 // handleInput simplifies control-flow for func main
146 func handleInput(w io.Writer, path string) error {
147 if path == `-` {
148 bw := bufio.NewWriter(w)
149 defer bw.Flush()
150 return run(bw, os.Stdin)
151 }
152
153 f, err := os.Open(path)
154 if err != nil {
155 // on windows, file-not-found error messages may mention `CreateFile`,
156 // even when trying to open files in read-only mode
157 return errors.New(`can't open file named ` + path)
158 }
159 defer f.Close()
160
161 bw := bufio.NewWriter(w)
162 defer bw.Flush()
163 return run(bw, f)
164 }
165
166 // escapedStringBytes helps func handleString treat all string bytes quickly
167 // and correctly, using their officially-supported JSON escape sequences
168 //
169 // https://www.rfc-editor.org/rfc/rfc8259#section-7
170 var escapedStringBytes = [256][]byte{
171 {'\\', 'u', '0', '0', '0', '0'}, {'\\', 'u', '0', '0', '0', '1'},
172 {'\\', 'u', '0', '0', '0', '2'}, {'\\', 'u', '0', '0', '0', '3'},
173 {'\\', 'u', '0', '0', '0', '4'}, {'\\', 'u', '0', '0', '0', '5'},
174 {'\\', 'u', '0', '0', '0', '6'}, {'\\', 'u', '0', '0', '0', '7'},
175 {'\\', 'b'}, {'\\', 't'},
176 {'\\', 'n'}, {'\\', 'u', '0', '0', '0', 'b'},
177 {'\\', 'f'}, {'\\', 'r'},
178 {'\\', 'u', '0', '0', '0', 'e'}, {'\\', 'u', '0', '0', '0', 'f'},
179 {'\\', 'u', '0', '0', '1', '0'}, {'\\', 'u', '0', '0', '1', '1'},
180 {'\\', 'u', '0', '0', '1', '2'}, {'\\', 'u', '0', '0', '1', '3'},
181 {'\\', 'u', '0', '0', '1', '4'}, {'\\', 'u', '0', '0', '1', '5'},
182 {'\\', 'u', '0', '0', '1', '6'}, {'\\', 'u', '0', '0', '1', '7'},
183 {'\\', 'u', '0', '0', '1', '8'}, {'\\', 'u', '0', '0', '1', '9'},
184 {'\\', 'u', '0', '0', '1', 'a'}, {'\\', 'u', '0', '0', '1', 'b'},
185 {'\\', 'u', '0', '0', '1', 'c'}, {'\\', 'u', '0', '0', '1', 'd'},
186 {'\\', 'u', '0', '0', '1', 'e'}, {'\\', 'u', '0', '0', '1', 'f'},
187 {32}, {33}, {'\\', '"'}, {35}, {36}, {37}, {38}, {39},
188 {40}, {41}, {42}, {43}, {44}, {45}, {46}, {47},
189 {48}, {49}, {50}, {51}, {52}, {53}, {54}, {55},
190 {56}, {57}, {58}, {59}, {60}, {61}, {62}, {63},
191 {64}, {65}, {66}, {67}, {68}, {69}, {70}, {71},
192 {72}, {73}, {74}, {75}, {76}, {77}, {78}, {79},
193 {80}, {81}, {82}, {83}, {84}, {85}, {86}, {87},
194 {88}, {89}, {90}, {91}, {'\\', '\\'}, {93}, {94}, {95},
195 {96}, {97}, {98}, {99}, {100}, {101}, {102}, {103},
196 {104}, {105}, {106}, {107}, {108}, {109}, {110}, {111},
197 {112}, {113}, {114}, {115}, {116}, {117}, {118}, {119},
198 {120}, {121}, {122}, {123}, {124}, {125}, {126}, {127},
199 {128}, {129}, {130}, {131}, {132}, {133}, {134}, {135},
200 {136}, {137}, {138}, {139}, {140}, {141}, {142}, {143},
201 {144}, {145}, {146}, {147}, {148}, {149}, {150}, {151},
202 {152}, {153}, {154}, {155}, {156}, {157}, {158}, {159},
203 {160}, {161}, {162}, {163}, {164}, {165}, {166}, {167},
204 {168}, {169}, {170}, {171}, {172}, {173}, {174}, {175},
205 {176}, {177}, {178}, {179}, {180}, {181}, {182}, {183},
206 {184}, {185}, {186}, {187}, {188}, {189}, {190}, {191},
207 {192}, {193}, {194}, {195}, {196}, {197}, {198}, {199},
208 {200}, {201}, {202}, {203}, {204}, {205}, {206}, {207},
209 {208}, {209}, {210}, {211}, {212}, {213}, {214}, {215},
210 {216}, {217}, {218}, {219}, {220}, {221}, {222}, {223},
211 {224}, {225}, {226}, {227}, {228}, {229}, {230}, {231},
212 {232}, {233}, {234}, {235}, {236}, {237}, {238}, {239},
213 {240}, {241}, {242}, {243}, {244}, {245}, {246}, {247},
214 {248}, {249}, {250}, {251}, {252}, {253}, {254}, {255},
215 }
216
217 // run does it all, given a reader and a writer
218 func run(w *bufio.Writer, r io.Reader) error {
219 dec := json.NewDecoder(r)
220 // avoid parsing numbers, so unusually-long numbers are kept verbatim,
221 // even if JSON parsers aren't required to guarantee such input-fidelity
222 // for numbers
223 dec.UseNumber()
224
225 t, err := dec.Token()
226 if err == io.EOF {
227 return errors.New(`input has no JSON values`)
228 }
229
230 if err = handleToken(w, dec, t, make([]any, 0, 50)); err != nil {
231 return err
232 }
233
234 _, err = dec.Token()
235 if err == io.EOF {
236 // input is over, so it's a success
237 return nil
238 }
239
240 if err == nil {
241 // a successful `read` is a failure, as it means there are
242 // trailing JSON tokens
243 return errors.New(`unexpected trailing data`)
244 }
245
246 // any other error, perhaps some invalid-JSON-syntax-type error
247 return err
248 }
249
250 // handleToken handles recursion for func run
251 func handleToken(w *bufio.Writer, dec *json.Decoder, t json.Token, path []any) error {
252 switch t := t.(type) {
253 case json.Delim:
254 switch t {
255 case json.Delim('['):
256 return handleArray(w, dec, path)
257 case json.Delim('{'):
258 return handleObject(w, dec, path)
259 default:
260 return errors.New(`unsupported JSON syntax ` + string(t))
261 }
262
263 case nil:
264 config.path(w, path)
265 config.null(w)
266 return endLine(w)
267
268 case bool:
269 config.path(w, path)
270 config.boolean(w, t)
271 return endLine(w)
272
273 case json.Number:
274 config.path(w, path)
275 config.number(w, t)
276 return endLine(w)
277
278 case string:
279 config.path(w, path)
280 config.text(w, t)
281 return endLine(w)
282
283 default:
284 // return fmt.Errorf(`unsupported token type %T`, t)
285 return errors.New(`invalid JSON token`)
286 }
287 }
288
289 // handleArray handles arrays for func handleToken
290 func handleArray(w *bufio.Writer, dec *json.Decoder, path []any) error {
291 config.path(w, path)
292 w.WriteString(config.arrayDecl)
293 if err := endLine(w); err != nil {
294 return err
295 }
296
297 path = append(path, 0)
298 last := len(path) - 1
299
300 for i := 0; true; i++ {
301 path[last] = i
302
303 t, err := dec.Token()
304 if err != nil {
305 return err
306 }
307
308 if t == json.Delim(']') {
309 return nil
310 }
311
312 err = handleToken(w, dec, t, path)
313 if err != nil {
314 return err
315 }
316 }
317
318 // make the compiler happy
319 return nil
320 }
321
322 // handleObject handles objects for func handleToken
323 func handleObject(w *bufio.Writer, dec *json.Decoder, path []any) error {
324 config.path(w, path)
325 w.WriteString(config.objectDecl)
326 if err := endLine(w); err != nil {
327 return err
328 }
329
330 path = append(path, ``)
331 last := len(path) - 1
332
333 for i := 0; true; i++ {
334 t, err := dec.Token()
335 if err != nil {
336 return err
337 }
338
339 if t == json.Delim('}') {
340 return nil
341 }
342
343 k, ok := t.(string)
344 if !ok {
345 return errors.New(`expected a string for a key-value pair`)
346 }
347
348 path[last] = k
349 if err != nil {
350 return err
351 }
352
353 t, err = dec.Token()
354 if err == io.EOF {
355 return errors.New(`expected a value for a key-value pair`)
356 }
357
358 err = handleToken(w, dec, t, path)
359 if err != nil {
360 return err
361 }
362 }
363
364 // make the compiler happy
365 return nil
366 }
367
368 func monoPath(w *bufio.Writer, path []any) error {
369 var buf [24]byte
370
371 w.WriteString(`json`)
372
373 for _, v := range path {
374 switch v := v.(type) {
375 case int:
376 w.WriteByte('[')
377 w.Write(strconv.AppendInt(buf[:0], int64(v), 10))
378 w.WriteByte(']')
379
380 case string:
381 if !needsEscaping(v) {
382 w.WriteByte('.')
383 w.WriteString(v)
384 continue
385 }
386 w.WriteByte('[')
387 monoString(w, v)
388 w.WriteByte(']')
389 }
390 }
391
392 w.WriteString(` = `)
393 return nil
394 }
395
396 func monoNull(w *bufio.Writer) error {
397 w.WriteString(`null`)
398 return nil
399 }
400
401 func monoBool(w *bufio.Writer, b bool) error {
402 if b {
403 w.WriteString(`true`)
404 } else {
405 w.WriteString(`false`)
406 }
407 return nil
408 }
409
410 func monoNumber(w *bufio.Writer, n json.Number) error {
411 w.WriteString(n.String())
412 return nil
413 }
414
415 func monoString(w *bufio.Writer, s string) error {
416 w.WriteByte('"')
417 for i := range s {
418 w.Write(escapedStringBytes[s[i]])
419 }
420 w.WriteByte('"')
421 return nil
422 }
423
424 func styledPath(w *bufio.Writer, path []any) error {
425 var buf [24]byte
426
427 w.WriteString("\x1b[38;2;135;95;255mjson\x1b[0m")
428
429 for _, v := range path {
430 switch v := v.(type) {
431 case int:
432 w.WriteString("\x1b[38;2;168;168;168m[")
433 w.WriteString("\x1b[38;2;0;135;95m")
434 w.Write(strconv.AppendInt(buf[:0], int64(v), 10))
435 w.WriteString("\x1b[38;2;168;168;168m]\x1b[0m")
436
437 case string:
438 if !needsEscaping(v) {
439 w.WriteString("\x1b[38;2;168;168;168m.")
440 w.WriteString("\x1b[38;2;135;95;255m")
441 w.WriteString(v)
442 w.WriteString("\x1b[0m")
443 continue
444 }
445
446 w.WriteString("\x1b[38;2;168;168;168m[")
447 styledString(w, v)
448 w.WriteString("\x1b[38;2;168;168;168m]\x1b[0m")
449 }
450 }
451
452 w.WriteString(" \x1b[38;2;168;168;168m=\x1b[0m ")
453 return nil
454 }
455
456 func styledNull(w *bufio.Writer) error {
457 w.WriteString("\x1b[38;2;168;168;168m")
458 w.WriteString(`null`)
459 w.WriteString("\x1b[0m")
460 return nil
461 }
462
463 func styledBool(w *bufio.Writer, b bool) error {
464 if b {
465 w.WriteString("\x1b[38;2;95;175;215mtrue\x1b[0m")
466 } else {
467 w.WriteString("\x1b[38;2;95;175;215mfalse\x1b[0m")
468 }
469 return nil
470 }
471
472 func styledNumber(w *bufio.Writer, n json.Number) error {
473 w.WriteString("\x1b[38;2;0;135;95m")
474 w.WriteString(n.String())
475 w.WriteString("\x1b[0m")
476 return nil
477 }
478
479 func styledString(w *bufio.Writer, s string) error {
480 w.WriteString("\x1b[38;2;168;168;168m\"\x1b[0m")
481 for i := range s {
482 w.Write(escapedStringBytes[s[i]])
483 }
484 w.WriteString("\x1b[38;2;168;168;168m\"\x1b[0m")
485 return nil
486 }
487
488 func needsEscaping(s string) bool {
489 for _, r := range s {
490 if r < ' ' || r > '~' {
491 return true
492 }
493
494 switch r {
495 case '"', '\'', '\\':
496 return true
497 }
498 }
499
500 return false
501 }
502
503 func endLine(w *bufio.Writer) error {
504 w.WriteByte(';')
505 if err := w.WriteByte('\n'); err == nil {
506 return nil
507 }
508 return io.EOF
509 }
File: ./nhex/ansi.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package nhex
26
27 import (
28 "bufio"
29 "fmt"
30 "strconv"
31 )
32
33 // styledHexBytes is a super-fast direct byte-to-result lookup table, and was
34 // autogenerated by running the command
35 //
36 // seq 0 255 | ./hex-styles.awk
37 var styledHexBytes = [256]string{
38 "\x1b[38;5;111m00 ", "\x1b[38;5;246m01 ",
39 "\x1b[38;5;246m02 ", "\x1b[38;5;246m03 ",
40 "\x1b[38;5;246m04 ", "\x1b[38;5;246m05 ",
41 "\x1b[38;5;246m06 ", "\x1b[38;5;246m07 ",
42 "\x1b[38;5;246m08 ", "\x1b[38;5;246m09 ",
43 "\x1b[38;5;246m0a ", "\x1b[38;5;246m0b ",
44 "\x1b[38;5;246m0c ", "\x1b[38;5;246m0d ",
45 "\x1b[38;5;246m0e ", "\x1b[38;5;246m0f ",
46 "\x1b[38;5;246m10 ", "\x1b[38;5;246m11 ",
47 "\x1b[38;5;246m12 ", "\x1b[38;5;246m13 ",
48 "\x1b[38;5;246m14 ", "\x1b[38;5;246m15 ",
49 "\x1b[38;5;246m16 ", "\x1b[38;5;246m17 ",
50 "\x1b[38;5;246m18 ", "\x1b[38;5;246m19 ",
51 "\x1b[38;5;246m1a ", "\x1b[38;5;246m1b ",
52 "\x1b[38;5;246m1c ", "\x1b[38;5;246m1d ",
53 "\x1b[38;5;246m1e ", "\x1b[38;5;246m1f ",
54 "\x1b[38;5;72m20\x1b[38;5;239m ", "\x1b[38;5;72m21\x1b[38;5;239m!",
55 "\x1b[38;5;72m22\x1b[38;5;239m\"", "\x1b[38;5;72m23\x1b[38;5;239m#",
56 "\x1b[38;5;72m24\x1b[38;5;239m$", "\x1b[38;5;72m25\x1b[38;5;239m%",
57 "\x1b[38;5;72m26\x1b[38;5;239m&", "\x1b[38;5;72m27\x1b[38;5;239m'",
58 "\x1b[38;5;72m28\x1b[38;5;239m(", "\x1b[38;5;72m29\x1b[38;5;239m)",
59 "\x1b[38;5;72m2a\x1b[38;5;239m*", "\x1b[38;5;72m2b\x1b[38;5;239m+",
60 "\x1b[38;5;72m2c\x1b[38;5;239m,", "\x1b[38;5;72m2d\x1b[38;5;239m-",
61 "\x1b[38;5;72m2e\x1b[38;5;239m.", "\x1b[38;5;72m2f\x1b[38;5;239m/",
62 "\x1b[38;5;72m30\x1b[38;5;239m0", "\x1b[38;5;72m31\x1b[38;5;239m1",
63 "\x1b[38;5;72m32\x1b[38;5;239m2", "\x1b[38;5;72m33\x1b[38;5;239m3",
64 "\x1b[38;5;72m34\x1b[38;5;239m4", "\x1b[38;5;72m35\x1b[38;5;239m5",
65 "\x1b[38;5;72m36\x1b[38;5;239m6", "\x1b[38;5;72m37\x1b[38;5;239m7",
66 "\x1b[38;5;72m38\x1b[38;5;239m8", "\x1b[38;5;72m39\x1b[38;5;239m9",
67 "\x1b[38;5;72m3a\x1b[38;5;239m:", "\x1b[38;5;72m3b\x1b[38;5;239m;",
68 "\x1b[38;5;72m3c\x1b[38;5;239m<", "\x1b[38;5;72m3d\x1b[38;5;239m=",
69 "\x1b[38;5;72m3e\x1b[38;5;239m>", "\x1b[38;5;72m3f\x1b[38;5;239m?",
70 "\x1b[38;5;72m40\x1b[38;5;239m@", "\x1b[38;5;72m41\x1b[38;5;239mA",
71 "\x1b[38;5;72m42\x1b[38;5;239mB", "\x1b[38;5;72m43\x1b[38;5;239mC",
72 "\x1b[38;5;72m44\x1b[38;5;239mD", "\x1b[38;5;72m45\x1b[38;5;239mE",
73 "\x1b[38;5;72m46\x1b[38;5;239mF", "\x1b[38;5;72m47\x1b[38;5;239mG",
74 "\x1b[38;5;72m48\x1b[38;5;239mH", "\x1b[38;5;72m49\x1b[38;5;239mI",
75 "\x1b[38;5;72m4a\x1b[38;5;239mJ", "\x1b[38;5;72m4b\x1b[38;5;239mK",
76 "\x1b[38;5;72m4c\x1b[38;5;239mL", "\x1b[38;5;72m4d\x1b[38;5;239mM",
77 "\x1b[38;5;72m4e\x1b[38;5;239mN", "\x1b[38;5;72m4f\x1b[38;5;239mO",
78 "\x1b[38;5;72m50\x1b[38;5;239mP", "\x1b[38;5;72m51\x1b[38;5;239mQ",
79 "\x1b[38;5;72m52\x1b[38;5;239mR", "\x1b[38;5;72m53\x1b[38;5;239mS",
80 "\x1b[38;5;72m54\x1b[38;5;239mT", "\x1b[38;5;72m55\x1b[38;5;239mU",
81 "\x1b[38;5;72m56\x1b[38;5;239mV", "\x1b[38;5;72m57\x1b[38;5;239mW",
82 "\x1b[38;5;72m58\x1b[38;5;239mX", "\x1b[38;5;72m59\x1b[38;5;239mY",
83 "\x1b[38;5;72m5a\x1b[38;5;239mZ", "\x1b[38;5;72m5b\x1b[38;5;239m[",
84 "\x1b[38;5;72m5c\x1b[38;5;239m\\", "\x1b[38;5;72m5d\x1b[38;5;239m]",
85 "\x1b[38;5;72m5e\x1b[38;5;239m^", "\x1b[38;5;72m5f\x1b[38;5;239m_",
86 "\x1b[38;5;72m60\x1b[38;5;239m`", "\x1b[38;5;72m61\x1b[38;5;239ma",
87 "\x1b[38;5;72m62\x1b[38;5;239mb", "\x1b[38;5;72m63\x1b[38;5;239mc",
88 "\x1b[38;5;72m64\x1b[38;5;239md", "\x1b[38;5;72m65\x1b[38;5;239me",
89 "\x1b[38;5;72m66\x1b[38;5;239mf", "\x1b[38;5;72m67\x1b[38;5;239mg",
90 "\x1b[38;5;72m68\x1b[38;5;239mh", "\x1b[38;5;72m69\x1b[38;5;239mi",
91 "\x1b[38;5;72m6a\x1b[38;5;239mj", "\x1b[38;5;72m6b\x1b[38;5;239mk",
92 "\x1b[38;5;72m6c\x1b[38;5;239ml", "\x1b[38;5;72m6d\x1b[38;5;239mm",
93 "\x1b[38;5;72m6e\x1b[38;5;239mn", "\x1b[38;5;72m6f\x1b[38;5;239mo",
94 "\x1b[38;5;72m70\x1b[38;5;239mp", "\x1b[38;5;72m71\x1b[38;5;239mq",
95 "\x1b[38;5;72m72\x1b[38;5;239mr", "\x1b[38;5;72m73\x1b[38;5;239ms",
96 "\x1b[38;5;72m74\x1b[38;5;239mt", "\x1b[38;5;72m75\x1b[38;5;239mu",
97 "\x1b[38;5;72m76\x1b[38;5;239mv", "\x1b[38;5;72m77\x1b[38;5;239mw",
98 "\x1b[38;5;72m78\x1b[38;5;239mx", "\x1b[38;5;72m79\x1b[38;5;239my",
99 "\x1b[38;5;72m7a\x1b[38;5;239mz", "\x1b[38;5;72m7b\x1b[38;5;239m{",
100 "\x1b[38;5;72m7c\x1b[38;5;239m|", "\x1b[38;5;72m7d\x1b[38;5;239m}",
101 "\x1b[38;5;72m7e\x1b[38;5;239m~", "\x1b[38;5;246m7f ",
102 "\x1b[38;5;246m80 ", "\x1b[38;5;246m81 ",
103 "\x1b[38;5;246m82 ", "\x1b[38;5;246m83 ",
104 "\x1b[38;5;246m84 ", "\x1b[38;5;246m85 ",
105 "\x1b[38;5;246m86 ", "\x1b[38;5;246m87 ",
106 "\x1b[38;5;246m88 ", "\x1b[38;5;246m89 ",
107 "\x1b[38;5;246m8a ", "\x1b[38;5;246m8b ",
108 "\x1b[38;5;246m8c ", "\x1b[38;5;246m8d ",
109 "\x1b[38;5;246m8e ", "\x1b[38;5;246m8f ",
110 "\x1b[38;5;246m90 ", "\x1b[38;5;246m91 ",
111 "\x1b[38;5;246m92 ", "\x1b[38;5;246m93 ",
112 "\x1b[38;5;246m94 ", "\x1b[38;5;246m95 ",
113 "\x1b[38;5;246m96 ", "\x1b[38;5;246m97 ",
114 "\x1b[38;5;246m98 ", "\x1b[38;5;246m99 ",
115 "\x1b[38;5;246m9a ", "\x1b[38;5;246m9b ",
116 "\x1b[38;5;246m9c ", "\x1b[38;5;246m9d ",
117 "\x1b[38;5;246m9e ", "\x1b[38;5;246m9f ",
118 "\x1b[38;5;246ma0 ", "\x1b[38;5;246ma1 ",
119 "\x1b[38;5;246ma2 ", "\x1b[38;5;246ma3 ",
120 "\x1b[38;5;246ma4 ", "\x1b[38;5;246ma5 ",
121 "\x1b[38;5;246ma6 ", "\x1b[38;5;246ma7 ",
122 "\x1b[38;5;246ma8 ", "\x1b[38;5;246ma9 ",
123 "\x1b[38;5;246maa ", "\x1b[38;5;246mab ",
124 "\x1b[38;5;246mac ", "\x1b[38;5;246mad ",
125 "\x1b[38;5;246mae ", "\x1b[38;5;246maf ",
126 "\x1b[38;5;246mb0 ", "\x1b[38;5;246mb1 ",
127 "\x1b[38;5;246mb2 ", "\x1b[38;5;246mb3 ",
128 "\x1b[38;5;246mb4 ", "\x1b[38;5;246mb5 ",
129 "\x1b[38;5;246mb6 ", "\x1b[38;5;246mb7 ",
130 "\x1b[38;5;246mb8 ", "\x1b[38;5;246mb9 ",
131 "\x1b[38;5;246mba ", "\x1b[38;5;246mbb ",
132 "\x1b[38;5;246mbc ", "\x1b[38;5;246mbd ",
133 "\x1b[38;5;246mbe ", "\x1b[38;5;246mbf ",
134 "\x1b[38;5;246mc0 ", "\x1b[38;5;246mc1 ",
135 "\x1b[38;5;246mc2 ", "\x1b[38;5;246mc3 ",
136 "\x1b[38;5;246mc4 ", "\x1b[38;5;246mc5 ",
137 "\x1b[38;5;246mc6 ", "\x1b[38;5;246mc7 ",
138 "\x1b[38;5;246mc8 ", "\x1b[38;5;246mc9 ",
139 "\x1b[38;5;246mca ", "\x1b[38;5;246mcb ",
140 "\x1b[38;5;246mcc ", "\x1b[38;5;246mcd ",
141 "\x1b[38;5;246mce ", "\x1b[38;5;246mcf ",
142 "\x1b[38;5;246md0 ", "\x1b[38;5;246md1 ",
143 "\x1b[38;5;246md2 ", "\x1b[38;5;246md3 ",
144 "\x1b[38;5;246md4 ", "\x1b[38;5;246md5 ",
145 "\x1b[38;5;246md6 ", "\x1b[38;5;246md7 ",
146 "\x1b[38;5;246md8 ", "\x1b[38;5;246md9 ",
147 "\x1b[38;5;246mda ", "\x1b[38;5;246mdb ",
148 "\x1b[38;5;246mdc ", "\x1b[38;5;246mdd ",
149 "\x1b[38;5;246mde ", "\x1b[38;5;246mdf ",
150 "\x1b[38;5;246me0 ", "\x1b[38;5;246me1 ",
151 "\x1b[38;5;246me2 ", "\x1b[38;5;246me3 ",
152 "\x1b[38;5;246me4 ", "\x1b[38;5;246me5 ",
153 "\x1b[38;5;246me6 ", "\x1b[38;5;246me7 ",
154 "\x1b[38;5;246me8 ", "\x1b[38;5;246me9 ",
155 "\x1b[38;5;246mea ", "\x1b[38;5;246meb ",
156 "\x1b[38;5;246mec ", "\x1b[38;5;246med ",
157 "\x1b[38;5;246mee ", "\x1b[38;5;246mef ",
158 "\x1b[38;5;246mf0 ", "\x1b[38;5;246mf1 ",
159 "\x1b[38;5;246mf2 ", "\x1b[38;5;246mf3 ",
160 "\x1b[38;5;246mf4 ", "\x1b[38;5;246mf5 ",
161 "\x1b[38;5;246mf6 ", "\x1b[38;5;246mf7 ",
162 "\x1b[38;5;246mf8 ", "\x1b[38;5;246mf9 ",
163 "\x1b[38;5;246mfa ", "\x1b[38;5;246mfb ",
164 "\x1b[38;5;246mfc ", "\x1b[38;5;246mfd ",
165 "\x1b[38;5;246mfe ", "\x1b[38;5;209mff ",
166 }
167
168 // hexSymbols is a direct lookup table combining 2 hex digits with either a
169 // space or a displayable ASCII symbol matching the byte's own ASCII value;
170 // this table was autogenerated by running the command
171 //
172 // seq 0 255 | ./hex-symbols.awk
173 var hexSymbols = [256]string{
174 `00 `, `01 `, `02 `, `03 `, `04 `, `05 `, `06 `, `07 `,
175 `08 `, `09 `, `0a `, `0b `, `0c `, `0d `, `0e `, `0f `,
176 `10 `, `11 `, `12 `, `13 `, `14 `, `15 `, `16 `, `17 `,
177 `18 `, `19 `, `1a `, `1b `, `1c `, `1d `, `1e `, `1f `,
178 `20 `, `21!`, `22"`, `23#`, `24$`, `25%`, `26&`, `27'`,
179 `28(`, `29)`, `2a*`, `2b+`, `2c,`, `2d-`, `2e.`, `2f/`,
180 `300`, `311`, `322`, `333`, `344`, `355`, `366`, `377`,
181 `388`, `399`, `3a:`, `3b;`, `3c<`, `3d=`, `3e>`, `3f?`,
182 `40@`, `41A`, `42B`, `43C`, `44D`, `45E`, `46F`, `47G`,
183 `48H`, `49I`, `4aJ`, `4bK`, `4cL`, `4dM`, `4eN`, `4fO`,
184 `50P`, `51Q`, `52R`, `53S`, `54T`, `55U`, `56V`, `57W`,
185 `58X`, `59Y`, `5aZ`, `5b[`, `5c\`, `5d]`, `5e^`, `5f_`,
186 "60`", `61a`, `62b`, `63c`, `64d`, `65e`, `66f`, `67g`,
187 `68h`, `69i`, `6aj`, `6bk`, `6cl`, `6dm`, `6en`, `6fo`,
188 `70p`, `71q`, `72r`, `73s`, `74t`, `75u`, `76v`, `77w`,
189 `78x`, `79y`, `7az`, `7b{`, `7c|`, `7d}`, `7e~`, `7f `,
190 `80 `, `81 `, `82 `, `83 `, `84 `, `85 `, `86 `, `87 `,
191 `88 `, `89 `, `8a `, `8b `, `8c `, `8d `, `8e `, `8f `,
192 `90 `, `91 `, `92 `, `93 `, `94 `, `95 `, `96 `, `97 `,
193 `98 `, `99 `, `9a `, `9b `, `9c `, `9d `, `9e `, `9f `,
194 `a0 `, `a1 `, `a2 `, `a3 `, `a4 `, `a5 `, `a6 `, `a7 `,
195 `a8 `, `a9 `, `aa `, `ab `, `ac `, `ad `, `ae `, `af `,
196 `b0 `, `b1 `, `b2 `, `b3 `, `b4 `, `b5 `, `b6 `, `b7 `,
197 `b8 `, `b9 `, `ba `, `bb `, `bc `, `bd `, `be `, `bf `,
198 `c0 `, `c1 `, `c2 `, `c3 `, `c4 `, `c5 `, `c6 `, `c7 `,
199 `c8 `, `c9 `, `ca `, `cb `, `cc `, `cd `, `ce `, `cf `,
200 `d0 `, `d1 `, `d2 `, `d3 `, `d4 `, `d5 `, `d6 `, `d7 `,
201 `d8 `, `d9 `, `da `, `db `, `dc `, `dd `, `de `, `df `,
202 `e0 `, `e1 `, `e2 `, `e3 `, `e4 `, `e5 `, `e6 `, `e7 `,
203 `e8 `, `e9 `, `ea `, `eb `, `ec `, `ed `, `ee `, `ef `,
204 `f0 `, `f1 `, `f2 `, `f3 `, `f4 `, `f5 `, `f6 `, `f7 `,
205 `f8 `, `f9 `, `fa `, `fb `, `fc `, `fd `, `fe `, `ff `,
206 }
207
208 const (
209 unknownStyle = 0
210 zeroStyle = 1
211 otherStyle = 2
212 asciiStyle = 3
213 allOnStyle = 4
214 )
215
216 // byteStyles turns bytes into one of several distinct visual types, which
217 // allows quickly telling when ANSI styles codes are repetitive and when
218 // they're actually needed
219 var byteStyles = [256]int{
220 zeroStyle, otherStyle, otherStyle, otherStyle,
221 otherStyle, otherStyle, otherStyle, otherStyle,
222 otherStyle, otherStyle, otherStyle, otherStyle,
223 otherStyle, otherStyle, otherStyle, otherStyle,
224 otherStyle, otherStyle, otherStyle, otherStyle,
225 otherStyle, otherStyle, otherStyle, otherStyle,
226 otherStyle, otherStyle, otherStyle, otherStyle,
227 otherStyle, otherStyle, otherStyle, otherStyle,
228 asciiStyle, asciiStyle, asciiStyle, asciiStyle,
229 asciiStyle, asciiStyle, asciiStyle, asciiStyle,
230 asciiStyle, asciiStyle, asciiStyle, asciiStyle,
231 asciiStyle, asciiStyle, asciiStyle, asciiStyle,
232 asciiStyle, asciiStyle, asciiStyle, asciiStyle,
233 asciiStyle, asciiStyle, asciiStyle, asciiStyle,
234 asciiStyle, asciiStyle, asciiStyle, asciiStyle,
235 asciiStyle, asciiStyle, asciiStyle, asciiStyle,
236 asciiStyle, asciiStyle, asciiStyle, asciiStyle,
237 asciiStyle, asciiStyle, asciiStyle, asciiStyle,
238 asciiStyle, asciiStyle, asciiStyle, asciiStyle,
239 asciiStyle, asciiStyle, asciiStyle, asciiStyle,
240 asciiStyle, asciiStyle, asciiStyle, asciiStyle,
241 asciiStyle, asciiStyle, asciiStyle, asciiStyle,
242 asciiStyle, asciiStyle, asciiStyle, asciiStyle,
243 asciiStyle, asciiStyle, asciiStyle, asciiStyle,
244 asciiStyle, asciiStyle, asciiStyle, asciiStyle,
245 asciiStyle, asciiStyle, asciiStyle, asciiStyle,
246 asciiStyle, asciiStyle, asciiStyle, asciiStyle,
247 asciiStyle, asciiStyle, asciiStyle, asciiStyle,
248 asciiStyle, asciiStyle, asciiStyle, asciiStyle,
249 asciiStyle, asciiStyle, asciiStyle, asciiStyle,
250 asciiStyle, asciiStyle, asciiStyle, asciiStyle,
251 asciiStyle, asciiStyle, asciiStyle, otherStyle,
252 otherStyle, otherStyle, otherStyle, otherStyle,
253 otherStyle, otherStyle, otherStyle, otherStyle,
254 otherStyle, otherStyle, otherStyle, otherStyle,
255 otherStyle, otherStyle, otherStyle, otherStyle,
256 otherStyle, otherStyle, otherStyle, otherStyle,
257 otherStyle, otherStyle, otherStyle, otherStyle,
258 otherStyle, otherStyle, otherStyle, otherStyle,
259 otherStyle, otherStyle, otherStyle, otherStyle,
260 otherStyle, otherStyle, otherStyle, otherStyle,
261 otherStyle, otherStyle, otherStyle, otherStyle,
262 otherStyle, otherStyle, otherStyle, otherStyle,
263 otherStyle, otherStyle, otherStyle, otherStyle,
264 otherStyle, otherStyle, otherStyle, otherStyle,
265 otherStyle, otherStyle, otherStyle, otherStyle,
266 otherStyle, otherStyle, otherStyle, otherStyle,
267 otherStyle, otherStyle, otherStyle, otherStyle,
268 otherStyle, otherStyle, otherStyle, otherStyle,
269 otherStyle, otherStyle, otherStyle, otherStyle,
270 otherStyle, otherStyle, otherStyle, otherStyle,
271 otherStyle, otherStyle, otherStyle, otherStyle,
272 otherStyle, otherStyle, otherStyle, otherStyle,
273 otherStyle, otherStyle, otherStyle, otherStyle,
274 otherStyle, otherStyle, otherStyle, otherStyle,
275 otherStyle, otherStyle, otherStyle, otherStyle,
276 otherStyle, otherStyle, otherStyle, otherStyle,
277 otherStyle, otherStyle, otherStyle, otherStyle,
278 otherStyle, otherStyle, otherStyle, otherStyle,
279 otherStyle, otherStyle, otherStyle, otherStyle,
280 otherStyle, otherStyle, otherStyle, otherStyle,
281 otherStyle, otherStyle, otherStyle, otherStyle,
282 otherStyle, otherStyle, otherStyle, otherStyle,
283 otherStyle, otherStyle, otherStyle, allOnStyle,
284 }
285
286 // writeMetaANSI shows metadata right before the ANSI-styled hex byte-view
287 func writeMetaANSI(w *bufio.Writer, fname string, fsize int, cfg config) {
288 if cfg.Title != "" {
289 fmt.Fprintf(w, "\x1b[4m%s\x1b[0m\n", cfg.Title)
290 w.WriteString("\n")
291 }
292
293 if fsize < 0 {
294 fmt.Fprintf(w, "• %s\n", fname)
295 } else {
296 const fs = "• %s \x1b[38;5;248m(%s bytes)\x1b[0m\n"
297 fmt.Fprintf(w, fs, fname, sprintCommas(fsize))
298 }
299
300 if cfg.Skip > 0 {
301 const fs = " \x1b[38;5;5mskipping first %s bytes\x1b[0m\n"
302 fmt.Fprintf(w, fs, sprintCommas(cfg.Skip))
303 }
304 if cfg.MaxBytes > 0 {
305 const fs = " \x1b[38;5;5mshowing only up to %s bytes\x1b[0m\n"
306 fmt.Fprintf(w, fs, sprintCommas(cfg.MaxBytes))
307 }
308 w.WriteString("\n")
309 }
310
311 // writeBufferANSI shows the hex byte-view using ANSI colors/styles
312 func writeBufferANSI(rc rendererConfig, first, second []byte) error {
313 // show a ruler every few lines to make eye-scanning easier
314 if rc.chunks%5 == 0 && rc.chunks > 0 {
315 writeRulerANSI(rc)
316 }
317
318 return writeLineANSI(rc, first, second)
319 }
320
321 // writeRulerANSI emits an indented ANSI-styled line showing spaced-out dots,
322 // so as to help eye-scan items on nearby output lines
323 func writeRulerANSI(rc rendererConfig) {
324 w := rc.out
325 if len(rc.ruler) == 0 {
326 w.WriteByte('\n')
327 return
328 }
329
330 w.WriteString("\x1b[38;5;248m")
331 indent := int(rc.offsetWidth) + len(padding)
332 writeSpaces(w, indent)
333 w.Write(rc.ruler)
334 w.WriteString("\x1b[0m\n")
335 }
336
337 func writeLineANSI(rc rendererConfig, first, second []byte) error {
338 w := rc.out
339
340 // start each line with the byte-offset for the 1st item shown on it
341 if rc.showOffsets {
342 writeStyledCounter(w, int(rc.offsetWidth), rc.offset)
343 w.WriteString(padding + "\x1b[48;5;254m")
344 } else {
345 w.WriteString(padding)
346 }
347
348 prevStyle := unknownStyle
349 for _, b := range first {
350 // using the slow/generic fmt.Fprintf is a performance bottleneck,
351 // since it's called for each input byte
352 // w.WriteString(styledHexBytes[b])
353
354 // this more complicated way of emitting output avoids repeating
355 // ANSI styles when dealing with bytes which aren't displayable
356 // ASCII symbols, thus emitting fewer bytes when dealing with
357 // general binary datasets; it makes no difference for plain-text
358 // ASCII input
359 style := byteStyles[b]
360 if style != prevStyle {
361 w.WriteString(styledHexBytes[b])
362 if style == asciiStyle {
363 // styling displayable ASCII symbols uses multiple different
364 // styles each time it happens, always forcing ANSI-style
365 // updates
366 style = unknownStyle
367 }
368 } else {
369 w.WriteString(hexSymbols[b])
370 }
371 prevStyle = style
372 }
373
374 w.WriteString("\x1b[0m")
375 if rc.showASCII {
376 writePlainASCII(w, first, second, int(rc.perLine))
377 }
378
379 return w.WriteByte('\n')
380 }
381
382 func writeStyledCounter(w *bufio.Writer, width int, n uint) {
383 var buf [32]byte
384 str := strconv.AppendUint(buf[:0], uint64(n), 10)
385
386 // left-pad the final result with leading spaces
387 writeSpaces(w, width-len(str))
388
389 var style bool
390 // emit leading part with 1 or 2 digits unstyled, ensuring the
391 // rest or the rendered number's string is a multiple of 3 long
392 if rem := len(str) % 3; rem != 0 {
393 w.Write(str[:rem])
394 str = str[rem:]
395 // next digit-group needs some styling
396 style = true
397 } else {
398 style = false
399 }
400
401 // alternate between styled/unstyled 3-digit groups
402 for len(str) > 0 {
403 if !style {
404 w.Write(str[:3])
405 } else {
406 w.WriteString("\x1b[38;5;248m")
407 w.Write(str[:3])
408 w.WriteString("\x1b[0m")
409 }
410
411 style = !style
412 str = str[3:]
413 }
414 }
File: ./nhex/config.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package nhex
26
27 import (
28 "bufio"
29 "bytes"
30 "flag"
31 "fmt"
32 )
33
34 const (
35 usageMaxBytes = `limit input up to n bytes; negative to disable`
36 usagePerLine = `how many bytes to show on each line`
37 usageSkip = `how many leading bytes to skip/ignore`
38 usageTitle = `use this to show a title/description`
39 usageTo = `the output format to use (plain or ansi)`
40 usagePlain = `show plain-text output, as opposed to ansi-styled output`
41 usageShowOffset = `start lines with the offset of the 1st byte shown on each`
42 usageShowASCII = `repeat all ASCII strings on the side, so they're searcheable`
43 )
44
45 const defaultOffsetCounterWidth = 8
46
47 const (
48 plainOutput = `plain`
49 ansiOutput = `ansi`
50 )
51
52 // config is the parsed cmd-line options given to the app
53 type config struct {
54 // MaxBytes limits how many bytes are shown; a negative value means no limit
55 MaxBytes int
56
57 // PerLine is how many bytes are shown per output line
58 PerLine int
59
60 // Skip is how many leading bytes to skip/ignore
61 Skip int
62
63 // OffsetCounterWidth is the max string-width; not exposed as a cmd-line option
64 OffsetCounterWidth uint
65
66 // Title is an optional title preceding the output proper
67 Title string
68
69 // To is the output format
70 To string
71
72 // Filenames is the list of input filenames
73 Filenames []string
74
75 // Ruler is a prerendered ruler to emit every few output lines
76 Ruler []byte
77
78 // ShowOffsets starts lines with the offset of the 1st byte shown on each
79 ShowOffsets bool
80
81 // ShowASCII shows a side-panel with searcheable ASCII-runs
82 ShowASCII bool
83 }
84
85 // parseFlags is the constructor for type config
86 func parseFlags(usage string) config {
87 flag.Usage = func() {
88 fmt.Fprintf(flag.CommandLine.Output(), "%s\n\nOptions\n\n", usage)
89 flag.PrintDefaults()
90 }
91
92 cfg := config{
93 MaxBytes: -1,
94 PerLine: 16,
95 OffsetCounterWidth: 0,
96 To: ansiOutput,
97 ShowOffsets: true,
98 ShowASCII: true,
99 }
100
101 plain := false
102 flag.IntVar(&cfg.MaxBytes, `max`, cfg.MaxBytes, usageMaxBytes)
103 flag.IntVar(&cfg.PerLine, `width`, cfg.PerLine, usagePerLine)
104 flag.IntVar(&cfg.Skip, `skip`, cfg.Skip, usageSkip)
105 flag.StringVar(&cfg.Title, `title`, cfg.Title, usageTitle)
106 flag.StringVar(&cfg.To, `to`, cfg.To, usageTo)
107 flag.BoolVar(&cfg.ShowOffsets, `n`, cfg.ShowOffsets, usageShowOffset)
108 flag.BoolVar(&cfg.ShowASCII, `ascii`, cfg.ShowASCII, usageShowASCII)
109 flag.BoolVar(&plain, `p`, plain, "alias for option `plain`")
110 flag.BoolVar(&plain, `plain`, plain, usagePlain)
111 flag.Parse()
112
113 if plain {
114 cfg.To = plainOutput
115 }
116
117 // normalize values for option -to
118 switch cfg.To {
119 case `text`, `plaintext`, `plain-text`:
120 cfg.To = plainOutput
121 }
122
123 cfg.Ruler = makeRuler(cfg.PerLine)
124 cfg.Filenames = flag.Args()
125 return cfg
126 }
127
128 // makeRuler prerenders a ruler-line, used to make the output lines breathe
129 func makeRuler(numitems int) []byte {
130 if n := numitems / 4; n > 0 {
131 var pat = []byte(` ·`)
132 return bytes.Repeat(pat, n)
133 }
134 return nil
135 }
136
137 // rendererConfig groups several arguments given to any of the rendering funcs
138 type rendererConfig struct {
139 // out is writer to send all output to
140 out *bufio.Writer
141
142 // offset is the byte-offset of the first byte shown on the current output
143 // line: if shown at all, it's shown at the start the line
144 offset uint
145
146 // chunks is the 0-based counter for byte-chunks/lines shown so far, which
147 // indirectly keeps track of when it's time to show a `breather` line
148 chunks uint
149
150 // ruler is the `ruler` content to show on `breather` lines
151 ruler []byte
152
153 // perLine is how many hex-encoded bytes are shown per line
154 perLine uint
155
156 // offsetWidth is the max string-width for the byte-offsets shown at the
157 // start of output lines, and determines those values' left-padding
158 offsetWidth uint
159
160 // showOffsets determines whether byte-offsets are shown at all
161 showOffsets bool
162
163 // showASCII determines whether the ASCII-panels are shown at all
164 showASCII bool
165 }
File: ./nhex/hex-styles.awk
1 #!/usr/bin/awk -f
2
3 # The MIT License (MIT)
4 #
5 # Copyright (c) 2026 pacman64
6 #
7 # Permission is hereby granted, free of charge, to any person obtaining a copy
8 # of this software and associated documentation files (the "Software"), to deal
9 # in the Software without restriction, including without limitation the rights
10 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11 # copies of the Software, and to permit persons to whom the Software is
12 # furnished to do so, subject to the following conditions:
13 #
14 # The above copyright notice and this permission notice shall be included in
15 # all copies or substantial portions of the Software.
16 #
17 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23 # SOFTWARE.
24
25
26 # all 0 bits
27 $0 == 0 {
28 print "\"\\x1b[38;5;111m00 \","
29 next
30 }
31
32 # ascii symbol which need backslashing
33 $0 == 34 || $0 == 92 {
34 printf "\"\\x1b[38;5;72m%02x\\x1b[38;5;239m\\%c\",\n", $0 + 0, $0
35 next
36 }
37
38 # all other ascii symbol
39 32 <= $0 && $0 <= 126 {
40 printf "\"\\x1b[38;5;72m%02x\\x1b[38;5;239m%c\",\n", $0 + 0, $0
41 next
42 }
43
44 # all 1 bits
45 $0 == 255 {
46 print "\"\\x1b[38;5;209mff \","
47 next
48 }
49
50 # all other bytes
51 1 {
52 printf "\"\\x1b[38;5;246m%02x \",\n", $0 + 0
53 next
54 }
File: ./nhex/hex-symbols.awk
1 #!/usr/bin/awk -f
2
3 # The MIT License (MIT)
4 #
5 # Copyright (c) 2026 pacman64
6 #
7 # Permission is hereby granted, free of charge, to any person obtaining a copy
8 # of this software and associated documentation files (the "Software"), to deal
9 # in the Software without restriction, including without limitation the rights
10 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11 # copies of the Software, and to permit persons to whom the Software is
12 # furnished to do so, subject to the following conditions:
13 #
14 # The above copyright notice and this permission notice shall be included in
15 # all copies or substantial portions of the Software.
16 #
17 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23 # SOFTWARE.
24
25
26 # ascii symbol which need backslashing
27 $0 == 34 || $0 == 92 {
28 printf "\"%02x\\%c\",\n", $0 + 0, $0
29 next
30 }
31
32 # all other ascii symbol
33 32 <= $0 && $0 <= 126 {
34 printf "\"%02x%c\",\n", $0 + 0, $0
35 next
36 }
37
38 # all other bytes
39 1 {
40 printf "\"%02x \",\n", $0 + 0
41 next
42 }
File: ./nhex/info.txt
1 nhex [options...] [filenames...]
2
3
4 Nice HEXadecimal is a simple hexadecimal viewer to easily inspect bytes
5 from files/data.
6
7 Each line shows the starting offset for the bytes shown, 16 of the bytes
8 themselves in base-16 notation, and any ASCII codes when the byte values
9 are in the typical ASCII range. The offsets shown are base-10.
10
11 The base-16 codes are color-coded, with most bytes shown in gray, while
12 all-1 and all-0 bytes are shown in orange and blue respectively.
13
14 All-0 bytes are the commonest kind in most binary file types and, along
15 with all-1 bytes are also a special case worth noticing when exploring
16 binary data, so it makes sense for them to stand out right away.
File: ./nhex/main.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package nhex
26
27 import (
28 "bufio"
29 "fmt"
30 "io"
31 "math"
32 "os"
33
34 _ "embed"
35 )
36
37 //go:embed info.txt
38 var usage string
39
40 func Main() {
41 err := run(parseFlags(usage))
42 if err != nil {
43 os.Stderr.WriteString(err.Error())
44 os.Stderr.WriteString("\n")
45 os.Exit(1)
46 }
47 }
48
49 func run(cfg config) error {
50 // f, _ := os.Create(`nh.prof`)
51 // defer f.Close()
52 // pprof.StartCPUProfile(f)
53 // defer pprof.StopCPUProfile()
54
55 w := bufio.NewWriterSize(os.Stdout, 16*1024)
56 defer w.Flush()
57
58 // with no filenames given, handle stdin and quit
59 if len(cfg.Filenames) == 0 {
60 return handle(w, os.Stdin, `<stdin>`, -1, cfg)
61 }
62
63 // show all files given
64 for i, fname := range cfg.Filenames {
65 if i > 0 {
66 w.WriteString("\n")
67 w.WriteString("\n")
68 }
69
70 err := handleFile(w, fname, cfg)
71 if err != nil {
72 return err
73 }
74 }
75
76 return nil
77 }
78
79 // handleFile is like handleReader, except it also shows file-related info
80 func handleFile(w *bufio.Writer, fname string, cfg config) error {
81 f, err := os.Open(fname)
82 if err != nil {
83 return err
84 }
85 defer f.Close()
86
87 stat, err := f.Stat()
88 if err != nil {
89 return handle(w, f, fname, -1, cfg)
90 }
91
92 fsize := int(stat.Size())
93 return handle(w, f, fname, fsize, cfg)
94 }
95
96 // handle shows some messages related to the input and the cmd-line options
97 // used, and then follows them by the hexadecimal byte-view
98 func handle(w *bufio.Writer, r io.Reader, name string, size int, cfg config) error {
99 skip(r, cfg.Skip)
100 if cfg.MaxBytes > 0 {
101 r = io.LimitReader(r, int64(cfg.MaxBytes))
102 }
103
104 // finish config setup based on the filesize, if a valid one was given
105 if cfg.OffsetCounterWidth < 1 {
106 if size < 1 {
107 cfg.OffsetCounterWidth = defaultOffsetCounterWidth
108 } else {
109 w := math.Log10(float64(size))
110 w = math.Max(math.Ceil(w), 1)
111 cfg.OffsetCounterWidth = uint(w)
112 }
113 }
114
115 switch cfg.To {
116 case plainOutput:
117 writeMetaPlain(w, name, size, cfg)
118 // when done, emit a new line in case only part of the last line is
119 // shown, which means no newline was emitted for it
120 defer w.WriteString("\n")
121 return render(w, r, cfg, writeBufferPlain)
122
123 case ansiOutput:
124 writeMetaANSI(w, name, size, cfg)
125 // when done, emit a new line in case only part of the last line is
126 // shown, which means no newline was emitted for it
127 defer w.WriteString("\x1b[0m\n")
128 return render(w, r, cfg, writeBufferANSI)
129
130 default:
131 const fs = `unsupported output format %q`
132 return fmt.Errorf(fs, cfg.To)
133 }
134 }
135
136 // skip ignores n bytes from the reader given
137 func skip(r io.Reader, n int) {
138 if n < 1 {
139 return
140 }
141
142 // use func Seek for input files, except for stdin, which you can't seek
143 if f, ok := r.(*os.File); ok && r != os.Stdin {
144 f.Seek(int64(n), io.SeekCurrent)
145 return
146 }
147 io.CopyN(io.Discard, r, int64(n))
148 }
149
150 // renderer is the type for the hex-view render funcs
151 type renderer func(rc rendererConfig, first, second []byte) error
152
153 // render reads all input and shows the hexadecimal byte-view for the input
154 // data via the rendering callback given
155 func render(w *bufio.Writer, r io.Reader, cfg config, fn renderer) error {
156 if cfg.PerLine < 1 {
157 cfg.PerLine = 16
158 }
159
160 rc := rendererConfig{
161 out: w,
162 offset: uint(cfg.Skip),
163 chunks: 0,
164 perLine: uint(cfg.PerLine),
165 ruler: cfg.Ruler,
166
167 offsetWidth: cfg.OffsetCounterWidth,
168 showOffsets: cfg.ShowOffsets,
169 showASCII: cfg.ShowASCII,
170 }
171
172 // calling func Read directly can sometimes result in chunks shorter
173 // than the max chunk-size, even when there are plenty of bytes yet
174 // to read; to avoid that, use a buffered-reader to explicitly fill
175 // a slice instead
176 br := bufio.NewReader(r)
177
178 // to show ASCII up to 1 full chunk ahead, 2 chunks are needed
179 cur := make([]byte, 0, cfg.PerLine)
180 ahead := make([]byte, 0, cfg.PerLine)
181
182 // the ASCII-panel's wide output requires staying 1 step/chunk behind,
183 // so to speak
184 cur, err := fillChunk(cur[:0], cfg.PerLine, br)
185 if len(cur) == 0 {
186 if err == io.EOF {
187 err = nil
188 }
189 return err
190 }
191
192 for {
193 ahead, err := fillChunk(ahead[:0], cfg.PerLine, br)
194 if err != nil && err != io.EOF {
195 return err
196 }
197
198 if len(ahead) == 0 {
199 // done, maybe except for an extra line of output
200 break
201 }
202
203 // show the byte-chunk on its own output line
204 err = fn(rc, cur, ahead)
205 if err != nil {
206 // probably a pipe was closed
207 return nil
208 }
209
210 rc.chunks++
211 rc.offset += uint(len(cur))
212 cur = cur[:copy(cur, ahead)]
213 }
214
215 // don't forget the last output line
216 if rc.chunks > 0 && len(cur) > 0 {
217 return fn(rc, cur, nil)
218 }
219 return nil
220 }
221
222 // fillChunk tries to read the number of bytes given, appending them to the
223 // byte-slice given; this func returns an EOF error only when no bytes are
224 // read, which somewhat simplifies error-handling for the func caller
225 func fillChunk(chunk []byte, n int, br *bufio.Reader) ([]byte, error) {
226 // read buffered-bytes up to the max chunk-size
227 for i := 0; i < n; i++ {
228 b, err := br.ReadByte()
229 if err == nil {
230 chunk = append(chunk, b)
231 continue
232 }
233
234 if err == io.EOF && i > 0 {
235 return chunk, nil
236 }
237 return chunk, err
238 }
239
240 // got the full byte-count asked for
241 return chunk, nil
242 }
File: ./nhex/numbers.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package nhex
26
27 import (
28 "bufio"
29 "io"
30 "math"
31 "strconv"
32 "strings"
33 )
34
35 // loopThousandsGroups comes from my lib/package `mathplus`: that's why it
36 // handles negatives, even though this app only uses it with non-negatives.
37 func loopThousandsGroups(n int, fn func(i, n int)) {
38 // 0 doesn't have a log10
39 if n == 0 {
40 fn(0, 0)
41 return
42 }
43
44 sign := +1
45 if n < 0 {
46 n = -n
47 sign = -1
48 }
49
50 intLog1000 := int(math.Log10(float64(n)) / 3)
51 remBase := int(math.Pow10(3 * intLog1000))
52
53 for i := 0; remBase > 0; i++ {
54 group := (1000 * n) / remBase / 1000
55 fn(i, sign*group)
56 // if original number was negative, ensure only first
57 // group gives a negative input to the callback
58 sign = +1
59
60 n %= remBase
61 remBase /= 1000
62 }
63 }
64
65 // sprintCommas turns the non-negative number given into a readable string,
66 // where digits are grouped-separated by commas
67 func sprintCommas(n int) string {
68 var sb strings.Builder
69 loopThousandsGroups(n, func(i, n int) {
70 if i == 0 {
71 var buf [4]byte
72 sb.Write(strconv.AppendInt(buf[:0], int64(n), 10))
73 return
74 }
75 sb.WriteByte(',')
76 writePad0Sub1000Counter(&sb, uint(n))
77 })
78 return sb.String()
79 }
80
81 // writePad0Sub1000Counter is an alternative to fmt.Fprintf(w, `%03d`, n)
82 func writePad0Sub1000Counter(w io.Writer, n uint) {
83 // precondition is 0...999
84 if n > 999 {
85 w.Write([]byte(`???`))
86 return
87 }
88
89 var buf [3]byte
90 buf[0] = byte(n/100) + '0'
91 n %= 100
92 buf[1] = byte(n/10) + '0'
93 buf[2] = byte(n%10) + '0'
94 w.Write(buf[:])
95 }
96
97 // writeHex is faster than calling fmt.Fprintf(w, `%02x`, b): this
98 // matters because it's called for every byte of input which isn't
99 // all 0s or all 1s
100 func writeHex(w *bufio.Writer, b byte) {
101 const hexDigits = `0123456789abcdef`
102 w.WriteByte(hexDigits[b>>4])
103 w.WriteByte(hexDigits[b&0x0f])
104 }
File: ./nhex/plain.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package nhex
26
27 import (
28 "bufio"
29 "fmt"
30 "strconv"
31 )
32
33 // padding is the padding/spacing emitted across each output line, except for
34 // the breather/ruler lines
35 const padding = ` `
36
37 // writeMetaPlain shows metadata right before the plain-text hex byte-view
38 func writeMetaPlain(w *bufio.Writer, fname string, fsize int, cfg config) {
39 if cfg.Title != `` {
40 w.WriteString(cfg.Title)
41 w.WriteString("\n")
42 w.WriteString("\n")
43 }
44
45 if fsize < 0 {
46 fmt.Fprintf(w, "• %s\n", fname)
47 } else {
48 const fs = "• %s (%s bytes)\n"
49 fmt.Fprintf(w, fs, fname, sprintCommas(fsize))
50 }
51
52 if cfg.Skip > 0 {
53 const fs = " skipping first %s bytes\n"
54 fmt.Fprintf(w, fs, sprintCommas(cfg.Skip))
55 }
56 if cfg.MaxBytes > 0 {
57 const fs = " showing only up to %s bytes\n"
58 fmt.Fprintf(w, fs, sprintCommas(cfg.MaxBytes))
59 }
60 w.WriteString("\n")
61 }
62
63 // writeBufferPlain shows the hex byte-view withOUT using ANSI colors/styles
64 func writeBufferPlain(rc rendererConfig, first, second []byte) error {
65 // show a ruler every few lines to make eye-scanning easier
66 if rc.chunks%5 == 0 && rc.chunks > 0 {
67 rc.out.WriteByte('\n')
68 }
69
70 return writeLinePlain(rc, first, second)
71 }
72
73 func writeLinePlain(rc rendererConfig, first, second []byte) error {
74 w := rc.out
75
76 // start each line with the byte-offset for the 1st item shown on it
77 if rc.showOffsets {
78 writePlainCounter(w, int(rc.offsetWidth), rc.offset)
79 w.WriteByte(' ')
80 } else {
81 w.WriteString(padding)
82 }
83
84 for _, b := range first {
85 // fmt.Fprintf(w, ` %02x`, b)
86 //
87 // the commented part above was a performance bottleneck, since
88 // the slow/generic fmt.Fprintf was called for each input byte
89 w.WriteByte(' ')
90 writeHex(w, b)
91 }
92
93 if rc.showASCII {
94 writePlainASCII(w, first, second, int(rc.perLine))
95 }
96
97 return w.WriteByte('\n')
98 }
99
100 // writePlainCounter just emits a left-padded number
101 func writePlainCounter(w *bufio.Writer, width int, n uint) {
102 var buf [32]byte
103 str := strconv.AppendUint(buf[:0], uint64(n), 10)
104 writeSpaces(w, width-len(str))
105 w.Write(str)
106 }
107
108 // writeRulerPlain emits a breather line using a ruler-like pattern of spaces
109 // and dots, to guide the eye across the main output lines
110 // func writeRulerPlain(w *bufio.Writer, indent int, offset int, numitems int) {
111 // writeSpaces(w, indent)
112 // for i := 0; i < numitems-1; i++ {
113 // if (i+offset+1)%5 == 0 {
114 // w.WriteString(` `)
115 // } else {
116 // w.WriteString(` ·`)
117 // }
118 // }
119 // }
120
121 // writeSpaces bulk-emits the number of spaces given
122 func writeSpaces(w *bufio.Writer, n int) {
123 const spaces = ` `
124 for ; n > len(spaces); n -= len(spaces) {
125 w.WriteString(spaces)
126 }
127 if n > 0 {
128 w.WriteString(spaces[:n])
129 }
130 }
131
132 // writePlainASCII emits the side-panel showing all ASCII runs for each line
133 func writePlainASCII(w *bufio.Writer, first, second []byte, perline int) {
134 // prev keeps track of the previous byte, so spaces are added
135 // when bytes change from non-visible-ASCII to visible-ASCII
136 prev := byte(0)
137
138 spaces := 3*(perline-len(first)) + len(padding)
139
140 for _, b := range first {
141 if 32 < b && b < 127 {
142 if !(32 < prev && prev < 127) {
143 writeSpaces(w, spaces)
144 spaces = 1
145 }
146 w.WriteByte(b)
147 }
148 prev = b
149 }
150
151 for _, b := range second {
152 if 32 < b && b < 127 {
153 if !(32 < prev && prev < 127) {
154 writeSpaces(w, spaces)
155 spaces = 1
156 }
157 w.WriteByte(b)
158 }
159 prev = b
160 }
161 }
File: ./njson/njson.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package njson
26
27 import (
28 "bufio"
29 "encoding/json"
30 "errors"
31 "io"
32 "os"
33 )
34
35 const info = `
36 njson [filepath...]
37
38 Nice JSON shows JSON data as ANSI-styled indented lines, using 2 spaces for
39 each indentation level.
40 `
41
42 // indent is how many spaces each indentation level uses
43 const indent = 2
44
45 const (
46 // boolStyle is bluish, and very distinct from all other colors used
47 boolStyle = "\x1b[38;2;95;175;215m"
48
49 // keyStyle is magenta, and very distinct from normal strings
50 keyStyle = "\x1b[38;2;135;95;255m"
51
52 // nullStyle is a light-gray, just like syntax elements, but the word
53 // `null` is wide enough to stand out from syntax items at a glance
54 nullStyle = syntaxStyle
55
56 // positiveNumberStyle is a nice green
57 positiveNumberStyle = "\x1b[38;2;0;135;95m"
58
59 // negativeNumberStyle is a nice red
60 negativeNumberStyle = "\x1b[38;2;204;0;0m"
61
62 // zeroNumberStyle is a nice blue
63 zeroNumberStyle = "\x1b[38;2;0;95;215m"
64
65 // stringStyle used to be bluish, but it's better to keep it plain,
66 // which also minimizes how many different colors the output can show
67 stringStyle = ""
68
69 // syntaxStyle is a light-gray, not too light, not too dark
70 syntaxStyle = "\x1b[38;2;168;168;168m"
71 )
72
73 func Main() {
74 args := os.Args[1:]
75
76 if len(args) > 0 {
77 switch args[0] {
78 case `-h`, `--h`, `-help`, `--help`:
79 os.Stdout.WriteString(info[1:])
80 return
81 }
82 }
83
84 if len(args) > 0 && args[0] == `--` {
85 args = args[1:]
86 }
87
88 if len(args) > 1 {
89 showError(errors.New(`multiple inputs not allowed`))
90 os.Exit(1)
91 }
92
93 // figure out whether input should come from a named file or from stdin
94 name := `-`
95 if len(args) == 1 {
96 name = args[0]
97 }
98
99 var err error
100 if name == `-` {
101 // handle lack of filepath arg, or `-` as the filepath
102 err = niceJSON(os.Stdout, os.Stdin)
103 } else {
104 // handle being given a normal filepath
105 err = handleFile(os.Stdout, os.Args[1])
106 }
107
108 if err != nil && err != io.EOF {
109 showError(err)
110 os.Exit(1)
111 }
112 }
113
114 // showError standardizes how errors look in this app
115 func showError(err error) {
116 os.Stderr.WriteString(err.Error())
117 os.Stderr.WriteString("\n")
118 }
119
120 // writeSpaces does what it says, minimizing calls to write-like funcs
121 func writeSpaces(w *bufio.Writer, n int) {
122 const spaces = ` `
123 for n >= len(spaces) {
124 w.WriteString(spaces)
125 n -= len(spaces)
126 }
127 if n > 0 {
128 w.WriteString(spaces[:n])
129 }
130 }
131
132 func handleFile(w io.Writer, path string) error {
133 // if f := strings.HasPrefix; f(path, `https://`) || f(path, `http://`) {
134 // resp, err := http.Get(path)
135 // if err != nil {
136 // return err
137 // }
138 // defer resp.Body.Close()
139 // return niceJSON(w, resp.Body)
140 // }
141
142 f, err := os.Open(path)
143 if err != nil {
144 // on windows, file-not-found error messages may mention `CreateFile`,
145 // even when trying to open files in read-only mode
146 return errors.New(`can't open file named ` + path)
147 }
148 defer f.Close()
149
150 return niceJSON(w, f)
151 }
152
153 func niceJSON(w io.Writer, r io.Reader) error {
154 bw := bufio.NewWriter(w)
155 defer bw.Flush()
156
157 dec := json.NewDecoder(r)
158 // using string-like json.Number values instead of float64 ones avoids
159 // unneeded reformatting of numbers; reformatting parsed float64 values
160 // can potentially even drop/change decimals, causing the output not to
161 // match the input digits exactly, which is best to avoid
162 dec.UseNumber()
163
164 t, err := dec.Token()
165 if err == io.EOF {
166 return errors.New(`empty input isn't valid JSON`)
167 }
168 if err != nil {
169 return err
170 }
171
172 if err := handleToken(bw, dec, t, 0, 0); err != nil {
173 return err
174 }
175 // don't forget to end the last output line
176 bw.WriteByte('\n')
177
178 if _, err := dec.Token(); err != io.EOF {
179 return errors.New(`unexpected trailing JSON data`)
180 }
181 return nil
182 }
183
184 func handleToken(w *bufio.Writer, d *json.Decoder, t json.Token, pre, level int) error {
185 switch t := t.(type) {
186 case json.Delim:
187 switch t {
188 case json.Delim('['):
189 return handleArray(w, d, pre, level)
190
191 case json.Delim('{'):
192 return handleObject(w, d, pre, level)
193
194 default:
195 // return fmt.Errorf(`unsupported JSON delimiter %v`, t)
196 return errors.New(`unsupported JSON delimiter`)
197 }
198
199 case nil:
200 return handleNull(w, pre)
201
202 case bool:
203 return handleBoolean(w, t, pre)
204
205 case string:
206 return handleString(w, t, pre)
207
208 case json.Number:
209 return handleNumber(w, t, pre)
210
211 default:
212 // return fmt.Errorf(`unsupported token type %T`, t)
213 return errors.New(`unsupported token type`)
214 }
215 }
216
217 func handleArray(w *bufio.Writer, d *json.Decoder, pre, level int) error {
218 for i := 0; true; i++ {
219 t, err := d.Token()
220 if err != nil {
221 return err
222 }
223
224 if t == json.Delim(']') {
225 if i == 0 {
226 writeSpaces(w, indent*pre)
227 w.WriteString(syntaxStyle + "[]\x1b[0m")
228 } else {
229 w.WriteString("\n")
230 writeSpaces(w, indent*level)
231 w.WriteString(syntaxStyle + "]\x1b[0m")
232 }
233 return nil
234 }
235
236 if i == 0 {
237 writeSpaces(w, indent*pre)
238 w.WriteString(syntaxStyle + "[\x1b[0m\n")
239 } else {
240 // this is a good spot to check for early-quit opportunities
241 w.WriteString(syntaxStyle + ",\x1b[0m\n")
242 if err := w.Flush(); err != nil {
243 // a write error may be the consequence of stdout being closed,
244 // perhaps by another app along a pipe
245 return io.EOF
246 }
247 }
248
249 if err := handleToken(w, d, t, level+1, level+1); err != nil {
250 return err
251 }
252 }
253
254 // make the compiler happy
255 return nil
256 }
257
258 func handleBoolean(w *bufio.Writer, b bool, pre int) error {
259 writeSpaces(w, indent*pre)
260 if b {
261 w.WriteString(boolStyle + "true\x1b[0m")
262 } else {
263 w.WriteString(boolStyle + "false\x1b[0m")
264 }
265 return nil
266 }
267
268 func handleKey(w *bufio.Writer, s string, pre int) error {
269 writeSpaces(w, indent*pre)
270 w.WriteString(syntaxStyle + "\"\x1b[0m" + keyStyle)
271 w.WriteString(s)
272 w.WriteString(syntaxStyle + "\":\x1b[0m ")
273 return nil
274 }
275
276 func handleNull(w *bufio.Writer, pre int) error {
277 writeSpaces(w, indent*pre)
278 w.WriteString(nullStyle + "null\x1b[0m")
279 return nil
280 }
281
282 // func handleNumber(w *bufio.Writer, n json.Number, pre int) error {
283 // writeSpaces(w, indent*pre)
284 // w.WriteString(numberStyle)
285 // w.WriteString(n.String())
286 // w.WriteString("\x1b[0m")
287 // return nil
288 // }
289
290 func handleNumber(w *bufio.Writer, n json.Number, pre int) error {
291 writeSpaces(w, indent*pre)
292 f, _ := n.Float64()
293 if f > 0 {
294 w.WriteString(positiveNumberStyle)
295 } else if f < 0 {
296 w.WriteString(negativeNumberStyle)
297 } else {
298 w.WriteString(zeroNumberStyle)
299 }
300 w.WriteString(n.String())
301 w.WriteString("\x1b[0m")
302 return nil
303 }
304
305 func handleObject(w *bufio.Writer, d *json.Decoder, pre, level int) error {
306 for i := 0; true; i++ {
307 t, err := d.Token()
308 if err != nil {
309 return err
310 }
311
312 if t == json.Delim('}') {
313 if i == 0 {
314 writeSpaces(w, indent*pre)
315 w.WriteString(syntaxStyle + "{}\x1b[0m")
316 } else {
317 w.WriteString("\n")
318 writeSpaces(w, indent*level)
319 w.WriteString(syntaxStyle + "}\x1b[0m")
320 }
321 return nil
322 }
323
324 if i == 0 {
325 writeSpaces(w, indent*pre)
326 w.WriteString(syntaxStyle + "{\x1b[0m\n")
327 } else {
328 // this is a good spot to check for early-quit opportunities
329 w.WriteString(syntaxStyle + ",\x1b[0m\n")
330 if err := w.Flush(); err != nil {
331 // a write error may be the consequence of stdout being closed,
332 // perhaps by another app along a pipe
333 return io.EOF
334 }
335 }
336
337 // the stdlib's JSON parser is supposed to complain about non-string
338 // keys anyway, but make sure just in case
339 k, ok := t.(string)
340 if !ok {
341 return errors.New(`expected key to be a string`)
342 }
343 if err := handleKey(w, k, level+1); err != nil {
344 return err
345 }
346
347 // handle value
348 t, err = d.Token()
349 if err != nil {
350 return err
351 }
352 if err := handleToken(w, d, t, 0, level+1); err != nil {
353 return err
354 }
355 }
356
357 // make the compiler happy
358 return nil
359 }
360
361 func needsEscaping(s string) bool {
362 for _, r := range s {
363 switch r {
364 case '"', '\\', '\t', '\r', '\n':
365 return true
366 }
367 }
368 return false
369 }
370
371 func handleString(w *bufio.Writer, s string, pre int) error {
372 writeSpaces(w, indent*pre)
373 w.WriteString(syntaxStyle + "\"\x1b[0m" + stringStyle)
374 if !needsEscaping(s) {
375 w.WriteString(s)
376 } else {
377 escapeString(w, s)
378 }
379 w.WriteString(syntaxStyle + "\"\x1b[0m")
380 return nil
381 }
382
383 func escapeString(w *bufio.Writer, s string) {
384 for _, r := range s {
385 switch r {
386 case '"', '\\':
387 w.WriteByte('\\')
388 w.WriteRune(r)
389 case '\t':
390 w.WriteByte('\\')
391 w.WriteByte('t')
392 case '\r':
393 w.WriteByte('\\')
394 w.WriteByte('r')
395 case '\n':
396 w.WriteByte('\\')
397 w.WriteByte('n')
398 default:
399 w.WriteRune(r)
400 }
401 }
402 }
File: ./nn/nn.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package nn
26
27 import (
28 "bufio"
29 "bytes"
30 "errors"
31 "io"
32 "os"
33 "strings"
34 )
35
36 const info = `
37 nn [options...] [file...]
38
39
40 Nice Numbers is an app which renders the UTF-8 text it's given to make long
41 numbers much easier to read. It does so by alternating 3-digit groups which
42 are colored using ANSI-codes with plain/unstyled 3-digit groups.
43
44 Unlike the common practice of inserting commas between 3-digit groups, this
45 trick doesn't widen the original text, keeping alignments across lines the
46 same.
47
48 Input is assumed to be UTF-8, and all CRLF byte-pairs are turned into line
49 feeds.
50
51 All (optional) leading options start with either single or double-dash,
52 and most of them change the style/color used. Some of the options are,
53 shown in their single-dash form:
54
55 -h, -help show this help message
56
57 -b use a blue color
58 -blue use a blue color
59 -bold bold-style digits
60 -g use a green color
61 -gray use a gray color (default)
62 -green use a green color
63 -hi use a highlighting/inverse style
64 -m use a magenta color
65 -magenta use a magenta color
66 -o use an orange color
67 -orange use an orange color
68 -r use a red color
69 -red use a red color
70 -u underline digits
71 -underline underline digits
72 `
73
74 func Main() {
75 args := os.Args[1:]
76
77 if len(args) > 0 {
78 switch args[0] {
79 case `-h`, `--h`, `-help`, `--help`:
80 os.Stdout.WriteString(info[1:])
81 return
82 }
83 }
84
85 options := true
86 if len(args) > 0 && args[0] == `--` {
87 options = false
88 args = args[1:]
89 }
90
91 style, _ := lookupStyle(`gray`)
92
93 // if the first argument is 1 or 2 dashes followed by a supported
94 // style-name, change the style used
95 if options && len(args) > 0 && strings.HasPrefix(args[0], `-`) {
96 name := args[0]
97 name = strings.TrimPrefix(name, `-`)
98 name = strings.TrimPrefix(name, `-`)
99 args = args[1:]
100
101 // check if the `dedashed` argument is a supported style-name
102 if s, ok := lookupStyle(name); ok {
103 style = s
104 } else {
105 os.Stderr.WriteString(`invalid style name `)
106 os.Stderr.WriteString(name)
107 os.Stderr.WriteString("\n")
108 os.Exit(1)
109 }
110 }
111
112 if err := run(os.Stdout, args, style); err != nil && err != io.EOF {
113 os.Stderr.WriteString(err.Error())
114 os.Stderr.WriteString("\n")
115 os.Exit(1)
116 }
117 }
118
119 func run(w io.Writer, args []string, style string) error {
120 bw := bufio.NewWriter(w)
121 defer bw.Flush()
122
123 if len(args) == 0 {
124 return restyle(bw, os.Stdin, style)
125 }
126
127 for _, name := range args {
128 if err := handleFile(bw, name, style); err != nil {
129 return err
130 }
131 }
132 return nil
133 }
134
135 func handleFile(w *bufio.Writer, name string, style string) error {
136 if name == `` || name == `-` {
137 return restyle(w, os.Stdin, style)
138 }
139
140 f, err := os.Open(name)
141 if err != nil {
142 return errors.New(`can't read from file named "` + name + `"`)
143 }
144 defer f.Close()
145
146 return restyle(w, f, style)
147 }
148
149 func restyle(w *bufio.Writer, r io.Reader, style string) error {
150 const gb = 1024 * 1024 * 1024
151 sc := bufio.NewScanner(r)
152 sc.Buffer(nil, 8*gb)
153
154 for i := 0; sc.Scan(); i++ {
155 s := sc.Bytes()
156 if i == 0 && bytes.HasPrefix(s, []byte{0xef, 0xbb, 0xbf}) {
157 s = s[3:]
158 }
159
160 restyleLine(w, s, style)
161 w.WriteByte('\n')
162 if err := w.Flush(); err != nil {
163 // a write error may be the consequence of stdout being closed,
164 // perhaps by another app along a pipe
165 return io.EOF
166 }
167 }
168 return sc.Err()
169 }
170
171 func lookupStyle(name string) (style string, ok bool) {
172 if alias, ok := styleAliases[name]; ok {
173 name = alias
174 }
175
176 style, ok = styles[name]
177 return style, ok
178 }
179
180 var styleAliases = map[string]string{
181 `b`: `blue`,
182 `g`: `green`,
183 `m`: `magenta`,
184 `o`: `orange`,
185 `p`: `purple`,
186 `r`: `red`,
187 `u`: `underline`,
188
189 `bolded`: `bold`,
190 `h`: `inverse`,
191 `hi`: `inverse`,
192 `highlight`: `inverse`,
193 `highlighted`: `inverse`,
194 `hilite`: `inverse`,
195 `hilited`: `inverse`,
196 `inv`: `inverse`,
197 `invert`: `inverse`,
198 `inverted`: `inverse`,
199 `underlined`: `underline`,
200
201 `bb`: `blueback`,
202 `bg`: `greenback`,
203 `bm`: `magentaback`,
204 `bo`: `orangeback`,
205 `bp`: `purpleback`,
206 `br`: `redback`,
207
208 `gb`: `greenback`,
209 `mb`: `magentaback`,
210 `ob`: `orangeback`,
211 `pb`: `purpleback`,
212 `rb`: `redback`,
213
214 `bblue`: `blueback`,
215 `bgray`: `grayback`,
216 `bgreen`: `greenback`,
217 `bmagenta`: `magentaback`,
218 `borange`: `orangeback`,
219 `bpurple`: `purpleback`,
220 `bred`: `redback`,
221
222 `backblue`: `blueback`,
223 `backgray`: `grayback`,
224 `backgreen`: `greenback`,
225 `backmagenta`: `magentaback`,
226 `backorange`: `orangeback`,
227 `backpurple`: `purpleback`,
228 `backred`: `redback`,
229 }
230
231 // styles turns style-names into the ANSI-code sequences used for the
232 // alternate groups of digits
233 var styles = map[string]string{
234 `blue`: "\x1b[38;2;0;95;215m",
235 `bold`: "\x1b[1m",
236 `gray`: "\x1b[38;2;168;168;168m",
237 `green`: "\x1b[38;2;0;135;95m",
238 `inverse`: "\x1b[7m",
239 `magenta`: "\x1b[38;2;215;0;255m",
240 `orange`: "\x1b[38;2;215;95;0m",
241 `plain`: "\x1b[0m",
242 `red`: "\x1b[38;2;204;0;0m",
243 `underline`: "\x1b[4m",
244
245 // `blue`: "\x1b[38;5;26m",
246 // `bold`: "\x1b[1m",
247 // `gray`: "\x1b[38;5;248m",
248 // `green`: "\x1b[38;5;29m",
249 // `inverse`: "\x1b[7m",
250 // `magenta`: "\x1b[38;5;99m",
251 // `orange`: "\x1b[38;5;166m",
252 // `plain`: "\x1b[0m",
253 // `red`: "\x1b[31m",
254 // `underline`: "\x1b[4m",
255
256 `blueback`: "\x1b[48;2;0;95;215m\x1b[38;2;238;238;238m",
257 `grayback`: "\x1b[48;2;168;168;168m\x1b[38;2;238;238;238m",
258 `greenback`: "\x1b[48;2;0;135;95m\x1b[38;2;238;238;238m",
259 `magentaback`: "\x1b[48;2;215;0;255m\x1b[38;2;238;238;238m",
260 `orangeback`: "\x1b[48;2;215;95;0m\x1b[38;2;238;238;238m",
261 `purpleback`: "\x1b[48;2;135;95;255m\x1b[38;2;238;238;238m",
262 `redback`: "\x1b[48;2;204;0;0m\x1b[38;2;238;238;238m",
263 }
264
265 // restyleLine renders the line given, using ANSI-styles to make any long
266 // numbers in it more legible; this func doesn't emit a line-feed, which
267 // is up to its caller
268 func restyleLine(w *bufio.Writer, line []byte, style string) {
269 for len(line) > 0 {
270 i := indexDigit(line)
271 if i < 0 {
272 // no (more) digits to style for sure
273 w.Write(line)
274 return
275 }
276
277 // emit line before current digit-run
278 w.Write(line[:i])
279 // advance to the start of the current digit-run
280 line = line[i:]
281
282 // see where the digit-run ends
283 j := indexNonDigit(line)
284 if j < 0 {
285 // the digit-run goes until the end
286 restyleDigits(w, line, style)
287 return
288 }
289
290 // emit styled digit-run
291 restyleDigits(w, line[:j], style)
292 // skip right past the end of the digit-run
293 line = line[j:]
294 }
295 }
296
297 // indexDigit finds the index of the first digit in a string, or -1 when the
298 // string has no decimal digits
299 func indexDigit(s []byte) int {
300 for i := 0; i < len(s); i++ {
301 switch s[i] {
302 case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
303 return i
304 }
305 }
306
307 // empty slice, or a slice without any digits
308 return -1
309 }
310
311 // indexNonDigit finds the index of the first non-digit in a string, or -1
312 // when the string is all decimal digits
313 func indexNonDigit(s []byte) int {
314 for i := 0; i < len(s); i++ {
315 switch s[i] {
316 case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
317 continue
318 default:
319 return i
320 }
321 }
322
323 // empty slice, or a slice which only has digits
324 return -1
325 }
326
327 // restyleDigits renders a run of digits as alternating styled/unstyled runs
328 // of 3 digits, which greatly improves readability, and is the only purpose
329 // of this app; string is assumed to be all decimal digits
330 func restyleDigits(w *bufio.Writer, digits []byte, altStyle string) {
331 if len(digits) < 4 {
332 // digit sequence is short, so emit it as is
333 w.Write(digits)
334 return
335 }
336
337 // separate leading 0..2 digits which don't align with the 3-digit groups
338 i := len(digits) % 3
339 // emit leading digits unstyled, if there are any
340 w.Write(digits[:i])
341 // the rest is guaranteed to have a length which is a multiple of 3
342 digits = digits[i:]
343
344 // start by styling, unless there were no leading digits
345 style := i != 0
346
347 for len(digits) > 0 {
348 if style {
349 w.WriteString(altStyle)
350 w.Write(digits[:3])
351 w.Write([]byte{'\x1b', '[', '0', 'm'})
352 } else {
353 w.Write(digits[:3])
354 }
355
356 // advance to the next triple: the start of this func is supposed
357 // to guarantee this step always works
358 digits = digits[3:]
359
360 // alternate between styled and unstyled 3-digit groups
361 style = !style
362 }
363 }
File: ./nn/nn_test.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package nn
26
27 import (
28 "bufio"
29 "strings"
30 "testing"
31 )
32
33 func TestRestyleLine(t *testing.T) {
34 var (
35 r = "\x1b[0m"
36 d = string(styles[`gray`])
37 )
38
39 var tests = []struct {
40 Input string
41 Expected string
42 }{
43 {``, ``},
44 {`abc`, `abc`},
45 {` abc 123456 `, ` abc 123` + d + `456` + r + ` `},
46 {` 123456789 text`, ` 123` + d + `456` + r + `789 text`},
47
48 {`0`, `0`},
49 {`01`, `01`},
50 {`012`, `012`},
51 {`0123`, `0` + d + `123` + r},
52 {`01234`, `01` + d + `234` + r},
53 {`012345`, `012` + d + `345` + r},
54 {`0123456`, `0` + d + `123` + r + `456`},
55 {`01234567`, `01` + d + `234` + r + `567`},
56 {`012345678`, `012` + d + `345` + r + `678`},
57 {`0123456789`, `0` + d + `123` + r + `456` + d + `789` + r},
58 {`01234567890`, `01` + d + `234` + r + `567` + d + `890` + r},
59 {`012345678901`, `012` + d + `345` + r + `678` + d + `901` + r},
60 {`0123456789012`, `0` + d + `123` + r + `456` + d + `789` + r + `012`},
61
62 {`00321`, `00` + d + `321` + r},
63 {`123.456789`, `123.` + `456` + d + `789` + r},
64 {`123456.123456`, `123` + d + `456` + r + `.` + `123` + d + `456` + r},
65 }
66
67 for _, tc := range tests {
68 t.Run(tc.Input, func(t *testing.T) {
69 var b strings.Builder
70 w := bufio.NewWriter(&b)
71 restyleLine(w, []byte(tc.Input), d)
72 w.Flush()
73
74 if got := b.String(); got != tc.Expected {
75 t.Fatalf(`expected %q, but got %q instead`, tc.Expected, got)
76 }
77 })
78 }
79 }
File: ./plain/plain.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package plain
26
27 import (
28 "bufio"
29 "bytes"
30 "errors"
31 "io"
32 "os"
33 )
34
35 const info = `
36 plain [options...] [file...]
37
38
39 Turn potentially ANSI-styled plain-text into actual plain-text.
40
41 Input is assumed to be UTF-8, and all CRLF byte-pairs are turned into line
42 feeds.
43
44 All (optional) leading options start with either single or double-dash:
45
46 -h, -help show this help message
47 `
48
49 func Main() {
50 buffered := false
51 args := os.Args[1:]
52
53 if len(args) > 0 {
54 switch args[0] {
55 case `-b`, `--b`, `-buffered`, `--buffered`:
56 buffered = true
57 args = args[1:]
58
59 case `-h`, `--h`, `-help`, `--help`:
60 os.Stdout.WriteString(info[1:])
61 return
62 }
63 }
64
65 if len(args) > 0 && args[0] == `--` {
66 args = args[1:]
67 }
68
69 liveLines := !buffered
70 if !buffered {
71 if _, err := os.Stdout.Seek(0, io.SeekCurrent); err == nil {
72 liveLines = false
73 }
74 }
75
76 if err := run(os.Stdout, args, liveLines); err != nil && err != io.EOF {
77 os.Stderr.WriteString(err.Error())
78 os.Stderr.WriteString("\n")
79 os.Exit(1)
80 }
81 }
82
83 func run(w io.Writer, args []string, live bool) error {
84 bw := bufio.NewWriter(w)
85 defer bw.Flush()
86
87 if len(args) == 0 {
88 return plain(bw, os.Stdin, live)
89 }
90
91 for _, name := range args {
92 if err := handleFile(bw, name, live); err != nil {
93 return err
94 }
95 }
96 return nil
97 }
98
99 func handleFile(w *bufio.Writer, name string, live bool) error {
100 if name == `` || name == `-` {
101 return plain(w, os.Stdin, live)
102 }
103
104 f, err := os.Open(name)
105 if err != nil {
106 return errors.New(`can't read from file named "` + name + `"`)
107 }
108 defer f.Close()
109
110 return plain(w, f, live)
111 }
112
113 // indexEscapeSequence finds the first ANSI-style escape-sequence, which is
114 // the multi-byte sequences starting with ESC[; the result is a pair of slice
115 // indices which can be independently negative when either the start/end of
116 // a sequence isn't found; given their fairly-common use, even the hyperlink
117 // ESC]8 sequences are supported
118 func indexEscapeSequence(s []byte) (int, int) {
119 var prev byte
120
121 for i, b := range s {
122 if prev == '\x1b' && b == '[' {
123 j := indexLetter(s[i+1:])
124 if j < 0 {
125 return i, -1
126 }
127 return i - 1, i + 1 + j + 1
128 }
129
130 if prev == '\x1b' && b == ']' && i+1 < len(s) && s[i+1] == '8' {
131 j := indexPair(s[i+1:], '\x1b', '\\')
132 if j < 0 {
133 return i, -1
134 }
135 return i - 1, i + 1 + j + 2
136 }
137
138 prev = b
139 }
140
141 return -1, -1
142 }
143
144 func indexLetter(s []byte) int {
145 for i, b := range s {
146 upper := b &^ 32
147 if 'A' <= upper && upper <= 'Z' {
148 return i
149 }
150 }
151
152 return -1
153 }
154
155 func indexPair(s []byte, x byte, y byte) int {
156 var prev byte
157
158 for i, b := range s {
159 if prev == x && b == y && i > 0 {
160 return i
161 }
162 prev = b
163 }
164
165 return -1
166 }
167
168 func plain(w *bufio.Writer, r io.Reader, live bool) error {
169 const gb = 1024 * 1024 * 1024
170 sc := bufio.NewScanner(r)
171 sc.Buffer(nil, 8*gb)
172
173 for i := 0; sc.Scan(); i++ {
174 s := sc.Bytes()
175 if i == 0 && bytes.HasPrefix(s, []byte{0xef, 0xbb, 0xbf}) {
176 s = s[3:]
177 }
178
179 for line := s; len(line) > 0; {
180 i, j := indexEscapeSequence(line)
181 if i < 0 {
182 w.Write(line)
183 break
184 }
185 if j < 0 {
186 j = len(line)
187 }
188
189 if i > 0 {
190 w.Write(line[:i])
191 }
192
193 line = line[j:]
194 }
195
196 if w.WriteByte('\n') != nil {
197 return io.EOF
198 }
199
200 if !live {
201 continue
202 }
203
204 if err := w.Flush(); err != nil {
205 return io.EOF
206 }
207 }
208
209 return sc.Err()
210 }
File: ./primes/primes.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package primes
26
27 import (
28 "bufio"
29 "math"
30 "os"
31 "strconv"
32 )
33
34 const info = `
35 primes [options...] [count...]
36
37
38 Show the first few prime numbers, starting from the lowest and showing one
39 per line. When not given how many primes to find, the default is 1 million.
40
41 All (optional) leading options start with either single or double-dash:
42
43 -h, -help show this help message
44 `
45
46 func Main() {
47 howMany := 1_000_000
48 if len(os.Args) > 1 {
49 switch os.Args[1] {
50 case `-h`, `--h`, `-help`, `--help`:
51 os.Stdout.WriteString(info[1:])
52 return
53 }
54
55 n, err := strconv.Atoi(os.Args[1])
56 if err != nil {
57 os.Stderr.WriteString(err.Error())
58 os.Stderr.WriteString("\n")
59 os.Exit(1)
60 }
61
62 if n < 0 {
63 n = 0
64 }
65 howMany = n
66 }
67
68 primes(howMany)
69 }
70
71 func primes(left int) {
72 bw := bufio.NewWriter(os.Stdout)
73 defer bw.Flush()
74
75 // 24 bytes are always enough for any 64-bit integer
76 var buf [24]byte
77
78 // 2 is the only even prime number
79 if left > 0 {
80 bw.WriteString("2\n")
81 left--
82 }
83
84 for n := uint64(3); left > 0; n += 2 {
85 if oddPrime(n) {
86 bw.Write(strconv.AppendUint(buf[:0], n, 10))
87 if err := bw.WriteByte('\n'); err != nil {
88 // assume errors come from closed stdout pipes
89 return
90 }
91 left--
92 }
93 }
94 }
95
96 // oddPrime assumes the number given to it is odd
97 func oddPrime(n uint64) bool {
98 max := uint64(math.Sqrt(float64(n)))
99 for div := uint64(3); div <= max; div += 2 {
100 if n%div == 0 {
101 return false
102 }
103 }
104 return true
105 }
File: ./realign/realign.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package realign
26
27 import (
28 "bufio"
29 "errors"
30 "io"
31 "os"
32 "strings"
33 "unicode/utf8"
34 )
35
36 const info = `
37 realign [options...] [filenames...]
38
39 Realign all detected columns, right-aligning any detected numbers in any
40 column. ANSI style-codes are also kept as given.
41
42 The options are, available both in single and double-dash versions
43
44 -h, -help show this help message
45 -m, -max-columns use the row with the most items for the item-count
46 `
47
48 func Main() {
49 maxCols := false
50 args := os.Args[1:]
51
52 for len(args) > 0 {
53 if args[0] == `--` {
54 args = args[1:]
55 break
56 }
57
58 switch args[0] {
59 case `-h`, `--h`, `-help`, `--help`:
60 os.Stdout.WriteString(info[1:])
61 return
62
63 case
64 `-m`, `--m`,
65 `-maxcols`, `--maxcols`,
66 `-max-columns`, `--max-columns`:
67 maxCols = true
68 args = args[1:]
69 continue
70 }
71
72 break
73 }
74
75 if err := run(args, maxCols); err != nil {
76 os.Stderr.WriteString(err.Error())
77 os.Stderr.WriteString("\n")
78 os.Exit(1)
79 }
80 }
81
82 // table has all summary info gathered from the data, along with the row
83 // themselves, stored as lines/strings
84 type table struct {
85 Columns int
86
87 Rows []string
88
89 MaxWidth []int
90
91 MaxDotDecimals []int
92
93 LoopItems func(s string, max int, t *table, f itemFunc)
94
95 MaxColumns bool
96 }
97
98 type itemFunc func(i int, s string, t *table)
99
100 func run(paths []string, maxCols bool) error {
101 var res table
102 res.MaxColumns = maxCols
103
104 for _, p := range paths {
105 if err := handleFile(&res, p); err != nil {
106 return err
107 }
108 }
109
110 if len(paths) == 0 {
111 if err := handleReader(&res, os.Stdin); err != nil {
112 return err
113 }
114 }
115
116 bw := bufio.NewWriterSize(os.Stdout, 32*1024)
117 defer bw.Flush()
118 realign(bw, res)
119 return nil
120 }
121
122 func handleFile(res *table, path string) error {
123 f, err := os.Open(path)
124 if err != nil {
125 // on windows, file-not-found error messages may mention `CreateFile`,
126 // even when trying to open files in read-only mode
127 return errors.New(`can't open file named ` + path)
128 }
129 defer f.Close()
130 return handleReader(res, f)
131 }
132
133 func handleReader(t *table, r io.Reader) error {
134 const gb = 1024 * 1024 * 1024
135 sc := bufio.NewScanner(r)
136 sc.Buffer(nil, 8*gb)
137
138 const maxInt = int(^uint(0) >> 1)
139 maxCols := maxInt
140
141 for i := 0; sc.Scan(); i++ {
142 s := sc.Text()
143 if i == 0 && strings.HasPrefix(s, "\xef\xbb\xbf") {
144 s = s[3:]
145 }
146
147 if len(s) == 0 {
148 if len(t.Rows) > 0 {
149 t.Rows = append(t.Rows, ``)
150 }
151 continue
152 }
153
154 t.Rows = append(t.Rows, s)
155
156 if t.Columns == 0 {
157 if t.LoopItems == nil {
158 if strings.IndexByte(s, '\t') >= 0 {
159 t.LoopItems = loopItemsTSV
160 } else {
161 t.LoopItems = loopItemsSSV
162 }
163 }
164
165 if !t.MaxColumns {
166 t.LoopItems(s, maxCols, t, updateColumnCount)
167 maxCols = t.Columns
168 }
169 }
170
171 t.LoopItems(s, maxCols, t, updateItem)
172 }
173
174 return sc.Err()
175 }
176
177 func updateColumnCount(i int, s string, t *table) {
178 t.Columns = i + 1
179 }
180
181 func updateItem(i int, s string, t *table) {
182 // ensure column-info-slices have enough room
183 if i >= len(t.MaxWidth) {
184 // update column-count if in max-columns mode
185 if t.MaxColumns {
186 t.Columns = i + 1
187 }
188 t.MaxWidth = append(t.MaxWidth, 0)
189 t.MaxDotDecimals = append(t.MaxDotDecimals, 0)
190 }
191
192 // keep track of widest rune-counts for each column
193 w := countWidth(s)
194 if t.MaxWidth[i] < w {
195 t.MaxWidth[i] = w
196 }
197
198 // update stats for numeric items
199 if isNumeric(s) {
200 dd := countDotDecimals(s)
201 if t.MaxDotDecimals[i] < dd {
202 t.MaxDotDecimals[i] = dd
203 }
204 }
205 }
206
207 // loopItemsSSV loops over a line's items, allocation-free style; when given
208 // empty strings, the callback func is never called
209 func loopItemsSSV(s string, max int, t *table, f itemFunc) {
210 s = trimTrailingSpaces(s)
211
212 for i := 0; true; i++ {
213 s = trimLeadingSpaces(s)
214 if len(s) == 0 {
215 return
216 }
217
218 if i+1 == max {
219 f(i, s, t)
220 return
221 }
222
223 j := strings.IndexByte(s, ' ')
224 if j < 0 {
225 f(i, s, t)
226 return
227 }
228
229 f(i, s[:j], t)
230 s = s[j+1:]
231 }
232 }
233
234 func trimLeadingSpaces(s string) string {
235 for len(s) > 0 && s[0] == ' ' {
236 s = s[1:]
237 }
238 return s
239 }
240
241 func trimTrailingSpaces(s string) string {
242 for len(s) > 0 && s[len(s)-1] == ' ' {
243 s = s[:len(s)-1]
244 }
245 return s
246 }
247
248 // loopItemsTSV loops over a line's tab-separated items, allocation-free style;
249 // when given empty strings, the callback func is never called
250 func loopItemsTSV(s string, max int, t *table, f itemFunc) {
251 if len(s) == 0 {
252 return
253 }
254
255 for i := 0; true; i++ {
256 if i+1 == max {
257 f(i, s, t)
258 return
259 }
260
261 j := strings.IndexByte(s, '\t')
262 if j < 0 {
263 f(i, s, t)
264 return
265 }
266
267 f(i, s[:j], t)
268 s = s[j+1:]
269 }
270 }
271
272 func skipLeadingEscapeSequences(s string) string {
273 for len(s) >= 2 {
274 if s[0] != '\x1b' {
275 return s
276 }
277
278 switch s[1] {
279 case '[':
280 s = skipSingleLeadingANSI(s[2:])
281
282 case ']':
283 if len(s) < 3 || s[2] != '8' {
284 return s
285 }
286 s = skipSingleLeadingOSC(s[3:])
287
288 default:
289 return s
290 }
291 }
292
293 return s
294 }
295
296 func skipSingleLeadingANSI(s string) string {
297 for len(s) > 0 {
298 upper := s[0] &^ 32
299 s = s[1:]
300 if 'A' <= upper && upper <= 'Z' {
301 break
302 }
303 }
304
305 return s
306 }
307
308 func skipSingleLeadingOSC(s string) string {
309 var prev byte
310
311 for len(s) > 0 {
312 b := s[0]
313 s = s[1:]
314 if prev == '\x1b' && b == '\\' {
315 break
316 }
317 prev = b
318 }
319
320 return s
321 }
322
323 // isNumeric checks if a string is valid/useable as a number
324 func isNumeric(s string) bool {
325 if len(s) == 0 {
326 return false
327 }
328
329 s = skipLeadingEscapeSequences(s)
330 if len(s) > 0 && (s[0] == '+' || s[0] == '-') {
331 s = s[1:]
332 }
333
334 s = skipLeadingEscapeSequences(s)
335 if len(s) == 0 {
336 return false
337 }
338 if s[0] == '.' {
339 return isDigits(s[1:])
340 }
341
342 digits := 0
343
344 for {
345 s = skipLeadingEscapeSequences(s)
346 if len(s) == 0 {
347 break
348 }
349
350 if s[0] == '.' {
351 return isDigits(s[1:])
352 }
353
354 if !('0' <= s[0] && s[0] <= '9') {
355 return false
356 }
357
358 digits++
359 s = s[1:]
360 }
361
362 s = skipLeadingEscapeSequences(s)
363 return len(s) == 0 && digits > 0
364 }
365
366 func isDigits(s string) bool {
367 if len(s) == 0 {
368 return false
369 }
370
371 digits := 0
372
373 for {
374 s = skipLeadingEscapeSequences(s)
375 if len(s) == 0 {
376 break
377 }
378
379 if '0' <= s[0] && s[0] <= '9' {
380 s = s[1:]
381 digits++
382 } else {
383 return false
384 }
385 }
386
387 s = skipLeadingEscapeSequences(s)
388 return len(s) == 0 && digits > 0
389 }
390
391 // countDecimals counts decimal digits from the string given, assuming it
392 // represents a valid/useable float64, when parsed
393 func countDecimals(s string) int {
394 dot := strings.IndexByte(s, '.')
395 if dot < 0 {
396 return 0
397 }
398
399 decs := 0
400 s = s[dot+1:]
401
402 for len(s) > 0 {
403 s = skipLeadingEscapeSequences(s)
404 if len(s) == 0 {
405 break
406 }
407 if '0' <= s[0] && s[0] <= '9' {
408 decs++
409 }
410 s = s[1:]
411 }
412
413 return decs
414 }
415
416 // countDotDecimals is like func countDecimals, but this one also includes
417 // the dot, when any decimals are present, else the count stays at 0
418 func countDotDecimals(s string) int {
419 decs := countDecimals(s)
420 if decs > 0 {
421 return decs + 1
422 }
423 return decs
424 }
425
426 func countWidth(s string) int {
427 width := 0
428
429 for len(s) > 0 {
430 i, j := indexEscapeSequence(s)
431 if i < 0 {
432 break
433 }
434 if j < 0 {
435 j = len(s)
436 }
437
438 width += utf8.RuneCountInString(s[:i])
439 s = s[j:]
440 }
441
442 // count trailing/all runes in strings which don't end with ANSI-sequences
443 width += utf8.RuneCountInString(s)
444 return width
445 }
446
447 // indexEscapeSequence finds the first ANSI-style escape-sequence, which is
448 // the multi-byte sequences starting with ESC[; the result is a pair of slice
449 // indices which can be independently negative when either the start/end of
450 // a sequence isn't found; given their fairly-common use, even the hyperlink
451 // ESC]8 sequences are supported
452 func indexEscapeSequence(s string) (int, int) {
453 var prev byte
454
455 for i := range s {
456 b := s[i]
457
458 if prev == '\x1b' && b == '[' {
459 j := indexLetter(s[i+1:])
460 if j < 0 {
461 return i, -1
462 }
463 return i - 1, i + 1 + j + 1
464 }
465
466 if prev == '\x1b' && b == ']' && i+1 < len(s) && s[i+1] == '8' {
467 j := indexPair(s[i+1:], '\x1b', '\\')
468 if j < 0 {
469 return i, -1
470 }
471 return i - 1, i + 1 + j + 2
472 }
473
474 prev = b
475 }
476
477 return -1, -1
478 }
479
480 func indexLetter(s string) int {
481 for i, b := range s {
482 upper := b &^ 32
483 if 'A' <= upper && upper <= 'Z' {
484 return i
485 }
486 }
487
488 return -1
489 }
490
491 func indexPair(s string, x byte, y byte) int {
492 var prev byte
493
494 for i := range s {
495 b := s[i]
496 if prev == x && b == y && i > 0 {
497 return i
498 }
499 prev = b
500 }
501
502 return -1
503 }
504
505 func realign(w *bufio.Writer, t table) {
506 due := 0
507 showItem := func(i int, s string, t *table) {
508 if i > 0 {
509 due += 2
510 }
511
512 if isNumeric(s) {
513 dd := countDotDecimals(s)
514 rpad := t.MaxDotDecimals[i] - dd
515 width := countWidth(s)
516 lpad := t.MaxWidth[i] - (width + rpad) + due
517 writeSpaces(w, lpad)
518 w.WriteString(s)
519 due = rpad
520 return
521 }
522
523 writeSpaces(w, due)
524 w.WriteString(s)
525 due = t.MaxWidth[i] - countWidth(s)
526 }
527
528 for _, line := range t.Rows {
529 due = 0
530 if len(line) > 0 {
531 t.LoopItems(line, t.Columns, &t, showItem)
532 }
533 if w.WriteByte('\n') != nil {
534 break
535 }
536 }
537 }
538
539 // writeSpaces does what it says, minimizing calls to write-like funcs
540 func writeSpaces(w *bufio.Writer, n int) {
541 const spaces = ` `
542 if n < 1 {
543 return
544 }
545
546 for n >= len(spaces) {
547 w.WriteString(spaces)
548 n -= len(spaces)
549 }
550 w.WriteString(spaces[:n])
551 }
File: ./realign/realign_test.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package realign
26
27 import "testing"
28
29 func TestCountWidth(t *testing.T) {
30 var tests = []struct {
31 name string
32 input string
33 expected int
34 }{
35 {`empty`, ``, 0},
36 {`empty ANSI`, "\x1b[38;5;0;0;0m\x1b[0m", 0},
37 {`simple plain`, `abc def`, 7},
38 {`unicode plain`, `abc●def`, 7},
39 {`simple ANSI`, "abc \x1b[7mde\x1b[0mf", 7},
40 {`unicode ANSI`, "abc●\x1b[7mde\x1b[0mf", 7},
41 }
42
43 for _, tc := range tests {
44 t.Run(tc.name, func(t *testing.T) {
45 got := countWidth(tc.input)
46 if got != tc.expected {
47 t.Errorf("expected width %d, got %d instead", tc.expected, got)
48 }
49 })
50 }
51 }
File: ./squeeze/squeeze.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package squeeze
26
27 import (
28 "bufio"
29 "bytes"
30 "errors"
31 "io"
32 "os"
33 )
34
35 const info = `
36 squeeze [filenames...]
37
38 Ignore leading/trailing spaces (and carriage-returns) on lines, also turning
39 all runs of multiple consecutive spaces into single spaces. Spaces around
40 tabs are ignored as well.
41 `
42
43 func Main() {
44 buffered := false
45 args := os.Args[1:]
46
47 if len(args) > 0 {
48 switch args[0] {
49 case `-b`, `--b`, `-buffered`, `--buffered`:
50 buffered = true
51 args = args[1:]
52
53 case `-h`, `--h`, `-help`, `--help`:
54 os.Stdout.WriteString(info[1:])
55 return
56 }
57 }
58
59 if len(args) > 0 && args[0] == `--` {
60 args = args[1:]
61 }
62
63 liveLines := !buffered
64 if !buffered {
65 if _, err := os.Stdout.Seek(0, io.SeekCurrent); err == nil {
66 liveLines = false
67 }
68 }
69
70 if err := run(os.Stdout, args, liveLines); err != nil && err != io.EOF {
71 os.Stderr.WriteString(err.Error())
72 os.Stderr.WriteString("\n")
73 os.Exit(1)
74 }
75 }
76
77 func run(w io.Writer, args []string, live bool) error {
78 bw := bufio.NewWriter(w)
79 defer bw.Flush()
80
81 if len(args) == 0 {
82 return squeeze(bw, os.Stdin, live)
83 }
84
85 for _, name := range args {
86 if err := handleFile(bw, name, live); err != nil {
87 return err
88 }
89 }
90 return nil
91 }
92
93 func handleFile(w *bufio.Writer, name string, live bool) error {
94 if name == `` || name == `-` {
95 return squeeze(w, os.Stdin, live)
96 }
97
98 f, err := os.Open(name)
99 if err != nil {
100 return errors.New(`can't read from file named "` + name + `"`)
101 }
102 defer f.Close()
103
104 return squeeze(w, f, live)
105 }
106
107 func squeeze(w *bufio.Writer, r io.Reader, live bool) error {
108 const gb = 1024 * 1024 * 1024
109 sc := bufio.NewScanner(r)
110 sc.Buffer(nil, 8*gb)
111
112 for i := 0; sc.Scan(); i++ {
113 s := sc.Bytes()
114 if i == 0 && bytes.HasPrefix(s, []byte{0xef, 0xbb, 0xbf}) {
115 s = s[3:]
116 }
117
118 writeSqueezed(w, s)
119 if w.WriteByte('\n') != nil {
120 return io.EOF
121 }
122
123 if !live {
124 continue
125 }
126
127 if err := w.Flush(); err != nil {
128 return io.EOF
129 }
130 }
131
132 return sc.Err()
133 }
134
135 func writeSqueezed(w *bufio.Writer, s []byte) {
136 // ignore leading spaces
137 for len(s) > 0 && s[0] == ' ' {
138 s = s[1:]
139 }
140
141 // ignore trailing spaces
142 for len(s) > 0 && s[len(s)-1] == ' ' {
143 s = s[:len(s)-1]
144 }
145
146 space := false
147
148 for len(s) > 0 {
149 switch s[0] {
150 case ' ':
151 s = s[1:]
152 space = true
153
154 case '\t':
155 s = s[1:]
156 space = false
157 for len(s) > 0 && s[0] == ' ' {
158 s = s[1:]
159 }
160 w.WriteByte('\t')
161
162 default:
163 if space {
164 w.WriteByte(' ')
165 space = false
166 }
167 w.WriteByte(s[0])
168 s = s[1:]
169 }
170 }
171 }
File: ./tcatl/tcatl.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package tcatl
26
27 import (
28 "bufio"
29 "bytes"
30 "errors"
31 "io"
32 "os"
33 "unicode/utf8"
34 )
35
36 const info = `
37 tcatl [options...] [file...]
38
39
40 Title and Concatenate lines emits lines from all the named sources given,
41 preceding each file's contents with its name, using an ANSI reverse style.
42
43 The name "-" stands for the standard input. When no names are given, the
44 standard input is used by default.
45
46 All (optional) leading options start with either single or double-dash:
47
48 -h, -help show this help message
49 `
50
51 func Main() {
52 args := os.Args[1:]
53 if len(args) > 0 {
54 switch args[0] {
55 case `-h`, `--h`, `-help`, `--help`:
56 os.Stdout.WriteString(info[1:])
57 return
58 }
59 }
60
61 if len(args) > 0 && args[0] == `--` {
62 args = args[1:]
63 }
64
65 if err := run(os.Stdout, args); err != nil {
66 os.Stderr.WriteString(err.Error())
67 os.Stderr.WriteString("\n")
68 os.Exit(1)
69 }
70 }
71
72 func run(w io.Writer, args []string) error {
73 bw := bufio.NewWriter(w)
74 defer bw.Flush()
75
76 if len(args) == 0 {
77 return tcatl(bw, os.Stdin, `-`)
78 }
79
80 for _, name := range args {
81 if err := handleFile(bw, name); err != nil {
82 return err
83 }
84 }
85 return nil
86 }
87
88 func handleFile(w *bufio.Writer, name string) error {
89 if name == `` || name == `-` {
90 return tcatl(w, os.Stdin, `-`)
91 }
92
93 f, err := os.Open(name)
94 if err != nil {
95 return errors.New(`can't read from file named "` + name + `"`)
96 }
97 defer f.Close()
98
99 return tcatl(w, f, name)
100 }
101
102 func tcatl(w *bufio.Writer, r io.Reader, name string) error {
103 w.WriteString("\x1b[7m")
104 w.WriteString(name)
105 writeSpaces(w, 80-utf8.RuneCountInString(name))
106 w.WriteString("\x1b[0m\n")
107 if err := w.Flush(); err != nil {
108 // a write error may be the consequence of stdout being closed,
109 // perhaps by another app along a pipe
110 return io.EOF
111 }
112
113 if catlFast(w, r) != nil {
114 return io.EOF
115 }
116 return nil
117 }
118
119 func catlFast(w *bufio.Writer, r io.Reader) error {
120 var buf [32 * 1024]byte
121 var last byte = '\n'
122
123 for i := 0; true; i++ {
124 n, err := r.Read(buf[:])
125 if n > 0 && err == io.EOF {
126 err = nil
127 }
128 if err == io.EOF {
129 if last != '\n' {
130 w.WriteByte('\n')
131 }
132 return nil
133 }
134
135 if err != nil {
136 return err
137 }
138
139 chunk := buf[:n]
140 if i == 0 && bytes.HasPrefix(chunk, []byte{0xef, 0xbb, 0xbf}) {
141 chunk = chunk[3:]
142 }
143
144 if len(chunk) >= 1 {
145 if _, err := w.Write(chunk); err != nil {
146 return io.EOF
147 }
148 last = chunk[len(chunk)-1]
149 }
150 }
151
152 return nil
153 }
154
155 // writeSpaces bulk-emits the number of spaces given
156 func writeSpaces(w *bufio.Writer, n int) {
157 const spaces = ` `
158 for ; n > len(spaces); n -= len(spaces) {
159 w.WriteString(spaces)
160 }
161 if n > 0 {
162 w.WriteString(spaces[:n])
163 }
164 }
File: ./teletype/teletype.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package teletype
26
27 import (
28 "bufio"
29 "errors"
30 "io"
31 "os"
32 "strings"
33 "time"
34 "unicode/utf8"
35 )
36
37 const info = `
38 teletype [options...] [files...]
39
40 Simulate the cadence of old-fashioned teletype machines, by slowing down
41 the output of ASCII/UTF-8 symbols from the inputs given.
42
43 All (optional) leading options start with either single or double-dash:
44
45 -h, -help show this help message
46 `
47
48 func Main() {
49 args := os.Args[1:]
50
51 if len(args) > 0 {
52 switch args[0] {
53 case `-h`, `--h`, `-help`, `--help`:
54 os.Stdout.WriteString(info[1:])
55 return
56 }
57 }
58
59 if len(args) > 0 && args[0] == `--` {
60 args = args[1:]
61 }
62
63 if err := run(os.Stdout, args); err != nil && err != io.EOF {
64 os.Stderr.WriteString(err.Error())
65 os.Stderr.WriteString("\n")
66 os.Exit(1)
67 }
68 }
69
70 func run(w io.Writer, args []string) error {
71 dashes := 0
72 for _, name := range args {
73 if name == `-` {
74 dashes++
75 }
76 if dashes > 1 {
77 return errors.New(`can't read stdin (dash) more than once`)
78 }
79 }
80
81 if len(args) == 0 {
82 return teletype(w, os.Stdin)
83 }
84
85 for _, name := range args {
86 if name == `-` {
87 if err := teletype(w, os.Stdin); err != nil {
88 return err
89 }
90 continue
91 }
92
93 if err := handleFile(w, name); err != nil {
94 return err
95 }
96 }
97 return nil
98 }
99
100 func handleFile(w io.Writer, name string) error {
101 if name == `` || name == `-` {
102 return teletype(w, os.Stdin)
103 }
104
105 f, err := os.Open(name)
106 if err != nil {
107 return errors.New(`can't read from file named "` + name + `"`)
108 }
109 defer f.Close()
110
111 return teletype(w, f)
112 }
113
114 func teletype(w io.Writer, r io.Reader) error {
115 const gb = 1024 * 1024 * 1024
116 sc := bufio.NewScanner(r)
117 sc.Buffer(nil, 8*gb)
118
119 for i := 0; sc.Scan(); i++ {
120 s := sc.Text()
121 if i == 0 && strings.HasPrefix(s, "\xef\xbb\xbf") {
122 s = s[3:]
123 }
124
125 var buf [4]byte
126 for _, r := range s {
127 time.Sleep(15 * time.Millisecond)
128 if _, err := w.Write(utf8.AppendRune(buf[:0], r)); err != nil {
129 return io.EOF
130 }
131 }
132
133 time.Sleep(750 * time.Millisecond)
134 if _, err := w.Write([]byte{'\n'}); err != nil {
135 return io.EOF
136 }
137 }
138
139 return sc.Err()
140 }
File: ./utfate/utfate.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package utfate
26
27 import (
28 "bufio"
29 "bytes"
30 "encoding/binary"
31 "errors"
32 "io"
33 "os"
34 "unicode"
35 "unicode/utf16"
36 )
37
38 const info = `
39 utfate [options...] [file...]
40
41 This app turns plain-text input into UTF-8. Supported input formats are
42
43 - ASCII
44 - UTF-8
45 - UTF-8 with a leading BOM
46 - UTF-16 BE
47 - UTF-16 LE
48 - UTF-32 BE
49 - UTF-32 LE
50
51 All (optional) leading options start with either single or double-dash:
52
53 -h, -help show this help message
54 `
55
56 func Main() {
57 if len(os.Args) > 1 {
58 switch os.Args[1] {
59 case `-h`, `--h`, `-help`, `--help`:
60 os.Stdout.WriteString(info[1:])
61 return
62 }
63 }
64
65 if err := run(os.Stdout, os.Args[1:]); err != nil && err != io.EOF {
66 os.Stderr.WriteString(err.Error())
67 os.Stderr.WriteString("\n")
68 os.Exit(1)
69 }
70 }
71
72 func run(w io.Writer, args []string) error {
73 bw := bufio.NewWriter(w)
74 defer bw.Flush()
75
76 for _, path := range args {
77 if err := handleFile(bw, path); err != nil {
78 return err
79 }
80 }
81
82 if len(args) == 0 {
83 return utfate(bw, os.Stdin)
84 }
85 return nil
86 }
87
88 func handleFile(w *bufio.Writer, name string) error {
89 if name == `-` {
90 return utfate(w, os.Stdin)
91 }
92
93 f, err := os.Open(name)
94 if err != nil {
95 return errors.New(`can't read from file named "` + name + `"`)
96 }
97 defer f.Close()
98
99 return utfate(w, f)
100 }
101
102 func utfate(w io.Writer, r io.Reader) error {
103 br := bufio.NewReader(r)
104 bw := bufio.NewWriter(w)
105 defer bw.Flush()
106
107 lead, err := br.Peek(4)
108 if err != nil && err != io.EOF {
109 return err
110 }
111
112 if bytes.HasPrefix(lead, []byte{'\x00', '\x00', '\xfe', '\xff'}) {
113 br.Discard(4)
114 return utf32toUTF8(bw, br, binary.BigEndian)
115 }
116
117 if bytes.HasPrefix(lead, []byte{'\xff', '\xfe', '\x00', '\x00'}) {
118 br.Discard(4)
119 return utf32toUTF8(bw, br, binary.LittleEndian)
120 }
121
122 if bytes.HasPrefix(lead, []byte{'\xfe', '\xff'}) {
123 br.Discard(2)
124 return utf16toUTF8(bw, br, readBytePairBE)
125 }
126
127 if bytes.HasPrefix(lead, []byte{'\xff', '\xfe'}) {
128 br.Discard(2)
129 return utf16toUTF8(bw, br, readBytePairLE)
130 }
131
132 if bytes.HasPrefix(lead, []byte{'\xef', '\xbb', '\xbf'}) {
133 br.Discard(3)
134 return handleUTF8(bw, br)
135 }
136
137 return handleUTF8(bw, br)
138 }
139
140 func handleUTF8(w *bufio.Writer, r *bufio.Reader) error {
141 for {
142 c, _, err := r.ReadRune()
143 if c == unicode.ReplacementChar {
144 return errors.New(`invalid UTF-8 stream`)
145 }
146 if err == io.EOF {
147 return nil
148 }
149 if err != nil {
150 return err
151 }
152
153 if _, err := w.WriteRune(c); err != nil {
154 return io.EOF
155 }
156 }
157 }
158
159 // fancyHandleUTF8 is kept only for reference, as its attempts at being clever
160 // don't seem to speed things up much when given ASCII input
161 func fancyHandleUTF8(w *bufio.Writer, r *bufio.Reader) error {
162 lookahead := 1
163 maxAhead := r.Size() / 2
164
165 for {
166 // look ahead to check for ASCII runs
167 ahead, err := r.Peek(lookahead)
168 if err == io.EOF {
169 return nil
170 }
171 if err != nil {
172 return err
173 }
174
175 // copy leading ASCII runs
176 n := leadASCII(ahead)
177 if n > 0 {
178 w.Write(ahead[:n])
179 r.Discard(n)
180 }
181
182 // adapt lookahead size
183 if n == len(ahead) && lookahead < maxAhead {
184 lookahead *= 2
185 } else if lookahead > 1 {
186 lookahead /= 2
187 }
188
189 if n == len(ahead) {
190 continue
191 }
192
193 c, _, err := r.ReadRune()
194 if c == unicode.ReplacementChar {
195 return errors.New(`invalid UTF-8 stream`)
196 }
197 if err == io.EOF {
198 return nil
199 }
200 if err != nil {
201 return err
202 }
203
204 if _, err := w.WriteRune(c); err != nil {
205 return io.EOF
206 }
207 }
208 }
209
210 // leadASCII is used by func fancyHandleUTF8
211 func leadASCII(buf []byte) int {
212 for i, b := range buf {
213 if b >= 128 {
214 return i
215 }
216 }
217 return len(buf)
218 }
219
220 // readPairFunc narrows source-code lines below
221 type readPairFunc func(*bufio.Reader) (byte, byte, error)
222
223 // utf16toUTF8 handles UTF-16 inputs for func utfate
224 func utf16toUTF8(w *bufio.Writer, r *bufio.Reader, read2 readPairFunc) error {
225 for {
226 a, b, err := read2(r)
227 if err == io.EOF {
228 return nil
229 }
230 if err != nil {
231 return err
232 }
233
234 c := rune(256*int(a) + int(b))
235 if utf16.IsSurrogate(c) {
236 a, b, err := read2(r)
237 if err == io.EOF {
238 return nil
239 }
240 if err != nil {
241 return err
242 }
243
244 next := rune(256*int(a) + int(b))
245 c = utf16.DecodeRune(c, next)
246 }
247
248 if _, err := w.WriteRune(c); err != nil {
249 return io.EOF
250 }
251 }
252 }
253
254 // readBytePairBE gets you a pair of bytes in big-endian (original) order
255 func readBytePairBE(br *bufio.Reader) (byte, byte, error) {
256 a, err := br.ReadByte()
257 if err != nil {
258 return a, 0, err
259 }
260
261 b, err := br.ReadByte()
262 return a, b, err
263 }
264
265 // readBytePairLE gets you a pair of bytes in little-endian order
266 func readBytePairLE(br *bufio.Reader) (byte, byte, error) {
267 a, b, err := readBytePairBE(br)
268 return b, a, err
269 }
270
271 // utf32toUTF8 handles UTF-32 inputs for func utfate
272 func utf32toUTF8(w *bufio.Writer, r *bufio.Reader, o binary.ByteOrder) error {
273 var n uint32
274 for {
275 err := binary.Read(r, o, &n)
276 if err == io.EOF {
277 return nil
278 }
279 if err != nil {
280 return err
281 }
282
283 if _, err := w.WriteRune(rune(n)); err != nil {
284 return io.EOF
285 }
286 }
287 }
File: ./waveout/bytes.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package waveout
26
27 import (
28 "encoding/binary"
29 "fmt"
30 "io"
31 "math"
32 )
33
34 // aiff header format
35 //
36 // http://paulbourke.net/dataformats/audio/
37 //
38 // wav header format
39 //
40 // http://soundfile.sapp.org/doc/WaveFormat/
41 // http://www-mmsp.ece.mcgill.ca/Documents/AudioFormats/WAVE/WAVE.html
42 // https://docs.fileformat.com/audio/wav/
43
44 const (
45 // maxInt helps convert float64 values into int16 ones
46 maxInt = 1<<15 - 1
47
48 // wavIntPCM declares integer PCM sound-data in a wav header
49 wavIntPCM = 1
50
51 // wavFloatPCM declares floating-point PCM sound-data in a wav header
52 wavFloatPCM = 3
53 )
54
55 // emitInt16LE writes a 16-bit signed integer in little-endian byte order
56 func emitInt16LE(w io.Writer, f float64) {
57 // binary.Write(w, binary.LittleEndian, int16(maxInt*f))
58 var buf [2]byte
59 binary.LittleEndian.PutUint16(buf[:2], uint16(int16(maxInt*f)))
60 w.Write(buf[:2])
61 }
62
63 // emitFloat32LE writes a 32-bit float in little-endian byte order
64 func emitFloat32LE(w io.Writer, f float64) {
65 var buf [4]byte
66 binary.LittleEndian.PutUint32(buf[:4], math.Float32bits(float32(f)))
67 w.Write(buf[:4])
68 }
69
70 // emitInt16BE writes a 16-bit signed integer in big-endian byte order
71 func emitInt16BE(w io.Writer, f float64) {
72 // binary.Write(w, binary.BigEndian, int16(maxInt*f))
73 var buf [2]byte
74 binary.BigEndian.PutUint16(buf[:2], uint16(int16(maxInt*f)))
75 w.Write(buf[:2])
76 }
77
78 // emitFloat32BE writes a 32-bit float in big-endian byte order
79 func emitFloat32BE(w io.Writer, f float64) {
80 var buf [4]byte
81 binary.BigEndian.PutUint32(buf[:4], math.Float32bits(float32(f)))
82 w.Write(buf[:4])
83 }
84
85 // wavSettings is an item in the type2wavSettings table
86 type wavSettings struct {
87 Type byte
88 BitsPerSample byte
89 }
90
91 // type2wavSettings encodes values used when emitting wav headers
92 var type2wavSettings = map[sampleFormat]wavSettings{
93 int16LE: {wavIntPCM, 16},
94 float32LE: {wavFloatPCM, 32},
95 }
96
97 // emitWaveHeader writes the start of a valid .wav file: since it also starts
98 // the wav data section and emits its size, you only need to write all samples
99 // after calling this func
100 func emitWaveHeader(w io.Writer, cfg outputConfig) error {
101 const fmtChunkSize = 16
102 duration := cfg.MaxTime
103 numchan := uint32(len(cfg.Scripts))
104 sampleRate := cfg.SampleRate
105
106 ws, ok := type2wavSettings[cfg.Samples]
107 if !ok {
108 const fs = `internal error: invalid output-format code %d`
109 return fmt.Errorf(fs, cfg.Samples)
110 }
111 kind := uint16(ws.Type)
112 bps := uint32(ws.BitsPerSample)
113
114 // byte rate
115 br := sampleRate * bps * numchan / 8
116 // data size in bytes
117 dataSize := uint32(float64(br) * duration)
118 // total file size
119 totalSize := uint32(dataSize + 44)
120
121 // general descriptor
122 w.Write([]byte(`RIFF`))
123 binary.Write(w, binary.LittleEndian, uint32(totalSize))
124 w.Write([]byte(`WAVE`))
125
126 // fmt chunk
127 w.Write([]byte(`fmt `))
128 binary.Write(w, binary.LittleEndian, uint32(fmtChunkSize))
129 binary.Write(w, binary.LittleEndian, uint16(kind))
130 binary.Write(w, binary.LittleEndian, uint16(numchan))
131 binary.Write(w, binary.LittleEndian, uint32(sampleRate))
132 binary.Write(w, binary.LittleEndian, uint32(br))
133 binary.Write(w, binary.LittleEndian, uint16(bps*numchan/8))
134 binary.Write(w, binary.LittleEndian, uint16(bps))
135
136 // start data chunk
137 w.Write([]byte(`data`))
138 binary.Write(w, binary.LittleEndian, uint32(dataSize))
139 return nil
140 }
File: ./waveout/config.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package waveout
26
27 import (
28 "errors"
29 "fmt"
30 "math"
31 "os"
32 "strconv"
33 "strings"
34 "time"
35
36 "../../pkg/timeplus"
37 )
38
39 // config has all the parsed cmd-line options
40 type config struct {
41 // Scripts has the source codes of all scripts for all channels
42 Scripts []string
43
44 // To is the output format
45 To string
46
47 // MaxTime is the play duration of the resulting sound
48 MaxTime float64
49
50 // SampleRate is the number of samples per second for all channels
51 SampleRate uint
52 }
53
54 // parseFlags is the constructor for type config
55 func parseFlags(usage string) (config, error) {
56 cfg := config{
57 To: `wav`,
58 MaxTime: math.NaN(),
59 SampleRate: 48_000,
60 }
61
62 args := os.Args[1:]
63 if len(args) == 0 {
64 fmt.Fprint(os.Stderr, usage)
65 os.Exit(0)
66 }
67
68 for _, s := range args {
69 switch s {
70 case `help`, `-h`, `--h`, `-help`, `--help`:
71 fmt.Fprint(os.Stdout, usage)
72 os.Exit(0)
73 }
74
75 err := cfg.handleArg(s)
76 if err != nil {
77 return cfg, err
78 }
79 }
80
81 if math.IsNaN(cfg.MaxTime) {
82 cfg.MaxTime = 1
83 }
84 if cfg.MaxTime < 0 {
85 const fs = `error: given negative duration %f`
86 return cfg, fmt.Errorf(fs, cfg.MaxTime)
87 }
88 return cfg, nil
89 }
90
91 func (c *config) handleArg(s string) error {
92 switch s {
93 case `44.1k`, `44.1K`:
94 c.SampleRate = 44_100
95 return nil
96
97 case `48k`, `48K`:
98 c.SampleRate = 48_000
99 return nil
100
101 case `dat`, `DAT`:
102 c.SampleRate = 48_000
103 return nil
104
105 case `cd`, `cda`, `CD`, `CDA`:
106 c.SampleRate = 44_100
107 return nil
108 }
109
110 // handle output-format names and their aliases
111 if kind, ok := name2type[s]; ok {
112 c.To = kind
113 return nil
114 }
115
116 // handle time formats, except when they're pure numbers
117 if math.IsNaN(c.MaxTime) {
118 dur, derr := timeplus.ParseDuration(s)
119 if derr == nil {
120 c.MaxTime = float64(dur) / float64(time.Second)
121 return nil
122 }
123 }
124
125 // handle sample-rate, given either in hertz or kilohertz
126 lc := strings.ToLower(s)
127 if strings.HasSuffix(lc, `khz`) {
128 lc = strings.TrimSuffix(lc, `khz`)
129 khz, err := strconv.ParseFloat(lc, 64)
130 if err != nil || isBadNumber(khz) || khz <= 0 {
131 const fs = `invalid sample-rate frequency %q`
132 return fmt.Errorf(fs, s)
133 }
134 c.SampleRate = uint(1_000 * khz)
135 return nil
136 } else if strings.HasSuffix(lc, `hz`) {
137 lc = strings.TrimSuffix(lc, `hz`)
138 hz, err := strconv.ParseUint(lc, 10, 64)
139 if err != nil {
140 const fs = `invalid sample-rate frequency %q`
141 return fmt.Errorf(fs, s)
142 }
143 c.SampleRate = uint(hz)
144 return nil
145 }
146
147 c.Scripts = append(c.Scripts, s)
148 return nil
149 }
150
151 type encoding byte
152 type headerType byte
153 type sampleFormat byte
154
155 const (
156 directEncoding encoding = 1
157 uriEncoding encoding = 2
158
159 noHeader headerType = 1
160 wavHeader headerType = 2
161
162 int16BE sampleFormat = 1
163 int16LE sampleFormat = 2
164 float32BE sampleFormat = 3
165 float32LE sampleFormat = 4
166 )
167
168 // name2type normalizes keys used for type2settings
169 var name2type = map[string]string{
170 `datauri`: `data-uri`,
171 `dataurl`: `data-uri`,
172 `data-uri`: `data-uri`,
173 `data-url`: `data-uri`,
174 `uri`: `data-uri`,
175 `url`: `data-uri`,
176
177 `raw`: `raw`,
178 `raw16be`: `raw16be`,
179 `raw16le`: `raw16le`,
180 `raw32be`: `raw32be`,
181 `raw32le`: `raw32le`,
182
183 `audio/x-wav`: `wave-16`,
184 `audio/x-wave`: `wave-16`,
185 `wav`: `wave-16`,
186 `wave`: `wave-16`,
187 `wav16`: `wave-16`,
188 `wave16`: `wave-16`,
189 `wav-16`: `wave-16`,
190 `wave-16`: `wave-16`,
191 `x-wav`: `wave-16`,
192 `x-wave`: `wave-16`,
193
194 `wav16uri`: `wave-16-uri`,
195 `wave-16-uri`: `wave-16-uri`,
196
197 `wav32uri`: `wave-32-uri`,
198 `wave-32-uri`: `wave-32-uri`,
199
200 `wav32`: `wave-32`,
201 `wave32`: `wave-32`,
202 `wav-32`: `wave-32`,
203 `wave-32`: `wave-32`,
204 }
205
206 // outputSettings are format-specific settings which are controlled by the
207 // output-format option on the cmd-line
208 type outputSettings struct {
209 Encoding encoding
210 Header headerType
211 Samples sampleFormat
212 }
213
214 // type2settings translates output-format names into the specific settings
215 // these imply
216 var type2settings = map[string]outputSettings{
217 ``: {directEncoding, wavHeader, int16LE},
218
219 `data-uri`: {uriEncoding, wavHeader, int16LE},
220 `raw`: {directEncoding, noHeader, int16LE},
221 `raw16be`: {directEncoding, noHeader, int16BE},
222 `raw16le`: {directEncoding, noHeader, int16LE},
223 `wave-16`: {directEncoding, wavHeader, int16LE},
224 `wave-16-uri`: {uriEncoding, wavHeader, int16LE},
225
226 `raw32be`: {directEncoding, noHeader, float32BE},
227 `raw32le`: {directEncoding, noHeader, float32LE},
228 `wave-32`: {directEncoding, wavHeader, float32LE},
229 `wave-32-uri`: {uriEncoding, wavHeader, float32LE},
230 }
231
232 // outputConfig has all the info the core of this app needs to make sound
233 type outputConfig struct {
234 // Scripts has the source codes of all scripts for all channels
235 Scripts []string
236
237 // MaxTime is the play duration of the resulting sound
238 MaxTime float64
239
240 // SampleRate is the number of samples per second for all channels
241 SampleRate uint32
242
243 // all the configuration details needed to emit output
244 outputSettings
245 }
246
247 // newOutputConfig is the constructor for type outputConfig, translating the
248 // cmd-line info from type config
249 func newOutputConfig(cfg config) (outputConfig, error) {
250 oc := outputConfig{
251 Scripts: cfg.Scripts,
252 MaxTime: cfg.MaxTime,
253 SampleRate: uint32(cfg.SampleRate),
254 }
255
256 if len(oc.Scripts) == 0 {
257 return oc, errors.New(`no formulas given`)
258 }
259
260 outFmt := strings.ToLower(strings.TrimSpace(cfg.To))
261 if alias, ok := name2type[outFmt]; ok {
262 outFmt = alias
263 }
264
265 set, ok := type2settings[outFmt]
266 if !ok {
267 const fs = `unsupported output format %q`
268 return oc, fmt.Errorf(fs, cfg.To)
269 }
270
271 oc.outputSettings = set
272 return oc, nil
273 }
274
275 // mimeType gives the format's corresponding MIME type, or an empty string
276 // if the type isn't URI-encodable
277 func (oc outputConfig) mimeType() string {
278 if oc.Header == wavHeader {
279 return `audio/x-wav`
280 }
281 return ``
282 }
283
284 func isBadNumber(f float64) bool {
285 return math.IsNaN(f) || math.IsInf(f, 0)
286 }
File: ./waveout/config_test.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package waveout
26
27 import "testing"
28
29 func TestTables(t *testing.T) {
30 for _, kind := range name2type {
31 // ensure all canonical format values are aliased to themselves
32 if _, ok := name2type[kind]; !ok {
33 const fs = `canonical format %q not set`
34 t.Fatalf(fs, kind)
35 }
36 }
37
38 for k, kind := range name2type {
39 // ensure each setting leads somewhere
40 set, ok := type2settings[kind]
41 if !ok {
42 const fs = `type alias %q has no setting for it`
43 t.Fatalf(fs, k)
44 }
45
46 // ensure all encoding codes are valid in the next step
47 switch set.Encoding {
48 case directEncoding, uriEncoding:
49 // ok
50 default:
51 const fs = `invalid encoding (code %d) from settings for %q`
52 t.Fatalf(fs, set.Encoding, kind)
53 }
54
55 // also ensure all header codes are valid
56 switch set.Header {
57 case noHeader, wavHeader:
58 // ok
59 default:
60 const fs = `invalid header (code %d) from settings for %q`
61 t.Fatalf(fs, set.Header, kind)
62 }
63
64 // as well as all sample-format codes
65 switch set.Samples {
66 case int16BE, int16LE, float32BE, float32LE:
67 // ok
68 default:
69 const fs = `invalid sample-format (code %d) from settings for %q`
70 t.Fatalf(fs, set.Header, kind)
71 }
72 }
73 }
File: ./waveout/info.txt
1 waveout [options...] [duration...] [formulas...]
2
3
4 This app emits wave-sound binary data using the script(s) given. Scripts
5 give you the float64-related functionality you may expect, from numeric
6 operations to several math functions. When given 1 formula, the result is
7 mono; when given 2 formulas (left and right), the result is stereo, and so
8 on.
9
10 Output is always uncompressed audio: `waveout` can emit that as is, or as a
11 base64-encoded data-URI, which you can use as a `src` attribute value in an
12 HTML audio tag. Output duration is 1 second by default, but you can change
13 that too by using a recognized time format.
14
15 The first recognized time format is the familiar hh:mm:ss, where the hours
16 are optional, and where seconds can have a decimal part after it.
17
18 The second recognized time format uses 1-letter shortcuts instead of colons
19 for each time component, each of which is optional: `h` stands for hour, `m`
20 for minutes, and `s` for seconds.
21
22
23 Output Formats
24
25 encoding header samples endian more info
26
27 wav direct wave int16 little default format
28
29 wav16 direct wave int16 little alias for `wav`
30 wav32 direct wave float32 little
31 uri data-URI wave int16 little MIME type is audio/x-wav
32
33 raw direct none int16 little
34 raw16le direct none int16 little alias for `raw`
35 raw32le direct none float32 little
36 raw16be direct none int16 big
37 raw32be direct none float32 big
38
39
40 Concrete Examples
41
42 # low-tones commonly used in club music as beats
43 waveout 2s 'sin(10 * tau * exp(-20 * u)) * exp(-2 * u)' > club-beats.wav
44
45 # 1 minute and 5 seconds of static-like random noise
46 waveout 1m5s 'rand()' > random-noise.wav
47
48 # many bell-like clicks in quick succession; can be a cellphone's ringtone
49 waveout 'sin(2048 * tau * t) * exp(-50 * (t%0.1))' > ringtone.wav
50
51 # similar to the door-opening sound from a dsc powerseries home alarm
52 waveout 'sin(4096 * tau * t) * exp(-10 * (t%0.1))' > home-alarm.wav
53
54 # watch your ears: quickly increases frequency up to 2khz
55 waveout 'sin(2_000 * t * tau * t)' > frequency-sweep.wav
56
57 # 1-second 400hz test tone
58 waveout 'sin(400 * tau * t)' > test-tone-400.wav
59
60 # 2s of a 440hz test tone, also called an A440 sound
61 waveout 2s 'sin(440 * tau * t)' > a440.wav
62
63 # 1s 400hz test tone with sudden volume drop at the end, to avoid clip
64 waveout 'sin(400 * tau * t) * min(1, exp(-100*(t-0.9)))' > nice-tone.wav
65
66 # old ringtone used in north america
67 waveout '0.5*sin(350 * tau * t) + 0.5*sin(450 * tau * t)' > na-ringtone.wav
68
69 # 20 seconds of periodic pings
70 waveout 20s 'sin(800 * tau * u) * exp(-20 * u)' > pings.wav
71
72 # 2 seconds of a european-style dial-tone
73 waveout 2s '(sin(350 * tau * t) + sin(450 * tau * t)) / 2' > dial-tone.wav
74
75 # 4 seconds of a north-american-style busy-phone signal
76 waveout 4s '(u < 0.5) * (sin(480*tau * t) + sin(620*tau * t)) / 2' > na-busy.wav
77
78 # hit the 51st key on a synthetic piano-like instrument
79 waveout 'sin(tau * 440 * 2**((51 - 49)/12) * t) * exp(-10*u)' > piano-key.wav
80
81 # hit of a synthetic snare-like sound
82 waveout 'random() * exp(-10 * t)' > synth-snare.wav
83
84 # a stereotypical `laser` sound
85 waveout 'sin(100 * tau * exp(-40 * t))' > laser.wav
File: ./waveout/main.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package waveout
26
27 import (
28 "bufio"
29 "encoding/base64"
30 "errors"
31 "fmt"
32 "io"
33 "os"
34
35 _ "embed"
36 )
37
38 //go:embed info.txt
39 var usage string
40
41 func Main() {
42 cfg, err := parseFlags(usage)
43 if err != nil {
44 fmt.Fprintln(os.Stderr, err.Error())
45 os.Exit(1)
46 }
47
48 oc, err := newOutputConfig(cfg)
49 if err != nil {
50 fmt.Fprintln(os.Stderr, err.Error())
51 os.Exit(1)
52 }
53
54 addDetermFuncs()
55
56 if err := run(oc); err != nil {
57 fmt.Fprintln(os.Stderr, err.Error())
58 os.Exit(1)
59 }
60 }
61
62 func run(cfg outputConfig) error {
63 // f, err := os.Create(`waveout.prof`)
64 // if err != nil {
65 // return err
66 // }
67 // defer f.Close()
68
69 // pprof.StartCPUProfile(f)
70 // defer pprof.StopCPUProfile()
71
72 w := bufio.NewWriterSize(os.Stdout, 64*1024)
73 defer w.Flush()
74
75 switch cfg.Encoding {
76 case directEncoding:
77 return runDirect(w, cfg)
78
79 case uriEncoding:
80 mtype := cfg.mimeType()
81 if mtype == `` {
82 return errors.New(`internal error: no MIME type`)
83 }
84
85 fmt.Fprintf(w, `data:%s;base64,`, mtype)
86 enc := base64.NewEncoder(base64.StdEncoding, w)
87 defer enc.Close()
88 return runDirect(enc, cfg)
89
90 default:
91 const fs = `internal error: wrong output-encoding code %d`
92 return fmt.Errorf(fs, cfg.Encoding)
93 }
94 }
95
96 // type2emitter chooses sample-emitter funcs from the format given
97 var type2emitter = map[sampleFormat]func(io.Writer, float64){
98 int16LE: emitInt16LE,
99 int16BE: emitInt16BE,
100 float32LE: emitFloat32LE,
101 float32BE: emitFloat32BE,
102 }
103
104 // runDirect emits sound-data bytes: this func can be called with writers
105 // which keep bytes as given, or with re-encoders, such as base64 writers
106 func runDirect(w io.Writer, cfg outputConfig) error {
107 switch cfg.Header {
108 case noHeader:
109 // do nothing, while avoiding error
110
111 case wavHeader:
112 emitWaveHeader(w, cfg)
113
114 default:
115 const fs = `internal error: wrong header code %d`
116 return fmt.Errorf(fs, cfg.Header)
117 }
118
119 emitter, ok := type2emitter[cfg.Samples]
120 if !ok {
121 const fs = `internal error: wrong output-format code %d`
122 return fmt.Errorf(fs, cfg.Samples)
123 }
124
125 if len(cfg.Scripts) == 1 {
126 return emitMono(w, cfg, emitter)
127 }
128 return emit(w, cfg, emitter)
129 }
File: ./waveout/scripts.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package waveout
26
27 import (
28 "io"
29 "math"
30 "math/rand"
31 "time"
32
33 "../fmscripts"
34 )
35
36 // makeDefs makes extra funcs and values available to scripts
37 func makeDefs(cfg outputConfig) map[string]any {
38 // copy extra built-in funcs
39 defs := make(map[string]any, len(extras)+6+5)
40 for k, v := range extras {
41 defs[k] = v
42 }
43
44 // add extra variables
45 defs[`t`] = 0.0
46 defs[`u`] = 0.0
47 defs[`d`] = cfg.MaxTime
48 defs[`dur`] = cfg.MaxTime
49 defs[`duration`] = cfg.MaxTime
50 defs[`end`] = cfg.MaxTime
51
52 // add pseudo-random funcs
53
54 seed := time.Now().UnixNano()
55 r := rand.New(rand.NewSource(seed))
56
57 rand := func() float64 {
58 return random01(r)
59 }
60 randomf := func() float64 {
61 return random(r)
62 }
63 rexpf := func(scale float64) float64 {
64 return rexp(r, scale)
65 }
66 rnormf := func(mu, sigma float64) float64 {
67 return rnorm(r, mu, sigma)
68 }
69
70 defs[`rand`] = rand
71 defs[`rand01`] = rand
72 defs[`random`] = randomf
73 defs[`rexp`] = rexpf
74 defs[`rnorm`] = rnormf
75
76 return defs
77 }
78
79 type emitFunc = func(io.Writer, float64)
80
81 // emit runs the formulas given to emit all wave samples
82 func emit(w io.Writer, cfg outputConfig, emit emitFunc) error {
83 var c fmscripts.Compiler
84 defs := makeDefs(cfg)
85
86 programs := make([]fmscripts.Program, 0, len(cfg.Scripts))
87 tvars := make([]*float64, 0, len(cfg.Scripts))
88 uvars := make([]*float64, 0, len(cfg.Scripts))
89
90 for _, s := range cfg.Scripts {
91 p, err := c.Compile(s, defs)
92 if err != nil {
93 return err
94 }
95 programs = append(programs, p)
96 t, _ := p.Get(`t`)
97 u, _ := p.Get(`u`)
98 tvars = append(tvars, t)
99 uvars = append(uvars, u)
100 }
101
102 dt := 1.0 / float64(cfg.SampleRate)
103 end := cfg.MaxTime
104
105 for i := 0.0; true; i++ {
106 now := dt * i
107 if now >= end {
108 return nil
109 }
110
111 _, u := math.Modf(now)
112
113 for j, p := range programs {
114 *tvars[j] = now
115 *uvars[j] = u
116 emit(w, p.Run())
117 }
118 }
119 return nil
120 }
121
122 // emitMono runs the formula given to emit all single-channel wave samples
123 func emitMono(w io.Writer, cfg outputConfig, emit emitFunc) error {
124 var c fmscripts.Compiler
125 mono, err := c.Compile(cfg.Scripts[0], makeDefs(cfg))
126 if err != nil {
127 return err
128 }
129
130 t, _ := mono.Get(`t`)
131 u, needsu := mono.Get(`u`)
132
133 dt := 1.0 / float64(cfg.SampleRate)
134 end := cfg.MaxTime
135
136 // update variable `u` only if script uses it: this can speed things
137 // up considerably when that variable isn't used
138 if needsu {
139 for i := 0.0; true; i++ {
140 now := dt * i
141 if now >= end {
142 return nil
143 }
144
145 *t = now
146 _, *u = math.Modf(now)
147 emit(w, mono.Run())
148 }
149 return nil
150 }
151
152 for i := 0.0; true; i++ {
153 now := dt * i
154 if now >= end {
155 return nil
156 }
157
158 *t = now
159 emit(w, mono.Run())
160 }
161 return nil
162 }
163
164 // // emitStereo runs the formula given to emit all 2-channel wave samples
165 // func emitStereo(w io.Writer, cfg outputConfig, emit emitFunc) error {
166 // defs := makeDefs(cfg)
167 // var c fmscripts.Compiler
168 // left, err := c.Compile(cfg.Scripts[0], defs)
169 // if err != nil {
170 // return err
171 // }
172 // right, err := c.Compile(cfg.Scripts[1], defs)
173 // if err != nil {
174 // return err
175 // }
176
177 // lt, _ := left.Get(`t`)
178 // rt, _ := right.Get(`t`)
179 // lu, luok := left.Get(`u`)
180 // ru, ruok := right.Get(`u`)
181
182 // dt := 1.0 / float64(cfg.SampleRate)
183 // end := cfg.MaxTime
184
185 // // update variable `u` only if script uses it: this can speed things
186 // // up considerably when that variable isn't used
187 // updateu := func(float64) {}
188 // if luok || ruok {
189 // updateu = func(now float64) {
190 // _, u := math.Modf(now)
191 // *lu = u
192 // *ru = u
193 // }
194 // }
195
196 // for i := 0.0; true; i++ {
197 // now := dt * i
198 // if now >= end {
199 // return nil
200 // }
201
202 // *rt = now
203 // *lt = now
204 // updateu(now)
205
206 // // most software seems to emit stereo pairs in left-right order
207 // emit(w, left.Run())
208 // emit(w, right.Run())
209 // }
210
211 // // keep the compiler happy
212 // return nil
213 // }
File: ./waveout/stdlib.go
1 /*
2 The MIT License (MIT)
3
4 Copyright (c) 2026 pacman64
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy of
7 this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights to
9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10 of the Software, and to permit persons to whom the Software is furnished to do
11 so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in all
14 copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 SOFTWARE.
23 */
24
25 package waveout
26
27 import (
28 "math"
29 "math/rand"
30
31 "../fmscripts"
32 "../mathplus"
33 )
34
35 // tau is exactly 1 loop around a circle, which is handy to turn frequencies
36 // into trigonometric angles, since they're measured in radians
37 const tau = 2 * math.Pi
38
39 // extras has funcs beyond what the script built-ins offer: those built-ins
40 // are for general math calculations, while these are specific for sound
41 // effects, other sound-related calculations, or to make pseudo-random values
42 var extras = map[string]any{
43 `hihat`: hihat,
44 }
45
46 // addDetermFuncs does what it says, ensuring these funcs are optimizable when
47 // they're given all-constant expressions as inputs
48 func addDetermFuncs() {
49 fmscripts.DefineDetFuncs(map[string]any{
50 `ascale`: mathplus.AnchoredScale,
51 `awrap`: mathplus.AnchoredWrap,
52 `clamp`: mathplus.Clamp,
53 `epa`: mathplus.Epanechnikov,
54 `epanechnikov`: mathplus.Epanechnikov,
55 `fract`: mathplus.Fract,
56 `gauss`: mathplus.Gauss,
57 `horner`: mathplus.Polyval,
58 `logistic`: mathplus.Logistic,
59 `mix`: mathplus.Mix,
60 `polyval`: mathplus.Polyval,
61 `scale`: mathplus.Scale,
62 `sign`: mathplus.Sign,
63 `sinc`: mathplus.Sinc,
64 `smoothstep`: mathplus.SmoothStep,
65 `step`: mathplus.Step,
66 `tricube`: mathplus.Tricube,
67 `unwrap`: mathplus.Unwrap,
68 `wrap`: mathplus.Wrap,
69
70 `drop`: dropsince,
71 `dropfrom`: dropsince,
72 `dropoff`: dropsince,
73 `dropsince`: dropsince,
74 `kick`: kick,
75 `kicklow`: kicklow,
76 `piano`: piano,
77 `pianokey`: piano,
78 `pickval`: pickval,
79 `pickvalue`: pickval,
80 `sched`: schedule,
81 `schedule`: schedule,
82 `timeval`: timeval,
83 `timevalues`: timeval,
84 })
85 }
86
87 // random01 returns a random value in 0 .. 1
88 func random01(r *rand.Rand) float64 {
89 return r.Float64()
90 }
91
92 // random returns a random value in -1 .. +1
93 func random(r *rand.Rand) float64 {
94 return (2 * r.Float64()) - 1
95 }
96
97 // rexp returns an exponentially-distributed random value using the scale
98 // (expected value) given
99 func rexp(r *rand.Rand, scale float64) float64 {
100 return scale * r.ExpFloat64()
101 }
102
103 // rnorm returns a normally-distributed random value using the mean and
104 // standard deviation given
105 func rnorm(r *rand.Rand, mu, sigma float64) float64 {
106 return r.NormFloat64()*sigma + mu
107 }
108
109 // make sample for a synthetic-drum kick
110 func kick(t float64, f, k float64) float64 {
111 const p = 0.085
112 return math.Sin(tau*f*math.Pow(p, t)) * math.Exp(-k*t)
113 }
114
115 // make sample for a heavier-sounding synthetic-drum kick
116 func kicklow(t float64, f, k float64) float64 {
117 const p = 0.08
118 return math.Sin(tau*f*math.Pow(p, t)) * math.Exp(-k*t)
119 }
120
121 // make sample for a synthetic hi-hat hit
122 func hihat(t float64, k float64) float64 {
123 return rand.Float64() * math.Exp(-k*t)
124 }
125
126 // schedule rearranges time, without being a time machine
127 func schedule(t float64, period, delay float64) float64 {
128 v := t + (1 - delay)
129 if v < 0 {
130 return 0
131 }
132 return math.Mod(v*period, period)
133 }
134
135 // make sample for a synthetic piano key being hit
136 func piano(t float64, n float64) float64 {
137 p := (math.Floor(n) - 49) / 12
138 f := 440 * math.Pow(2, p)
139 return math.Sin(tau * f * t)
140 }
141
142 // multiply rest of a formula with this for a quick volume drop at the end:
143 // this is handy to avoid clips when sounds end playing
144 func dropsince(t float64, start float64) float64 {
145 // return math.Min(1, math.Exp(-100*(t-start)))
146 if t <= start {
147 return 1
148 }
149 return math.Exp(-100 * (t - start))
150 }
151
152 // pickval requires at least 3 args, the first 2 being the current time and
153 // each slot's duration, respectively: these 2 are followed by all the values
154 // to pick for all time slots
155 func pickval(args ...float64) float64 {
156 if len(args) < 3 {
157 return 0
158 }
159
160 t := args[0]
161 slotdur := args[1]
162 values := args[2:]
163
164 u, _ := math.Modf(t / slotdur)
165 n := len(values)
166 i := int(u) % n
167 if 0 <= i && i < n {
168 return values[i]
169 }
170 return 0
171 }
172
173 // timeval requires at least 2 args, the first 2 being the current time and
174 // the total looping-period, respectively: these 2 are followed by pairs of
175 // numbers, each consisting of a timestamp and a matching value, in order
176 func timeval(args ...float64) float64 {
177 if len(args) < 2 {
178 return 0
179 }
180
181 t := args[0]
182 period := args[1]
183 u, _ := math.Modf(t / period)
184
185 // find the first value whose periodic timestamp is due
186 for rest := args[2:]; len(rest) >= 2; rest = rest[2:] {
187 if u >= rest[0]/period {
188 return rest[1]
189 }
190 }
191 return 0
192 }