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

Columnate input either by filling rows down-then-across (default)
or by aligning whitespace/separator-delimited input as a table
with -t. Mirrors just-bash's flag set (-t/-s/-o/-c/-n) and its
quirks: blank and whitespace-only lines are dropped before
formatting, consecutive delimiters merge unless -n is set, and
output ends with a trailing newline only when there is content.

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

Xe Iaso 7c870165 b2062e73

+507
+281
command/internal/column/column.go
··· 1 + package column 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "path" 9 + "regexp" 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("column: 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("column") 35 + set.SetParameters("[FILE]...") 36 + 37 + usage := func() { 38 + fmt.Fprint(stderr, "Usage: column [OPTION]... [FILE]...\n") 39 + fmt.Fprint(stderr, "Format input into multiple columns. By default, fills rows first. Use -t to create a table based on whitespace-delimited input.\n\n") 40 + fmt.Fprint(stderr, " -t, --table create a table (determine columns from input)\n") 41 + fmt.Fprint(stderr, " -s SEP input field delimiter (default: whitespace)\n") 42 + fmt.Fprint(stderr, " -o SEP output field delimiter (default: two spaces)\n") 43 + fmt.Fprint(stderr, " -c WIDTH output width for fill mode (default: 80)\n") 44 + fmt.Fprint(stderr, " -n don't merge multiple adjacent delimiters\n") 45 + fmt.Fprint(stderr, " --help display this help and exit\n") 46 + } 47 + set.SetUsage(usage) 48 + 49 + table := set.BoolLong("table", 't', "create a table (determine columns from input)") 50 + sep := set.String('s', "", "input field delimiter (default: whitespace)") 51 + outSep := set.String('o', " ", "output field delimiter (default: two spaces)") 52 + width := set.Int('c', 80, "output width for fill mode (default: 80)") 53 + noMerge := set.Bool('n', "don't merge multiple adjacent delimiters") 54 + help := set.BoolLong("help", 0, "display this help and exit") 55 + 56 + if err := set.Getopt(append([]string{"column"}, args...), nil); err != nil { 57 + fmt.Fprintf(stderr, "column: %s\n", err) 58 + usage() 59 + return interp.ExitStatus(1) 60 + } 61 + if *help { 62 + usage() 63 + return nil 64 + } 65 + 66 + files := set.Args() 67 + 68 + content, err := readInput(ec, files, stderr) 69 + if err != nil { 70 + return err 71 + } 72 + 73 + output := formatColumns(content, *table, *sep, *outSep, *width, *noMerge) 74 + io.WriteString(stdout, output) 75 + return nil 76 + } 77 + 78 + func formatColumns(content string, table bool, sep, outSep string, width int, noMerge bool) string { 79 + if strings.TrimSpace(content) == "" { 80 + return "" 81 + } 82 + 83 + lines := strings.Split(content, "\n") 84 + if strings.HasSuffix(content, "\n") && len(lines) > 0 && lines[len(lines)-1] == "" { 85 + lines = lines[:len(lines)-1] 86 + } 87 + 88 + var nonEmpty []string 89 + for _, line := range lines { 90 + if strings.TrimSpace(line) != "" { 91 + nonEmpty = append(nonEmpty, line) 92 + } 93 + } 94 + 95 + var output string 96 + if table { 97 + rows := make([][]string, 0, len(nonEmpty)) 98 + for _, line := range nonEmpty { 99 + rows = append(rows, splitFields(line, sep, noMerge)) 100 + } 101 + output = formatTable(rows, outSep) 102 + } else { 103 + var items []string 104 + for _, line := range nonEmpty { 105 + items = append(items, splitFields(line, sep, noMerge)...) 106 + } 107 + output = formatFill(items, width, outSep) 108 + } 109 + 110 + if len(output) > 0 { 111 + output += "\n" 112 + } 113 + return output 114 + } 115 + 116 + var ( 117 + spaceTabSingle = regexp.MustCompile(`[ \t]`) 118 + spaceTabRun = regexp.MustCompile(`[ \t]+`) 119 + ) 120 + 121 + func splitFields(line, sep string, noMerge bool) []string { 122 + if sep != "" { 123 + parts := strings.Split(line, sep) 124 + if noMerge { 125 + return parts 126 + } 127 + return removeEmpty(parts) 128 + } 129 + if noMerge { 130 + return spaceTabSingle.Split(line, -1) 131 + } 132 + return removeEmpty(spaceTabRun.Split(line, -1)) 133 + } 134 + 135 + func removeEmpty(in []string) []string { 136 + out := in[:0] 137 + for _, s := range in { 138 + if s != "" { 139 + out = append(out, s) 140 + } 141 + } 142 + return out 143 + } 144 + 145 + func formatTable(rows [][]string, outSep string) string { 146 + if len(rows) == 0 { 147 + return "" 148 + } 149 + var widths []int 150 + for _, row := range rows { 151 + for i, cell := range row { 152 + if i >= len(widths) { 153 + widths = append(widths, 0) 154 + } 155 + if len(cell) > widths[i] { 156 + widths[i] = len(cell) 157 + } 158 + } 159 + } 160 + 161 + lines := make([]string, 0, len(rows)) 162 + for _, row := range rows { 163 + cells := make([]string, len(row)) 164 + for i, cell := range row { 165 + if i == len(row)-1 { 166 + cells[i] = cell 167 + } else { 168 + cells[i] = padEnd(cell, widths[i]) 169 + } 170 + } 171 + lines = append(lines, strings.Join(cells, outSep)) 172 + } 173 + return strings.Join(lines, "\n") 174 + } 175 + 176 + func formatFill(items []string, width int, outSep string) string { 177 + if len(items) == 0 { 178 + return "" 179 + } 180 + maxItemWidth := 0 181 + for _, item := range items { 182 + maxItemWidth = max(maxItemWidth, len(item)) 183 + } 184 + sepWidth := len(outSep) 185 + colWidth := maxItemWidth + sepWidth 186 + numColumns := 1 187 + if colWidth > 0 { 188 + numColumns = max(1, (width+sepWidth)/colWidth) 189 + } 190 + numRows := (len(items) + numColumns - 1) / numColumns 191 + 192 + lines := make([]string, 0, numRows) 193 + for row := range numRows { 194 + var cells []string 195 + for col := range numColumns { 196 + index := col*numRows + row 197 + if index >= len(items) { 198 + continue 199 + } 200 + isLastInRow := col == numColumns-1 || (col+1)*numRows+row >= len(items) 201 + if isLastInRow { 202 + cells = append(cells, items[index]) 203 + } else { 204 + cells = append(cells, padEnd(items[index], maxItemWidth)) 205 + } 206 + } 207 + lines = append(lines, strings.Join(cells, outSep)) 208 + } 209 + return strings.Join(lines, "\n") 210 + } 211 + 212 + func padEnd(s string, width int) string { 213 + if len(s) >= width { 214 + return s 215 + } 216 + return s + strings.Repeat(" ", width-len(s)) 217 + } 218 + 219 + func readInput(ec *command.ExecContext, files []string, stderr io.Writer) (string, error) { 220 + if len(files) == 0 { 221 + if ec.Stdin == nil { 222 + return "", nil 223 + } 224 + data, err := io.ReadAll(ec.Stdin) 225 + if err != nil { 226 + return "", interp.ExitStatus(1) 227 + } 228 + return string(data), nil 229 + } 230 + var buf strings.Builder 231 + for _, file := range files { 232 + if file == "-" { 233 + if ec.Stdin == nil { 234 + continue 235 + } 236 + data, err := io.ReadAll(ec.Stdin) 237 + if err != nil { 238 + return "", interp.ExitStatus(1) 239 + } 240 + buf.Write(data) 241 + continue 242 + } 243 + if ec.FS == nil { 244 + fmt.Fprintf(stderr, "column: %s: No such file or directory\n", file) 245 + return "", interp.ExitStatus(1) 246 + } 247 + full := resolvePath(ec, file) 248 + f, err := ec.FS.Open(full) 249 + if err != nil { 250 + fmt.Fprintf(stderr, "column: %s: No such file or directory\n", file) 251 + return "", interp.ExitStatus(1) 252 + } 253 + data, err := io.ReadAll(f) 254 + f.Close() 255 + if err != nil { 256 + fmt.Fprintf(stderr, "column: %s: %v\n", file, err) 257 + return "", interp.ExitStatus(1) 258 + } 259 + buf.Write(data) 260 + } 261 + return buf.String(), nil 262 + } 263 + 264 + func resolvePath(ec *command.ExecContext, p string) string { 265 + dir := ec.Dir 266 + if dir == "" { 267 + dir = "." 268 + } 269 + if path.IsAbs(p) { 270 + p = strings.TrimPrefix(p, "/") 271 + if p == "" { 272 + return "." 273 + } 274 + return path.Clean(p) 275 + } 276 + joined := path.Join(dir, p) 277 + if joined == "" { 278 + return "." 279 + } 280 + return joined 281 + }
+224
command/internal/column/column_test.go
··· 1 + package column 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("words.txt", []byte("alpha beta gamma\n")) 27 + write("table.txt", []byte("name age city\nalice 30 nyc\nbob 25 sf\n")) 28 + write("csv.txt", []byte("a,b,c\nd,ee,f\n")) 29 + write("blank.txt", []byte(" \n\n\t\n")) 30 + write("dupes.txt", []byte("a,,b\n")) 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 TestColumn(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: "fill mode stdin three short items", 59 + args: nil, 60 + stdin: "alpha beta gamma\n", 61 + wantStdout: "alpha beta gamma\n", 62 + }, 63 + { 64 + name: "fill mode stdin via dash", 65 + args: []string{"-"}, 66 + stdin: "alpha beta gamma\n", 67 + wantStdout: "alpha beta gamma\n", 68 + }, 69 + { 70 + name: "fill mode reads single file", 71 + args: []string{"words.txt"}, 72 + wantStdout: "alpha beta gamma\n", 73 + }, 74 + { 75 + name: "fill mode narrow width forces multiple rows", 76 + args: []string{"-c", "8"}, 77 + stdin: "a b c d e\n", 78 + wantStdout: "a c e\nb d\n", 79 + }, 80 + { 81 + name: "fill mode custom output separator", 82 + args: []string{"-o", ", "}, 83 + stdin: "a b c\n", 84 + wantStdout: "a, b, c\n", 85 + }, 86 + { 87 + name: "table mode whitespace input aligns columns", 88 + args: []string{"-t"}, 89 + stdin: "name age city\nalice 30 nyc\nbob 25 sf\n", 90 + wantStdout: "name age city\nalice 30 nyc\nbob 25 sf\n", 91 + }, 92 + { 93 + name: "table mode reads from file", 94 + args: []string{"-t", "table.txt"}, 95 + wantStdout: "name age city\nalice 30 nyc\nbob 25 sf\n", 96 + }, 97 + { 98 + name: "table mode with comma separator", 99 + args: []string{"-t", "-s", ",", "csv.txt"}, 100 + wantStdout: "a b c\nd ee f\n", 101 + }, 102 + { 103 + name: "merge consecutive separators by default", 104 + args: []string{"-t", "-s", ",", "dupes.txt"}, 105 + wantStdout: "a b\n", 106 + }, 107 + { 108 + name: "no merge preserves empty fields", 109 + args: []string{"-t", "-s", ",", "-n", "dupes.txt"}, 110 + wantStdout: "a b\n", 111 + }, 112 + { 113 + name: "long table flag", 114 + args: []string{"--table"}, 115 + stdin: "a b\nccc d\n", 116 + wantStdout: "a b\nccc d\n", 117 + }, 118 + { 119 + name: "concatenates multiple files", 120 + args: []string{"words.txt", "words.txt"}, 121 + wantStdout: "alpha beta gamma alpha beta gamma\n", 122 + }, 123 + { 124 + name: "empty stdin produces empty output", 125 + args: nil, 126 + stdin: "", 127 + wantStdout: "", 128 + }, 129 + { 130 + name: "whitespace only input produces empty output", 131 + args: []string{"blank.txt"}, 132 + wantStdout: "", 133 + }, 134 + { 135 + name: "blank lines between rows are skipped in table mode", 136 + args: []string{"-t"}, 137 + stdin: "a b\n\nccc d\n", 138 + wantStdout: "a b\nccc d\n", 139 + }, 140 + { 141 + name: "missing file reports error", 142 + args: []string{"nope.txt"}, 143 + wantErrSub: "No such file or directory", 144 + wantErr: true, 145 + }, 146 + { 147 + name: "unknown flag returns error", 148 + args: []string{"--no-such-flag"}, 149 + wantErr: true, 150 + }, 151 + } 152 + 153 + for _, tt := range tests { 154 + t.Run(tt.name, func(t *testing.T) { 155 + stdout, stderr, err := run(t, tt.args, tt.stdin, newFS(t)) 156 + if tt.wantErr { 157 + if err == nil { 158 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 159 + } 160 + } else if err != nil { 161 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 162 + } 163 + if stdout != tt.wantStdout { 164 + t.Errorf("stdout = %q, want %q", stdout, tt.wantStdout) 165 + } 166 + if tt.wantErrSub != "" && !strings.Contains(stderr, tt.wantErrSub) { 167 + t.Errorf("stderr = %q, want substring %q", stderr, tt.wantErrSub) 168 + } 169 + }) 170 + } 171 + } 172 + 173 + func TestHelp(t *testing.T) { 174 + stdout, stderr, err := run(t, []string{"--help"}, "", newFS(t)) 175 + if err != nil { 176 + t.Fatalf("unexpected error: %v", err) 177 + } 178 + if stdout != "" { 179 + t.Errorf("expected empty stdout, got %q", stdout) 180 + } 181 + if !strings.Contains(stderr, "Usage: column [OPTION]... [FILE]...") { 182 + t.Errorf("usage line missing from stderr: %q", stderr) 183 + } 184 + if !strings.Contains(stderr, "-t, --table") { 185 + t.Errorf("table flag missing from help: %q", stderr) 186 + } 187 + } 188 + 189 + func TestNilContext(t *testing.T) { 190 + if err := (Impl{}).Exec(context.Background(), nil, nil); err == nil { 191 + t.Fatal("expected error for nil ExecContext") 192 + } 193 + } 194 + 195 + func TestSplitFields(t *testing.T) { 196 + tests := []struct { 197 + name string 198 + line string 199 + sep string 200 + noMerge bool 201 + want []string 202 + }{ 203 + {"whitespace default", "a b c", "", false, []string{"a", "b", "c"}}, 204 + {"whitespace no merge", "a b c", "", true, []string{"a", "b", "", "c"}}, 205 + {"comma default", "a,,b", ",", false, []string{"a", "b"}}, 206 + {"comma no merge", "a,,b", ",", true, []string{"a", "", "b"}}, 207 + {"tab whitespace", "a\tb", "", false, []string{"a", "b"}}, 208 + {"empty line whitespace", "", "", false, []string{}}, 209 + } 210 + 211 + for _, tt := range tests { 212 + t.Run(tt.name, func(t *testing.T) { 213 + got := splitFields(tt.line, tt.sep, tt.noMerge) 214 + if len(got) != len(tt.want) { 215 + t.Fatalf("len = %d, want %d (got=%q)", len(got), len(tt.want), got) 216 + } 217 + for i := range got { 218 + if got[i] != tt.want[i] { 219 + t.Errorf("[%d] = %q, want %q", i, got[i], tt.want[i]) 220 + } 221 + } 222 + }) 223 + } 224 + }
+2
command/registry/coreutils/coreutils.go
··· 5 5 "tangled.org/xeiaso.net/kefka/command/internal/basename" 6 6 "tangled.org/xeiaso.net/kefka/command/internal/cat" 7 7 "tangled.org/xeiaso.net/kefka/command/internal/clear" 8 + "tangled.org/xeiaso.net/kefka/command/internal/column" 8 9 "tangled.org/xeiaso.net/kefka/command/internal/falsecmd" 9 10 "tangled.org/xeiaso.net/kefka/command/internal/hostname" 10 11 "tangled.org/xeiaso.net/kefka/command/internal/ls" ··· 17 18 reg.Register("basename", basename.Impl{}) 18 19 reg.Register("cat", cat.Impl{}) 19 20 reg.Register("clear", clear.Impl{}) 21 + reg.Register("column", column.Impl{}) 20 22 reg.Register("false", falsecmd.Impl{}) 21 23 reg.Register("hostname", hostname.Impl{}) 22 24 reg.Register("ls", ls.Impl{})