File: plain.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 plain.go
  30 */
  31 
  32 package main
  33 
  34 import (
  35     "bufio"
  36     "errors"
  37     "io"
  38     "os"
  39 )
  40 
  41 // Note: the code is avoiding using the fmt package to save hundreds of
  42 // kilobytes on the resulting executable, which is a noticeable difference.
  43 
  44 const info = `
  45 plain [options...] [file...]
  46 
  47 
  48 Turn potentially ANSI-styled plain-text into actual plain-text.
  49 
  50 Input is assumed to be UTF-8, and all CRLF byte-pairs are turned into line
  51 feeds.
  52 
  53 All (optional) leading options start with either single or double-dash:
  54 
  55     -h          show this help message
  56     -help       show this help message
  57 `
  58 
  59 // errNoMoreOutput is a dummy error whose message is ignored, and which
  60 // causes the app to quit immediately and successfully
  61 var errNoMoreOutput = errors.New(`no more output`)
  62 
  63 func main() {
  64     buffered := false
  65     args := os.Args[1:]
  66 
  67     if len(args) > 0 {
  68         switch args[0] {
  69         case `-b`, `--b`, `-buffered`, `--buffered`:
  70             buffered = true
  71             args = args[1:]
  72 
  73         case `-h`, `--h`, `-help`, `--help`:
  74             os.Stdout.WriteString(info[1:])
  75             return
  76         }
  77     }
  78 
  79     if len(args) > 0 && args[0] == `--` {
  80         args = args[1:]
  81     }
  82 
  83     liveLines := !buffered
  84     if !buffered {
  85         if _, err := os.Stdout.Seek(0, io.SeekCurrent); err == nil {
  86             liveLines = false
  87         }
  88     }
  89 
  90     if err := run(os.Stdout, args, liveLines); isActualError(err) {
  91         os.Stderr.WriteString(err.Error())
  92         os.Stderr.WriteString("\n")
  93         os.Exit(1)
  94     }
  95 }
  96 
  97 func run(w io.Writer, args []string, live bool) error {
  98     bw := bufio.NewWriter(w)
  99     defer bw.Flush()
 100 
 101     if len(args) == 0 {
 102         return plain(bw, os.Stdin, live)
 103     }
 104 
 105     for _, name := range args {
 106         if err := handleFile(bw, name, live); err != nil {
 107             return err
 108         }
 109     }
 110     return nil
 111 }
 112 
 113 func handleFile(w *bufio.Writer, name string, live bool) error {
 114     if name == `` || name == `-` {
 115         return plain(w, os.Stdin, live)
 116     }
 117 
 118     f, err := os.Open(name)
 119     if err != nil {
 120         return errors.New(`can't read from file named "` + name + `"`)
 121     }
 122     defer f.Close()
 123 
 124     return plain(w, f, live)
 125 }
 126 
 127 // isActualError is to figure out whether not to ignore an error, and thus
 128 // show it as an error message
 129 func isActualError(err error) bool {
 130     return err != nil && err != errNoMoreOutput
 131 }
 132 
 133 // indexEscapeSequence finds the first ANSI-style escape-sequence, which is
 134 // the multi-byte sequences starting with ESC[; the result is a pair of slice
 135 // indices which can be independently negative when either the start/end of
 136 // a sequence isn't found; given their fairly-common use, even the hyperlink
 137 // ESC]8 sequences are supported
 138 func indexEscapeSequence(s []byte) (int, int) {
 139     var prev byte
 140 
 141     for i, b := range s {
 142         if prev == '\x1b' && b == '[' {
 143             j := indexLetter(s[i+1:])
 144             if j < 0 {
 145                 return i, -1
 146             }
 147             return i - 1, i + 1 + j + 1
 148         }
 149 
 150         if prev == '\x1b' && b == ']' && i+1 < len(s) && s[i+1] == '8' {
 151             j := indexPair(s[i+1:], '\x1b', '\\')
 152             if j < 0 {
 153                 return i, -1
 154             }
 155             return i - 1, i + 1 + j + 2
 156         }
 157 
 158         prev = b
 159     }
 160 
 161     return -1, -1
 162 }
 163 
 164 func indexLetter(s []byte) int {
 165     for i, b := range s {
 166         upper := b &^ 32
 167         if 'A' <= upper && upper <= 'Z' {
 168             return i
 169         }
 170     }
 171 
 172     return -1
 173 }
 174 
 175 func indexPair(s []byte, x byte, y byte) int {
 176     var prev byte
 177 
 178     for i, b := range s {
 179         if prev == x && b == y {
 180             return i
 181         }
 182         prev = b
 183     }
 184 
 185     return -1
 186 }
 187 
 188 func plain(w *bufio.Writer, r io.Reader, live bool) error {
 189     const gb = 1024 * 1024 * 1024
 190     sc := bufio.NewScanner(r)
 191     sc.Buffer(nil, 8*gb)
 192 
 193     for i := 0; sc.Scan(); i++ {
 194         s := sc.Bytes()
 195         if i == 0 && len(s) > 2 && s[0] == 0xef && s[1] == 0xbb && s[2] == 0xbf {
 196             s = s[3:]
 197         }
 198 
 199         for line := s; len(line) > 0; {
 200             i, j := indexEscapeSequence(line)
 201             if i < 0 {
 202                 w.Write(line)
 203                 break
 204             }
 205             if j < 0 {
 206                 j = len(line)
 207             }
 208 
 209             if i > 0 {
 210                 w.Write(line[:i])
 211             }
 212 
 213             line = line[j:]
 214         }
 215 
 216         if w.WriteByte('\n') != nil {
 217             return errNoMoreOutput
 218         }
 219 
 220         if !live {
 221             continue
 222         }
 223 
 224         if err := w.Flush(); err != nil {
 225             return errNoMoreOutput
 226         }
 227     }
 228 
 229     return sc.Err()
 230 }