File: ecoli.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 To compile a smaller-sized command-line app, you can use the `go` command as
  27 follows:
  28 
  29 go build -ldflags "-s -w" -trimpath ecoli.go
  30 */
  31 
  32 package main
  33 
  34 import (
  35     "bufio"
  36     "bytes"
  37     "io"
  38     "os"
  39     "regexp"
  40 )
  41 
  42 const info = `
  43 ecoli [options...] [regex/style pairs...]
  44 
  45 
  46 Expressions COloring LInes tries to match each line read from the standard
  47 input to the regexes given, coloring/styling with the named-style paired
  48 to the first matching regex, if any. Lines not matching any regex stay the
  49 same.
  50 
  51 The options are, available both in single and double-dash versions
  52 
  53     -h, -help                 show this help message
  54     -i, -ins, -insensitive    match the regexes given case-insensitively
  55 
  56 Some of the colors/styles available are:
  57 
  58     blue         blueback
  59     bold
  60     gray         grayback
  61     green        greenback
  62     inverse
  63     magenta      magentaback
  64     orange       orangeback
  65     purple       purpleback
  66     red          redback
  67     underline
  68 
  69 Some style aliases are:
  70 
  71     b       blue                  bb       blueback
  72     g       green                 gb       greenback
  73     m       magenta               mb       magentaback
  74     o       orange                ob       orangeback
  75     p       purple                pb       purpleback
  76     r       red                   rb       redback
  77     i       inverse (highlight)
  78     u       underline
  79 `
  80 
  81 var styleAliases = map[string]string{
  82     `b`: `blue`,
  83     `g`: `green`,
  84     `m`: `magenta`,
  85     `o`: `orange`,
  86     `p`: `purple`,
  87     `r`: `red`,
  88     `u`: `underline`,
  89 
  90     `bb`: `blueback`,
  91     `bg`: `greenback`,
  92     `bm`: `magentaback`,
  93     `bo`: `orangeback`,
  94     `bp`: `purpleback`,
  95     `br`: `redback`,
  96 
  97     `gb`: `greenback`,
  98     `mb`: `magentaback`,
  99     `ob`: `orangeback`,
 100     `pb`: `purpleback`,
 101     `rb`: `redback`,
 102 
 103     `hi`:  `inverse`,
 104     `inv`: `inverse`,
 105     `mag`: `magenta`,
 106 
 107     `du`: `doubleunderline`,
 108 
 109     `flip`: `inverse`,
 110     `swap`: `inverse`,
 111 
 112     `reset`:     `plain`,
 113     `highlight`: `inverse`,
 114     `hilite`:    `inverse`,
 115     `invert`:    `inverse`,
 116     `inverted`:  `inverse`,
 117     `swapped`:   `inverse`,
 118 
 119     `dunderline`:  `doubleunderline`,
 120     `dunderlined`: `doubleunderline`,
 121 
 122     `strikethrough`: `strike`,
 123     `strikethru`:    `strike`,
 124     `struck`:        `strike`,
 125 
 126     `underlined`: `underline`,
 127 
 128     `bblue`:    `blueback`,
 129     `bgray`:    `grayback`,
 130     `bgreen`:   `greenback`,
 131     `bmagenta`: `magentaback`,
 132     `borange`:  `orangeback`,
 133     `bpurple`:  `purpleback`,
 134     `bred`:     `redback`,
 135 
 136     `bgblue`:    `blueback`,
 137     `bggray`:    `grayback`,
 138     `bggreen`:   `greenback`,
 139     `bgmag`:     `magentaback`,
 140     `bgmagenta`: `magentaback`,
 141     `bgorange`:  `orangeback`,
 142     `bgpurple`:  `purpleback`,
 143     `bgred`:     `redback`,
 144 
 145     `bluebg`:    `blueback`,
 146     `graybg`:    `grayback`,
 147     `greenbg`:   `greenback`,
 148     `magbg`:     `magentaback`,
 149     `magentabg`: `magentaback`,
 150     `orangebg`:  `orangeback`,
 151     `purplebg`:  `purpleback`,
 152     `redbg`:     `redback`,
 153 
 154     `backblue`:    `blueback`,
 155     `backgray`:    `grayback`,
 156     `backgreen`:   `greenback`,
 157     `backmag`:     `magentaback`,
 158     `backmagenta`: `magentaback`,
 159     `backorange`:  `orangeback`,
 160     `backpurple`:  `purpleback`,
 161     `backred`:     `redback`,
 162 }
 163 
 164 var styles = map[string]string{
 165     `blue`:            "\x1b[38;2;0;95;215m",
 166     `bold`:            "\x1b[1m",
 167     `doubleunderline`: "\x1b[21m",
 168     `gray`:            "\x1b[38;2;168;168;168m",
 169     `green`:           "\x1b[38;2;0;135;95m",
 170     `inverse`:         "\x1b[7m",
 171     `magenta`:         "\x1b[38;2;215;0;255m",
 172     `orange`:          "\x1b[38;2;215;95;0m",
 173     `plain`:           "\x1b[0m",
 174     `purple`:          "\x1b[38;2;135;95;255m",
 175     `red`:             "\x1b[38;2;204;0;0m",
 176     `strike`:          "\x1b[9m",
 177     `underline`:       "\x1b[4m",
 178 
 179     `blueback`:    "\x1b[48;2;0;95;215m\x1b[38;2;238;238;238m",
 180     `grayback`:    "\x1b[48;2;168;168;168m\x1b[38;2;238;238;238m",
 181     `greenback`:   "\x1b[48;2;0;135;95m\x1b[38;2;238;238;238m",
 182     `magentaback`: "\x1b[48;2;215;0;255m\x1b[38;2;238;238;238m",
 183     `orangeback`:  "\x1b[48;2;215;95;0m\x1b[38;2;238;238;238m",
 184     `purpleback`:  "\x1b[48;2;135;95;255m\x1b[38;2;238;238;238m",
 185     `redback`:     "\x1b[48;2;204;0;0m\x1b[38;2;238;238;238m",
 186 }
 187 
 188 // pair has a regular-expression and its associated ANSI-code style together
 189 type pair struct {
 190     expr  *regexp.Regexp
 191     style string
 192 }
 193 
 194 func main() {
 195     buffered := false
 196     insensitive := false
 197     args := os.Args[1:]
 198 
 199     for len(args) > 0 {
 200         switch args[0] {
 201         case `-b`, `--b`, `-buffered`, `--buffered`:
 202             buffered = true
 203             args = args[1:]
 204             continue
 205 
 206         case `-h`, `--h`, `-help`, `--help`:
 207             os.Stdout.WriteString(info[1:])
 208             return
 209 
 210         case `-i`, `--i`, `-ins`, `--ins`:
 211             insensitive = true
 212             args = args[1:]
 213             continue
 214         }
 215 
 216         break
 217     }
 218 
 219     if len(args) > 0 && args[0] == `--` {
 220         args = args[1:]
 221     }
 222 
 223     if len(args)%2 != 0 {
 224         const msg = "you forgot the style-name for/after the last regex\n"
 225         os.Stderr.WriteString(msg)
 226         os.Exit(1)
 227     }
 228 
 229     nerr := 0
 230     pairs := make([]pair, 0, len(args)/2)
 231 
 232     for len(args) >= 2 {
 233         src := args[0]
 234         sname := args[1]
 235 
 236         var err error
 237         var exp *regexp.Regexp
 238         if insensitive {
 239             exp, err = regexp.Compile(`(?i)` + src)
 240         } else {
 241             exp, err = regexp.Compile(src)
 242         }
 243         if err != nil {
 244             os.Stderr.WriteString(err.Error())
 245             os.Stderr.WriteString("\n")
 246             nerr++
 247         }
 248 
 249         if alias, ok := styleAliases[sname]; ok {
 250             sname = alias
 251         }
 252 
 253         style, ok := styles[sname]
 254         if !ok {
 255             os.Stderr.WriteString("no style named `")
 256             os.Stderr.WriteString(args[1])
 257             os.Stderr.WriteString("`\n")
 258             nerr++
 259         }
 260 
 261         pairs = append(pairs, pair{expr: exp, style: style})
 262         args = args[2:]
 263     }
 264 
 265     if nerr > 0 {
 266         os.Exit(1)
 267     }
 268 
 269     liveLines := !buffered
 270     if !buffered {
 271         if _, err := os.Stdout.Seek(0, io.SeekCurrent); err == nil {
 272             liveLines = false
 273         }
 274     }
 275 
 276     sc := bufio.NewScanner(os.Stdin)
 277     sc.Buffer(nil, 8*1024*1024*1024)
 278     bw := bufio.NewWriter(os.Stdout)
 279     var plain []byte
 280 
 281     for i := 0; sc.Scan(); i++ {
 282         s := sc.Bytes()
 283         if i == 0 && bytes.HasPrefix(s, []byte{0xef, 0xbb, 0xbf}) {
 284             s = s[3:]
 285         }
 286         plain = appendPlain(plain[:0], s)
 287 
 288         if err := handleLine(bw, s, noANSI(plain), pairs); err != nil {
 289             return
 290         }
 291 
 292         if !liveLines {
 293             continue
 294         }
 295 
 296         if err := bw.Flush(); err != nil {
 297             return
 298         }
 299     }
 300 }
 301 
 302 // appendPlain extends the slice given using the non-ANSI parts of a string
 303 func appendPlain(dst []byte, src []byte) []byte {
 304     for len(src) > 0 {
 305         i, j := indexEscapeSequence(src)
 306         if i < 0 {
 307             dst = append(dst, src...)
 308             break
 309         }
 310         if j < 0 {
 311             j = len(src)
 312         }
 313 
 314         if i > 0 {
 315             dst = append(dst, src[:i]...)
 316         }
 317 
 318         src = src[j:]
 319     }
 320 
 321     return dst
 322 }
 323 
 324 // indexEscapeSequence finds the first ANSI-style escape-sequence, which is
 325 // the multi-byte sequences starting with ESC[; the result is a pair of slice
 326 // indices which can be independently negative when either the start/end of
 327 // a sequence isn't found; given their fairly-common use, even the hyperlink
 328 // ESC]8 sequences are supported
 329 func indexEscapeSequence(s []byte) (int, int) {
 330     var prev byte
 331 
 332     for i, b := range s {
 333         if prev == '\x1b' && b == '[' {
 334             j := indexLetter(s[i+1:])
 335             if j < 0 {
 336                 return i, -1
 337             }
 338             return i - 1, i + 1 + j + 1
 339         }
 340 
 341         if prev == '\x1b' && b == ']' && i+1 < len(s) && s[i+1] == '8' {
 342             j := indexPair(s[i+1:], '\x1b', '\\')
 343             if j < 0 {
 344                 return i, -1
 345             }
 346             return i - 1, i + 1 + j + 2
 347         }
 348 
 349         prev = b
 350     }
 351 
 352     return -1, -1
 353 }
 354 
 355 func indexLetter(s []byte) int {
 356     for i, b := range s {
 357         upper := b &^ 32
 358         if 'A' <= upper && upper <= 'Z' {
 359             return i
 360         }
 361     }
 362 
 363     return -1
 364 }
 365 
 366 func indexPair(s []byte, x byte, y byte) int {
 367     var prev byte
 368 
 369     for i, b := range s {
 370         if prev == x && b == y && i > 0 {
 371             return i
 372         }
 373         prev = b
 374     }
 375 
 376     return -1
 377 }
 378 
 379 // noANSI ensures arguments to func handleLine are given in the right order
 380 type noANSI []byte
 381 
 382 // handleLine styles the current line given to it using the first matching
 383 // regex, keeping it as given if none of the regexes match; it's given 2
 384 // strings: the first is the original line, the latter is its plain-text
 385 // version (with no ANSI codes) and is used for the regex-matching, since
 386 // ANSI codes use a mix of numbers and letters, which can themselves match
 387 func handleLine(w *bufio.Writer, s []byte, plain noANSI, pairs []pair) error {
 388     for _, p := range pairs {
 389         if p.expr.Match(plain) {
 390             w.WriteString(p.style)
 391             w.Write(s)
 392             w.WriteString("\x1b[0m")
 393             return w.WriteByte('\n')
 394         }
 395     }
 396 
 397     w.Write(s)
 398     return w.WriteByte('\n')
 399 }