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(uniq): add -f/-s/-w and output-file operand

Implements GNU coreutils compatibility for uniq:

- -f/--skip-fields N skips N blank-separated fields
before comparing
- -s/--skip-chars N skips N chars after fields
- -w/--check-chars N limits comparison length
- The two-positional form [INPUT [OUTPUT]] now treats
the second positional as the output file (previously
treated as another input)
- - operand routes OUTPUT to stdout

Retains GNU -i.

Refs: docs/posix2018/CONFORMANCE.md
Assisted-by: Claude Opus 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

Xe Iaso 21c399af 79c531ea

+269 -39
+109 -36
command/internal/uniq/uniq.go
··· 5 5 "errors" 6 6 "fmt" 7 7 "io" 8 + "os" 8 9 "path" 9 10 "strings" 10 11 ··· 36 37 usage := func() { 37 38 fmt.Fprint(stderr, "Usage: uniq [OPTION]... [INPUT [OUTPUT]]\n") 38 39 fmt.Fprint(stderr, "report or omit repeated lines\n\n") 39 - fmt.Fprint(stderr, " -c, --count prefix lines by the number of occurrences\n") 40 - fmt.Fprint(stderr, " -d, --repeated only print duplicate lines\n") 41 - fmt.Fprint(stderr, " -i, --ignore-case ignore case when comparing\n") 42 - fmt.Fprint(stderr, " -u, --unique only print unique lines\n") 43 - fmt.Fprint(stderr, " --help display this help and exit\n") 40 + fmt.Fprint(stderr, " -c, --count prefix lines by the number of occurrences\n") 41 + fmt.Fprint(stderr, " -d, --repeated only print duplicate lines\n") 42 + fmt.Fprint(stderr, " -f, --skip-fields=N avoid comparing the first N fields\n") 43 + fmt.Fprint(stderr, " -i, --ignore-case ignore case when comparing\n") 44 + fmt.Fprint(stderr, " -s, --skip-chars=N avoid comparing the first N characters\n") 45 + fmt.Fprint(stderr, " -u, --unique only print unique lines\n") 46 + fmt.Fprint(stderr, " -w, --check-chars=N compare no more than N characters in lines\n") 47 + fmt.Fprint(stderr, " --help display this help and exit\n") 44 48 } 45 49 set.SetUsage(usage) 46 50 ··· 48 52 duplicatesOnly := set.BoolLong("repeated", 'd', "only print duplicate lines") 49 53 uniqueOnly := set.BoolLong("unique", 'u', "only print unique lines") 50 54 ignoreCase := set.BoolLong("ignore-case", 'i', "ignore case when comparing") 55 + skipFields := set.IntLong("skip-fields", 'f', 0, "avoid comparing the first N fields") 56 + skipChars := set.IntLong("skip-chars", 's', 0, "avoid comparing the first N characters") 57 + checkChars := set.IntLong("check-chars", 'w', -1, "compare no more than N characters in lines") 51 58 help := set.BoolLong("help", 0, "display this help and exit") 52 59 53 60 if err := set.Getopt(append([]string{"uniq"}, args...), nil); err != nil { ··· 60 67 return nil 61 68 } 62 69 63 - files := set.Args() 64 - content, err := readAndConcat(ec, files, stderr) 70 + if *skipFields < 0 { 71 + fmt.Fprintf(stderr, "uniq: invalid number of fields to skip: %d\n", *skipFields) 72 + return interp.ExitStatus(1) 73 + } 74 + if *skipChars < 0 { 75 + fmt.Fprintf(stderr, "uniq: invalid number of characters to skip: %d\n", *skipChars) 76 + return interp.ExitStatus(1) 77 + } 78 + if set.IsSet("check-chars") && *checkChars < 0 { 79 + fmt.Fprintf(stderr, "uniq: invalid number of characters to compare: %d\n", *checkChars) 80 + return interp.ExitStatus(1) 81 + } 82 + 83 + positional := set.Args() 84 + if len(positional) > 2 { 85 + fmt.Fprintf(stderr, "uniq: extra operand %q\n", positional[2]) 86 + usage() 87 + return interp.ExitStatus(1) 88 + } 89 + 90 + var inputName string 91 + if len(positional) >= 1 { 92 + inputName = positional[0] 93 + } 94 + content, err := readInput(ec, inputName, stderr) 65 95 if err != nil { 66 96 return err 67 97 } 68 98 69 - io.WriteString(stdout, processUniq(content, *count, *duplicatesOnly, *uniqueOnly, *ignoreCase)) 99 + checkLimit := -1 100 + if set.IsSet("check-chars") { 101 + checkLimit = *checkChars 102 + } 103 + out := processUniq(content, *count, *duplicatesOnly, *uniqueOnly, *ignoreCase, *skipFields, *skipChars, checkLimit) 104 + 105 + if len(positional) == 2 && positional[1] != "-" { 106 + if ec.FS == nil { 107 + fmt.Fprintf(stderr, "uniq: %s: No such file or directory\n", positional[1]) 108 + return interp.ExitStatus(1) 109 + } 110 + full := resolvePath(ec, positional[1]) 111 + f, err := ec.FS.OpenFile(full, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) 112 + if err != nil { 113 + fmt.Fprintf(stderr, "uniq: %s: %v\n", positional[1], err) 114 + return interp.ExitStatus(1) 115 + } 116 + if _, err := io.WriteString(f, out); err != nil { 117 + f.Close() 118 + fmt.Fprintf(stderr, "uniq: %s: %v\n", positional[1], err) 119 + return interp.ExitStatus(1) 120 + } 121 + if err := f.Close(); err != nil { 122 + fmt.Fprintf(stderr, "uniq: %s: %v\n", positional[1], err) 123 + return interp.ExitStatus(1) 124 + } 125 + return nil 126 + } 127 + 128 + io.WriteString(stdout, out) 70 129 return nil 71 130 } 72 131 73 - func processUniq(content string, count, duplicatesOnly, uniqueOnly, ignoreCase bool) string { 132 + func processUniq(content string, count, duplicatesOnly, uniqueOnly, ignoreCase bool, skipFields, skipChars, checkChars int) string { 74 133 lines := strings.Split(content, "\n") 75 134 if len(lines) > 0 && lines[len(lines)-1] == "" { 76 135 lines = lines[:len(lines)-1] 77 136 } 78 137 if len(lines) == 0 { 79 138 return "" 139 + } 140 + 141 + keyOf := func(line string) string { 142 + k := skipFieldsAndChars(line, skipFields, skipChars) 143 + if checkChars >= 0 { 144 + kr := []rune(k) 145 + if checkChars < len(kr) { 146 + k = string(kr[:checkChars]) 147 + } 148 + } 149 + if ignoreCase { 150 + return strings.ToLower(k) 151 + } 152 + return k 80 153 } 81 154 82 155 type entry struct { ··· 84 157 count int 85 158 } 86 159 87 - equal := func(a, b string) bool { 88 - if ignoreCase { 89 - return strings.EqualFold(a, b) 90 - } 91 - return a == b 92 - } 93 - 94 160 result := make([]entry, 0, len(lines)) 95 161 current := lines[0] 162 + currentKey := keyOf(current) 96 163 currentCount := 1 97 164 for i := 1; i < len(lines); i++ { 98 - if equal(lines[i], current) { 165 + k := keyOf(lines[i]) 166 + if k == currentKey { 99 167 currentCount++ 100 168 continue 101 169 } 102 170 result = append(result, entry{line: current, count: currentCount}) 103 171 current = lines[i] 172 + currentKey = k 104 173 currentCount = 1 105 174 } 106 175 result = append(result, entry{line: current, count: currentCount}) ··· 123 192 return out.String() 124 193 } 125 194 126 - func readAndConcat(ec *command.ExecContext, files []string, stderr io.Writer) (string, error) { 127 - if len(files) == 0 { 128 - return readStdin(ec) 129 - } 130 - 131 - var b strings.Builder 132 - for _, f := range files { 133 - if f == "-" { 134 - data, err := readStdin(ec) 135 - if err != nil { 136 - return "", err 137 - } 138 - b.WriteString(data) 139 - continue 195 + func skipFieldsAndChars(line string, fields, chars int) string { 196 + runes := []rune(line) 197 + i := 0 198 + for f := 0; f < fields && i < len(runes); f++ { 199 + for i < len(runes) && isBlank(runes[i]) { 200 + i++ 140 201 } 141 - data, err := readFile(ec, f, stderr) 142 - if err != nil { 143 - return "", err 202 + for i < len(runes) && !isBlank(runes[i]) { 203 + i++ 144 204 } 145 - b.WriteString(data) 205 + } 206 + for c := 0; c < chars && i < len(runes); c++ { 207 + i++ 146 208 } 147 - return b.String(), nil 209 + return string(runes[i:]) 210 + } 211 + 212 + func isBlank(r rune) bool { 213 + return r == ' ' || r == '\t' 214 + } 215 + 216 + func readInput(ec *command.ExecContext, name string, stderr io.Writer) (string, error) { 217 + if name == "" || name == "-" { 218 + return readStdin(ec) 219 + } 220 + return readFile(ec, name, stderr) 148 221 } 149 222 150 223 func readStdin(ec *command.ExecContext) (string, error) {
+160 -3
command/internal/uniq/uniq_test.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 + "io" 6 7 "os" 7 8 "strings" 8 9 "testing" ··· 148 149 wantStdout: " 2 a\n 3 c\n", 149 150 }, 150 151 { 151 - name: "concatenates multiple file arguments", 152 - args: []string{"part1.txt", "part2.txt"}, 153 - wantStdout: "x\ny\n", 152 + name: "skip-fields collapses lines with same suffix", 153 + args: []string{"-f", "1"}, 154 + stdin: "x foo\ny foo\n", 155 + wantStdout: "x foo\n", 156 + }, 157 + { 158 + name: "skip-fields keeps lines with differing suffix", 159 + args: []string{"-f", "1"}, 160 + stdin: "a x\na y\n", 161 + wantStdout: "a x\na y\n", 162 + }, 163 + { 164 + name: "skip-fields multiple fields", 165 + args: []string{"-f", "2"}, 166 + stdin: "a a foo\nb b foo\n", 167 + wantStdout: "a a foo\n", 168 + }, 169 + { 170 + name: "skip-fields beyond available collapses", 171 + args: []string{"-f", "1"}, 172 + stdin: "foo\nbar\n", 173 + wantStdout: "foo\n", 174 + }, 175 + { 176 + name: "skip-fields long flag", 177 + args: []string{"--skip-fields=1"}, 178 + stdin: "x foo\ny foo\n", 179 + wantStdout: "x foo\n", 180 + }, 181 + { 182 + name: "skip-chars collapses with same suffix", 183 + args: []string{"-s", "2"}, 184 + stdin: "AAfoo\nBBfoo\n", 185 + wantStdout: "AAfoo\n", 186 + }, 187 + { 188 + name: "skip-chars keeps differing suffix", 189 + args: []string{"-s", "2"}, 190 + stdin: "aabar\naabaz\n", 191 + wantStdout: "aabar\naabaz\n", 192 + }, 193 + { 194 + name: "skip-chars long flag", 195 + args: []string{"--skip-chars=2"}, 196 + stdin: "AAfoo\nBBfoo\n", 197 + wantStdout: "AAfoo\n", 198 + }, 199 + { 200 + name: "skip-fields combined with skip-chars", 201 + args: []string{"-f", "1", "-s", "1"}, 202 + stdin: "aa Xfoo\nbb Xfoo\n", 203 + wantStdout: "aa Xfoo\n", 204 + }, 205 + { 206 + name: "check-chars limits comparison length", 207 + args: []string{"-w", "3"}, 208 + stdin: "fooaaa\nfoobbb\nbarccc\n", 209 + wantStdout: "fooaaa\nbarccc\n", 210 + }, 211 + { 212 + name: "check-chars zero collapses everything", 213 + args: []string{"-w", "0"}, 214 + stdin: "abc\ndef\nghi\n", 215 + wantStdout: "abc\n", 216 + }, 217 + { 218 + name: "check-chars long flag", 219 + args: []string{"--check-chars=2"}, 220 + stdin: "abxxx\nabyyy\nczzzz\n", 221 + wantStdout: "abxxx\nczzzz\n", 222 + }, 223 + { 224 + name: "check-chars exceeding line length keeps distinct", 225 + args: []string{"-w", "10"}, 226 + stdin: "ab\nac\n", 227 + wantStdout: "ab\nac\n", 228 + }, 229 + { 230 + name: "skip-fields skip-chars and check-chars combined", 231 + args: []string{"-f", "1", "-s", "1", "-w", "3"}, 232 + stdin: "a Xfoozzz\nb Xfooqqq\nc Xbarppp\n", 233 + wantStdout: "a Xfoozzz\nc Xbarppp\n", 234 + }, 235 + { 236 + name: "check-chars with ignore-case", 237 + args: []string{"-w", "3", "-i"}, 238 + stdin: "FOOaaa\nfooBBB\n", 239 + wantStdout: "FOOaaa\n", 154 240 }, 155 241 { 156 242 name: "missing file reports error", ··· 186 272 } 187 273 } 188 274 275 + func TestOutputFile(t *testing.T) { 276 + t.Run("two positionals writes file", func(t *testing.T) { 277 + fs := newFS(t) 278 + stdout, stderr, err := run(t, []string{"dups.txt", "out.txt"}, "", fs) 279 + if err != nil { 280 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 281 + } 282 + if stdout != "" { 283 + t.Errorf("expected empty stdout, got %q", stdout) 284 + } 285 + f, err := fs.Open("out.txt") 286 + if err != nil { 287 + t.Fatalf("output file not created: %v", err) 288 + } 289 + defer f.Close() 290 + got, err := io.ReadAll(f) 291 + if err != nil { 292 + t.Fatalf("read output: %v", err) 293 + } 294 + want := "a\nb\nc\nd\n" 295 + if string(got) != want { 296 + t.Errorf("output mismatch\n got: %q\nwant: %q", string(got), want) 297 + } 298 + }) 299 + 300 + t.Run("stdin to file via dash", func(t *testing.T) { 301 + fs := newFS(t) 302 + stdout, stderr, err := run(t, []string{"-", "out.txt"}, "x\nx\ny\n", fs) 303 + if err != nil { 304 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 305 + } 306 + if stdout != "" { 307 + t.Errorf("expected empty stdout, got %q", stdout) 308 + } 309 + f, err := fs.Open("out.txt") 310 + if err != nil { 311 + t.Fatalf("output file not created: %v", err) 312 + } 313 + defer f.Close() 314 + got, err := io.ReadAll(f) 315 + if err != nil { 316 + t.Fatalf("read output: %v", err) 317 + } 318 + want := "x\ny\n" 319 + if string(got) != want { 320 + t.Errorf("output mismatch\n got: %q\nwant: %q", string(got), want) 321 + } 322 + }) 323 + 324 + t.Run("output dash writes stdout", func(t *testing.T) { 325 + fs := newFS(t) 326 + stdout, stderr, err := run(t, []string{"dups.txt", "-"}, "", fs) 327 + if err != nil { 328 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 329 + } 330 + want := "a\nb\nc\nd\n" 331 + if stdout != want { 332 + t.Errorf("stdout mismatch\n got: %q\nwant: %q", stdout, want) 333 + } 334 + }) 335 + } 336 + 189 337 func TestHelp(t *testing.T) { 190 338 stdout, stderr, err := run(t, []string{"--help"}, "", newFS(t)) 191 339 if err != nil { ··· 202 350 } 203 351 if !strings.Contains(stderr, "-i, --ignore-case") { 204 352 t.Errorf("ignore-case flag missing from help: %q", stderr) 353 + } 354 + if !strings.Contains(stderr, "-f, --skip-fields") { 355 + t.Errorf("skip-fields flag missing from help: %q", stderr) 356 + } 357 + if !strings.Contains(stderr, "-s, --skip-chars") { 358 + t.Errorf("skip-chars flag missing from help: %q", stderr) 359 + } 360 + if !strings.Contains(stderr, "-w, --check-chars") { 361 + t.Errorf("check-chars flag missing from help: %q", stderr) 205 362 } 206 363 }