File: erase.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 erase.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 erase [options...] [regexes...]
  44 
  45 
  46 Ignore/remove all occurrences of all regex matches along lines read from the
  47 standard input. The regular-expression mode used is "re2", which is a superset
  48 of the commonly-used "extended-mode".
  49 
  50 All ANSI-style sequences are removed before trying to match-remove things, to
  51 avoid messing those up. Each regex erases all its occurrences on the current
  52 line in the order given among the arguments, so regex-order matters.
  53 
  54 The options are, available both in single and double-dash versions
  55 
  56     -h, -help    show this help message
  57     -i, -ins     match regexes case-insensitively
  58 `
  59 
  60 func main() {
  61     args := os.Args[1:]
  62     buffered := false
  63     insensitive := false
  64 
  65     for len(args) > 0 {
  66         switch args[0] {
  67         case `-b`, `--b`, `-buffered`, `--buffered`:
  68             buffered = true
  69             args = args[1:]
  70             continue
  71 
  72         case `-h`, `--h`, `-help`, `--help`:
  73             os.Stdout.WriteString(info[1:])
  74             return
  75 
  76         case `-i`, `--i`, `-ins`, `--ins`:
  77             insensitive = true
  78             args = args[1:]
  79             continue
  80         }
  81 
  82         break
  83     }
  84 
  85     if len(args) > 0 && args[0] == `--` {
  86         args = args[1:]
  87     }
  88 
  89     exprs := make([]*regexp.Regexp, 0, len(args))
  90 
  91     for _, s := range args {
  92         var err error
  93         var exp *regexp.Regexp
  94 
  95         if insensitive {
  96             exp, err = regexp.Compile(`(?i)` + s)
  97         } else {
  98             exp, err = regexp.Compile(s)
  99         }
 100 
 101         if err != nil {
 102             os.Stderr.WriteString(err.Error())
 103             os.Stderr.WriteString("\n")
 104             continue
 105         }
 106 
 107         exprs = append(exprs, exp)
 108     }
 109 
 110     // quit right away when given invalid regexes
 111     if len(exprs) < len(args) {
 112         os.Exit(1)
 113         return
 114     }
 115 
 116     liveLines := !buffered
 117     if !buffered {
 118         if _, err := os.Stdout.Seek(0, io.SeekCurrent); err == nil {
 119             liveLines = false
 120         }
 121     }
 122 
 123     err := run(os.Stdout, os.Stdin, exprs, liveLines)
 124     if err != nil && err != io.EOF {
 125         os.Stderr.WriteString(err.Error())
 126         os.Stderr.WriteString("\n")
 127         os.Exit(1)
 128         return
 129     }
 130 }
 131 
 132 func run(w io.Writer, r io.Reader, exprs []*regexp.Regexp, live bool) error {
 133     var buf []byte
 134     sc := bufio.NewScanner(r)
 135     sc.Buffer(nil, 8*1024*1024*1024)
 136     bw := bufio.NewWriter(w)
 137     defer bw.Flush()
 138 
 139     src := make([]byte, 8*1024)
 140     dst := make([]byte, 8*1024)
 141 
 142     for i := 0; sc.Scan(); i++ {
 143         line := sc.Bytes()
 144         if i == 0 && bytes.HasPrefix(line, []byte{0xef, 0xbb, 0xbf}) {
 145             line = line[3:]
 146         }
 147 
 148         s := line
 149         if bytes.IndexByte(s, '\x1b') >= 0 {
 150             buf = plain(buf[:0], s)
 151             s = buf
 152         }
 153 
 154         if len(exprs) > 0 {
 155             src = append(src[:0], s...)
 156             for _, exp := range exprs {
 157                 dst = erase(dst[:0], src, exp)
 158                 src = append(src[:0], dst...)
 159             }
 160             bw.Write(dst)
 161         } else {
 162             bw.Write(s)
 163         }
 164 
 165         if bw.WriteByte('\n') != nil {
 166             return io.EOF
 167         }
 168 
 169         if !live {
 170             continue
 171         }
 172 
 173         if bw.Flush() != nil {
 174             return io.EOF
 175         }
 176     }
 177 
 178     return sc.Err()
 179 }
 180 
 181 func erase(dst []byte, src []byte, with *regexp.Regexp) []byte {
 182     for len(src) > 0 {
 183         span := with.FindIndex(src)
 184         // also ignore empty regex matches to avoid infinite outer loops,
 185         // as skipping empty slices isn't advancing at all, leaving the
 186         // string stuck to being empty-matched forever by the same regex
 187         if len(span) != 2 || span[0] == span[1] || span[0] < 0 {
 188             return append(dst, src...)
 189         }
 190 
 191         start, end := span[0], span[1]
 192         dst = append(dst, src[:start]...)
 193         // avoid infinite loops caused by empty regex matches
 194         if start == end && end < len(src) {
 195             dst = append(dst, src[end])
 196             end++
 197         }
 198         src = src[end:]
 199     }
 200 
 201     return dst
 202 }
 203 
 204 func plain(dst []byte, src []byte) []byte {
 205     for len(src) > 0 {
 206         i, j := indexEscapeSequence(src)
 207         if i < 0 {
 208             dst = append(dst, src...)
 209             break
 210         }
 211         if j < 0 {
 212             j = len(src)
 213         }
 214 
 215         if i > 0 {
 216             dst = append(dst, src[:i]...)
 217         }
 218 
 219         src = src[j:]
 220     }
 221 
 222     return dst
 223 }
 224 
 225 // indexEscapeSequence finds the first ANSI-style escape-sequence, which is
 226 // the multi-byte sequences starting with ESC[; the result is a pair of slice
 227 // indices which can be independently negative when either the start/end of
 228 // a sequence isn't found; given their fairly-common use, even the hyperlink
 229 // ESC]8 sequences are supported
 230 func indexEscapeSequence(s []byte) (int, int) {
 231     var prev byte
 232 
 233     for i, b := range s {
 234         if prev == '\x1b' && b == '[' {
 235             j := indexLetter(s[i+1:])
 236             if j < 0 {
 237                 return i, -1
 238             }
 239             return i - 1, i + 1 + j + 1
 240         }
 241 
 242         if prev == '\x1b' && b == ']' && i+1 < len(s) && s[i+1] == '8' {
 243             j := indexPair(s[i+1:], '\x1b', '\\')
 244             if j < 0 {
 245                 return i, -1
 246             }
 247             return i - 1, i + 1 + j + 2
 248         }
 249 
 250         prev = b
 251     }
 252 
 253     return -1, -1
 254 }
 255 
 256 func indexLetter(s []byte) int {
 257     for i, b := range s {
 258         upper := b &^ 32
 259         if 'A' <= upper && upper <= 'Z' {
 260             return i
 261         }
 262     }
 263 
 264     return -1
 265 }
 266 
 267 func indexPair(s []byte, x byte, y byte) int {
 268     var prev byte
 269 
 270     for i, b := range s {
 271         if prev == x && b == y && i > 0 {
 272             return i
 273         }
 274         prev = b
 275     }
 276 
 277     return -1
 278 }