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     }
 103 }
 104 
 105 func run(w io.Writer, args []string, cfg config) error {
 106     bw := bufio.NewWriter(w)
 107     defer bw.Flush()
 108 
 109     dashes := 0
 110     for _, name := range args {
 111         if name == `-` {
 112             dashes++
 113         }
 114         if dashes > 1 {
 115             break
 116         }
 117     }
 118 
 119     if len(args) == 0 {
 120         return catl(bw, os.Stdin, cfg)
 121     }
 122 
 123     var stdin []byte
 124     gotStdin := false
 125 
 126     for _, name := range args {
 127         if name == `-` {
 128             if dashes == 1 {
 129                 if err := catl(bw, os.Stdin, cfg); err != nil {
 130                     return err
 131                 }
 132                 continue
 133             }
 134 
 135             if !gotStdin {
 136                 data, err := io.ReadAll(os.Stdin)
 137                 if err != nil {
 138                     return err
 139                 }
 140                 stdin = data
 141                 gotStdin = true
 142             }
 143 
 144             bw.Write(stdin)
 145             if len(stdin) > 0 && stdin[len(stdin)-1] != '\n' {
 146                 bw.WriteByte('\n')
 147             }
 148 
 149             if !cfg.liveLines {
 150                 continue
 151             }
 152 
 153             if err := bw.Flush(); err != nil {
 154                 return io.EOF
 155             }
 156 
 157             continue
 158         }
 159 
 160         if err := handleFile(bw, name, cfg); err != nil {
 161             return err
 162         }
 163     }
 164     return nil
 165 }
 166 
 167 func handleFile(w *bufio.Writer, name string, cfg config) error {
 168     if name == `` || name == `-` {
 169         return catl(w, os.Stdin, cfg)
 170     }
 171 
 172     f, err := os.Open(name)
 173     if err != nil {
 174         return errors.New(`can't read from file named "` + name + `"`)
 175     }
 176     defer f.Close()
 177 
 178     return catl(w, f, cfg)
 179 }
 180 
 181 func catl(w *bufio.Writer, r io.Reader, cfg config) error {
 182     if !cfg.liveLines {
 183         return catlFast(w, r, cfg.null)
 184     }
 185 
 186     const gb = 1024 * 1024 * 1024
 187     sc := bufio.NewScanner(r)
 188     sc.Buffer(nil, 8*gb)
 189     if cfg.null {
 190         sc.Split(splitNull)
 191     }
 192 
 193     for i := 0; sc.Scan(); i++ {
 194         s := sc.Bytes()
 195         if i == 0 && bytes.HasPrefix(s, []byte{0xef, 0xbb, 0xbf}) {
 196             s = s[3:]
 197         }
 198 
 199         w.Write(s)
 200         if w.WriteByte('\n') != nil {
 201             return io.EOF
 202         }
 203 
 204         if err := w.Flush(); err != nil {
 205             return io.EOF
 206         }
 207     }
 208 
 209     return sc.Err()
 210 }
 211 
 212 func catlFast(w *bufio.Writer, r io.Reader, null bool) error {
 213     var buf [32 * 1024]byte
 214     var last byte = '\n'
 215 
 216     for i := 0; true; i++ {
 217         n, err := r.Read(buf[:])
 218         if n > 0 && err == io.EOF {
 219             err = nil
 220         }
 221         if err == io.EOF {
 222             if last != '\n' {
 223                 w.WriteByte('\n')
 224             }
 225             return nil
 226         }
 227 
 228         if err != nil {
 229             return err
 230         }
 231 
 232         chunk := buf[:n]
 233         if i == 0 && bytes.HasPrefix(chunk, []byte{0xef, 0xbb, 0xbf}) {
 234             chunk = chunk[3:]
 235         }
 236 
 237         // change nulls into line-feeds to handle null-terminated lines
 238         if null {
 239             for i, b := range chunk {
 240                 if b == 0 {
 241                     chunk[i] = '\n'
 242                 }
 243             }
 244         }
 245 
 246         if len(chunk) >= 1 {
 247             if _, err := w.Write(chunk); err != nil {
 248                 return io.EOF
 249             }
 250             last = chunk[len(chunk)-1]
 251         }
 252     }
 253 
 254     return nil
 255 }
 256 
 257 // splitNull is given to bufio.Scanner.Split to handle null-terminated lines
 258 func splitNull(data []byte, atEOF bool) (advance int, token []byte, err error) {
 259     // handle leading null-terminated line, if found in the current chunk
 260     if i := bytes.IndexByte(data, 0); i >= 0 {
 261         return i + 1, data[:i], nil
 262     }
 263 
 264     // request more data, in case there's a null coming up later
 265     if !atEOF {
 266         return 0, nil, nil
 267     }
 268 
 269     // handle non-empty non-terminated last chunk
 270     if len(data) > 0 {
 271         return len(data), data, bufio.ErrFinalToken
 272     }
 273 
 274     // handle empty non-terminated last chunk
 275     return 0, nil, bufio.ErrFinalToken
 276 }