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

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

Xe Iaso d956a16c 8f14853a

+493
+268
command/internal/wc/wc.go
··· 1 + package wc 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 + type stats struct { 20 + lines int 21 + words int 22 + chars int 23 + } 24 + 25 + type fileResult struct { 26 + filename string 27 + stats stats 28 + } 29 + 30 + func (Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 31 + if ec == nil { 32 + return errors.New("wc: nil ExecContext") 33 + } 34 + 35 + stdout := ec.Stdout 36 + if stdout == nil { 37 + stdout = io.Discard 38 + } 39 + stderr := ec.Stderr 40 + if stderr == nil { 41 + stderr = io.Discard 42 + } 43 + 44 + set := getopt.New() 45 + set.SetProgram("wc") 46 + set.SetParameters("[FILE]...") 47 + 48 + usage := func() { 49 + fmt.Fprint(stderr, "Usage: wc [OPTION]... [FILE]...\n") 50 + fmt.Fprint(stderr, "Print newline, word, and byte counts for each FILE.\n\n") 51 + fmt.Fprint(stderr, " -c, --bytes print the byte counts\n") 52 + fmt.Fprint(stderr, " -m, --chars print the character counts\n") 53 + fmt.Fprint(stderr, " -l, --lines print the newline counts\n") 54 + fmt.Fprint(stderr, " -w, --words print the word counts\n") 55 + fmt.Fprint(stderr, " --help display this help and exit\n") 56 + } 57 + set.SetUsage(usage) 58 + 59 + linesFlag := set.BoolLong("lines", 'l', "print the newline counts") 60 + wordsFlag := set.BoolLong("words", 'w', "print the word counts") 61 + bytesFlag := set.BoolLong("bytes", 'c', "print the byte counts") 62 + charsFlag := set.BoolLong("chars", 'm', "print the character counts") 63 + helpFlag := set.BoolLong("help", 0, "display this help and exit") 64 + 65 + if err := set.Getopt(append([]string{"wc"}, args...), nil); err != nil { 66 + fmt.Fprintf(stderr, "wc: %s\n", err) 67 + usage() 68 + return interp.ExitStatus(1) 69 + } 70 + 71 + if *helpFlag { 72 + usage() 73 + return nil 74 + } 75 + 76 + showLines := *linesFlag 77 + showWords := *wordsFlag 78 + showChars := *bytesFlag || *charsFlag 79 + 80 + if !showLines && !showWords && !showChars { 81 + showLines = true 82 + showWords = true 83 + showChars = true 84 + } 85 + 86 + files := set.Args() 87 + 88 + if len(files) == 0 { 89 + data, err := readStdin(ec) 90 + if err != nil { 91 + fmt.Fprintf(stderr, "wc: %s\n", err) 92 + return interp.ExitStatus(1) 93 + } 94 + s := countStats(data) 95 + io.WriteString(stdout, formatStats(s, showLines, showWords, showChars, "", 0)+"\n") 96 + return nil 97 + } 98 + 99 + results := make([]fileResult, 0, len(files)) 100 + var total stats 101 + exitCode := 0 102 + 103 + for _, file := range files { 104 + data, err := readFile(ec, file) 105 + if err != nil { 106 + fmt.Fprintf(stderr, "wc: %s: No such file or directory\n", file) 107 + exitCode = 1 108 + continue 109 + } 110 + s := countStats(data) 111 + total.lines += s.lines 112 + total.words += s.words 113 + total.chars += s.chars 114 + results = append(results, fileResult{filename: file, stats: s}) 115 + } 116 + 117 + maxLines := 0 118 + maxWords := 0 119 + maxChars := 0 120 + if len(files) > 1 { 121 + maxLines = total.lines 122 + maxWords = total.words 123 + maxChars = total.chars 124 + } else { 125 + for _, r := range results { 126 + if r.stats.lines > maxLines { 127 + maxLines = r.stats.lines 128 + } 129 + if r.stats.words > maxWords { 130 + maxWords = r.stats.words 131 + } 132 + if r.stats.chars > maxChars { 133 + maxChars = r.stats.chars 134 + } 135 + } 136 + } 137 + 138 + maxWidth := 0 139 + if len(files) > 1 { 140 + maxWidth = 3 141 + } 142 + if showLines { 143 + if w := len(strconv.Itoa(maxLines)); w > maxWidth { 144 + maxWidth = w 145 + } 146 + } 147 + if showWords { 148 + if w := len(strconv.Itoa(maxWords)); w > maxWidth { 149 + maxWidth = w 150 + } 151 + } 152 + if showChars { 153 + if w := len(strconv.Itoa(maxChars)); w > maxWidth { 154 + maxWidth = w 155 + } 156 + } 157 + 158 + var out strings.Builder 159 + for _, r := range results { 160 + out.WriteString(formatStats(r.stats, showLines, showWords, showChars, r.filename, maxWidth)) 161 + out.WriteByte('\n') 162 + } 163 + 164 + if len(files) > 1 { 165 + out.WriteString(formatStats(total, showLines, showWords, showChars, "total", maxWidth)) 166 + out.WriteByte('\n') 167 + } 168 + 169 + io.WriteString(stdout, out.String()) 170 + 171 + if exitCode != 0 { 172 + return interp.ExitStatus(uint8(exitCode)) 173 + } 174 + return nil 175 + } 176 + 177 + func readStdin(ec *command.ExecContext) ([]byte, error) { 178 + if ec.Stdin == nil { 179 + return nil, nil 180 + } 181 + return io.ReadAll(ec.Stdin) 182 + } 183 + 184 + func readFile(ec *command.ExecContext, name string) ([]byte, error) { 185 + if name == "-" { 186 + return readStdin(ec) 187 + } 188 + if ec.FS == nil { 189 + return nil, errors.New("no filesystem") 190 + } 191 + f, err := ec.FS.Open(resolvePath(ec, name)) 192 + if err != nil { 193 + return nil, err 194 + } 195 + defer f.Close() 196 + return io.ReadAll(f) 197 + } 198 + 199 + func countStats(content []byte) stats { 200 + var s stats 201 + s.chars = len(content) 202 + inWord := false 203 + for _, c := range content { 204 + switch c { 205 + case '\n': 206 + s.lines++ 207 + if inWord { 208 + s.words++ 209 + inWord = false 210 + } 211 + case ' ', '\t', '\r': 212 + if inWord { 213 + s.words++ 214 + inWord = false 215 + } 216 + default: 217 + inWord = true 218 + } 219 + } 220 + if inWord { 221 + s.words++ 222 + } 223 + return s 224 + } 225 + 226 + func formatStats(s stats, showLines, showWords, showChars bool, filename string, minWidth int) string { 227 + var values []string 228 + if showLines { 229 + values = append(values, padLeft(strconv.Itoa(s.lines), minWidth)) 230 + } 231 + if showWords { 232 + values = append(values, padLeft(strconv.Itoa(s.words), minWidth)) 233 + } 234 + if showChars { 235 + values = append(values, padLeft(strconv.Itoa(s.chars), minWidth)) 236 + } 237 + result := strings.Join(values, " ") 238 + if filename != "" { 239 + result += " " + filename 240 + } 241 + return result 242 + } 243 + 244 + func padLeft(s string, n int) string { 245 + if len(s) >= n { 246 + return s 247 + } 248 + return strings.Repeat(" ", n-len(s)) + s 249 + } 250 + 251 + func resolvePath(ec *command.ExecContext, p string) string { 252 + dir := ec.Dir 253 + if dir == "" { 254 + dir = "." 255 + } 256 + if path.IsAbs(p) { 257 + p = strings.TrimPrefix(p, "/") 258 + if p == "" { 259 + return "." 260 + } 261 + return path.Clean(p) 262 + } 263 + joined := path.Join(dir, p) 264 + if joined == "" { 265 + return "." 266 + } 267 + return joined 268 + }
+225
command/internal/wc/wc_test.go
··· 1 + package wc 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 world\n")) 27 + write("multi.txt", []byte("one two three\nfour five\nsix\n")) 28 + write("empty.txt", []byte("")) 29 + write("nolf.txt", []byte("trailing")) 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 TestWc(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: "default counts from stdin", 58 + args: nil, 59 + stdin: "hello world\n", 60 + wantStdout: "1 2 12\n", 61 + }, 62 + { 63 + name: "default counts from file", 64 + args: []string{"hello.txt"}, 65 + wantStdout: " 1 2 12 hello.txt\n", 66 + }, 67 + { 68 + name: "dash means stdin", 69 + args: []string{"-"}, 70 + stdin: "hello world\n", 71 + wantStdout: " 1 2 12 -\n", 72 + }, 73 + { 74 + name: "empty stdin yields zeros", 75 + args: nil, 76 + stdin: "", 77 + wantStdout: "0 0 0\n", 78 + }, 79 + { 80 + name: "empty file yields zeros with filename", 81 + args: []string{"empty.txt"}, 82 + wantStdout: "0 0 0 empty.txt\n", 83 + }, 84 + { 85 + name: "lines flag only", 86 + args: []string{"-l"}, 87 + stdin: "a\nb\nc\n", 88 + wantStdout: "3\n", 89 + }, 90 + { 91 + name: "words flag only", 92 + args: []string{"-w"}, 93 + stdin: "alpha beta gamma\n", 94 + wantStdout: "3\n", 95 + }, 96 + { 97 + name: "bytes flag only", 98 + args: []string{"-c"}, 99 + stdin: "abcde", 100 + wantStdout: "5\n", 101 + }, 102 + { 103 + name: "chars flag treats bytes the same as -c", 104 + args: []string{"-m"}, 105 + stdin: "abcde", 106 + wantStdout: "5\n", 107 + }, 108 + { 109 + name: "long lines flag", 110 + args: []string{"--lines"}, 111 + stdin: "a\nb\n", 112 + wantStdout: "2\n", 113 + }, 114 + { 115 + name: "trailing word with no newline still counts", 116 + args: []string{"-lw"}, 117 + stdin: "trailing", 118 + wantStdout: "0 1\n", 119 + }, 120 + { 121 + name: "tab and cr split words", 122 + args: []string{"-w"}, 123 + stdin: "a\tb\rc d", 124 + wantStdout: "4\n", 125 + }, 126 + { 127 + name: "multi file totals", 128 + args: []string{"hello.txt", "multi.txt"}, 129 + wantStdout: " 1 2 12 hello.txt\n 3 6 28 multi.txt\n 4 8 40 total\n", 130 + }, 131 + { 132 + name: "multi file with -l flag", 133 + args: []string{"-l", "hello.txt", "multi.txt"}, 134 + wantStdout: " 1 hello.txt\n 3 multi.txt\n 4 total\n", 135 + }, 136 + { 137 + name: "missing file reports error", 138 + args: []string{"nope.txt"}, 139 + wantStdout: "", 140 + wantErrSub: "wc: nope.txt: No such file or directory", 141 + wantErr: true, 142 + }, 143 + { 144 + name: "missing file in list still counts present files", 145 + args: []string{"hello.txt", "nope.txt"}, 146 + wantStdout: " 1 2 12 hello.txt\n 1 2 12 total\n", 147 + wantErrSub: "wc: nope.txt: No such file or directory", 148 + wantErr: true, 149 + }, 150 + { 151 + name: "unknown flag returns error", 152 + args: []string{"--no-such-flag"}, 153 + wantErr: true, 154 + }, 155 + } 156 + 157 + for _, tt := range tests { 158 + t.Run(tt.name, func(t *testing.T) { 159 + stdout, stderr, err := run(t, tt.args, tt.stdin, newFS(t)) 160 + if tt.wantErr { 161 + if err == nil { 162 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 163 + } 164 + } else if err != nil { 165 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 166 + } 167 + if stdout != tt.wantStdout { 168 + t.Errorf("stdout mismatch\n got: %q\nwant: %q", stdout, tt.wantStdout) 169 + } 170 + if tt.wantErrSub != "" && !strings.Contains(stderr, tt.wantErrSub) { 171 + t.Errorf("stderr = %q, want substring %q", stderr, tt.wantErrSub) 172 + } 173 + }) 174 + } 175 + } 176 + 177 + func TestHelp(t *testing.T) { 178 + stdout, stderr, err := run(t, []string{"--help"}, "", newFS(t)) 179 + if err != nil { 180 + t.Fatalf("unexpected error: %v", err) 181 + } 182 + if stdout != "" { 183 + t.Errorf("expected empty stdout, got %q", stdout) 184 + } 185 + if !strings.Contains(stderr, "Usage: wc [OPTION]... [FILE]...") { 186 + t.Errorf("usage line missing from stderr: %q", stderr) 187 + } 188 + if !strings.Contains(stderr, "-l, --lines") { 189 + t.Errorf("lines flag missing from help: %q", stderr) 190 + } 191 + if !strings.Contains(stderr, "-w, --words") { 192 + t.Errorf("words flag missing from help: %q", stderr) 193 + } 194 + } 195 + 196 + func TestNilContext(t *testing.T) { 197 + if err := (Impl{}).Exec(context.Background(), nil, nil); err == nil { 198 + t.Fatal("expected error for nil ExecContext") 199 + } 200 + } 201 + 202 + func TestCountStats(t *testing.T) { 203 + tests := []struct { 204 + name string 205 + input string 206 + want stats 207 + }{ 208 + {"empty", "", stats{lines: 0, words: 0, chars: 0}}, 209 + {"single word no newline", "hello", stats{lines: 0, words: 1, chars: 5}}, 210 + {"single line with newline", "hello\n", stats{lines: 1, words: 1, chars: 6}}, 211 + {"multiple words", "a b c", stats{lines: 0, words: 3, chars: 5}}, 212 + {"tab separated", "a\tb\tc", stats{lines: 0, words: 3, chars: 5}}, 213 + {"mixed whitespace", " a b ", stats{lines: 0, words: 2, chars: 8}}, 214 + {"three lines", "one\ntwo\nthree\n", stats{lines: 3, words: 3, chars: 14}}, 215 + {"only newlines", "\n\n\n", stats{lines: 3, words: 0, chars: 3}}, 216 + } 217 + for _, tt := range tests { 218 + t.Run(tt.name, func(t *testing.T) { 219 + got := countStats([]byte(tt.input)) 220 + if got != tt.want { 221 + t.Errorf("countStats(%q) = %+v, want %+v", tt.input, got, tt.want) 222 + } 223 + }) 224 + } 225 + }