File: nn.go 1 /* 2 The MIT License (MIT) 3 4 Copyright © 2025 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 Single-file source-code for nn: this version has no http(s) support. Even 27 the unit-tests from the original nn are omitted. 28 29 To compile a smaller-sized command-line app, you can use the `go` command as 30 follows: 31 32 go build -ldflags "-s -w" -trimpath nn.go 33 */ 34 35 package main 36 37 import ( 38 "bufio" 39 "errors" 40 "io" 41 "os" 42 "strings" 43 ) 44 45 // Note: the code is avoiding using the fmt package to save hundreds of 46 // kilobytes on the resulting executable, which is a noticeable difference. 47 48 const info = ` 49 nn [options...] [file...] 50 51 52 Nice Numbers is an app which renders the UTF-8 text it's given to make long 53 numbers much easier to read. It does so by alternating 3-digit groups which 54 are colored using ANSI-codes with plain/unstyled 3-digit groups. 55 56 Unlike the common practice of inserting commas between 3-digit groups, this 57 trick doesn't widen the original text, keeping alignments across lines the 58 same. 59 60 Input is assumed to be UTF-8, and all CRLF byte-pairs are turned into line 61 feeds. 62 63 All (optional) leading options start with either single or double-dash, 64 and most of them change the style/color used. Some of the options are, 65 shown in their single-dash form: 66 67 -h show this help message 68 -help show this help message 69 70 -b use a blue color 71 -blue use a blue color 72 -bold bold-style digits 73 -g use a green color 74 -gray use a gray color (default) 75 -green use a green color 76 -hi use a highlighting/inverse style 77 -m use a magenta color 78 -magenta use a magenta color 79 -o use an orange color 80 -orange use an orange color 81 -r use a red color 82 -red use a red color 83 -u underline digits 84 -underline underline digits 85 ` 86 87 // errNoMoreOutput is a dummy error whose message is ignored, and which 88 // causes the app to quit immediately and successfully 89 var errNoMoreOutput = errors.New(`no more output`) 90 91 func main() { 92 if len(os.Args) > 1 { 93 switch os.Args[1] { 94 case `-h`, `--h`, `-help`, `--help`: 95 os.Stderr.WriteString(info[1:]) 96 return 97 } 98 } 99 100 args := os.Args[1:] 101 style, _ := lookupStyle(`gray`) 102 103 // if the first argument is 1 or 2 dashes followed by a supported 104 // style-name, change the style used 105 if len(args) > 0 && strings.HasPrefix(args[0], `-`) { 106 name := args[0] 107 name = strings.TrimPrefix(name, `-`) 108 name = strings.TrimPrefix(name, `-`) 109 args = args[1:] 110 111 // check if the `dedashed` argument is a supported style-name 112 if s, ok := lookupStyle(name); ok { 113 style = s 114 } else { 115 os.Stderr.WriteString(`invalid style name `) 116 os.Stderr.WriteString(name) 117 os.Stderr.WriteString("\n") 118 os.Exit(1) 119 } 120 } 121 122 if err := run(os.Stdout, args, style); isActualError(err) { 123 os.Stderr.WriteString(err.Error()) 124 os.Stderr.WriteString("\n") 125 os.Exit(1) 126 } 127 } 128 129 func run(w io.Writer, args []string, style []byte) error { 130 bw := bufio.NewWriter(w) 131 defer bw.Flush() 132 133 if len(args) == 0 { 134 return restyle(bw, os.Stdin, style) 135 } 136 137 for _, name := range args { 138 if err := handleFile(bw, name, style); err != nil { 139 return err 140 } 141 } 142 return nil 143 } 144 145 func handleFile(w *bufio.Writer, name string, style []byte) error { 146 if name == `` || name == `-` { 147 return restyle(w, os.Stdin, style) 148 } 149 150 f, err := os.Open(name) 151 if err != nil { 152 return errors.New(`can't read from file named "` + name + `"`) 153 } 154 defer f.Close() 155 156 return restyle(w, f, style) 157 } 158 159 // isActualError is to figure out whether not to ignore an error, and thus 160 // show it as an error message 161 func isActualError(err error) bool { 162 return err != nil && err != io.EOF && err != errNoMoreOutput 163 } 164 165 func restyle(w *bufio.Writer, r io.Reader, style []byte) error { 166 const gb = 1024 * 1024 * 1024 167 sc := bufio.NewScanner(r) 168 sc.Buffer(nil, 8*gb) 169 170 for sc.Scan() { 171 restyleLine(w, sc.Bytes(), style) 172 w.WriteByte('\n') 173 if err := w.Flush(); err != nil { 174 // a write error may be the consequence of stdout being closed, 175 // perhaps by another app along a pipe 176 return errNoMoreOutput 177 } 178 } 179 return sc.Err() 180 } 181 182 func lookupStyle(name string) (style []byte, ok bool) { 183 if alias, ok := styleAliases[name]; ok { 184 name = alias 185 } 186 187 style, ok = styles[name] 188 return style, ok 189 } 190 191 var styleAliases = map[string]string{ 192 `b`: `blue`, 193 `g`: `green`, 194 `m`: `magenta`, 195 `o`: `orange`, 196 `p`: `purple`, 197 `r`: `red`, 198 `u`: `underline`, 199 200 `bolded`: `bold`, 201 `h`: `inverse`, 202 `hi`: `inverse`, 203 `highlight`: `inverse`, 204 `highlighted`: `inverse`, 205 `hilite`: `inverse`, 206 `hilited`: `inverse`, 207 `inv`: `inverse`, 208 `invert`: `inverse`, 209 `inverted`: `inverse`, 210 `underlined`: `underline`, 211 212 `bb`: `blueback`, 213 `bg`: `greenback`, 214 `bm`: `magentaback`, 215 `bo`: `orangeback`, 216 `bp`: `purpleback`, 217 `br`: `redback`, 218 219 `gb`: `greenback`, 220 `mb`: `magentaback`, 221 `ob`: `orangeback`, 222 `pb`: `purpleback`, 223 `rb`: `redback`, 224 225 `bblue`: `blueback`, 226 `bgray`: `grayback`, 227 `bgreen`: `greenback`, 228 `bmagenta`: `magentaback`, 229 `borange`: `orangeback`, 230 `bpurple`: `purpleback`, 231 `bred`: `redback`, 232 233 `backblue`: `blueback`, 234 `backgray`: `grayback`, 235 `backgreen`: `greenback`, 236 `backmagenta`: `magentaback`, 237 `backorange`: `orangeback`, 238 `backpurple`: `purpleback`, 239 `backred`: `redback`, 240 } 241 242 // styles turns style-names into the ANSI-code sequences used for the 243 // alternate groups of digits 244 var styles = map[string][]byte{ 245 `blue`: []byte("\x1b[38;2;0;95;215m"), 246 `bold`: []byte("\x1b[1m"), 247 `gray`: []byte("\x1b[38;2;168;168;168m"), 248 `green`: []byte("\x1b[38;2;0;135;95m"), 249 `inverse`: []byte("\x1b[7m"), 250 `magenta`: []byte("\x1b[38;2;215;0;255m"), 251 `orange`: []byte("\x1b[38;2;215;95;0m"), 252 `plain`: []byte("\x1b[0m"), 253 `red`: []byte("\x1b[38;2;204;0;0m"), 254 `underline`: []byte("\x1b[4m"), 255 256 // `blue`: []byte("\x1b[38;5;26m"), 257 // `bold`: []byte("\x1b[1m"), 258 // `gray`: []byte("\x1b[38;5;248m"), 259 // `green`: []byte("\x1b[38;5;29m"), 260 // `inverse`: []byte("\x1b[7m"), 261 // `magenta`: []byte("\x1b[38;5;99m"), 262 // `orange`: []byte("\x1b[38;5;166m"), 263 // `plain`: []byte("\x1b[0m"), 264 // `red`: []byte("\x1b[31m"), 265 // `underline`: []byte("\x1b[4m"), 266 267 `blueback`: []byte("\x1b[48;2;0;95;215m\x1b[38;2;238;238;238m"), 268 `grayback`: []byte("\x1b[48;2;168;168;168m\x1b[38;2;238;238;238m"), 269 `greenback`: []byte("\x1b[48;2;0;135;95m\x1b[38;2;238;238;238m"), 270 `magentaback`: []byte("\x1b[48;2;215;0;255m\x1b[38;2;238;238;238m"), 271 `orangeback`: []byte("\x1b[48;2;215;95;0m\x1b[38;2;238;238;238m"), 272 `purpleback`: []byte("\x1b[48;2;135;95;255m\x1b[38;2;238;238;238m"), 273 `redback`: []byte("\x1b[48;2;204;0;0m\x1b[38;2;238;238;238m"), 274 } 275 276 // restyleLine renders the line given, using ANSI-styles to make any long 277 // numbers in it more legible; this func doesn't emit a line-feed, which 278 // is up to its caller 279 func restyleLine(w *bufio.Writer, line []byte, style []byte) { 280 for len(line) > 0 { 281 i := indexDigit(line) 282 if i < 0 { 283 // no (more) digits to style for sure 284 w.Write(line) 285 return 286 } 287 288 // emit line before current digit-run 289 w.Write(line[:i]) 290 // advance to the start of the current digit-run 291 line = line[i:] 292 293 // see where the digit-run ends 294 j := indexNonDigit(line) 295 if j < 0 { 296 // the digit-run goes until the end 297 restyleDigits(w, line, style) 298 return 299 } 300 301 // emit styled digit-run 302 restyleDigits(w, line[:j], style) 303 // skip right past the end of the digit-run 304 line = line[j:] 305 } 306 } 307 308 // indexDigit finds the index of the first digit in a string, or -1 when the 309 // string has no decimal digits 310 func indexDigit(s []byte) int { 311 for i := 0; i < len(s); i++ { 312 switch s[i] { 313 case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': 314 return i 315 } 316 } 317 318 // empty slice, or a slice without any digits 319 return -1 320 } 321 322 // indexNonDigit finds the index of the first non-digit in a string, or -1 323 // when the string is all decimal digits 324 func indexNonDigit(s []byte) int { 325 for i := 0; i < len(s); i++ { 326 switch s[i] { 327 case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': 328 continue 329 default: 330 return i 331 } 332 } 333 334 // empty slice, or a slice which only has digits 335 return -1 336 } 337 338 // restyleDigits renders a run of digits as alternating styled/unstyled runs 339 // of 3 digits, which greatly improves readability, and is the only purpose 340 // of this app; string is assumed to be all decimal digits 341 func restyleDigits(w *bufio.Writer, digits []byte, altStyle []byte) { 342 if len(digits) < 4 { 343 // digit sequence is short, so emit it as is 344 w.Write(digits) 345 return 346 } 347 348 // separate leading 0..2 digits which don't align with the 3-digit groups 349 i := len(digits) % 3 350 // emit leading digits unstyled, if there are any 351 w.Write(digits[:i]) 352 // the rest is guaranteed to have a length which is a multiple of 3 353 digits = digits[i:] 354 355 // start by styling, unless there were no leading digits 356 style := i != 0 357 358 for len(digits) > 0 { 359 if style { 360 w.Write(altStyle) 361 w.Write(digits[:3]) 362 w.Write([]byte{'\x1b', '[', '0', 'm'}) 363 } else { 364 w.Write(digits[:3]) 365 } 366 367 // advance to the next triple: the start of this func is supposed 368 // to guarantee this step always works 369 digits = digits[3:] 370 371 // alternate between styled and unstyled 3-digit groups 372 style = !style 373 } 374 }