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