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 }