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

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

Xe Iaso a23a618e 6426cab0

+475
+253
command/internal/head/head.go
··· 1 + package head 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("head: 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("head") 35 + set.SetParameters("[FILE]...") 36 + 37 + usage := func() { 38 + fmt.Fprint(stderr, "Usage: head [OPTION]... [FILE]...\n") 39 + fmt.Fprint(stderr, "Print the first 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 first NUM bytes\n") 43 + fmt.Fprint(stderr, " -n, --lines=NUM print the first NUM lines (default 10)\n") 44 + fmt.Fprint(stderr, " -q, --quiet never print headers giving file names\n") 45 + fmt.Fprint(stderr, " -v, --verbose always print headers giving file names\n") 46 + fmt.Fprint(stderr, " --help display this help and exit\n") 47 + } 48 + set.SetUsage(usage) 49 + 50 + bytesSpec := set.StringLong("bytes", 'c', "", "print the first NUM bytes") 51 + linesSpec := set.StringLong("lines", 'n', "", "print the first NUM lines (default 10)") 52 + quiet := set.BoolLong("quiet", 'q', "never print headers giving file names") 53 + silent := set.BoolLong("silent", 0, "alias for --quiet") 54 + verbose := set.BoolLong("verbose", 'v', "always print headers giving file names") 55 + help := set.BoolLong("help", 0, "display this help and exit") 56 + 57 + preArgs := preprocessShortNum(args) 58 + 59 + if err := set.Getopt(append([]string{"head"}, preArgs...), nil); err != nil { 60 + fmt.Fprintf(stderr, "head: %s\n", err) 61 + usage() 62 + return interp.ExitStatus(1) 63 + } 64 + if *help { 65 + usage() 66 + return nil 67 + } 68 + 69 + lines := 10 70 + bytes := 0 71 + bytesSet := false 72 + 73 + if *bytesSpec != "" { 74 + n, err := strconv.Atoi(*bytesSpec) 75 + if err != nil || n < 0 { 76 + fmt.Fprint(stderr, "head: invalid number of bytes\n") 77 + return interp.ExitStatus(1) 78 + } 79 + bytes = n 80 + bytesSet = true 81 + } 82 + if *linesSpec != "" { 83 + n, err := strconv.Atoi(*linesSpec) 84 + if err != nil || n < 0 { 85 + fmt.Fprint(stderr, "head: invalid number of lines\n") 86 + return interp.ExitStatus(1) 87 + } 88 + lines = n 89 + } 90 + 91 + isQuiet := *quiet || *silent 92 + files := set.Args() 93 + 94 + if len(files) == 0 { 95 + content, err := readStdin(ec) 96 + if err != nil { 97 + return err 98 + } 99 + io.WriteString(stdout, getHead(content, lines, bytes, bytesSet)) 100 + return nil 101 + } 102 + 103 + showHeaders := *verbose || (!isQuiet && len(files) > 1) 104 + var output strings.Builder 105 + exitCode := 0 106 + filesProcessed := 0 107 + 108 + for _, file := range files { 109 + content, err := readFile(ec, file) 110 + if err != nil { 111 + fmt.Fprintf(stderr, "head: %s: No such file or directory\n", file) 112 + exitCode = 1 113 + continue 114 + } 115 + if showHeaders { 116 + if filesProcessed > 0 { 117 + output.WriteByte('\n') 118 + } 119 + fmt.Fprintf(&output, "==> %s <==\n", file) 120 + } 121 + output.WriteString(getHead(content, lines, bytes, bytesSet)) 122 + filesProcessed++ 123 + } 124 + 125 + io.WriteString(stdout, output.String()) 126 + 127 + if exitCode != 0 { 128 + return interp.ExitStatus(uint8(exitCode)) 129 + } 130 + return nil 131 + } 132 + 133 + // preprocessShortNum rewrites the GNU coreutils -NUM shorthand (e.g. -5) 134 + // into "-n NUM" so that getopt can parse it. Stops at "--" and skips the 135 + // value of any preceding -c/-n/--bytes/--lines so that "-c -5" is left 136 + // alone for getopt to flag as invalid. 137 + func preprocessShortNum(args []string) []string { 138 + out := make([]string, 0, len(args)) 139 + skipNext := false 140 + seenDoubleDash := false 141 + for _, a := range args { 142 + if seenDoubleDash { 143 + out = append(out, a) 144 + continue 145 + } 146 + if skipNext { 147 + skipNext = false 148 + out = append(out, a) 149 + continue 150 + } 151 + if a == "--" { 152 + seenDoubleDash = true 153 + out = append(out, a) 154 + continue 155 + } 156 + if a == "-c" || a == "-n" || a == "--bytes" || a == "--lines" { 157 + skipNext = true 158 + out = append(out, a) 159 + continue 160 + } 161 + if len(a) >= 2 && a[0] == '-' && a[1] >= '0' && a[1] <= '9' { 162 + allDigits := true 163 + for _, c := range a[1:] { 164 + if c < '0' || c > '9' { 165 + allDigits = false 166 + break 167 + } 168 + } 169 + if allDigits { 170 + out = append(out, "-n", a[1:]) 171 + continue 172 + } 173 + } 174 + out = append(out, a) 175 + } 176 + return out 177 + } 178 + 179 + func getHead(content string, lines int, bytes int, bytesSet bool) string { 180 + if bytesSet { 181 + if bytes >= len(content) { 182 + return content 183 + } 184 + return content[:bytes] 185 + } 186 + if lines == 0 { 187 + return "" 188 + } 189 + pos := 0 190 + lineCount := 0 191 + for pos < len(content) && lineCount < lines { 192 + idx := strings.IndexByte(content[pos:], '\n') 193 + if idx == -1 { 194 + return content + "\n" 195 + } 196 + lineCount++ 197 + pos += idx + 1 198 + } 199 + if pos > 0 { 200 + return content[:pos] 201 + } 202 + return "" 203 + } 204 + 205 + func readStdin(ec *command.ExecContext) (string, error) { 206 + if ec.Stdin == nil { 207 + return "", nil 208 + } 209 + data, err := io.ReadAll(ec.Stdin) 210 + if err != nil { 211 + return "", interp.ExitStatus(1) 212 + } 213 + return string(data), nil 214 + } 215 + 216 + func readFile(ec *command.ExecContext, file string) (string, error) { 217 + if file == "-" { 218 + return readStdin(ec) 219 + } 220 + if ec.FS == nil { 221 + return "", errors.New("no filesystem") 222 + } 223 + full := resolvePath(ec, file) 224 + f, err := ec.FS.Open(full) 225 + if err != nil { 226 + return "", err 227 + } 228 + defer f.Close() 229 + data, err := io.ReadAll(f) 230 + if err != nil { 231 + return "", err 232 + } 233 + return string(data), nil 234 + } 235 + 236 + func resolvePath(ec *command.ExecContext, p string) string { 237 + dir := ec.Dir 238 + if dir == "" { 239 + dir = "." 240 + } 241 + if path.IsAbs(p) { 242 + p = strings.TrimPrefix(p, "/") 243 + if p == "" { 244 + return "." 245 + } 246 + return path.Clean(p) 247 + } 248 + joined := path.Join(dir, p) 249 + if joined == "" { 250 + return "." 251 + } 252 + return joined 253 + }
+222
command/internal/head/head_test.go
··· 1 + package head 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 TestHead(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: "L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10\n", 61 + }, 62 + { 63 + name: "default 10 lines from file", 64 + args: []string{"twelve.txt"}, 65 + wantStdout: "L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\nL10\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: "L1\nL2\nL3\n", 76 + }, 77 + { 78 + name: "short -n no space", 79 + args: []string{"-n3", "twelve.txt"}, 80 + wantStdout: "L1\nL2\nL3\n", 81 + }, 82 + { 83 + name: "long --lines=N", 84 + args: []string{"--lines=3", "twelve.txt"}, 85 + wantStdout: "L1\nL2\nL3\n", 86 + }, 87 + { 88 + name: "GNU shorthand -N", 89 + args: []string{"-3", "twelve.txt"}, 90 + wantStdout: "L1\nL2\nL3\n", 91 + }, 92 + { 93 + name: "lines zero produces nothing", 94 + args: []string{"-n", "0", "twelve.txt"}, 95 + wantStdout: "", 96 + }, 97 + { 98 + name: "byte mode", 99 + args: []string{"-c", "5", "bytes.txt"}, 100 + wantStdout: "abcde", 101 + }, 102 + { 103 + name: "byte mode no space", 104 + args: []string{"-c5", "bytes.txt"}, 105 + wantStdout: "abcde", 106 + }, 107 + { 108 + name: "long --bytes=N", 109 + args: []string{"--bytes=5", "bytes.txt"}, 110 + wantStdout: "abcde", 111 + }, 112 + { 113 + name: "byte mode beyond eof returns full content", 114 + args: []string{"-c", "100", "bytes.txt"}, 115 + wantStdout: "abcdefghij", 116 + }, 117 + { 118 + name: "no trailing newline gets one appended", 119 + args: []string{"-n", "10", "nofinalnl.txt"}, 120 + wantStdout: "x1\nx2\nx3\n", 121 + }, 122 + { 123 + name: "empty file produces empty output", 124 + args: []string{"empty.txt"}, 125 + wantStdout: "", 126 + }, 127 + { 128 + name: "stdin via dash", 129 + args: []string{"-n", "2", "-"}, 130 + stdin: "p\nq\nr\n", 131 + wantStdout: "p\nq\n", 132 + }, 133 + { 134 + name: "multiple files prepend headers", 135 + args: []string{"-n", "1", "five.txt", "twelve.txt"}, 136 + wantStdout: "==> five.txt <==\na\n\n==> twelve.txt <==\nL1\n", 137 + }, 138 + { 139 + name: "quiet suppresses headers across files", 140 + args: []string{"-q", "-n", "1", "five.txt", "twelve.txt"}, 141 + wantStdout: "a\nL1\n", 142 + }, 143 + { 144 + name: "silent alias suppresses headers", 145 + args: []string{"--silent", "-n", "1", "five.txt", "twelve.txt"}, 146 + wantStdout: "a\nL1\n", 147 + }, 148 + { 149 + name: "verbose forces header on single file", 150 + args: []string{"-v", "-n", "1", "five.txt"}, 151 + wantStdout: "==> five.txt <==\na\n", 152 + }, 153 + { 154 + name: "missing file errors but other files still processed", 155 + args: []string{"-n", "1", "nope.txt", "five.txt"}, 156 + wantStdout: "==> five.txt <==\na\n", 157 + wantErrSub: "head: nope.txt: No such file or directory", 158 + wantErr: true, 159 + }, 160 + { 161 + name: "negative lines is invalid", 162 + args: []string{"-n", "-5", "five.txt"}, 163 + wantErrSub: "invalid number of lines", 164 + wantErr: true, 165 + }, 166 + { 167 + name: "negative bytes is invalid", 168 + args: []string{"-c", "-5", "five.txt"}, 169 + wantErrSub: "invalid number of bytes", 170 + wantErr: true, 171 + }, 172 + { 173 + name: "non-numeric lines is invalid", 174 + args: []string{"-n", "abc", "five.txt"}, 175 + wantErrSub: "invalid number of lines", 176 + wantErr: true, 177 + }, 178 + { 179 + name: "unknown flag errors", 180 + args: []string{"--nope", "five.txt"}, 181 + wantErr: true, 182 + }, 183 + } 184 + 185 + for _, tt := range tests { 186 + t.Run(tt.name, func(t *testing.T) { 187 + stdout, stderr, err := run(t, tt.args, tt.stdin, newFS(t)) 188 + if tt.wantErr { 189 + if err == nil { 190 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 191 + } 192 + } else if err != nil { 193 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 194 + } 195 + if stdout != tt.wantStdout { 196 + t.Errorf("stdout = %q, want %q", stdout, tt.wantStdout) 197 + } 198 + if tt.wantErrSub != "" && !strings.Contains(stderr, tt.wantErrSub) { 199 + t.Errorf("stderr = %q, want substring %q", stderr, tt.wantErrSub) 200 + } 201 + }) 202 + } 203 + } 204 + 205 + func TestHelp(t *testing.T) { 206 + stdout, stderr, err := run(t, []string{"--help"}, "", newFS(t)) 207 + if err != nil { 208 + t.Fatalf("unexpected error: %v", err) 209 + } 210 + if stdout != "" { 211 + t.Errorf("expected empty stdout, got %q", stdout) 212 + } 213 + if !strings.Contains(stderr, "Usage: head [OPTION]... [FILE]...") { 214 + t.Errorf("usage line missing from stderr: %q", stderr) 215 + } 216 + if !strings.Contains(stderr, "--lines=NUM") { 217 + t.Errorf("lines flag missing from help: %q", stderr) 218 + } 219 + if !strings.Contains(stderr, "--bytes=NUM") { 220 + t.Errorf("bytes flag missing from help: %q", stderr) 221 + } 222 + }