File: nn/go.mod 1 module nn 2 3 go 1.18 File: nn/info.txt 1 nn [options...] [filenames...] 2 3 Nice Numbers is an app which renders the UTF-8 text it's given to make long 4 numbers much easier to read. It does so by alternating 3-digit groups which 5 are colored using ANSI-codes with plain/unstyled 3-digit groups. 6 7 Unlike the common practice of inserting commas between 3-digit groups, this 8 trick doesn't widen the original text, keeping alignments across lines the 9 same. 10 11 When not given filenames, it just reads from stdin; when given filenames, 12 a dash means use stdin, along with the other files. 13 14 Input is assumed to be UTF-8, and all CRLF byte-pairs are turned into line 15 feeds. 16 17 All (optional) leading options start with either single or double-dash, 18 and most of them change the style/color used. Some of the options are, 19 shown in their single-dash form: 20 21 -h show this help message 22 -help show this help message 23 24 -b use a blue color 25 -blue use a blue color 26 -bold bold-style digits 27 -g use a green color 28 -gray use a gray color (default) 29 -green use a green color 30 -hi use a highlighting/inverse style 31 -m use a magenta color 32 -magenta use a magenta color 33 -o use an orange color 34 -orange use an orange color 35 -r use a red color 36 -red use a red color 37 -u underline digits 38 -underline underline digits File: nn/main.go 1 package main 2 3 import ( 4 "bufio" 5 "errors" 6 "io" 7 "os" 8 "strings" 9 10 _ "embed" 11 ) 12 13 // Note: the code is avoiding using the fmt package to save hundreds of 14 // kilobytes on the resulting executable, which is a noticeable difference. 15 16 //go:embed info.txt 17 var info string 18 19 // errNoMoreOutput is a dummy error, whose message is ignored, and which 20 // causes the app to quit immediately and successfully 21 var errNoMoreOutput = errors.New(`no more output`) 22 23 func main() { 24 if len(os.Args) > 1 { 25 switch os.Args[1] { 26 case `-h`, `--h`, `-help`, `--help`: 27 os.Stderr.WriteString(info) 28 return 29 } 30 } 31 32 args := os.Args[1:] 33 style := styles[`gray`] 34 35 // if the first argument is 1 or 2 dashes followed by a supported 36 // style-name, change the style used 37 if len(args) > 0 && strings.HasPrefix(args[0], `-`) { 38 name := args[0] 39 name = strings.TrimPrefix(name, `-`) 40 name = strings.TrimPrefix(name, `-`) 41 42 // check if the `dedashed` argument is a supported style-name 43 if s, ok := styles[name]; ok { 44 style = s 45 // argument isn't a filepath, so ignore it 46 args = args[1:] 47 } 48 } 49 50 if err := run(args, style); err != nil && err != errNoMoreOutput { 51 os.Stderr.WriteString("\x1b[31m") 52 os.Stderr.WriteString(err.Error()) 53 os.Stderr.WriteString("\x1b[0m\n") 54 os.Exit(1) 55 } 56 } 57 58 // run handles all inputs, whether named or implied, for func main 59 func run(paths []string, style []byte) error { 60 // prevent trying to use stdin more than once 61 dashes := false 62 for _, path := range paths { 63 if path == `-` { 64 if dashes { 65 return errors.New(`can't use stdin more than once`) 66 } 67 dashes = true 68 } 69 } 70 71 w := bufio.NewWriter(os.Stdout) 72 defer w.Flush() 73 74 // just use stdin when not given any filenames 75 if len(paths) == 0 { 76 return handleFile(w, `-`, style) 77 } 78 79 for _, path := range paths { 80 if err := handleFile(w, path, style); err != nil { 81 return err 82 } 83 } 84 return nil 85 } 86 87 // handleFile is just a file-handling wrapper of func handleInput 88 func handleFile(w *bufio.Writer, path string, style []byte) error { 89 if path == `-` { 90 return handleInput(w, os.Stdin, style) 91 } 92 93 // if f := strings.HasPrefix; f(path, `https://`) || f(path, `http://`) { 94 // resp, err := http.Get(path) 95 // if err != nil { 96 // return err 97 // } 98 // defer resp.Body.Close() 99 // return handleInput(w, resp.Body, style) 100 // } 101 102 f, err := os.Open(path) 103 if err != nil { 104 // on windows, file-not-found error messages may mention `CreateFile`, 105 // even when trying to open files in read-only mode 106 return errors.New(`can't open file named ` + path) 107 } 108 defer f.Close() 109 return handleInput(w, f, style) 110 } 111 112 // handleInput simplifies control-flow in func handleFile 113 func handleInput(w *bufio.Writer, r io.Reader, style []byte) error { 114 const gb = 1024 * 1024 * 1024 115 sc := bufio.NewScanner(r) 116 sc.Buffer(nil, 8*gb) 117 118 for sc.Scan() { 119 restyleLine(w, sc.Bytes(), style) 120 if err := w.WriteByte('\n'); err != nil { 121 // a write error may be the consequence of stdout being closed, 122 // perhaps by another app along a pipe 123 return errNoMoreOutput 124 } 125 } 126 return sc.Err() 127 } File: nn/mit-license.txt 1 The MIT License (MIT) 2 3 Copyright © 2020-2025 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: nn/strings.go 1 package main 2 3 import ( 4 "bufio" 5 ) 6 7 // styles turns style-names into the ANSI-code sequences used for the 8 // alternate groups of digits 9 var styles = map[string][]byte{ 10 `b`: []byte("\x1b[38;5;26m"), // same as `blue` 11 `g`: []byte("\x1b[38;5;29m"), // same as `green` 12 `m`: []byte("\x1b[38;5;99m"), // same as `magenta` 13 `o`: []byte("\x1b[38;5;166m"), // same as `orange` 14 `r`: []byte("\x1b[31m"), // same as `red` 15 `u`: []byte("\x1b[4m"), // same as `underline` 16 17 `blue`: []byte("\x1b[38;5;26m"), 18 `bold`: []byte("\x1b[1m"), 19 `gray`: []byte("\x1b[38;5;248m"), 20 `green`: []byte("\x1b[38;5;29m"), 21 `inverse`: []byte("\x1b[7m"), 22 `magenta`: []byte("\x1b[38;5;99m"), 23 `orange`: []byte("\x1b[38;5;166m"), 24 `plain`: []byte("\x1b[0m"), 25 `red`: []byte("\x1b[31m"), 26 `underline`: []byte("\x1b[4m"), 27 } 28 29 // restyleLine renders the line given, using ANSI-styles to make any long 30 // numbers in it more legible; this func doesn't emit a line-feed, which 31 // is up to its caller 32 func restyleLine(w *bufio.Writer, line []byte, style []byte) { 33 for len(line) > 0 { 34 i := indexDigit(line) 35 if i < 0 { 36 // no (more) digits to style for sure 37 w.Write(line) 38 return 39 } 40 41 // some ANSI-style sequences use 4-digit numbers, which are long 42 // enough for this app to mangle 43 isANSI := i >= 2 && line[i-2] == '\x1b' && line[i-1] == '[' 44 45 // emit line before current digit-run 46 w.Write(line[:i]) 47 // advance to the start of the current digit-run 48 line = line[i:] 49 50 // see where the digit-run ends 51 j := indexNonDigit(line) 52 if j < 0 { 53 // the digit-run goes until the end 54 if !isANSI { 55 restyleDigits(w, line, style) 56 } else { 57 w.Write(line) 58 } 59 return 60 } 61 62 // emit styled digit-run... maybe 63 if !isANSI { 64 restyleDigits(w, line[:j], style) 65 } else { 66 w.Write(line[:j]) 67 } 68 69 // skip right past the end of the digit-run 70 line = line[j:] 71 } 72 } 73 74 // indexDigit finds the index of the first digit in a string, or -1 when the 75 // string has no decimal digits 76 func indexDigit(s []byte) int { 77 for i := 0; i < len(s); i++ { 78 switch s[i] { 79 case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': 80 return i 81 } 82 } 83 84 // empty slice, or a slice without any digits 85 return -1 86 } 87 88 // indexNonDigit finds the index of the first non-digit in a string, or -1 89 // when the string is all decimal digits 90 func indexNonDigit(s []byte) int { 91 for i := 0; i < len(s); i++ { 92 switch s[i] { 93 case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': 94 continue 95 default: 96 return i 97 } 98 } 99 100 // empty slice, or a slice which only has digits 101 return -1 102 } 103 104 // restyleDigits renders a run of digits as alternating styled/unstyled runs 105 // of 3 digits, which greatly improves readability, and is the only purpose 106 // of this app; string is assumed to be all decimal digits 107 func restyleDigits(w *bufio.Writer, digits []byte, altStyle []byte) { 108 if len(digits) < 4 { 109 // digit sequence is short, so emit it as is 110 w.Write(digits) 111 return 112 } 113 114 // separate leading 0..2 digits which don't align with the 3-digit groups 115 i := len(digits) % 3 116 // emit leading digits unstyled, if there are any 117 w.Write(digits[:i]) 118 // the rest is guaranteed to have a length which is a multiple of 3 119 digits = digits[i:] 120 121 // start by styling, unless there were no leading digits 122 style := i != 0 123 124 for len(digits) > 0 { 125 if style { 126 w.Write(altStyle) 127 w.Write(digits[:3]) 128 w.Write([]byte{'\x1b', '[', '0', 'm'}) 129 } else { 130 w.Write(digits[:3]) 131 } 132 133 // advance to the next triple: the start of this func is supposed 134 // to guarantee this step always works 135 digits = digits[3:] 136 137 // alternate between styled and unstyled 3-digit groups 138 style = !style 139 } 140 } File: nn/strings_test.go 1 package main 2 3 import ( 4 "bufio" 5 "strings" 6 "testing" 7 ) 8 9 func TestRestyleLine(t *testing.T) { 10 var ( 11 r = "\x1b[0m" 12 d = string(styles[`gray`]) 13 ) 14 15 var tests = []struct { 16 Input string 17 Expected string 18 }{ 19 {``, ``}, 20 {`abc`, `abc`}, 21 {` abc 123456 `, ` abc 123` + d + `456` + r + ` `}, 22 {` 123456789 text`, ` 123` + d + `456` + r + `789 text`}, 23 24 {`0`, `0`}, 25 {`01`, `01`}, 26 {`012`, `012`}, 27 {`0123`, `0` + d + `123` + r}, 28 {`01234`, `01` + d + `234` + r}, 29 {`012345`, `012` + d + `345` + r}, 30 {`0123456`, `0` + d + `123` + r + `456`}, 31 {`01234567`, `01` + d + `234` + r + `567`}, 32 {`012345678`, `012` + d + `345` + r + `678`}, 33 {`0123456789`, `0` + d + `123` + r + `456` + d + `789` + r}, 34 {`01234567890`, `01` + d + `234` + r + `567` + d + `890` + r}, 35 {`012345678901`, `012` + d + `345` + r + `678` + d + `901` + r}, 36 {`0123456789012`, `0` + d + `123` + r + `456` + d + `789` + r + `012`}, 37 38 {`00321`, `00` + d + `321` + r}, 39 {`123.456789`, `123.` + `456` + d + `789` + r}, 40 {`123456.123456`, `123` + d + `456` + r + `.` + `123` + d + `456` + r}, 41 } 42 43 for _, tc := range tests { 44 t.Run(tc.Input, func(t *testing.T) { 45 var b strings.Builder 46 w := bufio.NewWriter(&b) 47 restyleLine(w, []byte(tc.Input), []byte(d)) 48 w.Flush() 49 50 if got := b.String(); got != tc.Expected { 51 t.Fatalf(`expected %q, but got %q instead`, tc.Expected, got) 52 } 53 }) 54 } 55 }