File: catl.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 catl.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 catl [options...] [file...]
  44 
  45 
  46 Unlike "cat", conCATenate Lines ensures lines across inputs are never joined
  47 by accident, when an input's last line doesn't end with a line-feed.
  48 
  49 Input is assumed to be UTF-8, and all CRLF byte-pairs are turned into line
  50 feeds. Leading BOM (byte-order marks) on first lines are also ignored.
  51 
  52 All (optional) leading options start with either single or double-dash:
  53 
  54     -h, -help    show this help message
  55     -0, -null    turn null-byte-delimited chunks into proper lines
  56 `
  57 
  58 type config struct {
  59     null      bool
  60     liveLines bool
  61 }
  62 
  63 func main() {
  64     var cfg config
  65     cfg.liveLines = true
  66     args := os.Args[1:]
  67 
  68     for len(args) > 0 {
  69         switch args[0] {
  70         case `-0`, `--0`, `-null`, `--null`:
  71             cfg.null = true
  72             args = args[1:]
  73             continue
  74 
  75         case `-b`, `--b`, `-buffered`, `--buffered`:
  76             cfg.liveLines = false
  77             args = args[1:]
  78             continue
  79 
  80         case `-h`, `--h`, `-help`, `--help`:
  81             os.Stdout.WriteString(info[1:])
  82             return
  83         }
  84 
  85         break
  86     }
  87 
  88     if len(args) > 0 && args[0] == `--` {
  89         args = args[1:]
  90     }
  91 
  92     if cfg.liveLines {
  93         if _, err := os.Stdout.Seek(0, io.SeekCurrent); err == nil {
  94             cfg.liveLines = false
  95         }
  96     }
  97 
  98     if err := run(os.Stdout, args, cfg); err != nil && err != io.EOF {
  99         os.Stderr.WriteString(err.Error())
 100         os.Stderr.WriteString("\n")
 101         os.Exit(1)
 102         return
 103     }
 104 }
 105 
 106 func run(w io.Writer, args []string, cfg config) error {
 107     bw := bufio.NewWriter(w)
 108     defer bw.Flush()
 109 
 110     dashes := 0
 111     for _, name := range args {
 112         if name == `-` {
 113             dashes++
 114         }
 115         if dashes > 1 {
 116             break
 117         }
 118     }
 119 
 120     if len(args) == 0 {
 121         return catl(bw, os.Stdin, cfg)
 122     }
 123 
 124     var stdin []byte
 125     gotStdin := false
 126 
 127     for _, name := range args {
 128         if name == `-` {
 129             if dashes == 1 {
 130                 if err := catl(bw, os.Stdin, cfg); err != nil {
 131                     return err
 132                 }
 133                 continue
 134             }
 135 
 136             if !gotStdin {
 137                 data, err := io.ReadAll(os.Stdin)
 138                 if err != nil {
 139                     return err
 140                 }
 141                 stdin = data
 142                 gotStdin = true
 143             }
 144 
 145             bw.Write(stdin)
 146             if len(stdin) > 0 && stdin[len(stdin)-1] != '\n' {
 147                 bw.WriteByte('\n')
 148             }
 149 
 150             if !cfg.liveLines {
 151                 continue
 152             }
 153 
 154             if err := bw.Flush(); err != nil {
 155                 return io.EOF
 156             }
 157 
 158             continue
 159         }
 160 
 161         if err := handleFile(bw, name, cfg); err != nil {
 162             return err
 163         }
 164     }
 165     return nil
 166 }
 167 
 168 func handleFile(w *bufio.Writer, name string, cfg config) error {
 169     if name == `` || name == `-` {
 170         return catl(w, os.Stdin, cfg)
 171     }
 172 
 173     f, err := os.Open(name)
 174     if err != nil {
 175         return errors.New(`can't read from file named "` + name + `"`)
 176     }
 177     defer f.Close()
 178 
 179     return catl(w, f, cfg)
 180 }
 181 
 182 func catl(w *bufio.Writer, r io.Reader, cfg config) error {
 183     if !cfg.liveLines {
 184         return catlFast(w, r, cfg.null)
 185     }
 186 
 187     const gb = 1024 * 1024 * 1024
 188     sc := bufio.NewScanner(r)
 189     sc.Buffer(nil, 8*gb)
 190     if cfg.null {
 191         sc.Split(splitNull)
 192     }
 193 
 194     for i := 0; sc.Scan(); i++ {
 195         s := sc.Bytes()
 196         if i == 0 && bytes.HasPrefix(s, []byte{0xef, 0xbb, 0xbf}) {
 197             s = s[3:]
 198         }
 199 
 200         w.Write(s)
 201         if w.WriteByte('\n') != nil {
 202             return io.EOF
 203         }
 204 
 205         if err := w.Flush(); err != nil {
 206             return io.EOF
 207         }
 208     }
 209 
 210     return sc.Err()
 211 }
 212 
 213 func catlFast(w *bufio.Writer, r io.Reader, null bool) error {
 214     var buf [32 * 1024]byte
 215     var last byte = '\n'
 216 
 217     for i := 0; true; i++ {
 218         n, err := r.Read(buf[:])
 219         if n > 0 && err == io.EOF {
 220             err = nil
 221         }
 222         if err == io.EOF {
 223             if last != '\n' {
 224                 w.WriteByte('\n')
 225             }
 226             return nil
 227         }
 228 
 229         if err != nil {
 230             return err
 231         }
 232 
 233         chunk := buf[:n]
 234         if i == 0 && bytes.HasPrefix(chunk, []byte{0xef, 0xbb, 0xbf}) {
 235             chunk = chunk[3:]
 236         }
 237 
 238         // change nulls into line-feeds to handle null-terminated lines
 239         if null {
 240             for i, b := range chunk {
 241                 if b == 0 {
 242                     chunk[i] = '\n'
 243                 }
 244             }
 245         }
 246 
 247         if len(chunk) >= 1 {
 248             if _, err := w.Write(chunk); err != nil {
 249                 return io.EOF
 250             }
 251             last = chunk[len(chunk)-1]
 252         }
 253     }
 254 
 255     return nil
 256 }
 257 
 258 // splitNull is given to bufio.Scanner.Split to handle null-terminated lines
 259 func splitNull(data []byte, atEOF bool) (advance int, token []byte, err error) {
 260     // handle leading null-terminated line, if found in the current chunk
 261     if i := bytes.IndexByte(data, 0); i >= 0 {
 262         return i + 1, data[:i], nil
 263     }
 264 
 265     // request more data, in case there's a null coming up later
 266     if !atEOF {
 267         return 0, nil, nil
 268     }
 269 
 270     // handle non-empty non-terminated last chunk
 271     if len(data) > 0 {
 272         return len(data), data, bufio.ErrFinalToken
 273     }
 274 
 275     // handle empty non-terminated last chunk
 276     return 0, nil, bufio.ErrFinalToken
 277 }