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 }