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

Merge corresponding lines from files (or stdin via -) separated by
TABs by default. Mirrors GNU coreutils paste semantics as
implemented by just-bash: cyclic delimiters via -d, serial mode via
-s, multiple - args distribute stdin lines round-robin, and the BSD
"usage: paste ..." stderr message when no files are given.

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

Xe Iaso 60263e33 732f66dc

+407
+213
command/internal/paste/paste.go
··· 1 + package paste 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("paste: 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("paste") 34 + set.SetParameters("[FILE]...") 35 + 36 + usage := func() { 37 + fmt.Fprint(stderr, "Usage: paste [OPTION]... [FILE]...\n") 38 + fmt.Fprint(stderr, "Write lines consisting of the sequentially corresponding lines from\n") 39 + fmt.Fprint(stderr, "each FILE, separated by TABs, to standard output.\n") 40 + fmt.Fprint(stderr, "With no FILE, or when FILE is -, read standard input.\n\n") 41 + fmt.Fprint(stderr, " -d, --delimiters=LIST reuse characters from LIST instead of TABs\n") 42 + fmt.Fprint(stderr, " -s, --serial paste one file at a time instead of in parallel\n") 43 + fmt.Fprint(stderr, " --help display this help and exit\n") 44 + } 45 + set.SetUsage(usage) 46 + 47 + delimiters := set.StringLong("delimiters", 'd', "\t", "reuse characters from LIST instead of TABs") 48 + serial := set.BoolLong("serial", 's', "paste one file at a time instead of in parallel") 49 + help := set.BoolLong("help", 0, "display this help and exit") 50 + 51 + if err := set.Getopt(append([]string{"paste"}, args...), nil); err != nil { 52 + fmt.Fprintf(stderr, "paste: %s\n", err) 53 + usage() 54 + return interp.ExitStatus(1) 55 + } 56 + if *help { 57 + usage() 58 + return nil 59 + } 60 + 61 + files := set.Args() 62 + if len(files) == 0 { 63 + fmt.Fprint(stderr, "usage: paste [-s] [-d delimiters] file ...\n") 64 + return interp.ExitStatus(1) 65 + } 66 + 67 + stdinCount := 0 68 + for _, f := range files { 69 + if f == "-" { 70 + stdinCount++ 71 + } 72 + } 73 + 74 + var stdinLines []string 75 + if stdinCount > 0 { 76 + var err error 77 + stdinLines, err = readStdinLines(ec) 78 + if err != nil { 79 + return err 80 + } 81 + } 82 + 83 + fileContents := make([][]string, 0, len(files)) 84 + stdinIndex := 0 85 + for _, file := range files { 86 + if file == "-" { 87 + var slice []string 88 + for i := stdinIndex; i < len(stdinLines); i += stdinCount { 89 + slice = append(slice, stdinLines[i]) 90 + } 91 + fileContents = append(fileContents, slice) 92 + stdinIndex++ 93 + continue 94 + } 95 + lines, err := readFileLines(ec, file, stderr) 96 + if err != nil { 97 + return err 98 + } 99 + fileContents = append(fileContents, lines) 100 + } 101 + 102 + delim := *delimiters 103 + var output strings.Builder 104 + 105 + if *serial { 106 + for _, lines := range fileContents { 107 + output.WriteString(joinWithDelimiters(lines, delim)) 108 + output.WriteByte('\n') 109 + } 110 + } else { 111 + maxLines := 0 112 + for _, lines := range fileContents { 113 + if len(lines) > maxLines { 114 + maxLines = len(lines) 115 + } 116 + } 117 + for lineIdx := 0; lineIdx < maxLines; lineIdx++ { 118 + parts := make([]string, len(fileContents)) 119 + for i, lines := range fileContents { 120 + if lineIdx < len(lines) { 121 + parts[i] = lines[lineIdx] 122 + } 123 + } 124 + output.WriteString(joinWithDelimiters(parts, delim)) 125 + output.WriteByte('\n') 126 + } 127 + } 128 + 129 + io.WriteString(stdout, output.String()) 130 + return nil 131 + } 132 + 133 + func joinWithDelimiters(parts []string, delimiters string) string { 134 + if len(parts) == 0 { 135 + return "" 136 + } 137 + if len(parts) == 1 { 138 + return parts[0] 139 + } 140 + delimRunes := []rune(delimiters) 141 + if len(delimRunes) == 0 { 142 + return strings.Join(parts, "") 143 + } 144 + var b strings.Builder 145 + b.WriteString(parts[0]) 146 + for i := 1; i < len(parts); i++ { 147 + idx := (i - 1) % len(delimRunes) 148 + b.WriteRune(delimRunes[idx]) 149 + b.WriteString(parts[i]) 150 + } 151 + return b.String() 152 + } 153 + 154 + func splitLines(content string) []string { 155 + if content == "" { 156 + return nil 157 + } 158 + lines := strings.Split(content, "\n") 159 + if len(lines) > 0 && lines[len(lines)-1] == "" { 160 + lines = lines[:len(lines)-1] 161 + } 162 + return lines 163 + } 164 + 165 + func readStdinLines(ec *command.ExecContext) ([]string, error) { 166 + if ec.Stdin == nil { 167 + return nil, nil 168 + } 169 + data, err := io.ReadAll(ec.Stdin) 170 + if err != nil { 171 + return nil, interp.ExitStatus(1) 172 + } 173 + return splitLines(string(data)), nil 174 + } 175 + 176 + func readFileLines(ec *command.ExecContext, file string, stderr io.Writer) ([]string, error) { 177 + if ec.FS == nil { 178 + fmt.Fprintf(stderr, "paste: %s: No such file or directory\n", file) 179 + return nil, interp.ExitStatus(1) 180 + } 181 + full := resolvePath(ec, file) 182 + f, err := ec.FS.Open(full) 183 + if err != nil { 184 + fmt.Fprintf(stderr, "paste: %s: No such file or directory\n", file) 185 + return nil, interp.ExitStatus(1) 186 + } 187 + data, err := io.ReadAll(f) 188 + f.Close() 189 + if err != nil { 190 + fmt.Fprintf(stderr, "paste: %s: %v\n", file, err) 191 + return nil, interp.ExitStatus(1) 192 + } 193 + return splitLines(string(data)), nil 194 + } 195 + 196 + func resolvePath(ec *command.ExecContext, p string) string { 197 + dir := ec.Dir 198 + if dir == "" { 199 + dir = "." 200 + } 201 + if path.IsAbs(p) { 202 + p = strings.TrimPrefix(p, "/") 203 + if p == "" { 204 + return "." 205 + } 206 + return path.Clean(p) 207 + } 208 + joined := path.Join(dir, p) 209 + if joined == "" { 210 + return "." 211 + } 212 + return joined 213 + }
+192
command/internal/paste/paste_test.go
··· 1 + package paste 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("a.txt", []byte("a1\na2\na3\n")) 27 + write("b.txt", []byte("b1\nb2\n")) 28 + write("c.txt", []byte("c1\nc2\nc3\nc4\n")) 29 + write("empty.txt", []byte("")) 30 + write("nofinalnl.txt", []byte("x1\nx2")) 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 TestPaste(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: "parallel two files pads shorter", 59 + args: []string{"a.txt", "b.txt"}, 60 + wantStdout: "a1\tb1\na2\tb2\na3\t\n", 61 + }, 62 + { 63 + name: "parallel three files", 64 + args: []string{"a.txt", "b.txt", "c.txt"}, 65 + wantStdout: "a1\tb1\tc1\na2\tb2\tc2\na3\t\tc3\n\t\tc4\n", 66 + }, 67 + { 68 + name: "comma delimiter", 69 + args: []string{"-d", ",", "a.txt", "b.txt"}, 70 + wantStdout: "a1,b1\na2,b2\na3,\n", 71 + }, 72 + { 73 + name: "long delimiter flag", 74 + args: []string{"--delimiters=,", "a.txt", "b.txt"}, 75 + wantStdout: "a1,b1\na2,b2\na3,\n", 76 + }, 77 + { 78 + name: "cyclic delimiters with three files", 79 + args: []string{"-d", ",;", "a.txt", "b.txt", "c.txt"}, 80 + wantStdout: "a1,b1;c1\na2,b2;c2\na3,;c3\n,;c4\n", 81 + }, 82 + { 83 + name: "serial mode joins file lines", 84 + args: []string{"-s", "a.txt", "b.txt"}, 85 + wantStdout: "a1\ta2\ta3\nb1\tb2\n", 86 + }, 87 + { 88 + name: "serial with comma delimiter", 89 + args: []string{"-s", "-d", ",", "a.txt"}, 90 + wantStdout: "a1,a2,a3\n", 91 + }, 92 + { 93 + name: "single file parallel echoes lines", 94 + args: []string{"a.txt"}, 95 + wantStdout: "a1\na2\na3\n", 96 + }, 97 + { 98 + name: "stdin via dash", 99 + args: []string{"-"}, 100 + stdin: "x1\nx2\n", 101 + wantStdout: "x1\nx2\n", 102 + }, 103 + { 104 + name: "two dashes distribute stdin lines", 105 + args: []string{"-", "-"}, 106 + stdin: "a\nb\nc\nd\n", 107 + wantStdout: "a\tb\nc\td\n", 108 + }, 109 + { 110 + name: "file mixed with dash", 111 + args: []string{"a.txt", "-"}, 112 + stdin: "y1\ny2\n", 113 + wantStdout: "a1\ty1\na2\ty2\na3\t\n", 114 + }, 115 + { 116 + name: "no trailing newline still gets one", 117 + args: []string{"nofinalnl.txt"}, 118 + wantStdout: "x1\nx2\n", 119 + }, 120 + { 121 + name: "empty file parallel produces nothing", 122 + args: []string{"empty.txt"}, 123 + wantStdout: "", 124 + }, 125 + { 126 + name: "empty file serial produces single newline", 127 + args: []string{"-s", "empty.txt"}, 128 + wantStdout: "\n", 129 + }, 130 + { 131 + name: "empty delimiter concatenates", 132 + args: []string{"-d", "", "a.txt", "b.txt"}, 133 + wantStdout: "a1b1\na2b2\na3\n", 134 + }, 135 + { 136 + name: "no args is usage error", 137 + args: nil, 138 + wantStdout: "", 139 + wantErrSub: "usage: paste", 140 + wantErr: true, 141 + }, 142 + { 143 + name: "missing file errors", 144 + args: []string{"nope.txt"}, 145 + wantErrSub: "No such file or directory", 146 + wantErr: true, 147 + }, 148 + { 149 + name: "unknown flag errors", 150 + args: []string{"--nope"}, 151 + wantErr: true, 152 + }, 153 + } 154 + 155 + for _, tt := range tests { 156 + t.Run(tt.name, func(t *testing.T) { 157 + stdout, stderr, err := run(t, tt.args, tt.stdin, newFS(t)) 158 + if tt.wantErr { 159 + if err == nil { 160 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 161 + } 162 + } else if err != nil { 163 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 164 + } 165 + if stdout != tt.wantStdout { 166 + t.Errorf("stdout = %q, want %q", stdout, tt.wantStdout) 167 + } 168 + if tt.wantErrSub != "" && !strings.Contains(stderr, tt.wantErrSub) { 169 + t.Errorf("stderr = %q, want substring %q", stderr, tt.wantErrSub) 170 + } 171 + }) 172 + } 173 + } 174 + 175 + func TestHelp(t *testing.T) { 176 + stdout, stderr, err := run(t, []string{"--help"}, "", newFS(t)) 177 + if err != nil { 178 + t.Fatalf("unexpected error: %v", err) 179 + } 180 + if stdout != "" { 181 + t.Errorf("expected empty stdout, got %q", stdout) 182 + } 183 + if !strings.Contains(stderr, "Usage: paste [OPTION]... [FILE]...") { 184 + t.Errorf("usage line missing from stderr: %q", stderr) 185 + } 186 + if !strings.Contains(stderr, "--delimiters=LIST") { 187 + t.Errorf("delimiters flag missing from help: %q", stderr) 188 + } 189 + if !strings.Contains(stderr, "--serial") { 190 + t.Errorf("serial flag missing from help: %q", stderr) 191 + } 192 + }
+2
command/registry/coreutils/coreutils.go
··· 25 25 "tangled.org/xeiaso.net/kefka/command/internal/mkdir" 26 26 "tangled.org/xeiaso.net/kefka/command/internal/mv" 27 27 "tangled.org/xeiaso.net/kefka/command/internal/nl" 28 + "tangled.org/xeiaso.net/kefka/command/internal/paste" 28 29 "tangled.org/xeiaso.net/kefka/command/internal/truecmd" 29 30 "tangled.org/xeiaso.net/kefka/command/internal/unexpand" 30 31 "tangled.org/xeiaso.net/kefka/command/internal/zcat" ··· 56 57 reg.Register("mkdir", mkdir.Impl{}) 57 58 reg.Register("mv", mv.Impl{}) 58 59 reg.Register("nl", nl.Impl{}) 60 + reg.Register("paste", paste.Impl{}) 59 61 reg.Register("true", truecmd.Impl{}) 60 62 reg.Register("unexpand", unexpand.Impl{}) 61 63 reg.Register("zcat", zcat.Impl{})