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