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     "bytes"
  40     "errors"
  41     "io"
  42     "os"
  43     "strings"
  44 )
  45 
  46 const info = `
  47 nn [options...] [file...]
  48 
  49 
  50 Nice Numbers is an app which renders the UTF-8 text it's given to make long
  51 numbers much easier to read. It does so by alternating 3-digit groups which
  52 are colored using ANSI-codes with plain/unstyled 3-digit groups.
  53 
  54 Unlike the common practice of inserting commas between 3-digit groups, this
  55 trick doesn't widen the original text, keeping alignments across lines the
  56 same.
  57 
  58 Input is assumed to be UTF-8, and all CRLF byte-pairs are turned into line
  59 feeds.
  60 
  61 All (optional) leading options start with either single or double-dash,
  62 and most of them change the style/color used. Some of the options are,
  63 shown in their single-dash form:
  64 
  65     -h, -help    show this help message
  66 
  67     -b          use a blue color
  68     -blue       use a blue color
  69     -bold       bold-style digits
  70     -g          use a green color
  71     -gray       use a gray color (default)
  72     -green      use a green color
  73     -hi         use a highlighting/inverse style
  74     -m          use a magenta color
  75     -magenta    use a magenta color
  76     -o          use an orange color
  77     -orange     use an orange color
  78     -r          use a red color
  79     -red        use a red color
  80     -u          underline digits
  81     -underline  underline digits
  82 `
  83 
  84 func main() {
  85     args := os.Args[1:]
  86 
  87     if len(args) > 0 {
  88         switch args[0] {
  89         case `-h`, `--h`, `-help`, `--help`:
  90             os.Stdout.WriteString(info[1:])
  91             return
  92         }
  93     }
  94 
  95     options := true
  96     if len(args) > 0 && args[0] == `--` {
  97         options = false
  98         args = args[1:]
  99     }
 100 
 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 options && 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             return
 120         }
 121     }
 122 
 123     if err := run(os.Stdout, args, style); err != nil && err != io.EOF {
 124         os.Stderr.WriteString(err.Error())
 125         os.Stderr.WriteString("\n")
 126         os.Exit(1)
 127         return
 128     }
 129 }
 130 
 131 func run(w io.Writer, args []string, style string) error {
 132     bw := bufio.NewWriter(w)
 133     defer bw.Flush()
 134 
 135     if len(args) == 0 {
 136         return restyle(bw, os.Stdin, style)
 137     }
 138 
 139     for _, name := range args {
 140         if err := handleFile(bw, name, style); err != nil {
 141             return err
 142         }
 143     }
 144     return nil
 145 }
 146 
 147 func handleFile(w *bufio.Writer, name string, style string) error {
 148     if name == `` || name == `-` {
 149         return restyle(w, os.Stdin, style)
 150     }
 151 
 152     f, err := os.Open(name)
 153     if err != nil {
 154         return errors.New(`can't read from file named "` + name + `"`)
 155     }
 156     defer f.Close()
 157 
 158     return restyle(w, f, style)
 159 }
 160 
 161 func restyle(w *bufio.Writer, r io.Reader, style string) error {
 162     const gb = 1024 * 1024 * 1024
 163     sc := bufio.NewScanner(r)
 164     sc.Buffer(nil, 8*gb)
 165 
 166     for i := 0; sc.Scan(); i++ {
 167         s := sc.Bytes()
 168         if i == 0 && bytes.HasPrefix(s, []byte{0xef, 0xbb, 0xbf}) {
 169             s = s[3:]
 170         }
 171 
 172         restyleLine(w, s, style)
 173         w.WriteByte('\n')
 174         if err := w.Flush(); err != nil {
 175             // a write error may be the consequence of stdout being closed,
 176             // perhaps by another app along a pipe
 177             return io.EOF
 178         }
 179     }
 180     return sc.Err()
 181 }
 182 
 183 func lookupStyle(name string) (style string, ok bool) {
 184     if alias, ok := styleAliases[name]; ok {
 185         name = alias
 186     }
 187 
 188     style, ok = styles[name]
 189     return style, ok
 190 }
 191 
 192 var styleAliases = map[string]string{
 193     `b`: `blue`,
 194     `g`: `green`,
 195     `m`: `magenta`,
 196     `o`: `orange`,
 197     `p`: `purple`,
 198     `r`: `red`,
 199     `u`: `underline`,
 200 
 201     `bolded`:      `bold`,
 202     `h`:           `inverse`,
 203     `hi`:          `inverse`,
 204     `highlight`:   `inverse`,
 205     `highlighted`: `inverse`,
 206     `hilite`:      `inverse`,
 207     `hilited`:     `inverse`,
 208     `inv`:         `inverse`,
 209     `invert`:      `inverse`,
 210     `inverted`:    `inverse`,
 211     `underlined`:  `underline`,
 212 
 213     `bb`: `blueback`,
 214     `bg`: `greenback`,
 215     `bm`: `magentaback`,
 216     `bo`: `orangeback`,
 217     `bp`: `purpleback`,
 218     `br`: `redback`,
 219 
 220     `gb`: `greenback`,
 221     `mb`: `magentaback`,
 222     `ob`: `orangeback`,
 223     `pb`: `purpleback`,
 224     `rb`: `redback`,
 225 
 226     `bblue`:    `blueback`,
 227     `bgray`:    `grayback`,
 228     `bgreen`:   `greenback`,
 229     `bmagenta`: `magentaback`,
 230     `borange`:  `orangeback`,
 231     `bpurple`:  `purpleback`,
 232     `bred`:     `redback`,
 233 
 234     `backblue`:    `blueback`,
 235     `backgray`:    `grayback`,
 236     `backgreen`:   `greenback`,
 237     `backmagenta`: `magentaback`,
 238     `backorange`:  `orangeback`,
 239     `backpurple`:  `purpleback`,
 240     `backred`:     `redback`,
 241 }
 242 
 243 // styles turns style-names into the ANSI-code sequences used for the
 244 // alternate groups of digits
 245 var styles = map[string]string{
 246     `blue`:      "\x1b[38;2;0;95;215m",
 247     `bold`:      "\x1b[1m",
 248     `gray`:      "\x1b[38;2;168;168;168m",
 249     `green`:     "\x1b[38;2;0;135;95m",
 250     `inverse`:   "\x1b[7m",
 251     `magenta`:   "\x1b[38;2;215;0;255m",
 252     `orange`:    "\x1b[38;2;215;95;0m",
 253     `plain`:     "\x1b[0m",
 254     `red`:       "\x1b[38;2;204;0;0m",
 255     `underline`: "\x1b[4m",
 256 
 257     // `blue`:      "\x1b[38;5;26m",
 258     // `bold`:      "\x1b[1m",
 259     // `gray`:      "\x1b[38;5;248m",
 260     // `green`:     "\x1b[38;5;29m",
 261     // `inverse`:   "\x1b[7m",
 262     // `magenta`:   "\x1b[38;5;99m",
 263     // `orange`:    "\x1b[38;5;166m",
 264     // `plain`:     "\x1b[0m",
 265     // `red`:       "\x1b[31m",
 266     // `underline`: "\x1b[4m",
 267 
 268     `blueback`:    "\x1b[48;2;0;95;215m\x1b[38;2;238;238;238m",
 269     `grayback`:    "\x1b[48;2;168;168;168m\x1b[38;2;238;238;238m",
 270     `greenback`:   "\x1b[48;2;0;135;95m\x1b[38;2;238;238;238m",
 271     `magentaback`: "\x1b[48;2;215;0;255m\x1b[38;2;238;238;238m",
 272     `orangeback`:  "\x1b[48;2;215;95;0m\x1b[38;2;238;238;238m",
 273     `purpleback`:  "\x1b[48;2;135;95;255m\x1b[38;2;238;238;238m",
 274     `redback`:     "\x1b[48;2;204;0;0m\x1b[38;2;238;238;238m",
 275 }
 276 
 277 // restyleLine renders the line given, using ANSI-styles to make any long
 278 // numbers in it more legible; this func doesn't emit a line-feed, which
 279 // is up to its caller
 280 func restyleLine(w *bufio.Writer, line []byte, style string) {
 281     for len(line) > 0 {
 282         i := indexDigit(line)
 283         if i < 0 {
 284             // no (more) digits to style for sure
 285             w.Write(line)
 286             return
 287         }
 288 
 289         // emit line before current digit-run
 290         w.Write(line[:i])
 291         // advance to the start of the current digit-run
 292         line = line[i:]
 293 
 294         // see where the digit-run ends
 295         j := indexNonDigit(line)
 296         if j < 0 {
 297             // the digit-run goes until the end
 298             restyleDigits(w, line, style)
 299             return
 300         }
 301 
 302         // emit styled digit-run
 303         restyleDigits(w, line[:j], style)
 304         // skip right past the end of the digit-run
 305         line = line[j:]
 306     }
 307 }
 308 
 309 // indexDigit finds the index of the first digit in a string, or -1 when the
 310 // string has no decimal digits
 311 func indexDigit(s []byte) int {
 312     for i := 0; i < len(s); i++ {
 313         switch s[i] {
 314         case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
 315             return i
 316         }
 317     }
 318 
 319     // empty slice, or a slice without any digits
 320     return -1
 321 }
 322 
 323 // indexNonDigit finds the index of the first non-digit in a string, or -1
 324 // when the string is all decimal digits
 325 func indexNonDigit(s []byte) int {
 326     for i := 0; i < len(s); i++ {
 327         switch s[i] {
 328         case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
 329             continue
 330         default:
 331             return i
 332         }
 333     }
 334 
 335     // empty slice, or a slice which only has digits
 336     return -1
 337 }
 338 
 339 // restyleDigits renders a run of digits as alternating styled/unstyled runs
 340 // of 3 digits, which greatly improves readability, and is the only purpose
 341 // of this app; string is assumed to be all decimal digits
 342 func restyleDigits(w *bufio.Writer, digits []byte, altStyle string) {
 343     if len(digits) < 4 {
 344         // digit sequence is short, so emit it as is
 345         w.Write(digits)
 346         return
 347     }
 348 
 349     // separate leading 0..2 digits which don't align with the 3-digit groups
 350     i := len(digits) % 3
 351     // emit leading digits unstyled, if there are any
 352     w.Write(digits[:i])
 353     // the rest is guaranteed to have a length which is a multiple of 3
 354     digits = digits[i:]
 355 
 356     // start by styling, unless there were no leading digits
 357     style := i != 0
 358 
 359     for len(digits) > 0 {
 360         if style {
 361             w.WriteString(altStyle)
 362             w.Write(digits[:3])
 363             w.Write([]byte{'\x1b', '[', '0', 'm'})
 364         } else {
 365             w.Write(digits[:3])
 366         }
 367 
 368         // advance to the next triple: the start of this func is supposed
 369         // to guarantee this step always works
 370         digits = digits[3:]
 371 
 372         // alternate between styled and unstyled 3-digit groups
 373         style = !style
 374     }
 375 }