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

Wrap input lines at a column or byte width, optionally breaking at
spaces. Mirrors the just-bash fold semantics: tabs expand to the next
8-column boundary in column mode but count as one byte under -b, and
trailing newlines are preserved only when present in the input.

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

Xe Iaso 3957c739 e9eb91da

+464
+234
command/internal/fold/fold.go
··· 1 + package fold 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "path" 9 + "strconv" 10 + "strings" 11 + "unicode/utf8" 12 + 13 + "github.com/pborman/getopt/v2" 14 + "mvdan.cc/sh/v3/interp" 15 + "tangled.org/xeiaso.net/kefka/command" 16 + ) 17 + 18 + type Impl struct{} 19 + 20 + func (Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 21 + if ec == nil { 22 + return errors.New("fold: nil ExecContext") 23 + } 24 + 25 + stdout := ec.Stdout 26 + if stdout == nil { 27 + stdout = io.Discard 28 + } 29 + stderr := ec.Stderr 30 + if stderr == nil { 31 + stderr = io.Discard 32 + } 33 + 34 + set := getopt.New() 35 + set.SetProgram("fold") 36 + set.SetParameters("[FILE]...") 37 + 38 + usage := func() { 39 + fmt.Fprint(stderr, "Usage: fold [OPTION]... [FILE]...\n") 40 + fmt.Fprint(stderr, "Wrap input lines in each FILE, writing to standard output.\n") 41 + fmt.Fprint(stderr, "If no FILE is specified, standard input is read.\n\n") 42 + fmt.Fprint(stderr, " -w WIDTH Use WIDTH columns instead of 80\n") 43 + fmt.Fprint(stderr, " -s Break at spaces\n") 44 + fmt.Fprint(stderr, " -b Count bytes rather than columns\n") 45 + fmt.Fprint(stderr, " --help display this help and exit\n") 46 + } 47 + set.SetUsage(usage) 48 + 49 + widthSpec := set.StringLong("width", 'w', "80", "use WIDTH columns instead of 80") 50 + breakAtSpaces := set.BoolLong("spaces", 's', "break at spaces") 51 + countBytes := set.BoolLong("bytes", 'b', "count bytes rather than columns") 52 + help := set.BoolLong("help", 0, "display this help and exit") 53 + 54 + if err := set.Getopt(append([]string{"fold"}, args...), nil); err != nil { 55 + fmt.Fprintf(stderr, "fold: %s\n", err) 56 + usage() 57 + return interp.ExitStatus(1) 58 + } 59 + if *help { 60 + usage() 61 + return nil 62 + } 63 + 64 + width, err := strconv.Atoi(*widthSpec) 65 + if err != nil || width < 1 { 66 + fmt.Fprintf(stderr, "fold: invalid number of columns: '%s'\n", *widthSpec) 67 + return interp.ExitStatus(1) 68 + } 69 + 70 + files := set.Args() 71 + 72 + var output strings.Builder 73 + var execErr error 74 + if len(files) == 0 { 75 + content, err := readStdin(ec) 76 + if err != nil { 77 + return err 78 + } 79 + output.WriteString(processContent(content, width, *breakAtSpaces, *countBytes)) 80 + } else { 81 + for _, file := range files { 82 + content, err := readFile(ec, file, stderr) 83 + if err != nil { 84 + io.WriteString(stdout, output.String()) 85 + return err 86 + } 87 + output.WriteString(processContent(content, width, *breakAtSpaces, *countBytes)) 88 + } 89 + } 90 + 91 + io.WriteString(stdout, output.String()) 92 + return execErr 93 + } 94 + 95 + func processContent(content string, width int, breakAtSpaces, countBytes bool) string { 96 + if content == "" { 97 + return "" 98 + } 99 + lines := strings.Split(content, "\n") 100 + hasTrailingNewline := strings.HasSuffix(content, "\n") && lines[len(lines)-1] == "" 101 + if hasTrailingNewline { 102 + lines = lines[:len(lines)-1] 103 + } 104 + var out strings.Builder 105 + for i, line := range lines { 106 + if i > 0 { 107 + out.WriteByte('\n') 108 + } 109 + out.WriteString(foldLine(line, width, breakAtSpaces, countBytes)) 110 + } 111 + if hasTrailingNewline { 112 + out.WriteByte('\n') 113 + } 114 + return out.String() 115 + } 116 + 117 + func foldLine(line string, width int, breakAtSpaces, countBytes bool) string { 118 + if line == "" { 119 + return line 120 + } 121 + 122 + var ( 123 + result []string 124 + currentLine []byte 125 + currentColumn int 126 + lastSpace = -1 127 + lastSpaceCol int 128 + ) 129 + 130 + emit := func(charWidth int, isSpace bool, ch []byte) { 131 + if currentColumn+charWidth > width && len(currentLine) > 0 { 132 + if breakAtSpaces && lastSpace >= 0 { 133 + result = append(result, string(currentLine[:lastSpace+1])) 134 + rest := append([]byte(nil), currentLine[lastSpace+1:]...) 135 + currentLine = append(rest, ch...) 136 + currentColumn = currentColumn - lastSpaceCol - 1 + charWidth 137 + lastSpace = -1 138 + lastSpaceCol = 0 139 + return 140 + } 141 + result = append(result, string(currentLine)) 142 + currentLine = append(currentLine[:0:0], ch...) 143 + currentColumn = charWidth 144 + lastSpace = -1 145 + lastSpaceCol = 0 146 + return 147 + } 148 + preLen := len(currentLine) 149 + currentLine = append(currentLine, ch...) 150 + currentColumn += charWidth 151 + if isSpace { 152 + lastSpace = preLen + len(ch) - 1 153 + lastSpaceCol = currentColumn - charWidth 154 + } 155 + } 156 + 157 + if countBytes { 158 + for i := 0; i < len(line); i++ { 159 + b := line[i] 160 + emit(1, b == ' ' || b == '\t', []byte{b}) 161 + } 162 + } else { 163 + var buf [utf8.UTFMax]byte 164 + for _, r := range line { 165 + charWidth := 1 166 + switch r { 167 + case '\t': 168 + charWidth = 8 - (currentColumn % 8) 169 + case '\b': 170 + charWidth = -1 171 + } 172 + n := utf8.EncodeRune(buf[:], r) 173 + emit(charWidth, r == ' ' || r == '\t', buf[:n]) 174 + } 175 + } 176 + 177 + if len(currentLine) > 0 { 178 + result = append(result, string(currentLine)) 179 + } 180 + return strings.Join(result, "\n") 181 + } 182 + 183 + func readStdin(ec *command.ExecContext) (string, error) { 184 + if ec.Stdin == nil { 185 + return "", nil 186 + } 187 + data, err := io.ReadAll(ec.Stdin) 188 + if err != nil { 189 + return "", interp.ExitStatus(1) 190 + } 191 + return string(data), nil 192 + } 193 + 194 + func readFile(ec *command.ExecContext, file string, stderr io.Writer) (string, error) { 195 + if file == "-" { 196 + return readStdin(ec) 197 + } 198 + if ec.FS == nil { 199 + fmt.Fprintf(stderr, "fold: %s: No such file or directory\n", file) 200 + return "", interp.ExitStatus(1) 201 + } 202 + full := resolvePath(ec, file) 203 + f, err := ec.FS.Open(full) 204 + if err != nil { 205 + fmt.Fprintf(stderr, "fold: %s: No such file or directory\n", file) 206 + return "", interp.ExitStatus(1) 207 + } 208 + data, err := io.ReadAll(f) 209 + f.Close() 210 + if err != nil { 211 + fmt.Fprintf(stderr, "fold: %s: %v\n", file, err) 212 + return "", interp.ExitStatus(1) 213 + } 214 + return string(data), nil 215 + } 216 + 217 + func resolvePath(ec *command.ExecContext, p string) string { 218 + dir := ec.Dir 219 + if dir == "" { 220 + dir = "." 221 + } 222 + if path.IsAbs(p) { 223 + p = strings.TrimPrefix(p, "/") 224 + if p == "" { 225 + return "." 226 + } 227 + return path.Clean(p) 228 + } 229 + joined := path.Join(dir, p) 230 + if joined == "" { 231 + return "." 232 + } 233 + return joined 234 + }
+228
command/internal/fold/fold_test.go
··· 1 + package fold 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("long.txt", []byte("abcdefghij\n")) 28 + write("multi.txt", []byte("foo\nbar\n")) 29 + write("words.txt", []byte("the quick brown fox\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 TestFold(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 width passes short input through", 58 + args: nil, 59 + stdin: "hello world\n", 60 + wantStdout: "hello world\n", 61 + }, 62 + { 63 + name: "wraps at width from stdin", 64 + args: []string{"-w", "5"}, 65 + stdin: "abcdefghij\n", 66 + wantStdout: "abcde\nfghij\n", 67 + }, 68 + { 69 + name: "long flag width", 70 + args: []string{"--width=5"}, 71 + stdin: "abcdefghij\n", 72 + wantStdout: "abcde\nfghij\n", 73 + }, 74 + { 75 + name: "short flag with attached value", 76 + args: []string{"-w5"}, 77 + stdin: "abcdefghij\n", 78 + wantStdout: "abcde\nfghij\n", 79 + }, 80 + { 81 + name: "break at spaces", 82 + args: []string{"-s", "-w", "10"}, 83 + stdin: "the quick brown fox\n", 84 + wantStdout: "the quick \nbrown fox\n", 85 + }, 86 + { 87 + name: "combined short flags -sw", 88 + args: []string{"-sw", "10"}, 89 + stdin: "the quick brown fox\n", 90 + wantStdout: "the quick \nbrown fox\n", 91 + }, 92 + { 93 + name: "no space to break on falls back to width", 94 + args: []string{"-s", "-w", "5"}, 95 + stdin: "abcdefghij\n", 96 + wantStdout: "abcde\nfghij\n", 97 + }, 98 + { 99 + name: "empty input produces empty output", 100 + args: nil, 101 + stdin: "", 102 + wantStdout: "", 103 + }, 104 + { 105 + name: "no trailing newline preserved", 106 + args: []string{"-w", "5"}, 107 + stdin: "abcdefghij", 108 + wantStdout: "abcde\nfghij", 109 + }, 110 + { 111 + name: "multiple lines folded independently", 112 + args: []string{"-w", "3"}, 113 + stdin: "abcdef\nxyz\n", 114 + wantStdout: "abc\ndef\nxyz\n", 115 + }, 116 + { 117 + name: "tab expands to next column boundary", 118 + args: []string{"-w", "9"}, 119 + stdin: "a\tb\n", 120 + wantStdout: "a\tb\n", 121 + }, 122 + { 123 + name: "tab pushes line over width and wraps", 124 + args: []string{"-w", "5"}, 125 + stdin: "a\tbc\n", 126 + wantStdout: "a\n\t\nbc\n", 127 + }, 128 + { 129 + name: "byte mode counts bytes not columns", 130 + args: []string{"-b", "-w", "3"}, 131 + stdin: "abcdef\n", 132 + wantStdout: "abc\ndef\n", 133 + }, 134 + { 135 + name: "byte mode treats tab as one byte", 136 + args: []string{"-b", "-w", "3"}, 137 + stdin: "a\tbc\n", 138 + wantStdout: "a\tb\nc\n", 139 + }, 140 + { 141 + name: "fold from file", 142 + args: []string{"-w", "5", "long.txt"}, 143 + wantStdout: "abcde\nfghij\n", 144 + }, 145 + { 146 + name: "concatenates multiple files", 147 + args: []string{"-w", "3", "long.txt", "multi.txt"}, 148 + wantStdout: "abc\ndef\nghi\nj\nfoo\nbar\n", 149 + }, 150 + { 151 + name: "dash means stdin", 152 + args: []string{"-w", "5", "-"}, 153 + stdin: "abcdefghij\n", 154 + wantStdout: "abcde\nfghij\n", 155 + }, 156 + { 157 + name: "double dash terminator", 158 + args: []string{"-w", "5", "--", "long.txt"}, 159 + wantStdout: "abcde\nfghij\n", 160 + }, 161 + { 162 + name: "invalid width errors", 163 + args: []string{"-w", "abc"}, 164 + stdin: "hello\n", 165 + wantErrSub: "invalid number of columns", 166 + wantErr: true, 167 + }, 168 + { 169 + name: "zero width errors", 170 + args: []string{"-w", "0"}, 171 + stdin: "hello\n", 172 + wantErrSub: "invalid number of columns", 173 + wantErr: true, 174 + }, 175 + { 176 + name: "missing file errors", 177 + args: []string{"nope.txt"}, 178 + wantErrSub: "No such file or directory", 179 + wantErr: true, 180 + }, 181 + { 182 + name: "unknown flag errors", 183 + args: []string{"--no-such-flag"}, 184 + wantErr: true, 185 + }, 186 + } 187 + 188 + for _, tt := range tests { 189 + t.Run(tt.name, func(t *testing.T) { 190 + stdout, stderr, err := run(t, tt.args, tt.stdin, newFS(t)) 191 + if tt.wantErr { 192 + if err == nil { 193 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 194 + } 195 + } else if err != nil { 196 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 197 + } 198 + if stdout != tt.wantStdout { 199 + t.Errorf("stdout = %q, want %q", stdout, tt.wantStdout) 200 + } 201 + if tt.wantErrSub != "" && !strings.Contains(stderr, tt.wantErrSub) { 202 + t.Errorf("stderr = %q, want substring %q", stderr, tt.wantErrSub) 203 + } 204 + }) 205 + } 206 + } 207 + 208 + func TestHelp(t *testing.T) { 209 + stdout, stderr, err := run(t, []string{"--help"}, "", newFS(t)) 210 + if err != nil { 211 + t.Fatalf("unexpected error: %v", err) 212 + } 213 + if stdout != "" { 214 + t.Errorf("expected empty stdout, got %q", stdout) 215 + } 216 + if !strings.Contains(stderr, "Usage: fold [OPTION]... [FILE]...") { 217 + t.Errorf("usage line missing from stderr: %q", stderr) 218 + } 219 + if !strings.Contains(stderr, "-w WIDTH") { 220 + t.Errorf("width flag missing from help: %q", stderr) 221 + } 222 + if !strings.Contains(stderr, "-s") { 223 + t.Errorf("spaces flag missing from help: %q", stderr) 224 + } 225 + if !strings.Contains(stderr, "-b") { 226 + t.Errorf("bytes flag missing from help: %q", stderr) 227 + } 228 + }
+2
command/registry/coreutils/coreutils.go
··· 16 16 "tangled.org/xeiaso.net/kefka/command/internal/expr" 17 17 "tangled.org/xeiaso.net/kefka/command/internal/file" 18 18 "tangled.org/xeiaso.net/kefka/command/internal/falsecmd" 19 + "tangled.org/xeiaso.net/kefka/command/internal/fold" 19 20 "tangled.org/xeiaso.net/kefka/command/internal/hostname" 20 21 "tangled.org/xeiaso.net/kefka/command/internal/ls" 21 22 "tangled.org/xeiaso.net/kefka/command/internal/truecmd" ··· 39 40 reg.Register("expr", expr.Impl{}) 40 41 reg.Register("file", file.Impl{}) 41 42 reg.Register("false", falsecmd.Impl{}) 43 + reg.Register("fold", fold.Impl{}) 42 44 reg.Register("hostname", hostname.Impl{}) 43 45 reg.Register("ls", ls.Impl{}) 44 46 reg.Register("true", truecmd.Impl{})