File: hima.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 hima.go
  30 */
  31 
  32 package main
  33 
  34 import (
  35     "bufio"
  36     "bytes"
  37     "io"
  38     "os"
  39     "regexp"
  40     "strings"
  41 )
  42 
  43 const info = `
  44 hima [options...] [regexes...]
  45 
  46 
  47 HIlight MAtches ANSI-styles matching regular expressions along lines read
  48 from the standard input. The regular-expression mode used is "re2", which
  49 is a superset of the commonly-used "extended-mode".
  50 
  51 Regexes always avoid matching any ANSI-style sequences, to avoid messing
  52 those up. Also, multiple matches in a line never overlap: at each step
  53 along a line, the earliest-starting match among the regexes always wins,
  54 as the order regexes are given among the arguments never matters.
  55 
  56 The options are, available both in single and double-dash versions
  57 
  58     -h, -help      show this help message
  59     -f, -filter    filter out (ignore) lines with no matches
  60     -i, -ins       match regexes case-insensitively
  61 `
  62 
  63 const highlightStyle = "\x1b[7m"
  64 
  65 func main() {
  66     filter := false
  67     buffered := false
  68     insensitive := false
  69     args := os.Args[1:]
  70 
  71     for len(args) > 0 {
  72         switch args[0] {
  73         case `-b`, `--b`, `-buffered`, `--buffered`:
  74             buffered = true
  75             args = args[1:]
  76             continue
  77 
  78         case `-f`, `--f`, `-filter`, `--filter`:
  79             filter = true
  80             args = args[1:]
  81             continue
  82 
  83         case `-fi`, `--fi`, `-if`, `--if`:
  84             filter = true
  85             insensitive = true
  86             args = args[1:]
  87             continue
  88 
  89         case `-h`, `--h`, `-help`, `--help`:
  90             os.Stdout.WriteString(info[1:])
  91             return
  92 
  93         case `-i`, `--i`, `-ins`, `--ins`:
  94             insensitive = true
  95             args = args[1:]
  96             continue
  97         }
  98 
  99         break
 100     }
 101 
 102     if len(args) > 0 && args[0] == `--` {
 103         args = args[1:]
 104     }
 105 
 106     patterns := make([]pattern, 0, len(args))
 107 
 108     for _, s := range args {
 109         var err error
 110         var pat pattern
 111 
 112         if insensitive {
 113             pat, err = compile(`(?i)` + s)
 114         } else {
 115             pat, err = compile(s)
 116         }
 117 
 118         if err != nil {
 119             os.Stderr.WriteString(err.Error())
 120             os.Stderr.WriteString("\n")
 121             continue
 122         }
 123 
 124         patterns = append(patterns, pat)
 125     }
 126 
 127     // quit right away when given invalid regexes
 128     if len(patterns) < len(args) {
 129         os.Exit(1)
 130         return
 131     }
 132 
 133     liveLines := !buffered
 134     if !buffered {
 135         if _, err := os.Stdout.Seek(0, io.SeekCurrent); err == nil {
 136             liveLines = false
 137         }
 138     }
 139 
 140     ok, err := run(os.Stdout, os.Stdin, patterns, filter, liveLines)
 141     if err != nil && err != io.EOF {
 142         os.Stderr.WriteString(err.Error())
 143         os.Stderr.WriteString("\n")
 144         os.Exit(1)
 145         return
 146     }
 147 
 148     if !ok {
 149         os.Exit(1)
 150         return
 151     }
 152 }
 153 
 154 // pattern is a regular-expression pattern which distinguishes between the
 155 // start/end of a line and those of the chunks it can be used to match
 156 type pattern struct {
 157     // expr is the regular-expression
 158     expr *regexp.Regexp
 159 
 160     // begin is whether the regexp refers to the start of a line
 161     begin bool
 162 
 163     // end is whether the regexp refers to the end of a line
 164     end bool
 165 }
 166 
 167 func compile(src string) (pattern, error) {
 168     expr, err := regexp.Compile(src)
 169 
 170     var pat pattern
 171     pat.expr = expr
 172     pat.begin = strings.HasPrefix(src, `^`) || strings.HasPrefix(src, `(?i)^`)
 173     pat.end = strings.HasSuffix(src, `$`) && !strings.HasSuffix(src, `\$`)
 174     return pat, err
 175 }
 176 
 177 func (p pattern) findIndex(s []byte, i int, last int) (start int, stop int) {
 178     if i > 0 && p.begin {
 179         return -1, -1
 180     }
 181     if i != last && p.end {
 182         return -1, -1
 183     }
 184 
 185     span := p.expr.FindIndex(s)
 186     // also ignore empty regex matches to avoid infinite outer loops,
 187     // as skipping empty slices isn't advancing at all, leaving the
 188     // string stuck to being empty-matched forever by the same regex
 189     if len(span) != 2 || span[0] == span[1] {
 190         return -1, -1
 191     }
 192 
 193     return span[0], span[1]
 194 }
 195 
 196 func run(w io.Writer, r io.Reader, pats []pattern, filter, live bool) (bool, error) {
 197     sc := bufio.NewScanner(r)
 198     sc.Buffer(nil, 8*1024*1024*1024)
 199     bw := bufio.NewWriter(w)
 200     defer bw.Flush()
 201 
 202     ok := !filter
 203 
 204     for i := 0; sc.Scan(); i++ {
 205         s := sc.Bytes()
 206         if i == 0 && bytes.HasPrefix(s, []byte{0xef, 0xbb, 0xbf}) {
 207             s = s[3:]
 208         }
 209 
 210         n := 0
 211         last := countChunks(s) - 1
 212         if last < 0 {
 213             last = 0
 214         }
 215 
 216         if filter && !matches(s, pats, last) {
 217             continue
 218         }
 219         ok = true
 220 
 221         for len(s) > 0 {
 222             i, j := indexEscapeSequence(s)
 223             if i < 0 {
 224                 handleChunk(bw, s, pats, n, last)
 225                 break
 226             }
 227             if j < 0 {
 228                 j = len(s)
 229             }
 230 
 231             handleChunk(bw, s[:i], pats, n, last)
 232             if i > 0 {
 233                 n++
 234             }
 235 
 236             bw.Write(s[i:j])
 237 
 238             s = s[j:]
 239         }
 240 
 241         if bw.WriteByte('\n') != nil {
 242             return ok, io.EOF
 243         }
 244 
 245         if !live {
 246             continue
 247         }
 248 
 249         if bw.Flush() != nil {
 250             return ok, io.EOF
 251         }
 252     }
 253 
 254     err := sc.Err()
 255     return err == nil && ok, err
 256 }
 257 
 258 // matches finds out if any regex matches any substring around ANSI-sequences
 259 func matches(s []byte, patterns []pattern, last int) bool {
 260     n := 0
 261 
 262     for len(s) > 0 {
 263         i, j := indexEscapeSequence(s)
 264         if i < 0 {
 265             for _, p := range patterns {
 266                 if begin, _ := p.findIndex(s, n, last); begin >= 0 {
 267                     return true
 268                 }
 269             }
 270             return false
 271         }
 272 
 273         if j < 0 {
 274             j = len(s)
 275         }
 276 
 277         for _, p := range patterns {
 278             if begin, _ := p.findIndex(s[:i], n, last); begin >= 0 {
 279                 return true
 280             }
 281         }
 282 
 283         if i > 0 {
 284             n++
 285         }
 286 
 287         s = s[j:]
 288     }
 289 
 290     return false
 291 }
 292 
 293 func countChunks(s []byte) int {
 294     chunks := 0
 295 
 296     for len(s) > 0 {
 297         i, j := indexEscapeSequence(s)
 298         if i < 0 {
 299             break
 300         }
 301 
 302         if i > 0 {
 303             chunks++
 304         }
 305 
 306         if j < 0 {
 307             break
 308         }
 309         s = s[j:]
 310     }
 311 
 312     if len(s) > 0 {
 313         chunks++
 314     }
 315     return chunks
 316 }
 317 
 318 // indexEscapeSequence finds the first ANSI-style escape-sequence, which is
 319 // the multi-byte sequences starting with ESC[; the result is a pair of slice
 320 // indices which can be independently negative when either the start/end of
 321 // a sequence isn't found; given their fairly-common use, even the hyperlink
 322 // ESC]8 sequences are supported
 323 func indexEscapeSequence(s []byte) (int, int) {
 324     var prev byte
 325 
 326     for i, b := range s {
 327         if prev == '\x1b' && b == '[' {
 328             j := indexLetter(s[i+1:])
 329             if j < 0 {
 330                 return i, -1
 331             }
 332             return i - 1, i + 1 + j + 1
 333         }
 334 
 335         if prev == '\x1b' && b == ']' && i+1 < len(s) && s[i+1] == '8' {
 336             j := indexPair(s[i+1:], '\x1b', '\\')
 337             if j < 0 {
 338                 return i, -1
 339             }
 340             return i - 1, i + 1 + j + 2
 341         }
 342 
 343         prev = b
 344     }
 345 
 346     return -1, -1
 347 }
 348 
 349 func indexLetter(s []byte) int {
 350     for i, b := range s {
 351         upper := b &^ 32
 352         if 'A' <= upper && upper <= 'Z' {
 353             return i
 354         }
 355     }
 356 
 357     return -1
 358 }
 359 
 360 func indexPair(s []byte, x byte, y byte) int {
 361     var prev byte
 362 
 363     for i, b := range s {
 364         if prev == x && b == y && i > 0 {
 365             return i
 366         }
 367         prev = b
 368     }
 369 
 370     return -1
 371 }
 372 
 373 // note: looking at the results of restoring ANSI-styles after style-resets
 374 // doesn't seem to be worth it, as a previous version used to do
 375 
 376 // handleChunk handles line-slices around any detected ANSI-style sequences,
 377 // or even whole lines, when no ANSI-styles are found in them
 378 func handleChunk(w *bufio.Writer, s []byte, with []pattern, n int, last int) {
 379     for len(s) > 0 {
 380         start, end := -1, -1
 381         for _, p := range with {
 382             i, j := p.findIndex(s, n, last)
 383             if i >= 0 && (i < start || start < 0) {
 384                 start, end = i, j
 385             }
 386         }
 387 
 388         if start < 0 {
 389             w.Write(s)
 390             return
 391         }
 392 
 393         w.Write(s[:start])
 394         w.WriteString(highlightStyle)
 395         w.Write(s[start:end])
 396         w.WriteString("\x1b[0m")
 397 
 398         s = s[end:]
 399     }
 400 }