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