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

Concatenate files (or stdin when none are given or "-" is named) to
stdout, with optional -n/--number line numbering that continues
across files. Mirrors just-bash's trailing-newline handling so a
file ending in "\n" does not produce an extra numbered blank line,
and missing files report to stderr but do not abort the rest of the
input list.

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

Xe Iaso b2062e73 860bc831

+323
+147
command/internal/cat/cat.go
··· 1 + package cat 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(ctx context.Context, ec *command.ExecContext, args []string) error { 19 + if ec == nil { 20 + return errors.New("cat: 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("cat") 34 + set.SetParameters("[FILE]...") 35 + 36 + usage := func() { 37 + fmt.Fprint(stderr, "Usage: cat [OPTION]... [FILE]...\n") 38 + fmt.Fprint(stderr, "Concatenate FILE(s) to standard output.\n\n") 39 + fmt.Fprint(stderr, " -n, --number number all output lines\n") 40 + fmt.Fprint(stderr, " --help display this help and exit\n") 41 + } 42 + set.SetUsage(usage) 43 + 44 + number := set.BoolLong("number", 'n', "number all output lines") 45 + help := set.BoolLong("help", 0, "display this help and exit") 46 + 47 + if err := set.Getopt(append([]string{"cat"}, args...), nil); err != nil { 48 + fmt.Fprintf(stderr, "cat: %s\n", err) 49 + usage() 50 + return interp.ExitStatus(1) 51 + } 52 + 53 + if *help { 54 + usage() 55 + return nil 56 + } 57 + 58 + files := set.Args() 59 + if len(files) == 0 { 60 + files = []string{"-"} 61 + } 62 + 63 + exitCode := 0 64 + lineNumber := 1 65 + for _, file := range files { 66 + data, err := readOne(ec, file, stderr) 67 + if err != nil { 68 + exitCode = 1 69 + continue 70 + } 71 + if *number { 72 + out, next := addLineNumbers(string(data), lineNumber) 73 + io.WriteString(stdout, out) 74 + lineNumber = next 75 + } else { 76 + stdout.Write(data) 77 + } 78 + } 79 + 80 + if exitCode != 0 { 81 + return interp.ExitStatus(uint8(exitCode)) 82 + } 83 + return nil 84 + } 85 + 86 + func readOne(ec *command.ExecContext, file string, stderr io.Writer) ([]byte, error) { 87 + if file == "-" { 88 + if ec.Stdin == nil { 89 + return nil, nil 90 + } 91 + return io.ReadAll(ec.Stdin) 92 + } 93 + if ec.FS == nil { 94 + fmt.Fprintf(stderr, "cat: %s: No such file or directory\n", file) 95 + return nil, errors.New("no filesystem") 96 + } 97 + full := resolvePath(ec, file) 98 + f, err := ec.FS.Open(full) 99 + if err != nil { 100 + fmt.Fprintf(stderr, "cat: %s: No such file or directory\n", file) 101 + return nil, err 102 + } 103 + defer f.Close() 104 + return io.ReadAll(f) 105 + } 106 + 107 + func addLineNumbers(content string, startLine int) (string, int) { 108 + if content == "" { 109 + return "", startLine 110 + } 111 + lines := strings.Split(content, "\n") 112 + hasTrailingNewline := strings.HasSuffix(content, "\n") 113 + linesToNumber := lines 114 + if hasTrailingNewline { 115 + linesToNumber = lines[:len(lines)-1] 116 + } 117 + var b strings.Builder 118 + for i, line := range linesToNumber { 119 + if i > 0 { 120 + b.WriteByte('\n') 121 + } 122 + fmt.Fprintf(&b, "%6d\t%s", startLine+i, line) 123 + } 124 + if hasTrailingNewline { 125 + b.WriteByte('\n') 126 + } 127 + return b.String(), startLine + len(linesToNumber) 128 + } 129 + 130 + func resolvePath(ec *command.ExecContext, p string) string { 131 + dir := ec.Dir 132 + if dir == "" { 133 + dir = "." 134 + } 135 + if path.IsAbs(p) { 136 + p = strings.TrimPrefix(p, "/") 137 + if p == "" { 138 + return "." 139 + } 140 + return path.Clean(p) 141 + } 142 + joined := path.Join(dir, p) 143 + if joined == "" { 144 + return "." 145 + } 146 + return joined 147 + }
+174
command/internal/cat/cat_test.go
··· 1 + package cat 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("hello\n")) 27 + write("two.txt", []byte("world\n")) 28 + write("noeol.txt", []byte("noeol")) 29 + write("multi.txt", []byte("one\ntwo\nthree\n")) 30 + return fs 31 + } 32 + 33 + func run(t *testing.T, args []string, stdin string, fs billy.Filesystem) (string, string, error) { 34 + t.Helper() 35 + var stdout, stderr bytes.Buffer 36 + ec := &command.ExecContext{ 37 + Stdin: strings.NewReader(stdin), 38 + Stdout: &stdout, 39 + Stderr: &stderr, 40 + Dir: ".", 41 + FS: fs, 42 + } 43 + err := Impl{}.Exec(context.Background(), ec, args) 44 + return stdout.String(), stderr.String(), err 45 + } 46 + 47 + func TestCat(t *testing.T) { 48 + tests := []struct { 49 + name string 50 + args []string 51 + stdin string 52 + wantStdout string 53 + wantErrSub string 54 + wantErr bool 55 + }{ 56 + { 57 + name: "stdin no files", 58 + args: nil, 59 + stdin: "hello\n", 60 + wantStdout: "hello\n", 61 + }, 62 + { 63 + name: "stdin via dash", 64 + args: []string{"-"}, 65 + stdin: "hello\n", 66 + wantStdout: "hello\n", 67 + }, 68 + { 69 + name: "single file", 70 + args: []string{"hello.txt"}, 71 + wantStdout: "hello\n", 72 + }, 73 + { 74 + name: "concatenate multiple files", 75 + args: []string{"hello.txt", "two.txt"}, 76 + wantStdout: "hello\nworld\n", 77 + }, 78 + { 79 + name: "file then dash", 80 + args: []string{"hello.txt", "-"}, 81 + stdin: "from stdin\n", 82 + wantStdout: "hello\nfrom stdin\n", 83 + }, 84 + { 85 + name: "empty stdin", 86 + args: nil, 87 + stdin: "", 88 + wantStdout: "", 89 + }, 90 + { 91 + name: "preserves missing trailing newline", 92 + args: []string{"noeol.txt"}, 93 + wantStdout: "noeol", 94 + }, 95 + { 96 + name: "number short flag single line", 97 + args: []string{"-n", "hello.txt"}, 98 + wantStdout: " 1\thello\n", 99 + }, 100 + { 101 + name: "number long flag multi line", 102 + args: []string{"--number", "multi.txt"}, 103 + wantStdout: " 1\tone\n 2\ttwo\n 3\tthree\n", 104 + }, 105 + { 106 + name: "number continues across files", 107 + args: []string{"-n", "hello.txt", "two.txt"}, 108 + wantStdout: " 1\thello\n 2\tworld\n", 109 + }, 110 + { 111 + name: "number from stdin", 112 + args: []string{"-n"}, 113 + stdin: "a\nb\nc\n", 114 + wantStdout: " 1\ta\n 2\tb\n 3\tc\n", 115 + }, 116 + { 117 + name: "number with no trailing newline", 118 + args: []string{"-n", "noeol.txt"}, 119 + wantStdout: " 1\tnoeol", 120 + }, 121 + { 122 + name: "missing file reports error and continues", 123 + args: []string{"nope.txt", "hello.txt"}, 124 + wantStdout: "hello\n", 125 + wantErrSub: "No such file or directory", 126 + wantErr: true, 127 + }, 128 + { 129 + name: "double dash terminator", 130 + args: []string{"--", "hello.txt"}, 131 + wantStdout: "hello\n", 132 + }, 133 + { 134 + name: "unknown flag", 135 + args: []string{"--no-such-flag"}, 136 + wantErr: true, 137 + }, 138 + } 139 + 140 + for _, tt := range tests { 141 + t.Run(tt.name, func(t *testing.T) { 142 + stdout, stderr, err := run(t, tt.args, tt.stdin, newFS(t)) 143 + if tt.wantErr { 144 + if err == nil { 145 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 146 + } 147 + } else if err != nil { 148 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 149 + } 150 + if stdout != tt.wantStdout { 151 + t.Errorf("stdout = %q, want %q", stdout, tt.wantStdout) 152 + } 153 + if tt.wantErrSub != "" && !strings.Contains(stderr, tt.wantErrSub) { 154 + t.Errorf("stderr = %q, want substring %q", stderr, tt.wantErrSub) 155 + } 156 + }) 157 + } 158 + } 159 + 160 + func TestHelp(t *testing.T) { 161 + stdout, stderr, err := run(t, []string{"--help"}, "", newFS(t)) 162 + if err != nil { 163 + t.Fatalf("unexpected error: %v", err) 164 + } 165 + if stdout != "" { 166 + t.Errorf("expected empty stdout, got %q", stdout) 167 + } 168 + if !strings.Contains(stderr, "Usage: cat [OPTION]... [FILE]...") { 169 + t.Errorf("usage line missing from stderr: %q", stderr) 170 + } 171 + if !strings.Contains(stderr, "-n, --number") { 172 + t.Errorf("number flag missing from help: %q", stderr) 173 + } 174 + }
+2
command/registry/coreutils/coreutils.go
··· 3 3 import ( 4 4 "tangled.org/xeiaso.net/kefka/command/internal/base64" 5 5 "tangled.org/xeiaso.net/kefka/command/internal/basename" 6 + "tangled.org/xeiaso.net/kefka/command/internal/cat" 6 7 "tangled.org/xeiaso.net/kefka/command/internal/clear" 7 8 "tangled.org/xeiaso.net/kefka/command/internal/falsecmd" 8 9 "tangled.org/xeiaso.net/kefka/command/internal/hostname" ··· 14 15 func Register(reg *registry.Impl) { 15 16 reg.Register("base64", base64.Impl{}) 16 17 reg.Register("basename", basename.Impl{}) 18 + reg.Register("cat", cat.Impl{}) 17 19 reg.Register("clear", clear.Impl{}) 18 20 reg.Register("false", falsecmd.Impl{}) 19 21 reg.Register("hostname", hostname.Impl{})