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

Number lines of files, with body-style, format, width, separator,
starting number, and increment options. Mirrors the just-bash nl
defaults: only non-empty lines numbered (style t), right-justified
six-column numbers, tab separator. Non-numbered lines emit a
width-spaces pad followed by the separator so output stays aligned.
A shared line counter spans multiple files. Numeric flags reject
non-integer or sub-1-width input with coreutils-shaped diagnostics.

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

Xe Iaso ecab3935 d7824549

+492
+266
command/internal/nl/nl.go
··· 1 + package nl 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 + type options struct { 20 + bodyStyle string 21 + numberFormat string 22 + width int 23 + separator string 24 + startNumber int 25 + increment int 26 + } 27 + 28 + func (Impl) Exec(_ context.Context, ec *command.ExecContext, args []string) error { 29 + if ec == nil { 30 + return errors.New("nl: nil ExecContext") 31 + } 32 + 33 + stdout := ec.Stdout 34 + if stdout == nil { 35 + stdout = io.Discard 36 + } 37 + stderr := ec.Stderr 38 + if stderr == nil { 39 + stderr = io.Discard 40 + } 41 + 42 + set := getopt.New() 43 + set.SetProgram("nl") 44 + set.SetParameters("[FILE]...") 45 + 46 + usage := func() { 47 + fmt.Fprint(stderr, "Usage: nl [OPTION]... [FILE]...\n") 48 + fmt.Fprint(stderr, "Write each FILE to standard output, with line numbers added.\n") 49 + fmt.Fprint(stderr, "If no FILE is specified, standard input is read.\n\n") 50 + fmt.Fprint(stderr, " -b STYLE Body numbering style: a (all), t (non-empty), n (none)\n") 51 + fmt.Fprint(stderr, " -n FORMAT Number format: ln (left), rn (right), rz (right zeros)\n") 52 + fmt.Fprint(stderr, " -w WIDTH Number width (default: 6)\n") 53 + fmt.Fprint(stderr, " -s SEP Separator after number (default: TAB)\n") 54 + fmt.Fprint(stderr, " -v START Starting line number (default: 1)\n") 55 + fmt.Fprint(stderr, " -i INCR Line number increment (default: 1)\n") 56 + fmt.Fprint(stderr, " --help display this help and exit\n") 57 + } 58 + set.SetUsage(usage) 59 + 60 + bodyStyle := set.String('b', "t", "body numbering style") 61 + numberFormat := set.String('n', "rn", "number format") 62 + widthSpec := set.String('w', "6", "number width") 63 + separator := set.String('s', "\t", "separator after number") 64 + startSpec := set.String('v', "1", "starting line number") 65 + incrSpec := set.String('i', "1", "line number increment") 66 + help := set.BoolLong("help", 0, "display this help and exit") 67 + 68 + if err := set.Getopt(append([]string{"nl"}, args...), nil); err != nil { 69 + fmt.Fprintf(stderr, "nl: %s\n", err) 70 + usage() 71 + return interp.ExitStatus(1) 72 + } 73 + if *help { 74 + usage() 75 + return nil 76 + } 77 + 78 + switch *bodyStyle { 79 + case "a", "t", "n": 80 + default: 81 + fmt.Fprintf(stderr, "nl: invalid body numbering style: '%s'\n", *bodyStyle) 82 + return interp.ExitStatus(1) 83 + } 84 + 85 + switch *numberFormat { 86 + case "ln", "rn", "rz": 87 + default: 88 + fmt.Fprintf(stderr, "nl: invalid line numbering format: '%s'\n", *numberFormat) 89 + return interp.ExitStatus(1) 90 + } 91 + 92 + width, err := strconv.Atoi(*widthSpec) 93 + if err != nil || width < 1 { 94 + fmt.Fprintf(stderr, "nl: invalid line number field width: '%s'\n", *widthSpec) 95 + return interp.ExitStatus(1) 96 + } 97 + 98 + startNumber, err := strconv.Atoi(*startSpec) 99 + if err != nil { 100 + fmt.Fprintf(stderr, "nl: invalid starting line number: '%s'\n", *startSpec) 101 + return interp.ExitStatus(1) 102 + } 103 + 104 + increment, err := strconv.Atoi(*incrSpec) 105 + if err != nil { 106 + fmt.Fprintf(stderr, "nl: invalid line number increment: '%s'\n", *incrSpec) 107 + return interp.ExitStatus(1) 108 + } 109 + 110 + opts := options{ 111 + bodyStyle: *bodyStyle, 112 + numberFormat: *numberFormat, 113 + width: width, 114 + separator: *separator, 115 + startNumber: startNumber, 116 + increment: increment, 117 + } 118 + 119 + files := set.Args() 120 + lineNumber := opts.startNumber 121 + var output strings.Builder 122 + 123 + if len(files) == 0 { 124 + content, err := readStdin(ec) 125 + if err != nil { 126 + return err 127 + } 128 + out, _ := processContent(content, opts, lineNumber) 129 + output.WriteString(out) 130 + } else { 131 + for _, file := range files { 132 + content, err := readFile(ec, file, stderr) 133 + if err != nil { 134 + io.WriteString(stdout, output.String()) 135 + return err 136 + } 137 + out, next := processContent(content, opts, lineNumber) 138 + output.WriteString(out) 139 + lineNumber = next 140 + } 141 + } 142 + 143 + io.WriteString(stdout, output.String()) 144 + return nil 145 + } 146 + 147 + func processContent(content string, opts options, currentNumber int) (string, int) { 148 + if content == "" { 149 + return "", currentNumber 150 + } 151 + 152 + lines := strings.Split(content, "\n") 153 + hasTrailingNewline := strings.HasSuffix(content, "\n") && lines[len(lines)-1] == "" 154 + if hasTrailingNewline { 155 + lines = lines[:len(lines)-1] 156 + } 157 + 158 + var out strings.Builder 159 + lineNumber := currentNumber 160 + for i, line := range lines { 161 + if i > 0 { 162 + out.WriteByte('\n') 163 + } 164 + if shouldNumber(line, opts.bodyStyle) { 165 + out.WriteString(formatLineNumber(lineNumber, opts.numberFormat, opts.width)) 166 + out.WriteString(opts.separator) 167 + out.WriteString(line) 168 + lineNumber += opts.increment 169 + } else { 170 + out.WriteString(strings.Repeat(" ", opts.width)) 171 + out.WriteString(opts.separator) 172 + out.WriteString(line) 173 + } 174 + } 175 + if hasTrailingNewline { 176 + out.WriteByte('\n') 177 + } 178 + return out.String(), lineNumber 179 + } 180 + 181 + func shouldNumber(line, style string) bool { 182 + switch style { 183 + case "a": 184 + return true 185 + case "t": 186 + return strings.TrimSpace(line) != "" 187 + case "n": 188 + return false 189 + } 190 + return false 191 + } 192 + 193 + func formatLineNumber(num int, format string, width int) string { 194 + s := strconv.Itoa(num) 195 + switch format { 196 + case "ln": 197 + if len(s) >= width { 198 + return s 199 + } 200 + return s + strings.Repeat(" ", width-len(s)) 201 + case "rn": 202 + if len(s) >= width { 203 + return s 204 + } 205 + return strings.Repeat(" ", width-len(s)) + s 206 + case "rz": 207 + if len(s) >= width { 208 + return s 209 + } 210 + return strings.Repeat("0", width-len(s)) + s 211 + } 212 + return s 213 + } 214 + 215 + func readStdin(ec *command.ExecContext) (string, error) { 216 + if ec.Stdin == nil { 217 + return "", nil 218 + } 219 + data, err := io.ReadAll(ec.Stdin) 220 + if err != nil { 221 + return "", interp.ExitStatus(1) 222 + } 223 + return string(data), nil 224 + } 225 + 226 + func readFile(ec *command.ExecContext, file string, stderr io.Writer) (string, error) { 227 + if file == "-" { 228 + return readStdin(ec) 229 + } 230 + if ec.FS == nil { 231 + fmt.Fprintf(stderr, "nl: %s: No such file or directory\n", file) 232 + return "", interp.ExitStatus(1) 233 + } 234 + full := resolvePath(ec, file) 235 + f, err := ec.FS.Open(full) 236 + if err != nil { 237 + fmt.Fprintf(stderr, "nl: %s: No such file or directory\n", file) 238 + return "", interp.ExitStatus(1) 239 + } 240 + data, err := io.ReadAll(f) 241 + f.Close() 242 + if err != nil { 243 + fmt.Fprintf(stderr, "nl: %s: %v\n", file, err) 244 + return "", interp.ExitStatus(1) 245 + } 246 + return string(data), nil 247 + } 248 + 249 + func resolvePath(ec *command.ExecContext, p string) string { 250 + dir := ec.Dir 251 + if dir == "" { 252 + dir = "." 253 + } 254 + if path.IsAbs(p) { 255 + p = strings.TrimPrefix(p, "/") 256 + if p == "" { 257 + return "." 258 + } 259 + return path.Clean(p) 260 + } 261 + joined := path.Join(dir, p) 262 + if joined == "" { 263 + return "." 264 + } 265 + return joined 266 + }
+224
command/internal/nl/nl_test.go
··· 1 + package nl 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\nworld\n")) 27 + write("blanks.txt", []byte("alpha\n\nbravo\n\ncharlie\n")) 28 + write("nofinalnl.txt", []byte("foo\nbar")) 29 + write("empty.txt", []byte("")) 30 + write("part1.txt", []byte("a\nb\n")) 31 + write("part2.txt", []byte("c\nd\n")) 32 + return fs 33 + } 34 + 35 + func run(t *testing.T, args []string, stdin string, fs billy.Filesystem) (string, string, error) { 36 + t.Helper() 37 + var stdout, stderr bytes.Buffer 38 + ec := &command.ExecContext{ 39 + Stdin: strings.NewReader(stdin), 40 + Stdout: &stdout, 41 + Stderr: &stderr, 42 + Dir: ".", 43 + FS: fs, 44 + } 45 + err := Impl{}.Exec(context.Background(), ec, args) 46 + return stdout.String(), stderr.String(), err 47 + } 48 + 49 + func TestNl(t *testing.T) { 50 + tests := []struct { 51 + name string 52 + args []string 53 + stdin string 54 + wantStdout string 55 + wantErrSub string 56 + wantErr bool 57 + }{ 58 + { 59 + name: "default numbers non-empty lines from stdin", 60 + args: nil, 61 + stdin: "alpha\nbravo\n", 62 + wantStdout: " 1\talpha\n 2\tbravo\n", 63 + }, 64 + { 65 + name: "default skips blank lines (style t)", 66 + args: []string{"blanks.txt"}, 67 + wantStdout: " 1\talpha\n \t\n 2\tbravo\n \t\n 3\tcharlie\n", 68 + }, 69 + { 70 + name: "style a numbers all lines", 71 + args: []string{"-b", "a", "blanks.txt"}, 72 + wantStdout: " 1\talpha\n 2\t\n 3\tbravo\n 4\t\n 5\tcharlie\n", 73 + }, 74 + { 75 + name: "style n numbers no lines", 76 + args: []string{"-b", "n", "hello.txt"}, 77 + wantStdout: " \thello\n \tworld\n", 78 + }, 79 + { 80 + name: "style attached to flag (-ba)", 81 + args: []string{"-ba", "blanks.txt"}, 82 + wantStdout: " 1\talpha\n 2\t\n 3\tbravo\n 4\t\n 5\tcharlie\n", 83 + }, 84 + { 85 + name: "left-justified format", 86 + args: []string{"-n", "ln", "hello.txt"}, 87 + wantStdout: "1 \thello\n2 \tworld\n", 88 + }, 89 + { 90 + name: "right-justified zero padding", 91 + args: []string{"-n", "rz", "-w", "3", "hello.txt"}, 92 + wantStdout: "001\thello\n002\tworld\n", 93 + }, 94 + { 95 + name: "custom width", 96 + args: []string{"-w", "3", "hello.txt"}, 97 + wantStdout: " 1\thello\n 2\tworld\n", 98 + }, 99 + { 100 + name: "custom separator", 101 + args: []string{"-s", ": ", "hello.txt"}, 102 + wantStdout: " 1: hello\n 2: world\n", 103 + }, 104 + { 105 + name: "starting number", 106 + args: []string{"-v", "10", "hello.txt"}, 107 + wantStdout: " 10\thello\n 11\tworld\n", 108 + }, 109 + { 110 + name: "increment", 111 + args: []string{"-i", "5", "hello.txt"}, 112 + wantStdout: " 1\thello\n 6\tworld\n", 113 + }, 114 + { 115 + name: "no trailing newline preserved", 116 + args: []string{"nofinalnl.txt"}, 117 + wantStdout: " 1\tfoo\n 2\tbar", 118 + }, 119 + { 120 + name: "empty file produces no output", 121 + args: []string{"empty.txt"}, 122 + wantStdout: "", 123 + }, 124 + { 125 + name: "empty stdin produces no output", 126 + args: nil, 127 + stdin: "", 128 + wantStdout: "", 129 + }, 130 + { 131 + name: "dash means stdin", 132 + args: []string{"-"}, 133 + stdin: "alpha\nbravo\n", 134 + wantStdout: " 1\talpha\n 2\tbravo\n", 135 + }, 136 + { 137 + name: "multiple files share line counter", 138 + args: []string{"part1.txt", "part2.txt"}, 139 + wantStdout: " 1\ta\n 2\tb\n 3\tc\n 4\td\n", 140 + }, 141 + { 142 + name: "invalid body style", 143 + args: []string{"-b", "x", "hello.txt"}, 144 + wantErrSub: "invalid body numbering style", 145 + wantErr: true, 146 + }, 147 + { 148 + name: "invalid number format", 149 + args: []string{"-n", "xx", "hello.txt"}, 150 + wantErrSub: "invalid line numbering format", 151 + wantErr: true, 152 + }, 153 + { 154 + name: "invalid width zero", 155 + args: []string{"-w", "0", "hello.txt"}, 156 + wantErrSub: "invalid line number field width", 157 + wantErr: true, 158 + }, 159 + { 160 + name: "invalid width non-numeric", 161 + args: []string{"-w", "abc", "hello.txt"}, 162 + wantErrSub: "invalid line number field width", 163 + wantErr: true, 164 + }, 165 + { 166 + name: "invalid starting number", 167 + args: []string{"-v", "abc", "hello.txt"}, 168 + wantErrSub: "invalid starting line number", 169 + wantErr: true, 170 + }, 171 + { 172 + name: "invalid increment", 173 + args: []string{"-i", "abc", "hello.txt"}, 174 + wantErrSub: "invalid line number increment", 175 + wantErr: true, 176 + }, 177 + { 178 + name: "missing file", 179 + args: []string{"nope.txt"}, 180 + wantErrSub: "No such file or directory", 181 + wantErr: true, 182 + }, 183 + { 184 + name: "unknown flag", 185 + args: []string{"--nope"}, 186 + wantErr: true, 187 + }, 188 + } 189 + 190 + for _, tt := range tests { 191 + t.Run(tt.name, func(t *testing.T) { 192 + stdout, stderr, err := run(t, tt.args, tt.stdin, newFS(t)) 193 + if tt.wantErr { 194 + if err == nil { 195 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 196 + } 197 + } else if err != nil { 198 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 199 + } 200 + if stdout != tt.wantStdout { 201 + t.Errorf("stdout = %q, want %q", stdout, tt.wantStdout) 202 + } 203 + if tt.wantErrSub != "" && !strings.Contains(stderr, tt.wantErrSub) { 204 + t.Errorf("stderr = %q, want substring %q", stderr, tt.wantErrSub) 205 + } 206 + }) 207 + } 208 + } 209 + 210 + func TestHelp(t *testing.T) { 211 + stdout, stderr, err := run(t, []string{"--help"}, "", newFS(t)) 212 + if err != nil { 213 + t.Fatalf("unexpected error: %v", err) 214 + } 215 + if stdout != "" { 216 + t.Errorf("expected empty stdout, got %q", stdout) 217 + } 218 + if !strings.Contains(stderr, "Usage: nl [OPTION]... [FILE]...") { 219 + t.Errorf("usage line missing from stderr: %q", stderr) 220 + } 221 + if !strings.Contains(stderr, "-b STYLE") { 222 + t.Errorf("body style flag missing from help: %q", stderr) 223 + } 224 + }
+2
command/registry/coreutils/coreutils.go
··· 23 23 "tangled.org/xeiaso.net/kefka/command/internal/join" 24 24 "tangled.org/xeiaso.net/kefka/command/internal/ls" 25 25 "tangled.org/xeiaso.net/kefka/command/internal/mkdir" 26 + "tangled.org/xeiaso.net/kefka/command/internal/nl" 26 27 "tangled.org/xeiaso.net/kefka/command/internal/truecmd" 27 28 "tangled.org/xeiaso.net/kefka/command/internal/unexpand" 28 29 "tangled.org/xeiaso.net/kefka/command/internal/zcat" ··· 52 53 reg.Register("join", join.Impl{}) 53 54 reg.Register("ls", ls.Impl{}) 54 55 reg.Register("mkdir", mkdir.Impl{}) 56 + reg.Register("nl", nl.Impl{}) 55 57 reg.Register("true", truecmd.Impl{}) 56 58 reg.Register("unexpand", unexpand.Impl{}) 57 59 reg.Register("zcat", zcat.Impl{})