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 expand and unexpand from just-bash

Convert TABs to spaces and back. Mirrors GNU expand/unexpand
semantics as implemented by just-bash: -t accepts either a single
tab width or a comma-separated ascending list of explicit stops,
expand -i restricts conversion to leading whitespace, and
unexpand only converts interior runs when -a is given. The
trailing-newline of the input is preserved exactly.

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

Xe Iaso 311f183a e512808a

+961
+232
command/internal/expand/expand.go
··· 1 + package expand 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("expand: 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("expand") 35 + set.SetParameters("[FILE]...") 36 + 37 + usage := func() { 38 + fmt.Fprint(stderr, "Usage: expand [OPTION]... [FILE]...\n") 39 + fmt.Fprint(stderr, "Convert TABs in each FILE to spaces, writing to standard output.\n") 40 + fmt.Fprint(stderr, "If no FILE is specified, standard input is read.\n\n") 41 + fmt.Fprint(stderr, " -t N Use N spaces per tab (default: 8)\n") 42 + fmt.Fprint(stderr, " -t LIST Use comma-separated list of tab stops\n") 43 + fmt.Fprint(stderr, " -i Only convert leading tabs on each line\n") 44 + fmt.Fprint(stderr, " --help display this help and exit\n") 45 + } 46 + set.SetUsage(usage) 47 + 48 + tabsSpec := set.StringLong("tabs", 't', "8", "use N spaces per tab") 49 + leadingOnly := set.BoolLong("initial", 'i', "only convert leading tabs on each line") 50 + help := set.BoolLong("help", 0, "display this help and exit") 51 + 52 + if err := set.Getopt(append([]string{"expand"}, args...), nil); err != nil { 53 + fmt.Fprintf(stderr, "expand: %s\n", err) 54 + usage() 55 + return interp.ExitStatus(1) 56 + } 57 + if *help { 58 + usage() 59 + return nil 60 + } 61 + 62 + tabStops, ok := parseTabStops(*tabsSpec) 63 + if !ok { 64 + fmt.Fprintf(stderr, "expand: invalid tab size: '%s'\n", *tabsSpec) 65 + return interp.ExitStatus(1) 66 + } 67 + 68 + files := set.Args() 69 + 70 + var output strings.Builder 71 + var execErr error 72 + if len(files) == 0 { 73 + content, err := readStdin(ec) 74 + if err != nil { 75 + return err 76 + } 77 + output.WriteString(processContent(content, tabStops, *leadingOnly)) 78 + } else { 79 + for _, file := range files { 80 + content, err := readFile(ec, file, stderr) 81 + if err != nil { 82 + execErr = err 83 + break 84 + } 85 + output.WriteString(processContent(content, tabStops, *leadingOnly)) 86 + } 87 + } 88 + 89 + io.WriteString(stdout, output.String()) 90 + return execErr 91 + } 92 + 93 + func parseTabStops(spec string) ([]int, bool) { 94 + parts := strings.Split(spec, ",") 95 + stops := make([]int, 0, len(parts)) 96 + for _, p := range parts { 97 + n, err := strconv.Atoi(strings.TrimSpace(p)) 98 + if err != nil || n < 1 { 99 + return nil, false 100 + } 101 + stops = append(stops, n) 102 + } 103 + for i := 1; i < len(stops); i++ { 104 + if stops[i] <= stops[i-1] { 105 + return nil, false 106 + } 107 + } 108 + return stops, true 109 + } 110 + 111 + func getTabWidth(column int, tabStops []int) int { 112 + if len(tabStops) == 1 { 113 + w := tabStops[0] 114 + return w - (column % w) 115 + } 116 + for _, stop := range tabStops { 117 + if stop > column { 118 + return stop - column 119 + } 120 + } 121 + if len(tabStops) >= 2 { 122 + last := tabStops[len(tabStops)-1] 123 + prev := tabStops[len(tabStops)-2] 124 + interval := last - prev 125 + steps := (column-last)/interval + 1 126 + next := last + steps*interval 127 + return next - column 128 + } 129 + return 1 130 + } 131 + 132 + func expandLine(line string, tabStops []int, leadingOnly bool) string { 133 + var result strings.Builder 134 + column := 0 135 + inLeading := true 136 + for _, r := range line { 137 + if r == '\t' { 138 + if leadingOnly && !inLeading { 139 + result.WriteByte('\t') 140 + column++ 141 + continue 142 + } 143 + n := getTabWidth(column, tabStops) 144 + for range n { 145 + result.WriteByte(' ') 146 + } 147 + column += n 148 + continue 149 + } 150 + if r != ' ' { 151 + inLeading = false 152 + } 153 + result.WriteRune(r) 154 + column++ 155 + } 156 + return result.String() 157 + } 158 + 159 + func processContent(content string, tabStops []int, leadingOnly bool) string { 160 + if content == "" { 161 + return "" 162 + } 163 + lines := strings.Split(content, "\n") 164 + hasTrailingNewline := strings.HasSuffix(content, "\n") && lines[len(lines)-1] == "" 165 + if hasTrailingNewline { 166 + lines = lines[:len(lines)-1] 167 + } 168 + var out strings.Builder 169 + for i, line := range lines { 170 + if i > 0 { 171 + out.WriteByte('\n') 172 + } 173 + out.WriteString(expandLine(line, tabStops, leadingOnly)) 174 + } 175 + if hasTrailingNewline { 176 + out.WriteByte('\n') 177 + } 178 + return out.String() 179 + } 180 + 181 + func readStdin(ec *command.ExecContext) (string, error) { 182 + if ec.Stdin == nil { 183 + return "", nil 184 + } 185 + data, err := io.ReadAll(ec.Stdin) 186 + if err != nil { 187 + return "", interp.ExitStatus(1) 188 + } 189 + return string(data), nil 190 + } 191 + 192 + func readFile(ec *command.ExecContext, file string, stderr io.Writer) (string, error) { 193 + if file == "-" { 194 + return readStdin(ec) 195 + } 196 + if ec.FS == nil { 197 + fmt.Fprintf(stderr, "expand: %s: No such file or directory\n", file) 198 + return "", interp.ExitStatus(1) 199 + } 200 + full := resolvePath(ec, file) 201 + f, err := ec.FS.Open(full) 202 + if err != nil { 203 + fmt.Fprintf(stderr, "expand: %s: No such file or directory\n", file) 204 + return "", interp.ExitStatus(1) 205 + } 206 + data, err := io.ReadAll(f) 207 + f.Close() 208 + if err != nil { 209 + fmt.Fprintf(stderr, "expand: %s: %v\n", file, err) 210 + return "", interp.ExitStatus(1) 211 + } 212 + return string(data), nil 213 + } 214 + 215 + func resolvePath(ec *command.ExecContext, p string) string { 216 + dir := ec.Dir 217 + if dir == "" { 218 + dir = "." 219 + } 220 + if path.IsAbs(p) { 221 + p = strings.TrimPrefix(p, "/") 222 + if p == "" { 223 + return "." 224 + } 225 + return path.Clean(p) 226 + } 227 + joined := path.Join(dir, p) 228 + if joined == "" { 229 + return "." 230 + } 231 + return joined 232 + }
+231
command/internal/expand/expand_test.go
··· 1 + package expand 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("tabs.txt", []byte("a\tb\tc\n")) 27 + write("leading.txt", []byte("\thello\tworld\n")) 28 + write("multi.txt", []byte("foo\nbar\n")) 29 + return fs 30 + } 31 + 32 + func run(t *testing.T, args []string, stdin string, fs billy.Filesystem) (string, string, error) { 33 + t.Helper() 34 + var stdout, stderr bytes.Buffer 35 + ec := &command.ExecContext{ 36 + Stdin: strings.NewReader(stdin), 37 + Stdout: &stdout, 38 + Stderr: &stderr, 39 + Dir: ".", 40 + FS: fs, 41 + } 42 + err := Impl{}.Exec(context.Background(), ec, args) 43 + return stdout.String(), stderr.String(), err 44 + } 45 + 46 + func TestExpand(t *testing.T) { 47 + tests := []struct { 48 + name string 49 + args []string 50 + stdin string 51 + wantStdout string 52 + wantErrSub string 53 + wantErr bool 54 + }{ 55 + { 56 + name: "default 8-space tab from stdin", 57 + args: nil, 58 + stdin: "a\tb\n", 59 + wantStdout: "a b\n", 60 + }, 61 + { 62 + name: "default 8-space tab at column 0", 63 + args: nil, 64 + stdin: "\tx\n", 65 + wantStdout: " x\n", 66 + }, 67 + { 68 + name: "tab width 4 short flag", 69 + args: []string{"-t", "4"}, 70 + stdin: "a\tb\n", 71 + wantStdout: "a b\n", 72 + }, 73 + { 74 + name: "tab width 4 short flag combined", 75 + args: []string{"-t4"}, 76 + stdin: "a\tb\n", 77 + wantStdout: "a b\n", 78 + }, 79 + { 80 + name: "tab width 4 long flag", 81 + args: []string{"--tabs=4"}, 82 + stdin: "a\tb\n", 83 + wantStdout: "a b\n", 84 + }, 85 + { 86 + name: "explicit tab stops", 87 + args: []string{"-t", "4,8,12"}, 88 + stdin: "\t\t\tend\n", 89 + wantStdout: " end\n", 90 + }, 91 + { 92 + name: "explicit tab stops past last uses last interval", 93 + args: []string{"-t", "4,8"}, 94 + stdin: "a\t\t\tx\n", 95 + wantStdout: "a" + strings.Repeat(" ", 11) + "x\n", 96 + }, 97 + { 98 + name: "leading-only short flag preserves interior tabs", 99 + args: []string{"-i"}, 100 + stdin: "\thi\tworld\n", 101 + wantStdout: " hi\tworld\n", 102 + }, 103 + { 104 + name: "leading-only long flag", 105 + args: []string{"--initial"}, 106 + stdin: "\thi\tworld\n", 107 + wantStdout: " hi\tworld\n", 108 + }, 109 + { 110 + name: "leading-only with mixed leading whitespace", 111 + args: []string{"-i", "-t", "4"}, 112 + stdin: " \tfoo\tbar\n", 113 + wantStdout: " foo\tbar\n", 114 + }, 115 + { 116 + name: "expand from file", 117 + args: []string{"-t", "4", "tabs.txt"}, 118 + wantStdout: "a b c\n", 119 + }, 120 + { 121 + name: "concatenates multiple files", 122 + args: []string{"-t", "4", "tabs.txt", "multi.txt"}, 123 + wantStdout: "a b c\nfoo\nbar\n", 124 + }, 125 + { 126 + name: "dash means stdin", 127 + args: []string{"-t", "4", "-"}, 128 + stdin: "a\tb\n", 129 + wantStdout: "a b\n", 130 + }, 131 + { 132 + name: "double dash terminator", 133 + args: []string{"-t", "4", "--", "tabs.txt"}, 134 + wantStdout: "a b c\n", 135 + }, 136 + { 137 + name: "empty stdin produces empty output", 138 + args: nil, 139 + stdin: "", 140 + wantStdout: "", 141 + }, 142 + { 143 + name: "no trailing newline preserved", 144 + args: []string{"-t", "4"}, 145 + stdin: "a\tb", 146 + wantStdout: "a b", 147 + }, 148 + { 149 + name: "non-tab characters pass through", 150 + args: nil, 151 + stdin: "no tabs here\n", 152 + wantStdout: "no tabs here\n", 153 + }, 154 + { 155 + name: "tab after text uses column position", 156 + args: []string{"-t", "4"}, 157 + stdin: "ab\tc\n", 158 + wantStdout: "ab c\n", 159 + }, 160 + { 161 + name: "invalid tab size errors", 162 + args: []string{"-t", "abc"}, 163 + stdin: "a\tb\n", 164 + wantErrSub: "invalid tab size", 165 + wantErr: true, 166 + }, 167 + { 168 + name: "zero tab size errors", 169 + args: []string{"-t", "0"}, 170 + stdin: "a\tb\n", 171 + wantErrSub: "invalid tab size", 172 + wantErr: true, 173 + }, 174 + { 175 + name: "non-ascending tab stops error", 176 + args: []string{"-t", "4,3"}, 177 + stdin: "a\tb\n", 178 + wantErrSub: "invalid tab size", 179 + wantErr: true, 180 + }, 181 + { 182 + name: "missing file errors", 183 + args: []string{"nope.txt"}, 184 + wantErrSub: "No such file or directory", 185 + wantErr: true, 186 + }, 187 + { 188 + name: "unknown flag errors", 189 + args: []string{"--no-such-flag"}, 190 + wantErr: true, 191 + }, 192 + } 193 + 194 + for _, tt := range tests { 195 + t.Run(tt.name, func(t *testing.T) { 196 + stdout, stderr, err := run(t, tt.args, tt.stdin, newFS(t)) 197 + if tt.wantErr { 198 + if err == nil { 199 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 200 + } 201 + } else if err != nil { 202 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 203 + } 204 + if stdout != tt.wantStdout { 205 + t.Errorf("stdout = %q, want %q", stdout, tt.wantStdout) 206 + } 207 + if tt.wantErrSub != "" && !strings.Contains(stderr, tt.wantErrSub) { 208 + t.Errorf("stderr = %q, want substring %q", stderr, tt.wantErrSub) 209 + } 210 + }) 211 + } 212 + } 213 + 214 + func TestHelp(t *testing.T) { 215 + stdout, stderr, err := run(t, []string{"--help"}, "", newFS(t)) 216 + if err != nil { 217 + t.Fatalf("unexpected error: %v", err) 218 + } 219 + if stdout != "" { 220 + t.Errorf("expected empty stdout, got %q", stdout) 221 + } 222 + if !strings.Contains(stderr, "Usage: expand [OPTION]... [FILE]...") { 223 + t.Errorf("usage line missing from stderr: %q", stderr) 224 + } 225 + if !strings.Contains(stderr, "-t N") { 226 + t.Errorf("tab-size flag missing from help: %q", stderr) 227 + } 228 + if !strings.Contains(stderr, "-i") { 229 + t.Errorf("leading-only flag missing from help: %q", stderr) 230 + } 231 + }
+263
command/internal/unexpand/unexpand.go
··· 1 + package unexpand 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("unexpand: 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("unexpand") 35 + set.SetParameters("[FILE]...") 36 + 37 + usage := func() { 38 + fmt.Fprint(stderr, "Usage: unexpand [OPTION]... [FILE]...\n") 39 + fmt.Fprint(stderr, "Convert blanks in each FILE to TABs, writing to standard output.\n") 40 + fmt.Fprint(stderr, "If no FILE is specified, standard input is read.\n\n") 41 + fmt.Fprint(stderr, " -t N Use N spaces per tab (default: 8)\n") 42 + fmt.Fprint(stderr, " -t LIST Use comma-separated list of tab stops\n") 43 + fmt.Fprint(stderr, " -a Convert all sequences of blanks (not just leading)\n") 44 + fmt.Fprint(stderr, " --help display this help and exit\n") 45 + } 46 + set.SetUsage(usage) 47 + 48 + tabsSpec := set.StringLong("tabs", 't', "8", "use N spaces per tab") 49 + allBlanks := set.BoolLong("all", 'a', "convert all blanks, not just leading ones") 50 + help := set.BoolLong("help", 0, "display this help and exit") 51 + 52 + if err := set.Getopt(append([]string{"unexpand"}, args...), nil); err != nil { 53 + fmt.Fprintf(stderr, "unexpand: %s\n", err) 54 + usage() 55 + return interp.ExitStatus(1) 56 + } 57 + if *help { 58 + usage() 59 + return nil 60 + } 61 + 62 + tabStops, ok := parseTabStops(*tabsSpec) 63 + if !ok { 64 + fmt.Fprintf(stderr, "unexpand: invalid tab size: '%s'\n", *tabsSpec) 65 + return interp.ExitStatus(1) 66 + } 67 + 68 + files := set.Args() 69 + 70 + var output strings.Builder 71 + var execErr error 72 + if len(files) == 0 { 73 + content, err := readStdin(ec) 74 + if err != nil { 75 + return err 76 + } 77 + output.WriteString(processContent(content, tabStops, *allBlanks)) 78 + } else { 79 + for _, file := range files { 80 + content, err := readFile(ec, file, stderr) 81 + if err != nil { 82 + execErr = err 83 + break 84 + } 85 + output.WriteString(processContent(content, tabStops, *allBlanks)) 86 + } 87 + } 88 + 89 + io.WriteString(stdout, output.String()) 90 + return execErr 91 + } 92 + 93 + func parseTabStops(spec string) ([]int, bool) { 94 + parts := strings.Split(spec, ",") 95 + stops := make([]int, 0, len(parts)) 96 + for _, p := range parts { 97 + n, err := strconv.Atoi(strings.TrimSpace(p)) 98 + if err != nil || n < 1 { 99 + return nil, false 100 + } 101 + stops = append(stops, n) 102 + } 103 + for i := 1; i < len(stops); i++ { 104 + if stops[i] <= stops[i-1] { 105 + return nil, false 106 + } 107 + } 108 + return stops, true 109 + } 110 + 111 + func getNextTabStop(column int, tabStops []int) int { 112 + if len(tabStops) == 1 { 113 + w := tabStops[0] 114 + return column + (w - (column % w)) 115 + } 116 + for _, stop := range tabStops { 117 + if stop > column { 118 + return stop 119 + } 120 + } 121 + if len(tabStops) >= 2 { 122 + last := tabStops[len(tabStops)-1] 123 + prev := tabStops[len(tabStops)-2] 124 + interval := last - prev 125 + steps := (column-last)/interval + 1 126 + return last + steps*interval 127 + } 128 + return -1 129 + } 130 + 131 + func unexpandLine(line string, tabStops []int, allBlanks bool) string { 132 + var result strings.Builder 133 + column := 0 134 + spaceRun := 0 135 + spaceRunStart := 0 136 + inLeading := true 137 + 138 + flushSpaces := func() { 139 + if spaceRun == 0 { 140 + return 141 + } 142 + endColumn := spaceRunStart + spaceRun 143 + if !allBlanks && !inLeading { 144 + for range spaceRun { 145 + result.WriteByte(' ') 146 + } 147 + spaceRun = 0 148 + return 149 + } 150 + currentPos := spaceRunStart 151 + for currentPos < endColumn { 152 + nextStop := getNextTabStop(currentPos, tabStops) 153 + if nextStop <= endColumn && nextStop > currentPos { 154 + result.WriteByte('\t') 155 + currentPos = nextStop 156 + continue 157 + } 158 + break 159 + } 160 + remaining := endColumn - currentPos 161 + for range remaining { 162 + result.WriteByte(' ') 163 + } 164 + spaceRun = 0 165 + } 166 + 167 + for _, r := range line { 168 + switch r { 169 + case ' ': 170 + if spaceRun == 0 { 171 + spaceRunStart = column 172 + } 173 + spaceRun++ 174 + column++ 175 + case '\t': 176 + flushSpaces() 177 + result.WriteByte('\t') 178 + column = getNextTabStop(column, tabStops) 179 + default: 180 + flushSpaces() 181 + result.WriteRune(r) 182 + column++ 183 + inLeading = false 184 + } 185 + } 186 + flushSpaces() 187 + return result.String() 188 + } 189 + 190 + func processContent(content string, tabStops []int, allBlanks bool) string { 191 + if content == "" { 192 + return "" 193 + } 194 + lines := strings.Split(content, "\n") 195 + hasTrailingNewline := strings.HasSuffix(content, "\n") && lines[len(lines)-1] == "" 196 + if hasTrailingNewline { 197 + lines = lines[:len(lines)-1] 198 + } 199 + var out strings.Builder 200 + for i, line := range lines { 201 + if i > 0 { 202 + out.WriteByte('\n') 203 + } 204 + out.WriteString(unexpandLine(line, tabStops, allBlanks)) 205 + } 206 + if hasTrailingNewline { 207 + out.WriteByte('\n') 208 + } 209 + return out.String() 210 + } 211 + 212 + func readStdin(ec *command.ExecContext) (string, error) { 213 + if ec.Stdin == nil { 214 + return "", nil 215 + } 216 + data, err := io.ReadAll(ec.Stdin) 217 + if err != nil { 218 + return "", interp.ExitStatus(1) 219 + } 220 + return string(data), nil 221 + } 222 + 223 + func readFile(ec *command.ExecContext, file string, stderr io.Writer) (string, error) { 224 + if file == "-" { 225 + return readStdin(ec) 226 + } 227 + if ec.FS == nil { 228 + fmt.Fprintf(stderr, "unexpand: %s: No such file or directory\n", file) 229 + return "", interp.ExitStatus(1) 230 + } 231 + full := resolvePath(ec, file) 232 + f, err := ec.FS.Open(full) 233 + if err != nil { 234 + fmt.Fprintf(stderr, "unexpand: %s: No such file or directory\n", file) 235 + return "", interp.ExitStatus(1) 236 + } 237 + data, err := io.ReadAll(f) 238 + f.Close() 239 + if err != nil { 240 + fmt.Fprintf(stderr, "unexpand: %s: %v\n", file, err) 241 + return "", interp.ExitStatus(1) 242 + } 243 + return string(data), nil 244 + } 245 + 246 + func resolvePath(ec *command.ExecContext, p string) string { 247 + dir := ec.Dir 248 + if dir == "" { 249 + dir = "." 250 + } 251 + if path.IsAbs(p) { 252 + p = strings.TrimPrefix(p, "/") 253 + if p == "" { 254 + return "." 255 + } 256 + return path.Clean(p) 257 + } 258 + joined := path.Join(dir, p) 259 + if joined == "" { 260 + return "." 261 + } 262 + return joined 263 + }
+231
command/internal/unexpand/unexpand_test.go
··· 1 + package unexpand 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("spaces.txt", []byte(" hello\n")) 27 + write("middle.txt", []byte("a b c\n")) 28 + write("multi.txt", []byte("foo\nbar\n")) 29 + return fs 30 + } 31 + 32 + func run(t *testing.T, args []string, stdin string, fs billy.Filesystem) (string, string, error) { 33 + t.Helper() 34 + var stdout, stderr bytes.Buffer 35 + ec := &command.ExecContext{ 36 + Stdin: strings.NewReader(stdin), 37 + Stdout: &stdout, 38 + Stderr: &stderr, 39 + Dir: ".", 40 + FS: fs, 41 + } 42 + err := Impl{}.Exec(context.Background(), ec, args) 43 + return stdout.String(), stderr.String(), err 44 + } 45 + 46 + func TestUnexpand(t *testing.T) { 47 + tests := []struct { 48 + name string 49 + args []string 50 + stdin string 51 + wantStdout string 52 + wantErrSub string 53 + wantErr bool 54 + }{ 55 + { 56 + name: "default 8-space leading run becomes a tab", 57 + args: nil, 58 + stdin: " hello\n", 59 + wantStdout: "\thello\n", 60 + }, 61 + { 62 + name: "interior spaces left alone by default", 63 + args: nil, 64 + stdin: "a b\n", 65 + wantStdout: "a b\n", 66 + }, 67 + { 68 + name: "all-blanks short flag converts interior runs", 69 + args: []string{"-a"}, 70 + stdin: "a b\n", 71 + wantStdout: "a\tb\n", 72 + }, 73 + { 74 + name: "all-blanks long flag", 75 + args: []string{"--all"}, 76 + stdin: "a b\n", 77 + wantStdout: "a\tb\n", 78 + }, 79 + { 80 + name: "tab width 4 short flag", 81 + args: []string{"-t", "4"}, 82 + stdin: " hello\n", 83 + wantStdout: "\thello\n", 84 + }, 85 + { 86 + name: "tab width 4 long flag", 87 + args: []string{"--tabs=4"}, 88 + stdin: " hello\n", 89 + wantStdout: "\thello\n", 90 + }, 91 + { 92 + name: "explicit tab stops", 93 + args: []string{"-t", "4,8"}, 94 + stdin: " end\n", 95 + wantStdout: "\t\tend\n", 96 + }, 97 + { 98 + name: "partial space run not at tab stop preserved", 99 + args: []string{"-t", "4"}, 100 + stdin: " hi\n", 101 + wantStdout: " hi\n", 102 + }, 103 + { 104 + name: "leading run with extra spaces tabs the multiple of width and keeps remainder", 105 + args: []string{"-t", "4"}, 106 + stdin: " hi\n", 107 + wantStdout: "\t hi\n", 108 + }, 109 + { 110 + name: "tab in input passes through and advances column", 111 + args: []string{"-a", "-t", "4"}, 112 + stdin: "a\t b\n", 113 + wantStdout: "a\t b\n", 114 + }, 115 + { 116 + name: "round trip with expand-style 8-space input", 117 + args: nil, 118 + stdin: " hi\n", 119 + wantStdout: "\thi\n", 120 + }, 121 + { 122 + name: "unexpand from file", 123 + args: []string{"spaces.txt"}, 124 + wantStdout: "\thello\n", 125 + }, 126 + { 127 + name: "concatenates multiple files", 128 + args: []string{"spaces.txt", "multi.txt"}, 129 + wantStdout: "\thello\nfoo\nbar\n", 130 + }, 131 + { 132 + name: "dash means stdin", 133 + args: []string{"-"}, 134 + stdin: " hi\n", 135 + wantStdout: "\thi\n", 136 + }, 137 + { 138 + name: "double dash terminator", 139 + args: []string{"--", "spaces.txt"}, 140 + wantStdout: "\thello\n", 141 + }, 142 + { 143 + name: "empty stdin produces empty output", 144 + args: nil, 145 + stdin: "", 146 + wantStdout: "", 147 + }, 148 + { 149 + name: "no trailing newline preserved", 150 + args: nil, 151 + stdin: " hi", 152 + wantStdout: "\thi", 153 + }, 154 + { 155 + name: "no spaces stays unchanged", 156 + args: nil, 157 + stdin: "no spaces here\n", 158 + wantStdout: "no spaces here\n", 159 + }, 160 + { 161 + name: "invalid tab size errors", 162 + args: []string{"-t", "abc"}, 163 + stdin: " hi\n", 164 + wantErrSub: "invalid tab size", 165 + wantErr: true, 166 + }, 167 + { 168 + name: "zero tab size errors", 169 + args: []string{"-t", "0"}, 170 + stdin: " hi\n", 171 + wantErrSub: "invalid tab size", 172 + wantErr: true, 173 + }, 174 + { 175 + name: "non-ascending tab stops error", 176 + args: []string{"-t", "4,3"}, 177 + stdin: " hi\n", 178 + wantErrSub: "invalid tab size", 179 + wantErr: true, 180 + }, 181 + { 182 + name: "missing file errors", 183 + args: []string{"nope.txt"}, 184 + wantErrSub: "No such file or directory", 185 + wantErr: true, 186 + }, 187 + { 188 + name: "unknown flag errors", 189 + args: []string{"--no-such-flag"}, 190 + wantErr: true, 191 + }, 192 + } 193 + 194 + for _, tt := range tests { 195 + t.Run(tt.name, func(t *testing.T) { 196 + stdout, stderr, err := run(t, tt.args, tt.stdin, newFS(t)) 197 + if tt.wantErr { 198 + if err == nil { 199 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 200 + } 201 + } else if err != nil { 202 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 203 + } 204 + if stdout != tt.wantStdout { 205 + t.Errorf("stdout = %q, want %q", stdout, tt.wantStdout) 206 + } 207 + if tt.wantErrSub != "" && !strings.Contains(stderr, tt.wantErrSub) { 208 + t.Errorf("stderr = %q, want substring %q", stderr, tt.wantErrSub) 209 + } 210 + }) 211 + } 212 + } 213 + 214 + func TestHelp(t *testing.T) { 215 + stdout, stderr, err := run(t, []string{"--help"}, "", newFS(t)) 216 + if err != nil { 217 + t.Fatalf("unexpected error: %v", err) 218 + } 219 + if stdout != "" { 220 + t.Errorf("expected empty stdout, got %q", stdout) 221 + } 222 + if !strings.Contains(stderr, "Usage: unexpand [OPTION]... [FILE]...") { 223 + t.Errorf("usage line missing from stderr: %q", stderr) 224 + } 225 + if !strings.Contains(stderr, "-t N") { 226 + t.Errorf("tab-size flag missing from help: %q", stderr) 227 + } 228 + if !strings.Contains(stderr, "-a") { 229 + t.Errorf("all-blanks flag missing from help: %q", stderr) 230 + } 231 + }
+4
command/registry/coreutils/coreutils.go
··· 12 12 "tangled.org/xeiaso.net/kefka/command/internal/diff" 13 13 "tangled.org/xeiaso.net/kefka/command/internal/dirname" 14 14 "tangled.org/xeiaso.net/kefka/command/internal/du" 15 + "tangled.org/xeiaso.net/kefka/command/internal/expand" 15 16 "tangled.org/xeiaso.net/kefka/command/internal/falsecmd" 16 17 "tangled.org/xeiaso.net/kefka/command/internal/hostname" 17 18 "tangled.org/xeiaso.net/kefka/command/internal/ls" 18 19 "tangled.org/xeiaso.net/kefka/command/internal/truecmd" 20 + "tangled.org/xeiaso.net/kefka/command/internal/unexpand" 19 21 "tangled.org/xeiaso.net/kefka/command/registry" 20 22 ) 21 23 ··· 31 33 reg.Register("diff", diff.Impl{}) 32 34 reg.Register("dirname", dirname.Impl{}) 33 35 reg.Register("du", du.Impl{}) 36 + reg.Register("expand", expand.Impl{}) 34 37 reg.Register("false", falsecmd.Impl{}) 35 38 reg.Register("hostname", hostname.Impl{}) 36 39 reg.Register("ls", ls.Impl{}) 37 40 reg.Register("true", truecmd.Impl{}) 41 + reg.Register("unexpand", unexpand.Impl{}) 38 42 }