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 tac from just-bash

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

Xe Iaso afb33438 93e49635

+304
+142
command/internal/tac/tac.go
··· 1 + package tac 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "path" 9 + "strings" 10 + 11 + "github.com/pborman/getopt/v2" 12 + "mvdan.cc/sh/v3/interp" 13 + "tangled.org/xeiaso.net/kefka/command" 14 + ) 15 + 16 + type Impl struct{} 17 + 18 + func (Impl) Exec(_ context.Context, ec *command.ExecContext, args []string) error { 19 + if ec == nil { 20 + return errors.New("tac: nil ExecContext") 21 + } 22 + 23 + stdout := ec.Stdout 24 + if stdout == nil { 25 + stdout = io.Discard 26 + } 27 + stderr := ec.Stderr 28 + if stderr == nil { 29 + stderr = io.Discard 30 + } 31 + 32 + set := getopt.New() 33 + set.SetProgram("tac") 34 + set.SetParameters("[FILE]...") 35 + 36 + usage := func() { 37 + fmt.Fprint(stderr, "Usage: tac [OPTION]... [FILE]...\n") 38 + fmt.Fprint(stderr, "Write each FILE to standard output, last line first.\n") 39 + fmt.Fprint(stderr, "With no FILE, or when FILE is -, read standard input.\n\n") 40 + fmt.Fprint(stderr, " --help display this help and exit\n") 41 + } 42 + set.SetUsage(usage) 43 + 44 + help := set.BoolLong("help", 0, "display this help and exit") 45 + 46 + if err := set.Getopt(append([]string{"tac"}, args...), nil); err != nil { 47 + fmt.Fprintf(stderr, "tac: %s\n", err) 48 + usage() 49 + return interp.ExitStatus(1) 50 + } 51 + if *help { 52 + usage() 53 + return nil 54 + } 55 + 56 + positional := set.Args() 57 + 58 + var content string 59 + if len(positional) > 0 && positional[0] != "-" { 60 + data, err := readFile(ec, positional[0], stderr) 61 + if err != nil { 62 + return err 63 + } 64 + content = data 65 + } else { 66 + data, err := readStdin(ec) 67 + if err != nil { 68 + return err 69 + } 70 + content = data 71 + } 72 + 73 + io.WriteString(stdout, reverseLines(content)) 74 + return nil 75 + } 76 + 77 + func reverseLines(content string) string { 78 + lines := strings.Split(content, "\n") 79 + if len(lines) > 0 && lines[len(lines)-1] == "" { 80 + lines = lines[:len(lines)-1] 81 + } 82 + if len(lines) == 0 { 83 + return "" 84 + } 85 + 86 + var out strings.Builder 87 + for i := len(lines) - 1; i >= 0; i-- { 88 + out.WriteString(lines[i]) 89 + out.WriteByte('\n') 90 + } 91 + return out.String() 92 + } 93 + 94 + func readStdin(ec *command.ExecContext) (string, error) { 95 + if ec.Stdin == nil { 96 + return "", nil 97 + } 98 + data, err := io.ReadAll(ec.Stdin) 99 + if err != nil { 100 + return "", interp.ExitStatus(1) 101 + } 102 + return string(data), nil 103 + } 104 + 105 + func readFile(ec *command.ExecContext, file string, stderr io.Writer) (string, error) { 106 + if ec.FS == nil { 107 + fmt.Fprintf(stderr, "tac: %s: No such file or directory\n", file) 108 + return "", interp.ExitStatus(1) 109 + } 110 + full := resolvePath(ec, file) 111 + f, err := ec.FS.Open(full) 112 + if err != nil { 113 + fmt.Fprintf(stderr, "tac: %s: No such file or directory\n", file) 114 + return "", interp.ExitStatus(1) 115 + } 116 + data, err := io.ReadAll(f) 117 + f.Close() 118 + if err != nil { 119 + fmt.Fprintf(stderr, "tac: %s: %v\n", file, err) 120 + return "", interp.ExitStatus(1) 121 + } 122 + return string(data), nil 123 + } 124 + 125 + func resolvePath(ec *command.ExecContext, p string) string { 126 + dir := ec.Dir 127 + if dir == "" { 128 + dir = "." 129 + } 130 + if path.IsAbs(p) { 131 + p = strings.TrimPrefix(p, "/") 132 + if p == "" { 133 + return "." 134 + } 135 + return path.Clean(p) 136 + } 137 + joined := path.Join(dir, p) 138 + if joined == "" { 139 + return "." 140 + } 141 + return joined 142 + }
+162
command/internal/tac/tac_test.go
··· 1 + package tac 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("hello.txt", []byte("alpha\nbravo\ncharlie\n")) 27 + write("nofinalnl.txt", []byte("foo\nbar")) 28 + write("blanks.txt", []byte("a\n\nb\n")) 29 + write("single.txt", []byte("only\n")) 30 + write("empty.txt", []byte("")) 31 + write("justnewline.txt", []byte("\n")) 32 + return fs 33 + } 34 + 35 + func run(t *testing.T, args []string, stdin string, fs billy.Filesystem) (string, string, error) { 36 + t.Helper() 37 + var stdout, stderr bytes.Buffer 38 + ec := &command.ExecContext{ 39 + Stdin: strings.NewReader(stdin), 40 + Stdout: &stdout, 41 + Stderr: &stderr, 42 + Dir: ".", 43 + FS: fs, 44 + } 45 + err := Impl{}.Exec(context.Background(), ec, args) 46 + return stdout.String(), stderr.String(), err 47 + } 48 + 49 + func TestTac(t *testing.T) { 50 + tests := []struct { 51 + name string 52 + args []string 53 + stdin string 54 + wantStdout string 55 + wantErrSub string 56 + wantErr bool 57 + }{ 58 + { 59 + name: "reverses lines from stdin", 60 + args: nil, 61 + stdin: "alpha\nbravo\ncharlie\n", 62 + wantStdout: "charlie\nbravo\nalpha\n", 63 + }, 64 + { 65 + name: "reverses lines from file", 66 + args: []string{"hello.txt"}, 67 + wantStdout: "charlie\nbravo\nalpha\n", 68 + }, 69 + { 70 + name: "dash means stdin", 71 + args: []string{"-"}, 72 + stdin: "one\ntwo\n", 73 + wantStdout: "two\none\n", 74 + }, 75 + { 76 + name: "no trailing newline still reverses", 77 + args: []string{"nofinalnl.txt"}, 78 + wantStdout: "bar\nfoo\n", 79 + }, 80 + { 81 + name: "preserves blank lines", 82 + args: []string{"blanks.txt"}, 83 + wantStdout: "b\n\na\n", 84 + }, 85 + { 86 + name: "single line file", 87 + args: []string{"single.txt"}, 88 + wantStdout: "only\n", 89 + }, 90 + { 91 + name: "empty file produces no output", 92 + args: []string{"empty.txt"}, 93 + wantStdout: "", 94 + }, 95 + { 96 + name: "empty stdin produces no output", 97 + args: nil, 98 + stdin: "", 99 + wantStdout: "", 100 + }, 101 + { 102 + name: "lone newline", 103 + args: []string{"justnewline.txt"}, 104 + wantStdout: "\n", 105 + }, 106 + { 107 + name: "stdin without trailing newline", 108 + args: nil, 109 + stdin: "x\ny", 110 + wantStdout: "y\nx\n", 111 + }, 112 + { 113 + name: "round trip via stdin", 114 + args: nil, 115 + stdin: "charlie\nbravo\nalpha\n", 116 + wantStdout: "alpha\nbravo\ncharlie\n", 117 + }, 118 + { 119 + name: "missing file", 120 + args: []string{"nope.txt"}, 121 + wantErrSub: "tac: nope.txt: No such file or directory", 122 + wantErr: true, 123 + }, 124 + { 125 + name: "unknown flag", 126 + args: []string{"--nope"}, 127 + wantErr: true, 128 + }, 129 + } 130 + 131 + for _, tt := range tests { 132 + t.Run(tt.name, func(t *testing.T) { 133 + stdout, stderr, err := run(t, tt.args, tt.stdin, newFS(t)) 134 + if tt.wantErr { 135 + if err == nil { 136 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 137 + } 138 + } else if err != nil { 139 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 140 + } 141 + if stdout != tt.wantStdout { 142 + t.Errorf("stdout = %q, want %q", stdout, tt.wantStdout) 143 + } 144 + if tt.wantErrSub != "" && !strings.Contains(stderr, tt.wantErrSub) { 145 + t.Errorf("stderr = %q, want substring %q", stderr, tt.wantErrSub) 146 + } 147 + }) 148 + } 149 + } 150 + 151 + func TestHelp(t *testing.T) { 152 + stdout, stderr, err := run(t, []string{"--help"}, "", newFS(t)) 153 + if err != nil { 154 + t.Fatalf("unexpected error: %v", err) 155 + } 156 + if stdout != "" { 157 + t.Errorf("expected empty stdout, got %q", stdout) 158 + } 159 + if !strings.Contains(stderr, "Usage: tac [OPTION]... [FILE]...") { 160 + t.Errorf("usage line missing from stderr: %q", stderr) 161 + } 162 + }