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

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

Xe Iaso 410becb6 03a3ced9

+498
+293
command/internal/tr/tr.go
··· 1 + package tr 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "slices" 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("tr: 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("tr") 34 + set.SetParameters("SET1 [SET2]") 35 + 36 + usage := func() { 37 + fmt.Fprint(stderr, "Usage: tr [OPTION]... SET1 [SET2]\n") 38 + fmt.Fprint(stderr, "Translate, squeeze, and/or delete characters from standard input,\n") 39 + fmt.Fprint(stderr, "writing to standard output.\n\n") 40 + fmt.Fprint(stderr, " -c, -C, --complement use the complement of SET1\n") 41 + fmt.Fprint(stderr, " -d, --delete delete characters in SET1\n") 42 + fmt.Fprint(stderr, " -s, --squeeze-repeats squeeze repeated characters\n") 43 + fmt.Fprint(stderr, " --help display this help and exit\n\n") 44 + fmt.Fprint(stderr, "SET syntax:\n") 45 + fmt.Fprint(stderr, " a-z character range\n") 46 + fmt.Fprint(stderr, " [:alnum:] all letters and digits\n") 47 + fmt.Fprint(stderr, " [:alpha:] all letters\n") 48 + fmt.Fprint(stderr, " [:digit:] all digits\n") 49 + fmt.Fprint(stderr, " [:lower:] all lowercase letters\n") 50 + fmt.Fprint(stderr, " [:upper:] all uppercase letters\n") 51 + fmt.Fprint(stderr, " [:space:] all whitespace\n") 52 + fmt.Fprint(stderr, " [:blank:] horizontal whitespace\n") 53 + fmt.Fprint(stderr, " [:punct:] all punctuation\n") 54 + fmt.Fprint(stderr, " [:print:] all printable characters\n") 55 + fmt.Fprint(stderr, " [:graph:] all printable characters except space\n") 56 + fmt.Fprint(stderr, " [:cntrl:] all control characters\n") 57 + fmt.Fprint(stderr, " [:xdigit:] all hexadecimal digits\n") 58 + fmt.Fprint(stderr, " \\n, \\t, \\r escape sequences\n") 59 + } 60 + set.SetUsage(usage) 61 + 62 + complementLong := set.BoolLong("complement", 'c', "use the complement of SET1") 63 + complementUpper := set.Bool('C', "use the complement of SET1") 64 + deleteFlag := set.BoolLong("delete", 'd', "delete characters in SET1") 65 + squeezeFlag := set.BoolLong("squeeze-repeats", 's', "squeeze repeated characters") 66 + help := set.BoolLong("help", 0, "display this help and exit") 67 + 68 + if err := set.Getopt(append([]string{"tr"}, args...), nil); err != nil { 69 + fmt.Fprintf(stderr, "tr: %s\n", err) 70 + usage() 71 + return interp.ExitStatus(1) 72 + } 73 + if *help { 74 + usage() 75 + return nil 76 + } 77 + 78 + complement := *complementLong || *complementUpper 79 + deleteMode := *deleteFlag 80 + squeezeMode := *squeezeFlag 81 + sets := set.Args() 82 + 83 + if len(sets) < 1 { 84 + fmt.Fprint(stderr, "tr: missing operand\n") 85 + return interp.ExitStatus(1) 86 + } 87 + if !deleteMode && !squeezeMode && len(sets) < 2 { 88 + fmt.Fprint(stderr, "tr: missing operand after SET1\n") 89 + return interp.ExitStatus(1) 90 + } 91 + 92 + set1, err := expandSet(sets[0]) 93 + if err != nil { 94 + fmt.Fprintf(stderr, "%s\n", err) 95 + return interp.ExitStatus(1) 96 + } 97 + var set2 []rune 98 + if len(sets) > 1 { 99 + set2, err = expandSet(sets[1]) 100 + if err != nil { 101 + fmt.Fprintf(stderr, "%s\n", err) 102 + return interp.ExitStatus(1) 103 + } 104 + } 105 + 106 + var input []byte 107 + if ec.Stdin != nil { 108 + input, err = io.ReadAll(ec.Stdin) 109 + if err != nil { 110 + return interp.ExitStatus(1) 111 + } 112 + } 113 + content := []rune(string(input)) 114 + 115 + set1Has := func(r rune) bool { 116 + return slices.Contains(set1, r) 117 + } 118 + inSet1 := func(r rune) bool { 119 + has := set1Has(r) 120 + if complement { 121 + return !has 122 + } 123 + return has 124 + } 125 + 126 + var output []rune 127 + 128 + switch { 129 + case deleteMode: 130 + for _, r := range content { 131 + if !inSet1(r) { 132 + output = append(output, r) 133 + } 134 + } 135 + case squeezeMode && len(sets) == 1: 136 + var prev rune 137 + havePrev := false 138 + for _, r := range content { 139 + if havePrev && inSet1(r) && r == prev { 140 + continue 141 + } 142 + output = append(output, r) 143 + prev = r 144 + havePrev = true 145 + } 146 + default: 147 + if complement { 148 + var target rune 149 + haveTarget := len(set2) > 0 150 + if haveTarget { 151 + target = set2[len(set2)-1] 152 + } 153 + for _, r := range content { 154 + if !set1Has(r) { 155 + if haveTarget { 156 + output = append(output, target) 157 + } 158 + } else { 159 + output = append(output, r) 160 + } 161 + } 162 + } else { 163 + tmap := make(map[rune]rune, len(set1)) 164 + for i, r := range set1 { 165 + switch { 166 + case i < len(set2): 167 + tmap[r] = set2[i] 168 + case len(set2) > 0: 169 + tmap[r] = set2[len(set2)-1] 170 + default: 171 + tmap[r] = r 172 + } 173 + } 174 + for _, r := range content { 175 + if t, ok := tmap[r]; ok { 176 + output = append(output, t) 177 + } else { 178 + output = append(output, r) 179 + } 180 + } 181 + } 182 + 183 + if squeezeMode { 184 + set2Has := func(r rune) bool { 185 + return slices.Contains(set2, r) 186 + } 187 + squeezed := make([]rune, 0, len(output)) 188 + var prev rune 189 + havePrev := false 190 + for _, r := range output { 191 + if havePrev && set2Has(r) && r == prev { 192 + continue 193 + } 194 + squeezed = append(squeezed, r) 195 + prev = r 196 + havePrev = true 197 + } 198 + output = squeezed 199 + } 200 + } 201 + 202 + io.WriteString(stdout, string(output)) 203 + return nil 204 + } 205 + 206 + var posixClasses = []struct { 207 + name string 208 + chars string 209 + }{ 210 + {"[:alnum:]", "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"}, 211 + {"[:alpha:]", "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"}, 212 + {"[:blank:]", " \t"}, 213 + {"[:cntrl:]", buildCntrl()}, 214 + {"[:digit:]", "0123456789"}, 215 + {"[:graph:]", buildRange(33, 126)}, 216 + {"[:lower:]", "abcdefghijklmnopqrstuvwxyz"}, 217 + {"[:print:]", buildRange(32, 126)}, 218 + {"[:punct:]", "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"}, 219 + {"[:space:]", " \t\n\r\f\v"}, 220 + {"[:upper:]", "ABCDEFGHIJKLMNOPQRSTUVWXYZ"}, 221 + {"[:xdigit:]", "0123456789ABCDEFabcdef"}, 222 + } 223 + 224 + func buildRange(lo, hi int) string { 225 + var b strings.Builder 226 + for c := lo; c <= hi; c++ { 227 + b.WriteByte(byte(c)) 228 + } 229 + return b.String() 230 + } 231 + 232 + func buildCntrl() string { 233 + var b strings.Builder 234 + for c := range 32 { 235 + b.WriteByte(byte(c)) 236 + } 237 + b.WriteByte(127) 238 + return b.String() 239 + } 240 + 241 + func expandSet(s string) ([]rune, error) { 242 + rs := []rune(s) 243 + var out []rune 244 + i := 0 245 + for i < len(rs) { 246 + if rs[i] == '[' && i+1 < len(rs) && rs[i+1] == ':' { 247 + matched := false 248 + for _, cls := range posixClasses { 249 + if strings.HasPrefix(string(rs[i:]), cls.name) { 250 + out = append(out, []rune(cls.chars)...) 251 + i += len([]rune(cls.name)) 252 + matched = true 253 + break 254 + } 255 + } 256 + if matched { 257 + continue 258 + } 259 + } 260 + 261 + if rs[i] == '\\' && i+1 < len(rs) { 262 + switch rs[i+1] { 263 + case 'n': 264 + out = append(out, '\n') 265 + case 't': 266 + out = append(out, '\t') 267 + case 'r': 268 + out = append(out, '\r') 269 + default: 270 + out = append(out, rs[i+1]) 271 + } 272 + i += 2 273 + continue 274 + } 275 + 276 + if i+2 < len(rs) && rs[i+1] == '-' { 277 + start := rs[i] 278 + end := rs[i+2] 279 + if int(end)-int(start) > 65536 { 280 + return nil, fmt.Errorf("tr: character range too large: '%c-%c'", start, end) 281 + } 282 + for c := start; c <= end; c++ { 283 + out = append(out, c) 284 + } 285 + i += 3 286 + continue 287 + } 288 + 289 + out = append(out, rs[i]) 290 + i++ 291 + } 292 + return out, nil 293 + }
+205
command/internal/tr/tr_test.go
··· 1 + package tr 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "strings" 7 + "testing" 8 + 9 + "tangled.org/xeiaso.net/kefka/command" 10 + ) 11 + 12 + func run(t *testing.T, args []string, stdin string) (string, string, error) { 13 + t.Helper() 14 + var stdout, stderr bytes.Buffer 15 + ec := &command.ExecContext{ 16 + Stdin: strings.NewReader(stdin), 17 + Stdout: &stdout, 18 + Stderr: &stderr, 19 + Dir: ".", 20 + } 21 + err := Impl{}.Exec(context.Background(), ec, args) 22 + return stdout.String(), stderr.String(), err 23 + } 24 + 25 + func TestTr(t *testing.T) { 26 + tests := []struct { 27 + name string 28 + args []string 29 + stdin string 30 + wantStdout string 31 + wantErrSub string 32 + wantErr bool 33 + }{ 34 + { 35 + name: "translate lowercase to uppercase via range", 36 + args: []string{"a-z", "A-Z"}, 37 + stdin: "hello world", 38 + wantStdout: "HELLO WORLD", 39 + }, 40 + { 41 + name: "translate using POSIX class", 42 + args: []string{"[:lower:]", "[:upper:]"}, 43 + stdin: "Hello World", 44 + wantStdout: "HELLO WORLD", 45 + }, 46 + { 47 + name: "set2 shorter than set1 reuses last char", 48 + args: []string{"abcde", "X"}, 49 + stdin: "abcdef", 50 + wantStdout: "XXXXXf", 51 + }, 52 + { 53 + name: "delete characters in set1", 54 + args: []string{"-d", "aeiou"}, 55 + stdin: "hello world", 56 + wantStdout: "hll wrld", 57 + }, 58 + { 59 + name: "delete with --delete long form", 60 + args: []string{"--delete", "0-9"}, 61 + stdin: "abc123def456", 62 + wantStdout: "abcdef", 63 + }, 64 + { 65 + name: "squeeze repeated characters in set1", 66 + args: []string{"-s", " "}, 67 + stdin: "hello world foo", 68 + wantStdout: "hello world foo", 69 + }, 70 + { 71 + name: "squeeze with --squeeze-repeats long form", 72 + args: []string{"--squeeze-repeats", "a"}, 73 + stdin: "aaabbbaaaccc", 74 + wantStdout: "abbbaccc", 75 + }, 76 + { 77 + name: "complement with -c deletes everything except set1", 78 + args: []string{"-cd", "0-9"}, 79 + stdin: "abc123def", 80 + wantStdout: "123", 81 + }, 82 + { 83 + name: "complement with -C is alias for -c", 84 + args: []string{"-Cd", "0-9"}, 85 + stdin: "abc123def", 86 + wantStdout: "123", 87 + }, 88 + { 89 + name: "complement with --complement long form", 90 + args: []string{"--complement", "-d", "a-z"}, 91 + stdin: "Hello World", 92 + wantStdout: "elloorld", 93 + }, 94 + { 95 + name: "translate with squeeze collapses repeated set2 chars", 96 + args: []string{"-s", "ab", "xx"}, 97 + stdin: "aabbab", 98 + wantStdout: "x", 99 + }, 100 + { 101 + name: "complement translate maps to last char of set2", 102 + args: []string{"-c", "0-9", "*"}, 103 + stdin: "abc123def", 104 + wantStdout: "***123***", 105 + }, 106 + { 107 + name: "escape sequence \\n in set", 108 + args: []string{"\\n", " "}, 109 + stdin: "a\nb\nc", 110 + wantStdout: "a b c", 111 + }, 112 + { 113 + name: "escape sequence \\t in set", 114 + args: []string{"\\t", " "}, 115 + stdin: "a\tb\tc", 116 + wantStdout: "a b c", 117 + }, 118 + { 119 + name: "POSIX class [:digit:] deletion", 120 + args: []string{"-d", "[:digit:]"}, 121 + stdin: "abc123def456", 122 + wantStdout: "abcdef", 123 + }, 124 + { 125 + name: "rot13 round trip", 126 + args: []string{"a-zA-Z", "n-za-mN-ZA-M"}, 127 + stdin: "Hello, World!", 128 + wantStdout: "Uryyb, Jbeyq!", 129 + }, 130 + { 131 + name: "empty input produces empty output", 132 + args: []string{"a", "b"}, 133 + stdin: "", 134 + wantStdout: "", 135 + }, 136 + { 137 + name: "characters not in set1 pass through", 138 + args: []string{"abc", "xyz"}, 139 + stdin: "abcdef", 140 + wantStdout: "xyzdef", 141 + }, 142 + { 143 + name: "missing operand", 144 + args: nil, 145 + wantErrSub: "tr: missing operand", 146 + wantErr: true, 147 + }, 148 + { 149 + name: "missing operand after SET1", 150 + args: []string{"abc"}, 151 + stdin: "abc", 152 + wantErrSub: "tr: missing operand after SET1", 153 + wantErr: true, 154 + }, 155 + { 156 + name: "delete with one set is allowed", 157 + args: []string{"-d", "a"}, 158 + stdin: "banana", 159 + wantStdout: "bnn", 160 + }, 161 + { 162 + name: "squeeze with one set is allowed", 163 + args: []string{"-s", "a"}, 164 + stdin: "baaanaaa", 165 + wantStdout: "bana", 166 + }, 167 + { 168 + name: "unknown flag", 169 + args: []string{"--nope", "a", "b"}, 170 + wantErr: true, 171 + }, 172 + } 173 + 174 + for _, tt := range tests { 175 + t.Run(tt.name, func(t *testing.T) { 176 + stdout, stderr, err := run(t, tt.args, tt.stdin) 177 + if tt.wantErr { 178 + if err == nil { 179 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 180 + } 181 + } else if err != nil { 182 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 183 + } 184 + if stdout != tt.wantStdout { 185 + t.Errorf("stdout = %q, want %q", stdout, tt.wantStdout) 186 + } 187 + if tt.wantErrSub != "" && !strings.Contains(stderr, tt.wantErrSub) { 188 + t.Errorf("stderr = %q, want substring %q", stderr, tt.wantErrSub) 189 + } 190 + }) 191 + } 192 + } 193 + 194 + func TestHelp(t *testing.T) { 195 + stdout, stderr, err := run(t, []string{"--help"}, "") 196 + if err != nil { 197 + t.Fatalf("unexpected error: %v", err) 198 + } 199 + if stdout != "" { 200 + t.Errorf("expected empty stdout, got %q", stdout) 201 + } 202 + if !strings.Contains(stderr, "Usage: tr [OPTION]... SET1 [SET2]") { 203 + t.Errorf("usage line missing from stderr: %q", stderr) 204 + } 205 + }