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

Selects character or field ranges from each line of input. Mirrors
the just-bash semantics, including the dedupe-by-value behavior
extractByRanges applies in field mode (overlapping or repeated
ranges collapse to a single occurrence per distinct field value)
and the inline non-deduping selection used in character mode.

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

Xe Iaso 7501bea3 bd4cf2b4

+485
+250
command/internal/cut/cut.go
··· 1 + package cut 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(ctx context.Context, ec *command.ExecContext, args []string) error { 20 + if ec == nil { 21 + return errors.New("cut: 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("cut") 35 + set.SetParameters("[FILE]...") 36 + 37 + usage := func() { 38 + fmt.Fprint(stderr, "Usage: cut [OPTION]... [FILE]...\n") 39 + fmt.Fprint(stderr, "Remove sections from each line of FILE(s).\n\n") 40 + fmt.Fprint(stderr, " -c LIST select only these characters\n") 41 + fmt.Fprint(stderr, " -d DELIM use DELIM instead of TAB for field delimiter\n") 42 + fmt.Fprint(stderr, " -f LIST select only these fields\n") 43 + fmt.Fprint(stderr, " -s, --only-delimited do not print lines without delimiters\n") 44 + fmt.Fprint(stderr, " --help display this help and exit\n") 45 + } 46 + set.SetUsage(usage) 47 + 48 + charSpec := set.String('c', "", "select only these characters") 49 + delim := set.String('d', "\t", "use DELIM instead of TAB for field delimiter") 50 + fieldSpec := set.String('f', "", "select only these fields") 51 + suppressNoDelim := set.BoolLong("only-delimited", 's', "do not print lines without delimiters") 52 + help := set.BoolLong("help", 0, "display this help and exit") 53 + 54 + if err := set.Getopt(append([]string{"cut"}, args...), nil); err != nil { 55 + fmt.Fprintf(stderr, "cut: %s\n", err) 56 + usage() 57 + return interp.ExitStatus(1) 58 + } 59 + if *help { 60 + usage() 61 + return nil 62 + } 63 + 64 + if *fieldSpec == "" && *charSpec == "" { 65 + fmt.Fprint(stderr, "cut: you must specify a list of bytes, characters, or fields\n") 66 + return interp.ExitStatus(1) 67 + } 68 + 69 + files := set.Args() 70 + content, err := readInput(ec, files, stderr) 71 + if err != nil { 72 + return err 73 + } 74 + 75 + spec := *fieldSpec 76 + if spec == "" { 77 + spec = *charSpec 78 + } 79 + if spec == "" { 80 + spec = "1" 81 + } 82 + ranges := parseRanges(spec) 83 + 84 + d := *delim 85 + if d == "" { 86 + d = "\t" 87 + } 88 + 89 + lines := strings.Split(content, "\n") 90 + if len(lines) > 0 && lines[len(lines)-1] == "" { 91 + lines = lines[:len(lines)-1] 92 + } 93 + 94 + var out strings.Builder 95 + for _, line := range lines { 96 + if *charSpec != "" { 97 + chars := []rune(line) 98 + var selected []rune 99 + for _, r := range ranges { 100 + start := r.start - 1 101 + end := r.end 102 + if r.toEnd { 103 + end = len(chars) 104 + } 105 + for i := start; i < end && i < len(chars); i++ { 106 + if i >= 0 { 107 + selected = append(selected, chars[i]) 108 + } 109 + } 110 + } 111 + out.WriteString(string(selected)) 112 + out.WriteString("\n") 113 + } else { 114 + if *suppressNoDelim && !strings.Contains(line, d) { 115 + continue 116 + } 117 + fields := strings.Split(line, d) 118 + selected := extractByRanges(fields, ranges) 119 + out.WriteString(strings.Join(selected, d)) 120 + out.WriteString("\n") 121 + } 122 + } 123 + 124 + io.WriteString(stdout, out.String()) 125 + return nil 126 + } 127 + 128 + type cutRange struct { 129 + start, end int 130 + toEnd bool 131 + } 132 + 133 + func parseRanges(spec string) []cutRange { 134 + var ranges []cutRange 135 + for part := range strings.SplitSeq(spec, ",") { 136 + if strings.Contains(part, "-") { 137 + pieces := strings.SplitN(part, "-", 2) 138 + startStr, endStr := pieces[0], pieces[1] 139 + r := cutRange{start: 1, toEnd: true} 140 + if startStr != "" { 141 + if n, err := strconv.Atoi(startStr); err == nil { 142 + r.start = n 143 + } else { 144 + r.start = 0 145 + } 146 + } 147 + if endStr != "" { 148 + r.toEnd = false 149 + if n, err := strconv.Atoi(endStr); err == nil { 150 + r.end = n 151 + } else { 152 + r.end = 0 153 + } 154 + } 155 + ranges = append(ranges, r) 156 + } else { 157 + if n, err := strconv.Atoi(part); err == nil { 158 + ranges = append(ranges, cutRange{start: n, end: n}) 159 + } else { 160 + ranges = append(ranges, cutRange{start: 0, end: 0}) 161 + } 162 + } 163 + } 164 + return ranges 165 + } 166 + 167 + func extractByRanges(items []string, ranges []cutRange) []string { 168 + var result []string 169 + seen := make(map[string]struct{}) 170 + for _, r := range ranges { 171 + start := r.start - 1 172 + end := r.end 173 + if r.toEnd { 174 + end = len(items) 175 + } 176 + for i := start; i < end && i < len(items); i++ { 177 + if i >= 0 { 178 + if _, ok := seen[items[i]]; !ok { 179 + seen[items[i]] = struct{}{} 180 + result = append(result, items[i]) 181 + } 182 + } 183 + } 184 + } 185 + return result 186 + } 187 + 188 + func readInput(ec *command.ExecContext, files []string, stderr io.Writer) (string, error) { 189 + if len(files) == 0 { 190 + if ec.Stdin == nil { 191 + return "", nil 192 + } 193 + data, err := io.ReadAll(ec.Stdin) 194 + if err != nil { 195 + return "", interp.ExitStatus(1) 196 + } 197 + return string(data), nil 198 + } 199 + var buf strings.Builder 200 + for _, file := range files { 201 + if file == "-" { 202 + if ec.Stdin == nil { 203 + continue 204 + } 205 + data, err := io.ReadAll(ec.Stdin) 206 + if err != nil { 207 + return "", interp.ExitStatus(1) 208 + } 209 + buf.Write(data) 210 + continue 211 + } 212 + if ec.FS == nil { 213 + fmt.Fprintf(stderr, "cut: %s: No such file or directory\n", file) 214 + return "", interp.ExitStatus(1) 215 + } 216 + full := resolvePath(ec, file) 217 + f, err := ec.FS.Open(full) 218 + if err != nil { 219 + fmt.Fprintf(stderr, "cut: %s: No such file or directory\n", file) 220 + return "", interp.ExitStatus(1) 221 + } 222 + data, err := io.ReadAll(f) 223 + f.Close() 224 + if err != nil { 225 + fmt.Fprintf(stderr, "cut: %s: %v\n", file, err) 226 + return "", interp.ExitStatus(1) 227 + } 228 + buf.Write(data) 229 + } 230 + return buf.String(), nil 231 + } 232 + 233 + func resolvePath(ec *command.ExecContext, p string) string { 234 + dir := ec.Dir 235 + if dir == "" { 236 + dir = "." 237 + } 238 + if path.IsAbs(p) { 239 + p = strings.TrimPrefix(p, "/") 240 + if p == "" { 241 + return "." 242 + } 243 + return path.Clean(p) 244 + } 245 + joined := path.Join(dir, p) 246 + if joined == "" { 247 + return "." 248 + } 249 + return joined 250 + }
+233
command/internal/cut/cut_test.go
··· 1 + package cut 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("tab.txt", []byte("a\tb\tc\nd\te\tf\n")) 27 + write("csv.txt", []byte("one,two,three\nfour,five,six\n")) 28 + write("mixed.txt", []byte("has,delim\nnodelim\nalso,delim\n")) 29 + write("chars.txt", []byte("abcdef\nghijkl\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 TestCut(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: "field tab default from stdin", 58 + args: []string{"-f1"}, 59 + stdin: "a\tb\tc\n", 60 + wantStdout: "a\n", 61 + }, 62 + { 63 + name: "field tab default from file", 64 + args: []string{"-f", "2", "tab.txt"}, 65 + wantStdout: "b\ne\n", 66 + }, 67 + { 68 + name: "field with custom delimiter", 69 + args: []string{"-d", ",", "-f", "2", "csv.txt"}, 70 + wantStdout: "two\nfive\n", 71 + }, 72 + { 73 + name: "field comma list", 74 + args: []string{"-d", ",", "-f", "1,3", "csv.txt"}, 75 + wantStdout: "one,three\nfour,six\n", 76 + }, 77 + { 78 + name: "field range", 79 + args: []string{"-d", ",", "-f", "1-2", "csv.txt"}, 80 + wantStdout: "one,two\nfour,five\n", 81 + }, 82 + { 83 + name: "field open-ended start", 84 + args: []string{"-d", ",", "-f", "2-", "csv.txt"}, 85 + wantStdout: "two,three\nfive,six\n", 86 + }, 87 + { 88 + name: "field open-ended end", 89 + args: []string{"-d", ",", "-f", "-2", "csv.txt"}, 90 + wantStdout: "one,two\nfour,five\n", 91 + }, 92 + { 93 + name: "field out of range yields empty", 94 + args: []string{"-d", ",", "-f", "5", "csv.txt"}, 95 + wantStdout: "\n\n", 96 + }, 97 + { 98 + name: "char single", 99 + args: []string{"-c", "1"}, 100 + stdin: "abcdef\n", 101 + wantStdout: "a\n", 102 + }, 103 + { 104 + name: "char list", 105 + args: []string{"-c", "1,3,5"}, 106 + stdin: "abcdef\n", 107 + wantStdout: "ace\n", 108 + }, 109 + { 110 + name: "char range", 111 + args: []string{"-c", "1-3"}, 112 + stdin: "abcdef\n", 113 + wantStdout: "abc\n", 114 + }, 115 + { 116 + name: "char open-ended end", 117 + args: []string{"-c", "-3", "chars.txt"}, 118 + wantStdout: "abc\nghi\n", 119 + }, 120 + { 121 + name: "char open-ended start", 122 + args: []string{"-c", "4-", "chars.txt"}, 123 + wantStdout: "def\njkl\n", 124 + }, 125 + { 126 + name: "char allows duplicate codepoints", 127 + args: []string{"-c", "1,1,2"}, 128 + stdin: "abc\n", 129 + wantStdout: "aab\n", 130 + }, 131 + { 132 + name: "suppress lines without delimiter", 133 + args: []string{"-d", ",", "-f", "1", "-s", "mixed.txt"}, 134 + wantStdout: "has\nalso\n", 135 + }, 136 + { 137 + name: "without -s lines without delimiter pass through", 138 + args: []string{"-d", ",", "-f", "1", "mixed.txt"}, 139 + wantStdout: "has\nnodelim\nalso\n", 140 + }, 141 + { 142 + name: "long form only-delimited", 143 + args: []string{"-d", ",", "-f", "1", "--only-delimited", "mixed.txt"}, 144 + wantStdout: "has\nalso\n", 145 + }, 146 + { 147 + name: "stdin via dash", 148 + args: []string{"-d", ",", "-f", "1", "-"}, 149 + stdin: "x,y\nz,w\n", 150 + wantStdout: "x\nz\n", 151 + }, 152 + { 153 + name: "concatenates multiple files", 154 + args: []string{"-d", ",", "-f", "1", "csv.txt", "mixed.txt"}, 155 + wantStdout: "one\nfour\nhas\nnodelim\nalso\n", 156 + }, 157 + { 158 + name: "empty stdin produces no output", 159 + args: []string{"-f", "1"}, 160 + stdin: "", 161 + wantStdout: "", 162 + }, 163 + { 164 + name: "no trailing newline in input still emits one", 165 + args: []string{"-d", ",", "-f", "1"}, 166 + stdin: "a,b", 167 + wantStdout: "a\n", 168 + }, 169 + { 170 + name: "duplicate field values are deduped", 171 + args: []string{"-d", ",", "-f", "1-3"}, 172 + stdin: "a,a,b\n", 173 + wantStdout: "a,b\n", 174 + }, 175 + { 176 + name: "double dash terminator", 177 + args: []string{"-d", ",", "-f", "1", "--", "csv.txt"}, 178 + wantStdout: "one\nfour\n", 179 + }, 180 + { 181 + name: "missing -c and -f errors", 182 + args: []string{"csv.txt"}, 183 + wantErrSub: "you must specify", 184 + wantErr: true, 185 + }, 186 + { 187 + name: "missing file errors", 188 + args: []string{"-f", "1", "nope.txt"}, 189 + wantErrSub: "No such file or directory", 190 + wantErr: true, 191 + }, 192 + { 193 + name: "unknown flag errors", 194 + args: []string{"--no-such-flag"}, 195 + wantErr: true, 196 + }, 197 + } 198 + 199 + for _, tt := range tests { 200 + t.Run(tt.name, func(t *testing.T) { 201 + stdout, stderr, err := run(t, tt.args, tt.stdin, newFS(t)) 202 + if tt.wantErr { 203 + if err == nil { 204 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 205 + } 206 + } else if err != nil { 207 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 208 + } 209 + if stdout != tt.wantStdout { 210 + t.Errorf("stdout = %q, want %q", stdout, tt.wantStdout) 211 + } 212 + if tt.wantErrSub != "" && !strings.Contains(stderr, tt.wantErrSub) { 213 + t.Errorf("stderr = %q, want substring %q", stderr, tt.wantErrSub) 214 + } 215 + }) 216 + } 217 + } 218 + 219 + func TestHelp(t *testing.T) { 220 + stdout, stderr, err := run(t, []string{"--help"}, "", newFS(t)) 221 + if err != nil { 222 + t.Fatalf("unexpected error: %v", err) 223 + } 224 + if stdout != "" { 225 + t.Errorf("expected empty stdout, got %q", stdout) 226 + } 227 + if !strings.Contains(stderr, "Usage: cut [OPTION]... [FILE]...") { 228 + t.Errorf("usage line missing from stderr: %q", stderr) 229 + } 230 + if !strings.Contains(stderr, "-d DELIM") { 231 + t.Errorf("delimiter flag missing from help: %q", stderr) 232 + } 233 + }
+2
command/registry/coreutils/coreutils.go
··· 7 7 "tangled.org/xeiaso.net/kefka/command/internal/clear" 8 8 "tangled.org/xeiaso.net/kefka/command/internal/column" 9 9 "tangled.org/xeiaso.net/kefka/command/internal/cp" 10 + "tangled.org/xeiaso.net/kefka/command/internal/cut" 10 11 "tangled.org/xeiaso.net/kefka/command/internal/falsecmd" 11 12 "tangled.org/xeiaso.net/kefka/command/internal/hostname" 12 13 "tangled.org/xeiaso.net/kefka/command/internal/ls" ··· 21 22 reg.Register("clear", clear.Impl{}) 22 23 reg.Register("column", column.Impl{}) 23 24 reg.Register("cp", cp.Impl{}) 25 + reg.Register("cut", cut.Impl{}) 24 26 reg.Register("false", falsecmd.Impl{}) 25 27 reg.Register("hostname", hostname.Impl{}) 26 28 reg.Register("ls", ls.Impl{})