File: ./go.mod
   1 module nn
   2 
   3 go 1.18

     File: ./info.txt
   1 nn [options...] [filenames...]
   2 
   3 Nice Numbers is an app which renders the UTF-8 text it's given to make long
   4 numbers much easier to read. It does so by alternating 3-digit groups which
   5 are colored using ANSI-codes with plain/unstyled 3-digit groups.
   6 
   7 Unlike the common practice of inserting commas between 3-digit groups, this
   8 trick doesn't widen the original text, keeping alignments across lines the
   9 same.
  10 
  11 When not given filenames, it just reads from stdin; when given filenames,
  12 a dash means use stdin, along with the other files.
  13 
  14 Input is assumed to be UTF-8, and all CRLF byte-pairs are turned into line
  15 feeds.
  16 
  17 All (optional) leading options start with either single or double-dash,
  18 and most of them change the style/color used. Some of the options are,
  19 shown in their single-dash form:
  20 
  21     -h          show this help message
  22     -help       show this help message
  23 
  24     -b          use a blue color
  25     -blue       use a blue color
  26     -bold       bold-style digits
  27     -g          use a green color
  28     -gray       use a gray color (default)
  29     -green      use a green color
  30     -hi         use a highlighting/inverse style
  31     -m          use a magenta color
  32     -magenta    use a magenta color
  33     -o          use an orange color
  34     -orange     use an orange color
  35     -r          use a red color
  36     -red        use a red color
  37     -u          underline digits
  38     -underline  underline digits

     File: ./main.go
   1 package main
   2 
   3 import (
   4     "bufio"
   5     "errors"
   6     "io"
   7     "os"
   8     "strings"
   9 
  10     _ "embed"
  11 )
  12 
  13 // note: the code is avoiding using the fmt package to save hundreds of
  14 // kilobytes on the resulting executable, which is a noticeable difference
  15 
  16 //go:embed info.txt
  17 var info string
  18 
  19 // errNoMoreOutput is a dummy error, whose message is ignored, and which
  20 // causes the app to quit immediately and successfully
  21 var errNoMoreOutput = errors.New(`no more output`)
  22 
  23 func main() {
  24     if len(os.Args) > 1 {
  25         switch os.Args[1] {
  26         case `-h`, `--h`, `-help`, `--help`:
  27             os.Stderr.WriteString(info)
  28             return
  29         }
  30     }
  31 
  32     args := os.Args[1:]
  33     style := styles[`gray`]
  34 
  35     // if the first argument is 1 or 2 dashes followed by a supported
  36     // style-name, change the style used
  37     if len(args) > 0 && strings.HasPrefix(args[0], `-`) {
  38         name := args[0]
  39         name = strings.TrimPrefix(name, `-`)
  40         name = strings.TrimPrefix(name, `-`)
  41 
  42         // check if the `dedashed` argument is a supported style-name
  43         if s, ok := styles[name]; ok {
  44             style = s
  45             // argument isn't a filepath, so ignore it
  46             args = args[1:]
  47         }
  48     }
  49 
  50     if err := run(args, style); err != nil && err != errNoMoreOutput {
  51         os.Stderr.WriteString("\x1b[31m")
  52         os.Stderr.WriteString(err.Error())
  53         os.Stderr.WriteString("\x1b[0m\n")
  54         os.Exit(1)
  55     }
  56 }
  57 
  58 // run handles all inputs, whether named or implied, for func main
  59 func run(paths []string, style []byte) error {
  60     // prevent trying to use stdin more than once
  61     dashes := false
  62     for _, path := range paths {
  63         if path == `-` {
  64             if dashes {
  65                 return errors.New(`can't use stdin more than once`)
  66             }
  67             dashes = true
  68         }
  69     }
  70 
  71     w := bufio.NewWriter(os.Stdout)
  72     defer w.Flush()
  73 
  74     // just use stdin when not given any filenames
  75     if len(paths) == 0 {
  76         return handleFile(w, `-`, style)
  77     }
  78 
  79     for _, path := range paths {
  80         if err := handleFile(w, path, style); err != nil {
  81             return err
  82         }
  83     }
  84     return nil
  85 }
  86 
  87 // handleFile is just a file-handling wrapper of func handleInput
  88 func handleFile(w *bufio.Writer, path string, style []byte) error {
  89     if path == `-` {
  90         return handleInput(w, os.Stdin, style)
  91     }
  92 
  93     // if f := strings.HasPrefix; f(path, `https://`) || f(path, `http://`) {
  94     //  resp, err := http.Get(path)
  95     //  if err != nil {
  96     //      return err
  97     //  }
  98     //  defer resp.Body.Close()
  99     //  return handleInput(w, resp.Body, style)
 100     // }
 101 
 102     f, err := os.Open(path)
 103     if err != nil {
 104         // return err
 105         return errors.New(`can't open file named ` + path)
 106     }
 107     defer f.Close()
 108     return handleInput(w, f, style)
 109 }
 110 
 111 // handleInput simplifies control-flow in func handleFile
 112 func handleInput(w *bufio.Writer, r io.Reader, style []byte) error {
 113     const gb = 1024 * 1024 * 1024
 114     sc := bufio.NewScanner(r)
 115     sc.Buffer(nil, 8*gb)
 116 
 117     for sc.Scan() {
 118         restyleLine(w, sc.Bytes(), style)
 119         if err := w.WriteByte('\n'); err != nil {
 120             // a write error may be the consequence of stdout being closed,
 121             // perhaps by another app along a pipe
 122             return errNoMoreOutput
 123         }
 124     }
 125     return sc.Err()
 126 }

     File: ./mit-license.txt
   1 The MIT License (MIT)
   2 
   3 Copyright © 2024 pacman64
   4 
   5 Permission is hereby granted, free of charge, to any person obtaining a copy of
   6 this software and associated documentation files (the “Software”), to deal
   7 in the Software without restriction, including without limitation the rights to
   8 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
   9 of the Software, and to permit persons to whom the Software is furnished to do
  10 so, subject to the following conditions:
  11 
  12 The above copyright notice and this permission notice shall be included in all
  13 copies or substantial portions of the Software.
  14 
  15 THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  16 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  17 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  18 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  19 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  20 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  21 SOFTWARE.

     File: ./strings.go
   1 package main
   2 
   3 import (
   4     "bufio"
   5 )
   6 
   7 // styles turns style-names into the ANSI-code sequences used for the
   8 // alternate groups of digits
   9 var styles = map[string][]byte{
  10     `b`: []byte("\x1b[38;5;26m"),  // same as `blue`
  11     `g`: []byte("\x1b[38;5;29m"),  // same as `green`
  12     `m`: []byte("\x1b[38;5;99m"),  // same as `magenta`
  13     `o`: []byte("\x1b[38;5;166m"), // same as `orange`
  14     `r`: []byte("\x1b[31m"),       // same as `red`
  15     `u`: []byte("\x1b[4m"),        // same as `underline`
  16 
  17     `blue`:      []byte("\x1b[38;5;26m"),
  18     `bold`:      []byte("\x1b[1m"),
  19     `gray`:      []byte("\x1b[38;5;249m"),
  20     `green`:     []byte("\x1b[38;5;29m"),
  21     `inverse`:   []byte("\x1b[7m"),
  22     `magenta`:   []byte("\x1b[38;5;99m"),
  23     `orange`:    []byte("\x1b[38;5;166m"),
  24     `plain`:     []byte("\x1b[0m"),
  25     `red`:       []byte("\x1b[31m"),
  26     `underline`: []byte("\x1b[4m"),
  27 }
  28 
  29 // restyleLine renders the line given, using ANSI-styles to make any long
  30 // numbers in it more legible; this func doesn't emit a line-feed, which
  31 // is up to its caller
  32 func restyleLine(w *bufio.Writer, line []byte, style []byte) {
  33     for len(line) > 0 {
  34         i := indexDigit(line)
  35         if i < 0 {
  36             // no (more) digits to style for sure
  37             w.Write(line)
  38             return
  39         }
  40 
  41         // emit line before current digit-run
  42         w.Write(line[:i])
  43         // advance to the start of the current digit-run
  44         line = line[i:]
  45 
  46         // see where the digit-run ends
  47         j := indexNonDigit(line)
  48         if j < 0 {
  49             // the digit-run goes until the end
  50             restyleDigits(w, line, style)
  51             return
  52         }
  53 
  54         // emit styled digit-run
  55         restyleDigits(w, line[:j], style)
  56         // skip right past the end of the digit-run
  57         line = line[j:]
  58     }
  59 }
  60 
  61 // indexDigit finds the index of the first digit in a string, or -1 when the
  62 // string has no decimal digits
  63 func indexDigit(s []byte) int {
  64     for i, r := range s {
  65         if '0' <= r && r <= '9' {
  66             return i
  67         }
  68     }
  69 
  70     // empty slice, or a slice without any digits
  71     return -1
  72 }
  73 
  74 // indexNonDigit finds the index of the first non-digit in a string, or -1
  75 // when the string is all decimal digits
  76 func indexNonDigit(s []byte) int {
  77     for i, r := range s {
  78         if '0' <= r && r <= '9' {
  79             continue
  80         }
  81         return i
  82     }
  83 
  84     // empty slice, or a slice which only has digits
  85     return -1
  86 }
  87 
  88 // restyleDigits renders a run of digits as alternating styled/unstyled runs
  89 // of 3 digits, which greatly improves readability, and is the only purpose
  90 // of this app; string is assumed to be all decimal digits
  91 func restyleDigits(w *bufio.Writer, digits []byte, altStyle []byte) {
  92     if len(digits) < 4 {
  93         // digit sequence is short, so emit it as is
  94         w.Write(digits)
  95         return
  96     }
  97 
  98     // separate leading 0..2 digits which don't align with the 3-digit groups
  99     i := len(digits) % 3
 100     // emit leading digits unstyled, if there are any
 101     w.Write(digits[:i])
 102     // the rest is guaranteed to have a length which is a multiple of 3
 103     digits = digits[i:]
 104 
 105     // start by styling, unless there were no leading digits
 106     style := i != 0
 107 
 108     for len(digits) > 0 {
 109         if style {
 110             w.Write(altStyle)
 111             w.Write(digits[:3])
 112             w.Write([]byte{'\x1b', '[', '0', 'm'})
 113         } else {
 114             w.Write(digits[:3])
 115         }
 116 
 117         // advance to the next triple: the start of this func is supposed
 118         // to guarantee this step always works
 119         digits = digits[3:]
 120 
 121         // alternate between styled and unstyled 3-digit groups
 122         style = !style
 123     }
 124 }

     File: ./strings_test.go
   1 package main
   2 
   3 import (
   4     "bufio"
   5     "strings"
   6     "testing"
   7 )
   8 
   9 func TestRestyleLine(t *testing.T) {
  10     var (
  11         r = "\x1b[0m"
  12         d = string(styles[`gray`])
  13     )
  14 
  15     var tests = []struct {
  16         Input    string
  17         Expected string
  18     }{
  19         {``, ``},
  20         {`abc`, `abc`},
  21         {`  abc 123456 `, `  abc 123` + d + `456` + r + ` `},
  22         {`  123456789 text`, `  123` + d + `456` + r + `789 text`},
  23 
  24         {`0`, `0`},
  25         {`01`, `01`},
  26         {`012`, `012`},
  27         {`0123`, `0` + d + `123` + r},
  28         {`01234`, `01` + d + `234` + r},
  29         {`012345`, `012` + d + `345` + r},
  30         {`0123456`, `0` + d + `123` + r + `456`},
  31         {`01234567`, `01` + d + `234` + r + `567`},
  32         {`012345678`, `012` + d + `345` + r + `678`},
  33         {`0123456789`, `0` + d + `123` + r + `456` + d + `789` + r},
  34         {`01234567890`, `01` + d + `234` + r + `567` + d + `890` + r},
  35         {`012345678901`, `012` + d + `345` + r + `678` + d + `901` + r},
  36         {`0123456789012`, `0` + d + `123` + r + `456` + d + `789` + r + `012`},
  37 
  38         {`00321`, `00` + d + `321` + r},
  39         {`123.456789`, `123.` + `456` + d + `789` + r},
  40         {`123456.123456`, `123` + d + `456` + r + `.` + `123` + d + `456` + r},
  41     }
  42 
  43     for _, tc := range tests {
  44         t.Run(tc.Input, func(t *testing.T) {
  45             var b strings.Builder
  46             w := bufio.NewWriter(&b)
  47             restyleLine(w, []byte(tc.Input), []byte(d))
  48             w.Flush()
  49 
  50             if got := b.String(); got != tc.Expected {
  51                 t.Fatalf(`expected %q, but got %q instead`, tc.Expected, got)
  52             }
  53         })
  54     }
  55 }