A virtual jailed shell environment for Go apps backed by an io/fs#FS.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(command): port tail from just-bash

Assisted-by: Claude Opus 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

Xe Iaso 7b0d6700 afb33438

+527
+290
command/internal/tail/tail.go
··· 1 + package tail 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "path" 9 + "strconv" 10 + "strings" 11 + 12 + "github.com/pborman/getopt/v2" 13 + "mvdan.cc/sh/v3/interp" 14 + "tangled.org/xeiaso.net/kefka/command" 15 + ) 16 + 17 + type Impl struct{} 18 + 19 + func (Impl) Exec(_ context.Context, ec *command.ExecContext, args []string) error { 20 + if ec == nil { 21 + return errors.New("tail: nil ExecContext") 22 + } 23 + 24 + stdout := ec.Stdout 25 + if stdout == nil { 26 + stdout = io.Discard 27 + } 28 + stderr := ec.Stderr 29 + if stderr == nil { 30 + stderr = io.Discard 31 + } 32 + 33 + set := getopt.New() 34 + set.SetProgram("tail") 35 + set.SetParameters("[FILE]...") 36 + 37 + usage := func() { 38 + fmt.Fprint(stderr, "Usage: tail [OPTION]... [FILE]...\n") 39 + fmt.Fprint(stderr, "Print the last 10 lines of each FILE to standard output.\n") 40 + fmt.Fprint(stderr, "With more than one FILE, precede each with a header giving the file name.\n") 41 + fmt.Fprint(stderr, "With no FILE, or when FILE is -, read standard input.\n\n") 42 + fmt.Fprint(stderr, " -c, --bytes=NUM print the last NUM bytes\n") 43 + fmt.Fprint(stderr, " -n, --lines=NUM print the last NUM lines (default 10)\n") 44 + fmt.Fprint(stderr, " -n +NUM print starting from line NUM\n") 45 + fmt.Fprint(stderr, " -q, --quiet never print headers giving file names\n") 46 + fmt.Fprint(stderr, " -v, --verbose always print headers giving file names\n") 47 + fmt.Fprint(stderr, " --help display this help and exit\n") 48 + } 49 + set.SetUsage(usage) 50 + 51 + bytesSpec := set.StringLong("bytes", 'c', "", "print the last NUM bytes") 52 + linesSpec := set.StringLong("lines", 'n', "", "print the last NUM lines (default 10)") 53 + quiet := set.BoolLong("quiet", 'q', "never print headers giving file names") 54 + silent := set.BoolLong("silent", 0, "alias for --quiet") 55 + verbose := set.BoolLong("verbose", 'v', "always print headers giving file names") 56 + help := set.BoolLong("help", 0, "display this help and exit") 57 + 58 + preArgs := preprocessShortNum(args) 59 + 60 + if err := set.Getopt(append([]string{"tail"}, preArgs...), nil); err != nil { 61 + fmt.Fprintf(stderr, "tail: %s\n", err) 62 + usage() 63 + return interp.ExitStatus(1) 64 + } 65 + if *help { 66 + usage() 67 + return nil 68 + } 69 + 70 + lines := 10 71 + bytes := 0 72 + bytesSet := false 73 + fromLine := false 74 + 75 + if *bytesSpec != "" { 76 + n, err := strconv.Atoi(*bytesSpec) 77 + if err != nil || n < 0 { 78 + fmt.Fprint(stderr, "tail: invalid number of bytes\n") 79 + return interp.ExitStatus(1) 80 + } 81 + bytes = n 82 + bytesSet = true 83 + } 84 + if *linesSpec != "" { 85 + spec := *linesSpec 86 + if strings.HasPrefix(spec, "+") { 87 + fromLine = true 88 + spec = spec[1:] 89 + } 90 + n, err := strconv.Atoi(spec) 91 + if err != nil || n < 0 { 92 + fmt.Fprint(stderr, "tail: invalid number of lines\n") 93 + return interp.ExitStatus(1) 94 + } 95 + lines = n 96 + } 97 + 98 + isQuiet := *quiet || *silent 99 + files := set.Args() 100 + 101 + if len(files) == 0 { 102 + content, err := readStdin(ec) 103 + if err != nil { 104 + return err 105 + } 106 + io.WriteString(stdout, getTail(content, lines, bytes, bytesSet, fromLine)) 107 + return nil 108 + } 109 + 110 + showHeaders := *verbose || (!isQuiet && len(files) > 1) 111 + var output strings.Builder 112 + exitCode := 0 113 + filesProcessed := 0 114 + 115 + for _, file := range files { 116 + content, err := readFile(ec, file) 117 + if err != nil { 118 + fmt.Fprintf(stderr, "tail: %s: No such file or directory\n", file) 119 + exitCode = 1 120 + continue 121 + } 122 + if showHeaders { 123 + if filesProcessed > 0 { 124 + output.WriteByte('\n') 125 + } 126 + fmt.Fprintf(&output, "==> %s <==\n", file) 127 + } 128 + output.WriteString(getTail(content, lines, bytes, bytesSet, fromLine)) 129 + filesProcessed++ 130 + } 131 + 132 + io.WriteString(stdout, output.String()) 133 + 134 + if exitCode != 0 { 135 + return interp.ExitStatus(uint8(exitCode)) 136 + } 137 + return nil 138 + } 139 + 140 + // preprocessShortNum rewrites the GNU coreutils -NUM shorthand (e.g. -5) 141 + // into "-n NUM" so that getopt can parse it. Stops at "--" and skips the 142 + // value of any preceding -c/-n/--bytes/--lines so that "-c -5" is left 143 + // alone for getopt to flag as invalid. 144 + func preprocessShortNum(args []string) []string { 145 + out := make([]string, 0, len(args)) 146 + skipNext := false 147 + seenDoubleDash := false 148 + for _, a := range args { 149 + if seenDoubleDash { 150 + out = append(out, a) 151 + continue 152 + } 153 + if skipNext { 154 + skipNext = false 155 + out = append(out, a) 156 + continue 157 + } 158 + if a == "--" { 159 + seenDoubleDash = true 160 + out = append(out, a) 161 + continue 162 + } 163 + if a == "-c" || a == "-n" || a == "--bytes" || a == "--lines" { 164 + skipNext = true 165 + out = append(out, a) 166 + continue 167 + } 168 + if len(a) >= 2 && a[0] == '-' && a[1] >= '0' && a[1] <= '9' { 169 + allDigits := true 170 + for _, c := range a[1:] { 171 + if c < '0' || c > '9' { 172 + allDigits = false 173 + break 174 + } 175 + } 176 + if allDigits { 177 + out = append(out, "-n", a[1:]) 178 + continue 179 + } 180 + } 181 + out = append(out, a) 182 + } 183 + return out 184 + } 185 + 186 + func getTail(content string, lines int, bytes int, bytesSet bool, fromLine bool) string { 187 + if bytesSet { 188 + if bytes >= len(content) { 189 + return content 190 + } 191 + return content[len(content)-bytes:] 192 + } 193 + n := len(content) 194 + if n == 0 { 195 + return "" 196 + } 197 + if fromLine { 198 + pos := 0 199 + lineCount := 1 200 + for pos < n && lineCount < lines { 201 + idx := strings.IndexByte(content[pos:], '\n') 202 + if idx == -1 { 203 + break 204 + } 205 + lineCount++ 206 + pos += idx + 1 207 + } 208 + result := content[pos:] 209 + if !strings.HasSuffix(result, "\n") { 210 + result += "\n" 211 + } 212 + return result 213 + } 214 + if lines == 0 { 215 + return "" 216 + } 217 + pos := n - 1 218 + if content[pos] == '\n' { 219 + pos-- 220 + } 221 + lineCount := 0 222 + for pos >= 0 && lineCount < lines { 223 + if content[pos] == '\n' { 224 + lineCount++ 225 + if lineCount == lines { 226 + pos++ 227 + break 228 + } 229 + } 230 + pos-- 231 + } 232 + if pos < 0 { 233 + pos = 0 234 + } 235 + result := content[pos:] 236 + if content[n-1] != '\n' { 237 + result += "\n" 238 + } 239 + return result 240 + } 241 + 242 + func readStdin(ec *command.ExecContext) (string, error) { 243 + if ec.Stdin == nil { 244 + return "", nil 245 + } 246 + data, err := io.ReadAll(ec.Stdin) 247 + if err != nil { 248 + return "", interp.ExitStatus(1) 249 + } 250 + return string(data), nil 251 + } 252 + 253 + func readFile(ec *command.ExecContext, file string) (string, error) { 254 + if file == "-" { 255 + return readStdin(ec) 256 + } 257 + if ec.FS == nil { 258 + return "", errors.New("no filesystem") 259 + } 260 + full := resolvePath(ec, file) 261 + f, err := ec.FS.Open(full) 262 + if err != nil { 263 + return "", err 264 + } 265 + defer f.Close() 266 + data, err := io.ReadAll(f) 267 + if err != nil { 268 + return "", err 269 + } 270 + return string(data), nil 271 + } 272 + 273 + func resolvePath(ec *command.ExecContext, p string) string { 274 + dir := ec.Dir 275 + if dir == "" { 276 + dir = "." 277 + } 278 + if path.IsAbs(p) { 279 + p = strings.TrimPrefix(p, "/") 280 + if p == "" { 281 + return "." 282 + } 283 + return path.Clean(p) 284 + } 285 + joined := path.Join(dir, p) 286 + if joined == "" { 287 + return "." 288 + } 289 + return joined 290 + }
+237
command/internal/tail/tail_test.go
··· 1 + package tail 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "os" 7 + "strings" 8 + "testing" 9 + 10 + "github.com/go-git/go-billy/v5" 11 + "github.com/go-git/go-billy/v5/memfs" 12 + "tangled.org/xeiaso.net/kefka/command" 13 + ) 14 + 15 + func newFS(t *testing.T) billy.Filesystem { 16 + t.Helper() 17 + fs := memfs.New() 18 + write := func(name string, data []byte) { 19 + f, err := fs.OpenFile(name, os.O_CREATE|os.O_WRONLY, 0o644) 20 + if err != nil { 21 + t.Fatal(err) 22 + } 23 + f.Write(data) 24 + f.Close() 25 + } 26 + write("twelve.txt", []byte("L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10\nL11\nL12\n")) 27 + write("five.txt", []byte("a\nb\nc\nd\ne\n")) 28 + write("nofinalnl.txt", []byte("x1\nx2\nx3")) 29 + write("empty.txt", []byte("")) 30 + write("bytes.txt", []byte("abcdefghij")) 31 + return fs 32 + } 33 + 34 + func run(t *testing.T, args []string, stdin string, fs billy.Filesystem) (string, string, error) { 35 + t.Helper() 36 + var stdout, stderr bytes.Buffer 37 + ec := &command.ExecContext{ 38 + Stdin: strings.NewReader(stdin), 39 + Stdout: &stdout, 40 + Stderr: &stderr, 41 + Dir: ".", 42 + FS: fs, 43 + } 44 + err := Impl{}.Exec(context.Background(), ec, args) 45 + return stdout.String(), stderr.String(), err 46 + } 47 + 48 + func TestTail(t *testing.T) { 49 + tests := []struct { 50 + name string 51 + args []string 52 + stdin string 53 + wantStdout string 54 + wantErrSub string 55 + wantErr bool 56 + }{ 57 + { 58 + name: "default 10 lines from stdin", 59 + stdin: "L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10\nL11\nL12\n", 60 + wantStdout: "L3\nL4\nL5\nL6\nL7\nL8\nL9\nL10\nL11\nL12\n", 61 + }, 62 + { 63 + name: "default 10 lines from file", 64 + args: []string{"twelve.txt"}, 65 + wantStdout: "L3\nL4\nL5\nL6\nL7\nL8\nL9\nL10\nL11\nL12\n", 66 + }, 67 + { 68 + name: "fewer lines than requested", 69 + args: []string{"five.txt"}, 70 + wantStdout: "a\nb\nc\nd\ne\n", 71 + }, 72 + { 73 + name: "short -n flag", 74 + args: []string{"-n", "3", "twelve.txt"}, 75 + wantStdout: "L10\nL11\nL12\n", 76 + }, 77 + { 78 + name: "short -n no space", 79 + args: []string{"-n3", "twelve.txt"}, 80 + wantStdout: "L10\nL11\nL12\n", 81 + }, 82 + { 83 + name: "long --lines=N", 84 + args: []string{"--lines=3", "twelve.txt"}, 85 + wantStdout: "L10\nL11\nL12\n", 86 + }, 87 + { 88 + name: "GNU shorthand -N", 89 + args: []string{"-3", "twelve.txt"}, 90 + wantStdout: "L10\nL11\nL12\n", 91 + }, 92 + { 93 + name: "from line +N space form", 94 + args: []string{"-n", "+10", "twelve.txt"}, 95 + wantStdout: "L10\nL11\nL12\n", 96 + }, 97 + { 98 + name: "from line +N no space", 99 + args: []string{"-n+10", "twelve.txt"}, 100 + wantStdout: "L10\nL11\nL12\n", 101 + }, 102 + { 103 + name: "from line +1 prints all", 104 + args: []string{"-n", "+1", "five.txt"}, 105 + wantStdout: "a\nb\nc\nd\ne\n", 106 + }, 107 + { 108 + name: "lines zero produces nothing", 109 + args: []string{"-n", "0", "twelve.txt"}, 110 + wantStdout: "", 111 + }, 112 + { 113 + name: "byte mode", 114 + args: []string{"-c", "3", "bytes.txt"}, 115 + wantStdout: "hij", 116 + }, 117 + { 118 + name: "byte mode no space", 119 + args: []string{"-c3", "bytes.txt"}, 120 + wantStdout: "hij", 121 + }, 122 + { 123 + name: "long --bytes=N", 124 + args: []string{"--bytes=3", "bytes.txt"}, 125 + wantStdout: "hij", 126 + }, 127 + { 128 + name: "byte mode beyond eof returns full content", 129 + args: []string{"-c", "100", "bytes.txt"}, 130 + wantStdout: "abcdefghij", 131 + }, 132 + { 133 + name: "no trailing newline gets one appended", 134 + args: []string{"-n", "2", "nofinalnl.txt"}, 135 + wantStdout: "x2\nx3\n", 136 + }, 137 + { 138 + name: "empty file produces empty output", 139 + args: []string{"empty.txt"}, 140 + wantStdout: "", 141 + }, 142 + { 143 + name: "stdin via dash", 144 + args: []string{"-n", "2", "-"}, 145 + stdin: "p\nq\nr\n", 146 + wantStdout: "q\nr\n", 147 + }, 148 + { 149 + name: "multiple files prepend headers", 150 + args: []string{"-n", "1", "five.txt", "twelve.txt"}, 151 + wantStdout: "==> five.txt <==\ne\n\n==> twelve.txt <==\nL12\n", 152 + }, 153 + { 154 + name: "quiet suppresses headers across files", 155 + args: []string{"-q", "-n", "1", "five.txt", "twelve.txt"}, 156 + wantStdout: "e\nL12\n", 157 + }, 158 + { 159 + name: "silent alias suppresses headers", 160 + args: []string{"--silent", "-n", "1", "five.txt", "twelve.txt"}, 161 + wantStdout: "e\nL12\n", 162 + }, 163 + { 164 + name: "verbose forces header on single file", 165 + args: []string{"-v", "-n", "1", "five.txt"}, 166 + wantStdout: "==> five.txt <==\ne\n", 167 + }, 168 + { 169 + name: "missing file errors but other files still processed", 170 + args: []string{"-n", "1", "nope.txt", "five.txt"}, 171 + wantStdout: "==> five.txt <==\ne\n", 172 + wantErrSub: "tail: nope.txt: No such file or directory", 173 + wantErr: true, 174 + }, 175 + { 176 + name: "negative lines is invalid", 177 + args: []string{"-n", "-5", "five.txt"}, 178 + wantErrSub: "invalid number of lines", 179 + wantErr: true, 180 + }, 181 + { 182 + name: "negative bytes is invalid", 183 + args: []string{"-c", "-5", "five.txt"}, 184 + wantErrSub: "invalid number of bytes", 185 + wantErr: true, 186 + }, 187 + { 188 + name: "non-numeric lines is invalid", 189 + args: []string{"-n", "abc", "five.txt"}, 190 + wantErrSub: "invalid number of lines", 191 + wantErr: true, 192 + }, 193 + { 194 + name: "unknown flag errors", 195 + args: []string{"--nope", "five.txt"}, 196 + wantErr: true, 197 + }, 198 + } 199 + 200 + for _, tt := range tests { 201 + t.Run(tt.name, func(t *testing.T) { 202 + stdout, stderr, err := run(t, tt.args, tt.stdin, newFS(t)) 203 + if tt.wantErr { 204 + if err == nil { 205 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 206 + } 207 + } else if err != nil { 208 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 209 + } 210 + if stdout != tt.wantStdout { 211 + t.Errorf("stdout = %q, want %q", stdout, tt.wantStdout) 212 + } 213 + if tt.wantErrSub != "" && !strings.Contains(stderr, tt.wantErrSub) { 214 + t.Errorf("stderr = %q, want substring %q", stderr, tt.wantErrSub) 215 + } 216 + }) 217 + } 218 + } 219 + 220 + func TestHelp(t *testing.T) { 221 + stdout, stderr, err := run(t, []string{"--help"}, "", newFS(t)) 222 + if err != nil { 223 + t.Fatalf("unexpected error: %v", err) 224 + } 225 + if stdout != "" { 226 + t.Errorf("expected empty stdout, got %q", stdout) 227 + } 228 + if !strings.Contains(stderr, "Usage: tail [OPTION]... [FILE]...") { 229 + t.Errorf("usage line missing from stderr: %q", stderr) 230 + } 231 + if !strings.Contains(stderr, "--lines=NUM") { 232 + t.Errorf("lines flag missing from help: %q", stderr) 233 + } 234 + if !strings.Contains(stderr, "+NUM") { 235 + t.Errorf("from-line +NUM doc missing from help: %q", stderr) 236 + } 237 + }