File: ./config.go
   1 package main
   2 
   3 import (
   4     "errors"
   5     "flag"
   6     "fmt"
   7     "regexp"
   8     "strings"
   9 )
  10 
  11 const (
  12     uniqueUsage      = "style lines by uniqueness"
  13     insensitiveUsage = "match using case-insensitive mode"
  14     byExtensionUsage = "style lines by file extension"
  15     columnUsage      = "tab-separated field index (1..n) to color with"
  16     headerUsage      = "keep 1st line plain when coloring by column"
  17 )
  18 
  19 // config has all the parsed cmd-line arguments
  20 type config struct {
  21     // Patterns are the regex/style pairs to use on the input.
  22     Patterns []pattern
  23 
  24     // Column is a 1-based field in a tab-separated line to pick: the default
  25     // 0 disables column-based picking.
  26     Column int
  27 
  28     // Header is to keep 1st line plain when in table/TSV mode.
  29     Header bool
  30 
  31     // Insensitive toggles case-insensitive mode for line matches.
  32     Insensitive bool
  33 
  34     // Unique toggles styling lines by uniqueness.
  35     Unique bool
  36 
  37     // ByExtensions toggles styling lines by file extension.
  38     ByExtension bool
  39 
  40     // Buffer toggles buffering of (standard) output: it's best to keep this
  41     // turned off, so live output appears immediately.
  42     Buffer bool
  43 }
  44 
  45 func parseFlags(usage string) (config, []error) {
  46     flag.Usage = func() {
  47         fmt.Fprintf(flag.CommandLine.Output(), "%s\n\nOptions\n\n", usage)
  48         flag.PrintDefaults()
  49     }
  50 
  51     cfg := config{
  52         Column: 0,    // don't pick TSV fields by default
  53         Header: true, // when in TSV mode assume a header line by default
  54     }
  55     flag.BoolVar(&cfg.Unique, "unique", cfg.Unique, uniqueUsage)
  56     flag.BoolVar(&cfg.Insensitive, "i", cfg.Insensitive, insensitiveUsage)
  57     flag.BoolVar(&cfg.ByExtension, "ext", cfg.ByExtension, byExtensionUsage)
  58     flag.IntVar(&cfg.Column, "col", cfg.Column, columnUsage)
  59     flag.BoolVar(&cfg.Header, "header", cfg.Header, headerUsage)
  60     flag.Parse()
  61 
  62     pats, errs := parsePatterns(flag.Args(), cfg)
  63     if cfg.Column != 0 && cfg.Unique {
  64         const msg = "options -col and -unique are mutually exclusive"
  65         errs = append(errs, errors.New(msg))
  66     }
  67     if cfg.Column != 0 && cfg.ByExtension {
  68         const msg = "options -col and -ext are mutually exclusive"
  69         errs = append(errs, errors.New(msg))
  70     }
  71     if cfg.Unique && cfg.ByExtension {
  72         const msg = "options -unique and -ext are mutually exclusive"
  73         errs = append(errs, errors.New(msg))
  74     }
  75     cfg.Patterns = pats
  76     return cfg, errs
  77 }
  78 
  79 // pattern is a regex/style pair: styles are strings used to lookup the actual
  80 // ANSI-code styles used for matching lines
  81 type pattern struct {
  82     Expression *regexp.Regexp
  83     Style      string
  84 }
  85 
  86 // parsePatterns turns the cmd-line args after the app's name, and tries to
  87 // make sense of them as regex/style pairs for later use
  88 func parsePatterns(args []string, cfg config) ([]pattern, []error) {
  89     if len(args) == 0 {
  90         return nil, nil
  91     }
  92 
  93     if len(args) == 1 {
  94         // follow a single regex with the automatic green-color style
  95         return parsePatterns([]string{args[0], "green"}, cfg)
  96     }
  97 
  98     if len(args)%2 == 1 {
  99         // when the last regex has no matching style after it, use the
 100         // invert style as its default
 101         args = append(args, "invert")
 102     }
 103 
 104     var errs []error
 105     pats := make([]pattern, 0, len(args)/2)
 106 
 107     for i, arg := range args {
 108         // the 1st of every 2 args is the regex pattern
 109         if i%2 == 0 {
 110             if cfg.Insensitive {
 111                 arg = strings.ToLower(arg)
 112             }
 113 
 114             re, err := regexp.Compile(fmt.Sprint(arg))
 115             if err != nil {
 116                 errs = append(errs, err)
 117                 continue
 118             }
 119 
 120             pats = append(pats, pattern{Expression: re})
 121             continue
 122         }
 123 
 124         // the 2nd of every 2 args is the style to use for matches
 125         if st, ok := parseStyle(arg); ok {
 126             pats[len(pats)-1].Style = st
 127             continue
 128         }
 129 
 130         errs = append(errs, fmt.Errorf("unsupported style %q", arg))
 131     }
 132 
 133     return pats, errs
 134 }

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

     File: ./info.txt
   1 ecoli [options...] [regex/color pairs...]
   2 
   3 Expressions COloring LInes tries to match each line to the 1st applicable
   4 RE2-style regular expression given and colors it with its associated color.
   5 
   6 Each regex must precede its corresponding color/style as follows
   7     ecoli regex color regex color regex color ...
   8 
   9 Colors/styles available include, along with their 1/2-letter shortcuts
  10     red        r
  11     green      g
  12     blue       b
  13     magenta    m
  14     pink       p
  15     orange     o
  16     yellow     y
  17     cyan       c
  18     gray       gr
  19     invert     in
  20     bold       bo
  21     italic     it
  22     underline  un
  23     strike     st
  24 
  25 The RE2 regular expression syntax is very similar to the other commonly-used
  26 alternatives, and is described at https://github.com/google/re2/wiki/Syntax

     File: ./main.go
   1 package main
   2 
   3 import (
   4     "bufio"
   5     "fmt"
   6     "io"
   7     "os"
   8     "path/filepath"
   9     "strings"
  10 
  11     _ "embed"
  12 )
  13 
  14 //go:embed info.txt
  15 var usage string
  16 
  17 const maxbufsize = 8 * 1024 * 1024 * 1024
  18 
  19 func main() {
  20     cfg, errs := parseFlags(usage)
  21 
  22     if len(errs) > 0 {
  23         // show all errors, not just the first one
  24         for _, err := range errs {
  25             fmt.Fprintln(os.Stderr, err.Error())
  26         }
  27         os.Exit(1)
  28     }
  29 
  30     if err := run(cfg); err != nil {
  31         fmt.Fprintln(os.Stderr, err.Error())
  32         os.Exit(1)
  33     }
  34 }
  35 
  36 func run(cfg config) error {
  37     // f, _ := os.Create("ecoli.prof")
  38     // defer f.Close()
  39     // pprof.StartCPUProfile(f)
  40     // defer pprof.StopCPUProfile()
  41 
  42     var w io.Writer = os.Stdout
  43     if cfg.Buffer {
  44         bw := bufio.NewWriter(os.Stdout)
  45         defer bw.Flush()
  46         w = bw
  47     }
  48 
  49     if cfg.ByExtension {
  50         // style lines by detected file-extension
  51         st := newStyler(palette)
  52         sc := bufio.NewScanner(os.Stdin)
  53         sc.Buffer(nil, maxbufsize)
  54 
  55         for sc.Scan() {
  56             line := sc.Text()
  57             key := filepath.Ext(line)
  58             if cfg.Insensitive {
  59                 key = strings.ToLower(key)
  60             }
  61             if err := styleLine(w, line, st.style(key)); err != nil {
  62                 return nil // probably a pipe was closed
  63             }
  64         }
  65         return nil
  66     }
  67 
  68     if cfg.Unique {
  69         // style lines by their unique value
  70         st := newStyler(palette)
  71         sc := bufio.NewScanner(os.Stdin)
  72         sc.Buffer(nil, maxbufsize)
  73 
  74         for sc.Scan() {
  75             line := sc.Text()
  76             key := line
  77             if cfg.Insensitive {
  78                 key = strings.ToLower(key)
  79             }
  80             if err := styleLine(w, line, st.style(key)); err != nil {
  81                 return nil // probably a pipe was closed
  82             }
  83         }
  84         return nil
  85     }
  86 
  87     if cfg.Column != 0 {
  88         // style lines by unique values from the column given
  89         return showTable(w, os.Stdin, cfg)
  90     }
  91 
  92     // normal mode: style lines by first matching regex
  93     sc := bufio.NewScanner(os.Stdin)
  94     sc.Buffer(nil, maxbufsize)
  95 
  96     for sc.Scan() {
  97         line := sc.Text()
  98         if cfg.Insensitive {
  99             line = strings.ToLower(line)
 100         }
 101         st := matchStyle(line, cfg.Patterns)
 102         if err := styleLine(w, line, st); err != nil {
 103             return nil // probably a pipe was closed
 104         }
 105     }
 106     return nil
 107 }
 108 
 109 // matchStyle looks-up all the regex-pattern-pairs given to find the style
 110 // of the first matching regex, or the empty string when there are no matches
 111 func matchStyle(s string, pats []pattern) string {
 112     for _, p := range pats {
 113         if p.Expression.MatchString(s) {
 114             return p.Style
 115         }
 116     }
 117     return ""
 118 }
 119 
 120 // showTable runs the app in table-mode, using unique values from
 121 // the column given to style each whole line
 122 func showTable(w io.Writer, r io.Reader, cfg config) error {
 123     sc := bufio.NewScanner(r)
 124     sc.Buffer(nil, maxbufsize)
 125     styler := newStyler(palette)
 126     const errf = "with %d columns available, 1-based column index %d is invalid"
 127 
 128     for linenum := 0; sc.Scan(); linenum++ {
 129         line := sc.Text()
 130 
 131         // handle first/header line
 132         if linenum == 0 {
 133             n := numItems(line)
 134             if cfg.Column < -n || cfg.Column > n {
 135                 return fmt.Errorf(errf, n, cfg.Column)
 136             }
 137 
 138             // translate negative indices to positive ones
 139             if cfg.Column < 0 {
 140                 cfg.Column += numItems(line) + 1
 141             }
 142 
 143             // keep header line plain if asked to
 144             if cfg.Header {
 145                 fmt.Fprintln(w, line)
 146                 continue
 147             }
 148         }
 149 
 150         item := pickItem(line, cfg.Column-1)
 151         if cfg.Insensitive {
 152             item = strings.ToLower(item)
 153         }
 154         if err := styleLine(w, line, styler.style(item)); err != nil {
 155             return nil // probably a pipe was closed
 156         }
 157     }
 158 
 159     return nil
 160 }
 161 
 162 // styleLine emits line styled as given, followed by a new-line rune
 163 func styleLine(w io.Writer, line string, style string) error {
 164     if style == "" {
 165         _, err := fmt.Fprintln(w, line)
 166         return err
 167     }
 168 
 169     fmt.Fprint(w, style)
 170     fmt.Fprint(w, line)
 171     _, err := fmt.Fprint(w, "\x1b[0m\n")
 172     return err
 173 }
 174 
 175 // func styleLine(w *bufio.Writer, line string, style string) error {
 176 //  if style == "" {
 177 //      w.WriteString(line)
 178 //      return w.WriteByte('\n')
 179 //  }
 180 
 181 //  w.WriteString(style)
 182 //  w.WriteString(line)
 183 //  _, err := w.WriteString("\x1b[0m\n")
 184 //  return err
 185 // }

     File: ./strings.go
   1 package main
   2 
   3 import (
   4     "strings"
   5 )
   6 
   7 // pickItem returns an item substring, using a 1-based item number, and
   8 // without having to split the original string, so it's allocation-free:
   9 // if there aren't enough items, returns an empty string
  10 func pickItem(line string, i int) string {
  11     // skip all preceding items
  12     for j := i; j > 0; j-- {
  13         k := indexSeparator(line)
  14         if k < 0 {
  15             // not enough items, so return an empty string
  16             return ""
  17         }
  18         line = line[k:]
  19     }
  20 
  21     if k := indexSeparator(line); k >= 0 {
  22         // pick the first remaining item
  23         return line[:k]
  24     }
  25     // pick the last item, the only one remaining
  26     return line
  27 }
  28 
  29 // numItems counts how many items a string/line has, without having to split
  30 // the original string, so it's allocation-free
  31 func numItems(line string) int {
  32     if line == "" {
  33         return 0
  34     }
  35 
  36     n := 1
  37     for {
  38         i := indexSeparator(line)
  39         if i < 0 {
  40             return n
  41         }
  42         line = line[i+1:]
  43         n++
  44     }
  45 }
  46 
  47 // indexSeparator finds the first run of 2 or more spaces or the first single
  48 // tab from the string given to it
  49 func indexSeparator(s string) int {
  50     // try with multiple consecutive spaces as the field-separator
  51     i := strings.Index(s, "  ")
  52     // if so, point to the last such consecutive space
  53     if i >= 0 {
  54         s = s[i:]
  55         for j, r := range s {
  56             if r != ' ' {
  57                 return i + j
  58             }
  59         }
  60     }
  61 
  62     // if not, try with tab as the field-separator
  63     return strings.IndexRune(s, '\t')
  64 }

     File: ./styles.go
   1 package main
   2 
   3 import (
   4     "fmt"
   5     "strings"
   6 )
   7 
   8 const (
   9     // reset = "\x1b[0m"
  10 
  11     red     = "\x1b[38;5;1m"
  12     green   = "\x1b[38;5;28m"  // 22m, 28m, 34m
  13     blue    = "\x1b[38;5;25m"  // actual blue is unreadable
  14     magenta = "\x1b[38;5;93m"  // 135m
  15     pink    = "\x1b[38;5;207m" // 213m
  16     orange  = "\x1b[38;5;202m" // 208m
  17     gray    = "\x1b[38;5;244m" // 240m
  18     cyan    = "\x1b[38;5;39m"  // 6m, 39m
  19     yellow  = "\x1b[48;5;11m"  // yellow is readable only as a background
  20 
  21     // teal = "\x1b[38;5;30m"
  22 
  23     strike    = "\x1b[9m"
  24     underline = "\x1b[4m"
  25     invert    = "\x1b[7m"
  26     bold      = "\x1b[1m"
  27     italicize = "\x1b[3m"
  28 )
  29 
  30 // hex2int is only used by func parseHexPair
  31 var hex2int = map[byte]byte{
  32     '0': 0, '1': 1, '2': 2, '3': 3, '4': 4,
  33     '5': 5, '6': 6, '7': 7, '8': 8, '9': 9,
  34     'a': 10, 'b': 11, 'c': 12, 'd': 13, 'e': 14, 'f': 15,
  35     'A': 10, 'B': 11, 'C': 12, 'D': 13, 'E': 14, 'F': 15,
  36 }
  37 
  38 // palette is the set of colors used by the auto-coloring options, which are
  39 //
  40 // - by column value
  41 // - by whole-line value
  42 // - by trailing file extension
  43 var palette = []string{
  44     red, green, blue, magenta, pink,
  45     orange, gray, cyan,
  46 }
  47 
  48 // name2style turns canonical style names into ANSI-code styles
  49 var name2style = map[string]string{
  50     "red":     red,
  51     "green":   green,
  52     "blue":    blue,
  53     "magenta": magenta,
  54     "pink":    pink,
  55     "orange":  orange,
  56     "gray":    gray,
  57     "cyan":    cyan,
  58     "yellow":  yellow,
  59 
  60     // non-coloring styles
  61     "strike":    strike,
  62     "underline": underline,
  63     "invert":    invert,
  64     "bold":      bold,
  65     "italic":    italicize,
  66 }
  67 
  68 // alias2name enable convenient shortcuts for the user
  69 var alias2name = map[string]string{
  70     // aliases/shortcuts for colors
  71     "r":    "red",
  72     "g":    "green",
  73     "b":    "blue",
  74     "blu":  "blue",
  75     "m":    "magenta",
  76     "ma":   "magenta",
  77     "mag":  "magenta",
  78     "p":    "pink",
  79     "pi":   "pink",
  80     "o":    "orange",
  81     "or":   "orange",
  82     "grey": "gray",
  83     "c":    "cyan",
  84     "cy":   "cyan",
  85     "y":    "yellow",
  86     "ye":   "yellow",
  87 
  88     // aliases/shortcuts for non-coloring styles
  89     "st":         "strike",
  90     "str":        "strike",
  91     "un":         "underline",
  92     "under":      "underline",
  93     "underlined": "underline",
  94     "in":         "invert",
  95     "inv":        "invert",
  96     "inverted":   "invert",
  97     "re":         "invert",
  98     "rev":        "invert",
  99     "revert":     "invert",
 100     "reverted":   "invert",
 101     "bo":         "bold",
 102     "it":         "italic",
 103     "italicize":  "italic",
 104 }
 105 
 106 // circularStyler is an auto-styler which reuses the standard color palette
 107 // when there are too many unique values
 108 type circularStyler struct {
 109     // the circular source of values
 110     src []string
 111 
 112     // remembers previously-assigned key-value pairs
 113     kv map[string]string
 114 
 115     // circular-index to add new styles
 116     n int
 117 }
 118 
 119 // newStyler is the constructor for struct circularStyler
 120 func newStyler(src []string) circularStyler {
 121     cs := circularStyler{src: src, kv: make(map[string]string)}
 122     cs.kv[""] = "" // use the empty value for empty items
 123     return cs
 124 }
 125 
 126 // style lookups the string given, matching with a style, possibly adding a
 127 // new entry if it's the first time such a string was looked up
 128 func (cs *circularStyler) style(k string) string {
 129     v, ok := cs.kv[k]
 130     if !ok {
 131         v = cs.src[cs.n]
 132         cs.n++
 133         cs.n %= len(cs.src)
 134         cs.kv[k] = v
 135     }
 136     return v
 137 }
 138 
 139 func parseStyle(s string) (style string, ok bool) {
 140     if ansi, ok := parseHexColor(s); ok {
 141         return ansi, true
 142     }
 143 
 144     if alias, ok := alias2name[s]; ok {
 145         s = alias
 146     }
 147     ansi, ok := name2style[s]
 148     return ansi, ok
 149 }
 150 
 151 func parseHexColor(s string) (style string, ok bool) {
 152     var r, g, b int
 153     s = strings.TrimPrefix(s, "#")
 154 
 155     switch len(s) {
 156     case 3:
 157         r = parseHexPair(s[0], s[0])
 158         g = parseHexPair(s[1], s[1])
 159         b = parseHexPair(s[2], s[2])
 160 
 161     case 6:
 162         r = parseHexPair(s[0], s[1])
 163         g = parseHexPair(s[2], s[3])
 164         b = parseHexPair(s[4], s[5])
 165 
 166     default:
 167         return "", false
 168     }
 169 
 170     if r < 0 || g < 0 || b < 0 {
 171         return "", false
 172     }
 173     return fmt.Sprintf("\x1b[38;2;%d;%d;%dm", r, g, b), true
 174 }
 175 
 176 // parseHexPair takes 2 bytes taken in order from a string and parses them
 177 // into an integer; result is negative if symbols aren't valid hexadecimal
 178 func parseHexPair(high, low byte) int {
 179     a, ok := hex2int[high]
 180     if !ok {
 181         return -1
 182     }
 183     b, ok := hex2int[low]
 184     if !ok {
 185         return -1
 186     }
 187     return 16*int(a) + int(b)
 188 }