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 w.WriteByte('\n') 176 if err := w.Flush(); err != nil { 177 // a write error may be the consequence of stdout being closed, 178 // perhaps by another app along a pipe 179 return errNoMoreOutput 180 } 181 } 182 return sc.Err() 183 } 184 185 func lookupStyle(name string) (style []byte, ok bool) { 186 if alias, ok := styleAliases[name]; ok { 187 name = alias 188 } 189 190 style, ok = styles[name] 191 return style, ok 192 } 193 194 var styleAliases = map[string]string{ 195 `b`: `blue`, 196 `g`: `green`, 197 `m`: `magenta`, 198 `o`: `orange`, 199 `p`: `purple`, 200 `r`: `red`, 201 `u`: `underline`, 202 203 `bolded`: `bold`, 204 `h`: `inverse`, 205 `hi`: `inverse`, 206 `highlight`: `inverse`, 207 `highlighted`: `inverse`, 208 `hilite`: `inverse`, 209 `hilited`: `inverse`, 210 `inv`: `inverse`, 211 `invert`: `inverse`, 212 `inverted`: `inverse`, 213 `underlined`: `underline`, 214 215 `bb`: `blueback`, 216 `bg`: `greenback`, 217 `bm`: `magentaback`, 218 `bo`: `orangeback`, 219 `bp`: `purpleback`, 220 `br`: `redback`, 221 222 `gb`: `greenback`, 223 `mb`: `magentaback`, 224 `ob`: `orangeback`, 225 `pb`: `purpleback`, 226 `rb`: `redback`, 227 228 `bblue`: `blueback`, 229 `bgray`: `grayback`, 230 `bgreen`: `greenback`, 231 `bmagenta`: `magentaback`, 232 `borange`: `orangeback`, 233 `bpurple`: `purpleback`, 234 `bred`: `redback`, 235 236 `backblue`: `blueback`, 237 `backgray`: `grayback`, 238 `backgreen`: `greenback`, 239 `backmagenta`: `magentaback`, 240 `backorange`: `orangeback`, 241 `backpurple`: `purpleback`, 242 `backred`: `redback`, 243 } 244 245 // styles turns style-names into the ANSI-code sequences used for the 246 // alternate groups of digits 247 var styles = map[string][]byte{ 248 `blue`: []byte("\x1b[38;2;0;95;215m"), 249 `bold`: []byte("\x1b[1m"), 250 `gray`: []byte("\x1b[38;2;168;168;168m"), 251 `green`: []byte("\x1b[38;2;0;135;95m"), 252 `inverse`: []byte("\x1b[7m"), 253 `magenta`: []byte("\x1b[38;2;215;0;255m"), 254 `orange`: []byte("\x1b[38;2;215;95;0m"), 255 `plain`: []byte("\x1b[0m"), 256 `red`: []byte("\x1b[38;2;204;0;0m"), 257 `underline`: []byte("\x1b[4m"), 258 259 // `blue`: []byte("\x1b[38;5;26m"), 260 // `bold`: []byte("\x1b[1m"), 261 // `gray`: []byte("\x1b[38;5;248m"), 262 // `green`: []byte("\x1b[38;5;29m"), 263 // `inverse`: []byte("\x1b[7m"), 264 // `magenta`: []byte("\x1b[38;5;99m"), 265 // `orange`: []byte("\x1b[38;5;166m"), 266 // `plain`: []byte("\x1b[0m"), 267 // `red`: []byte("\x1b[31m"), 268 // `underline`: []byte("\x1b[4m"), 269 270 `blueback`: []byte("\x1b[48;2;0;95;215m\x1b[38;2;238;238;238m"), 271 `grayback`: []byte("\x1b[48;2;168;168;168m\x1b[38;2;238;238;238m"), 272 `greenback`: []byte("\x1b[48;2;0;135;95m\x1b[38;2;238;238;238m"), 273 `magentaback`: []byte("\x1b[48;2;215;0;255m\x1b[38;2;238;238;238m"), 274 `orangeback`: []byte("\x1b[48;2;215;95;0m\x1b[38;2;238;238;238m"), 275 `purpleback`: []byte("\x1b[48;2;135;95;255m\x1b[38;2;238;238;238m"), 276 `redback`: []byte("\x1b[48;2;204;0;0m\x1b[38;2;238;238;238m"), 277 } 278 279 // restyleLine renders the line given, using ANSI-styles to make any long 280 // numbers in it more legible; this func doesn't emit a line-feed, which 281 // is up to its caller 282 func restyleLine(w *bufio.Writer, line []byte, style []byte) { 283 for len(line) > 0 { 284 i := indexDigit(line) 285 if i < 0 { 286 // no (more) digits to style for sure 287 w.Write(line) 288 return 289 } 290 291 // emit line before current digit-run 292 w.Write(line[:i]) 293 // advance to the start of the current digit-run 294 line = line[i:] 295 296 // see where the digit-run ends 297 j := indexNonDigit(line) 298 if j < 0 { 299 // the digit-run goes until the end 300 restyleDigits(w, line, style) 301 return 302 } 303 304 // emit styled digit-run 305 restyleDigits(w, line[:j], style) 306 // skip right past the end of the digit-run 307 line = line[j:] 308 } 309 } 310 311 // indexDigit finds the index of the first digit in a string, or -1 when the 312 // string has no decimal digits 313 func indexDigit(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 return i 318 } 319 } 320 321 // empty slice, or a slice without any digits 322 return -1 323 } 324 325 // indexNonDigit finds the index of the first non-digit in a string, or -1 326 // when the string is all decimal digits 327 func indexNonDigit(s []byte) int { 328 for i := 0; i < len(s); i++ { 329 switch s[i] { 330 case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': 331 continue 332 default: 333 return i 334 } 335 } 336 337 // empty slice, or a slice which only has digits 338 return -1 339 } 340 341 // restyleDigits renders a run of digits as alternating styled/unstyled runs 342 // of 3 digits, which greatly improves readability, and is the only purpose 343 // of this app; string is assumed to be all decimal digits 344 func restyleDigits(w *bufio.Writer, digits []byte, altStyle []byte) { 345 if len(digits) < 4 { 346 // digit sequence is short, so emit it as is 347 w.Write(digits) 348 return 349 } 350 351 // separate leading 0..2 digits which don't align with the 3-digit groups 352 i := len(digits) % 3 353 // emit leading digits unstyled, if there are any 354 w.Write(digits[:i]) 355 // the rest is guaranteed to have a length which is a multiple of 3 356 digits = digits[i:] 357 358 // start by styling, unless there were no leading digits 359 style := i != 0 360 361 for len(digits) > 0 { 362 if style { 363 w.Write(altStyle) 364 w.Write(digits[:3]) 365 w.Write([]byte{'\x1b', '[', '0', 'm'}) 366 } else { 367 w.Write(digits[:3]) 368 } 369 370 // advance to the next triple: the start of this func is supposed 371 // to guarantee this step always works 372 digits = digits[3:] 373 374 // alternate between styled and unstyled 3-digit groups 375 style = !style 376 } 377 }