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