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 }