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