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 }