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(tr): add escapes, character classes, and equiv/repeat

Implements GNU coreutils compatibility for tr:

- Octal escapes \\nnn (1-3 digits, max 0o377)
- Backslash escapes \\\\, \\a, \\b, \\f, \\n, \\r, \\t, \\v
- All 12 [:class:] character classes (alnum, alpha,
blank, cntrl, digit, graph, lower, print, punct, space,
upper, xdigit)
- [=equiv=] equivalence classes as single char (default
C locale)
- [c*n] repeat-in-SET2 with octal/decimal count, [c*]
pads SET2 to SET1 length
- -c and -C as synonyms

SET2 short of SET1 (without -d -s) extends last char.

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

Xe Iaso 3f2e2fc0 5bc0d634

+501 -72
+331 -72
command/internal/tr/tr.go
··· 1 + // Package tr implements the tr coreutil for kefka. 2 + // 3 + // kefka aims for GNU-coreutils compatibility, not strict POSIX. Several 4 + // constructs are deliberately implemented as a GNU-subset rather than a 5 + // fully LC_COLLATE/LC_CTYPE-aware implementation: 6 + // 7 + // - [=c=] equivalence classes match only the character c itself. GNU 8 + // coreutils itself only treats characters as equivalent to themselves 9 + // in the C locale (and in practice in any locale, because no system 10 + // locale defines real equivalence classes). This matches that 11 + // behaviour without consulting the locale at all. 12 + // - -C ("complement by character") is treated identically to -c 13 + // ("complement by byte value"). kefka does not maintain a separate 14 + // LC_CTYPE-aware character set, so the two are equivalent for our 15 + // purposes. 16 + // - [:class:] character classes are populated using Go's unicode 17 + // package over the ASCII range [0, 0x80). This matches the behaviour 18 + // of GNU tr in the C locale (which is what kefka effectively is). 19 + // - Character ranges (a-z) are byte-based. GNU tr is also byte-based 20 + // by default in the C locale. 1 21 package tr 2 22 3 23 import ( ··· 6 26 "fmt" 7 27 "io" 8 28 "slices" 29 + "strconv" 9 30 "strings" 31 + "unicode" 10 32 11 33 "github.com/pborman/getopt/v2" 12 34 "mvdan.cc/sh/v3/interp" ··· 42 64 fmt.Fprint(stderr, " -s, --squeeze-repeats squeeze repeated characters\n") 43 65 fmt.Fprint(stderr, " --help display this help and exit\n\n") 44 66 fmt.Fprint(stderr, "SET syntax:\n") 45 - fmt.Fprint(stderr, " a-z character range\n") 67 + fmt.Fprint(stderr, " \\NNN character with octal value NNN (1 to 3 octal digits)\n") 68 + fmt.Fprint(stderr, " \\\\ backslash\n") 69 + fmt.Fprint(stderr, " \\a audible BEL\n") 70 + fmt.Fprint(stderr, " \\b backspace\n") 71 + fmt.Fprint(stderr, " \\f form feed\n") 72 + fmt.Fprint(stderr, " \\n new line\n") 73 + fmt.Fprint(stderr, " \\r return\n") 74 + fmt.Fprint(stderr, " \\t horizontal tab\n") 75 + fmt.Fprint(stderr, " \\v vertical tab\n") 76 + fmt.Fprint(stderr, " CHAR1-CHAR2 all characters from CHAR1 to CHAR2 in ascending order\n") 77 + fmt.Fprint(stderr, " [CHAR*] in SET2, copies of CHAR until length of SET1\n") 78 + fmt.Fprint(stderr, " [CHAR*REPEAT] REPEAT copies of CHAR, REPEAT octal if starting with 0\n") 46 79 fmt.Fprint(stderr, " [:alnum:] all letters and digits\n") 47 80 fmt.Fprint(stderr, " [:alpha:] all letters\n") 81 + fmt.Fprint(stderr, " [:blank:] all horizontal whitespace\n") 82 + fmt.Fprint(stderr, " [:cntrl:] all control characters\n") 48 83 fmt.Fprint(stderr, " [:digit:] all digits\n") 49 - fmt.Fprint(stderr, " [:lower:] all lowercase letters\n") 50 - fmt.Fprint(stderr, " [:upper:] all uppercase letters\n") 51 - fmt.Fprint(stderr, " [:space:] all whitespace\n") 52 - fmt.Fprint(stderr, " [:blank:] horizontal whitespace\n") 53 - fmt.Fprint(stderr, " [:punct:] all punctuation\n") 54 - fmt.Fprint(stderr, " [:print:] all printable characters\n") 55 - fmt.Fprint(stderr, " [:graph:] all printable characters except space\n") 56 - fmt.Fprint(stderr, " [:cntrl:] all control characters\n") 84 + fmt.Fprint(stderr, " [:graph:] all printable characters, not including space\n") 85 + fmt.Fprint(stderr, " [:lower:] all lower case letters\n") 86 + fmt.Fprint(stderr, " [:print:] all printable characters, including space\n") 87 + fmt.Fprint(stderr, " [:punct:] all punctuation characters\n") 88 + fmt.Fprint(stderr, " [:space:] all horizontal or vertical whitespace\n") 89 + fmt.Fprint(stderr, " [:upper:] all upper case letters\n") 57 90 fmt.Fprint(stderr, " [:xdigit:] all hexadecimal digits\n") 58 - fmt.Fprint(stderr, " \\n, \\t, \\r escape sequences\n") 91 + fmt.Fprint(stderr, " [=CHAR=] all characters which are equivalent to CHAR\n") 59 92 } 60 93 set.SetUsage(usage) 61 94 ··· 89 122 return interp.ExitStatus(1) 90 123 } 91 124 92 - set1, err := expandSet(sets[0]) 125 + set1, err := expandSet1(sets[0]) 93 126 if err != nil { 94 127 fmt.Fprintf(stderr, "%s\n", err) 95 128 return interp.ExitStatus(1) 96 129 } 97 130 var set2 []rune 98 131 if len(sets) > 1 { 99 - set2, err = expandSet(sets[1]) 132 + set2, err = expandSet2(sets[1], len(set1)) 100 133 if err != nil { 101 134 fmt.Fprintf(stderr, "%s\n", err) 102 135 return interp.ExitStatus(1) ··· 203 236 return nil 204 237 } 205 238 206 - var posixClasses = []struct { 207 - name string 208 - chars string 209 - }{ 210 - {"[:alnum:]", "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"}, 211 - {"[:alpha:]", "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"}, 212 - {"[:blank:]", " \t"}, 213 - {"[:cntrl:]", buildCntrl()}, 214 - {"[:digit:]", "0123456789"}, 215 - {"[:graph:]", buildRange(33, 126)}, 216 - {"[:lower:]", "abcdefghijklmnopqrstuvwxyz"}, 217 - {"[:print:]", buildRange(32, 126)}, 218 - {"[:punct:]", "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"}, 219 - {"[:space:]", " \t\n\r\f\v"}, 220 - {"[:upper:]", "ABCDEFGHIJKLMNOPQRSTUVWXYZ"}, 221 - {"[:xdigit:]", "0123456789ABCDEFabcdef"}, 239 + type classDef struct { 240 + name string 241 + in func(rune) bool 222 242 } 223 243 224 - func buildRange(lo, hi int) string { 225 - var b strings.Builder 226 - for c := lo; c <= hi; c++ { 227 - b.WriteByte(byte(c)) 244 + var posixClasses = []classDef{ 245 + {"alnum", func(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) }}, 246 + {"alpha", unicode.IsLetter}, 247 + {"blank", func(r rune) bool { return r == ' ' || r == '\t' }}, 248 + {"cntrl", unicode.IsControl}, 249 + {"digit", unicode.IsDigit}, 250 + {"graph", func(r rune) bool { return unicode.IsPrint(r) && r != ' ' }}, 251 + {"lower", unicode.IsLower}, 252 + {"print", unicode.IsPrint}, 253 + {"punct", unicode.IsPunct}, 254 + {"space", unicode.IsSpace}, 255 + {"upper", unicode.IsUpper}, 256 + {"xdigit", func(r rune) bool { 257 + return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') 258 + }}, 259 + } 260 + 261 + func expandClass(name string) ([]rune, bool) { 262 + for _, c := range posixClasses { 263 + if c.name == name { 264 + var out []rune 265 + for r := rune(0); r < 0x80; r++ { 266 + if c.in(r) { 267 + out = append(out, r) 268 + } 269 + } 270 + return out, true 271 + } 228 272 } 229 - return b.String() 273 + return nil, false 274 + } 275 + 276 + // parseEscape reads one character from rs starting at i, applying backslash 277 + // escapes. It returns the rune, the number of input runes consumed, and any 278 + // error. 279 + func parseEscape(rs []rune, i int) (rune, int, error) { 280 + if rs[i] != '\\' || i+1 >= len(rs) { 281 + return rs[i], 1, nil 282 + } 283 + c := rs[i+1] 284 + switch c { 285 + case '\\': 286 + return '\\', 2, nil 287 + case 'a': 288 + return '\a', 2, nil 289 + case 'b': 290 + return '\b', 2, nil 291 + case 'f': 292 + return '\f', 2, nil 293 + case 'n': 294 + return '\n', 2, nil 295 + case 'r': 296 + return '\r', 2, nil 297 + case 't': 298 + return '\t', 2, nil 299 + case 'v': 300 + return '\v', 2, nil 301 + } 302 + if c >= '0' && c <= '7' { 303 + end := i + 2 304 + for end < len(rs) && end-(i+1) < 3 && rs[end] >= '0' && rs[end] <= '7' { 305 + end++ 306 + } 307 + v, err := strconv.ParseInt(string(rs[i+1:end]), 8, 32) 308 + if err != nil { 309 + return 0, 0, fmt.Errorf("tr: invalid octal escape: \\%s", string(rs[i+1:end])) 310 + } 311 + return rune(v), end - i, nil 312 + } 313 + return c, 2, nil 314 + } 315 + 316 + // matchClass returns the class name and consumed length if rs[i:] starts with 317 + // "[:class:]". Returns ok=false if not a class construct. 318 + func matchClass(rs []rune, i int) (string, int, bool) { 319 + if i+1 >= len(rs) || rs[i] != '[' || rs[i+1] != ':' { 320 + return "", 0, false 321 + } 322 + end := i + 2 323 + for end < len(rs) && rs[end] != ':' { 324 + end++ 325 + } 326 + if end+1 >= len(rs) || rs[end] != ':' || rs[end+1] != ']' { 327 + return "", 0, false 328 + } 329 + return string(rs[i+2 : end]), end + 2 - i, true 230 330 } 231 331 232 - func buildCntrl() string { 233 - var b strings.Builder 234 - for c := range 32 { 235 - b.WriteByte(byte(c)) 332 + // matchEquiv returns the equiv rune and consumed length if rs[i:] starts with 333 + // "[=x=]". Returns ok=false if not an equivalence-class construct. 334 + func matchEquiv(rs []rune, i int) (rune, int, bool) { 335 + if i+1 >= len(rs) || rs[i] != '[' || rs[i+1] != '=' { 336 + return 0, 0, false 337 + } 338 + r, n, err := parseEscape(rs, i+2) 339 + if err != nil { 340 + return 0, 0, false 341 + } 342 + end := i + 2 + n 343 + if end+1 >= len(rs) || rs[end] != '=' || rs[end+1] != ']' { 344 + return 0, 0, false 345 + } 346 + return r, end + 2 - i, true 347 + } 348 + 349 + // matchRepeat returns the rune, count, count-omitted flag, and consumed length 350 + // if rs[i:] starts with "[x*]" or "[x*N]". Returns ok=false otherwise. 351 + func matchRepeat(rs []rune, i int) (rune, int, bool, int, bool) { 352 + if rs[i] != '[' || i+1 >= len(rs) { 353 + return 0, 0, false, 0, false 354 + } 355 + r, n, err := parseEscape(rs, i+1) 356 + if err != nil { 357 + return 0, 0, false, 0, false 358 + } 359 + pos := i + 1 + n 360 + if pos >= len(rs) || rs[pos] != '*' { 361 + return 0, 0, false, 0, false 362 + } 363 + pos++ 364 + digitsStart := pos 365 + for pos < len(rs) && rs[pos] != ']' { 366 + pos++ 367 + } 368 + if pos >= len(rs) || rs[pos] != ']' { 369 + return 0, 0, false, 0, false 370 + } 371 + digits := string(rs[digitsStart:pos]) 372 + if digits == "" { 373 + return r, 0, true, pos + 1 - i, true 236 374 } 237 - b.WriteByte(127) 238 - return b.String() 375 + base := 10 376 + if strings.HasPrefix(digits, "0") { 377 + base = 8 378 + } 379 + count, err := strconv.ParseInt(digits, base, 64) 380 + if err != nil || count < 0 { 381 + return 0, 0, false, 0, false 382 + } 383 + if count == 0 { 384 + return r, 0, true, pos + 1 - i, true 385 + } 386 + return r, int(count), false, pos + 1 - i, true 239 387 } 240 388 241 - func expandSet(s string) ([]rune, error) { 389 + func expandSet1(s string) ([]rune, error) { 242 390 rs := []rune(s) 243 391 var out []rune 244 392 i := 0 245 393 for i < len(rs) { 246 - if rs[i] == '[' && i+1 < len(rs) && rs[i+1] == ':' { 247 - matched := false 248 - for _, cls := range posixClasses { 249 - if strings.HasPrefix(string(rs[i:]), cls.name) { 250 - out = append(out, []rune(cls.chars)...) 251 - i += len([]rune(cls.name)) 252 - matched = true 253 - break 254 - } 394 + if name, n, ok := matchClass(rs, i); ok { 395 + runes, found := expandClass(name) 396 + if !found { 397 + return nil, fmt.Errorf("tr: invalid character class %q", name) 398 + } 399 + out = append(out, runes...) 400 + i += n 401 + continue 402 + } 403 + if r, n, ok := matchEquiv(rs, i); ok { 404 + out = append(out, r) 405 + i += n 406 + continue 407 + } 408 + if _, _, _, n, ok := matchRepeat(rs, i); ok { 409 + _ = n 410 + return nil, errors.New("tr: the [c*] repeat construct may not appear in string1") 411 + } 412 + 413 + r, n, err := parseEscape(rs, i) 414 + if err != nil { 415 + return nil, err 416 + } 417 + nextStart := i + n 418 + 419 + if nextStart < len(rs) && rs[nextStart] == '-' && nextStart+1 < len(rs) { 420 + end, m, err := parseEscape(rs, nextStart+1) 421 + if err != nil { 422 + return nil, err 423 + } 424 + if int(end)-int(r) > 65536 { 425 + return nil, fmt.Errorf("tr: character range too large: '%c-%c'", r, end) 426 + } 427 + if end < r { 428 + return nil, fmt.Errorf("tr: range-endpoints of '%c-%c' are in reverse collating sequence order", r, end) 255 429 } 256 - if matched { 257 - continue 430 + for c := r; c <= end; c++ { 431 + out = append(out, c) 258 432 } 433 + i = nextStart + 1 + m 434 + continue 259 435 } 260 436 261 - if rs[i] == '\\' && i+1 < len(rs) { 262 - switch rs[i+1] { 263 - case 'n': 264 - out = append(out, '\n') 265 - case 't': 266 - out = append(out, '\t') 267 - case 'r': 268 - out = append(out, '\r') 269 - default: 270 - out = append(out, rs[i+1]) 437 + out = append(out, r) 438 + i = nextStart 439 + } 440 + return out, nil 441 + } 442 + 443 + func expandSet2(s string, set1Len int) ([]rune, error) { 444 + rs := []rune(s) 445 + var segments []segment 446 + i := 0 447 + for i < len(rs) { 448 + if name, n, ok := matchClass(rs, i); ok { 449 + runes, found := expandClass(name) 450 + if !found { 451 + return nil, fmt.Errorf("tr: invalid character class %q", name) 271 452 } 272 - i += 2 453 + segments = append(segments, segment{runes: runes}) 454 + i += n 455 + continue 456 + } 457 + if r, n, ok := matchEquiv(rs, i); ok { 458 + segments = append(segments, segment{runes: []rune{r}}) 459 + i += n 460 + continue 461 + } 462 + if r, count, omitted, n, ok := matchRepeat(rs, i); ok { 463 + seg := segment{repeatRune: r, repeatCount: count, repeatPad: omitted} 464 + segments = append(segments, seg) 465 + i += n 273 466 continue 274 467 } 275 468 276 - if i+2 < len(rs) && rs[i+1] == '-' { 277 - start := rs[i] 278 - end := rs[i+2] 279 - if int(end)-int(start) > 65536 { 280 - return nil, fmt.Errorf("tr: character range too large: '%c-%c'", start, end) 469 + r, n, err := parseEscape(rs, i) 470 + if err != nil { 471 + return nil, err 472 + } 473 + nextStart := i + n 474 + 475 + if nextStart < len(rs) && rs[nextStart] == '-' && nextStart+1 < len(rs) { 476 + end, m, err := parseEscape(rs, nextStart+1) 477 + if err != nil { 478 + return nil, err 281 479 } 282 - for c := start; c <= end; c++ { 283 - out = append(out, c) 480 + if int(end)-int(r) > 65536 { 481 + return nil, fmt.Errorf("tr: character range too large: '%c-%c'", r, end) 482 + } 483 + if end < r { 484 + return nil, fmt.Errorf("tr: range-endpoints of '%c-%c' are in reverse collating sequence order", r, end) 485 + } 486 + var rng []rune 487 + for c := r; c <= end; c++ { 488 + rng = append(rng, c) 284 489 } 285 - i += 3 490 + segments = append(segments, segment{runes: rng}) 491 + i = nextStart + 1 + m 286 492 continue 287 493 } 288 494 289 - out = append(out, rs[i]) 290 - i++ 495 + segments = append(segments, segment{runes: []rune{r}}) 496 + i = nextStart 497 + } 498 + 499 + fixed := 0 500 + pads := 0 501 + for _, seg := range segments { 502 + if seg.repeatPad { 503 + pads++ 504 + continue 505 + } 506 + if seg.repeatCount > 0 { 507 + fixed += seg.repeatCount 508 + continue 509 + } 510 + fixed += len(seg.runes) 511 + } 512 + 513 + padTotal := 0 514 + if pads > 0 && fixed < set1Len { 515 + padTotal = set1Len - fixed 516 + } 517 + perPad := 0 518 + extra := 0 519 + if pads > 0 { 520 + perPad = padTotal / pads 521 + extra = padTotal % pads 522 + } 523 + 524 + var out []rune 525 + for _, seg := range segments { 526 + switch { 527 + case seg.repeatPad: 528 + n := perPad 529 + if extra > 0 { 530 + n++ 531 + extra-- 532 + } 533 + for k := 0; k < n; k++ { 534 + out = append(out, seg.repeatRune) 535 + } 536 + case seg.repeatCount > 0: 537 + for k := 0; k < seg.repeatCount; k++ { 538 + out = append(out, seg.repeatRune) 539 + } 540 + default: 541 + out = append(out, seg.runes...) 542 + } 291 543 } 292 544 return out, nil 293 545 } 546 + 547 + type segment struct { 548 + runes []rune 549 + repeatRune rune 550 + repeatCount int 551 + repeatPad bool 552 + }
+170
command/internal/tr/tr_test.go
··· 169 169 args: []string{"--nope", "a", "b"}, 170 170 wantErr: true, 171 171 }, 172 + { 173 + name: "octal escape \\101 maps A", 174 + args: []string{"\\101", "a"}, 175 + stdin: "A", 176 + wantStdout: "a", 177 + }, 178 + { 179 + name: "octal escape \\060 maps 0", 180 + args: []string{"\\060", "X"}, 181 + stdin: "0a0", 182 + wantStdout: "XaX", 183 + }, 184 + { 185 + name: "backslash \\\\ is literal backslash", 186 + args: []string{"\\\\", "X"}, 187 + stdin: "a\\b", 188 + wantStdout: "aXb", 189 + }, 190 + { 191 + name: "backslash \\a maps BEL", 192 + args: []string{"\\a", "X"}, 193 + stdin: "a\ab", 194 + wantStdout: "aXb", 195 + }, 196 + { 197 + name: "backslash \\b maps backspace", 198 + args: []string{"\\b", "X"}, 199 + stdin: "a\bb", 200 + wantStdout: "aXb", 201 + }, 202 + { 203 + name: "backslash \\f maps form feed", 204 + args: []string{"\\f", "X"}, 205 + stdin: "a\fb", 206 + wantStdout: "aXb", 207 + }, 208 + { 209 + name: "backslash \\v maps vertical tab", 210 + args: []string{"\\v", "X"}, 211 + stdin: "a\vb", 212 + wantStdout: "aXb", 213 + }, 214 + { 215 + name: "backslash \\r maps carriage return", 216 + args: []string{"\\r", "X"}, 217 + stdin: "a\rb", 218 + wantStdout: "aXb", 219 + }, 220 + { 221 + name: "unknown backslash escape silently strips backslash", 222 + args: []string{"\\Z", "X"}, 223 + stdin: "aZb", 224 + wantStdout: "aXb", 225 + }, 226 + { 227 + name: "POSIX class lower to upper", 228 + args: []string{"[:lower:]", "[:upper:]"}, 229 + stdin: "hello", 230 + wantStdout: "HELLO", 231 + }, 232 + { 233 + name: "POSIX class upper to lower", 234 + args: []string{"[:upper:]", "[:lower:]"}, 235 + stdin: "WORLD", 236 + wantStdout: "world", 237 + }, 238 + { 239 + name: "delete digit class", 240 + args: []string{"-d", "[:digit:]"}, 241 + stdin: "a1b2c3", 242 + wantStdout: "abc", 243 + }, 244 + { 245 + name: "delete space class", 246 + args: []string{"-d", "[:space:]"}, 247 + stdin: "a b\tc", 248 + wantStdout: "abc", 249 + }, 250 + { 251 + name: "delete blank class", 252 + args: []string{"-d", "[:blank:]"}, 253 + stdin: "a b\tc d", 254 + wantStdout: "abcd", 255 + }, 256 + { 257 + name: "equivalence class is single char", 258 + args: []string{"[=e=]", "X"}, 259 + stdin: "hello", 260 + wantStdout: "hXllo", 261 + }, 262 + { 263 + name: "set2 padded with last char to length of set1", 264 + args: []string{"a-c", "X"}, 265 + stdin: "abc", 266 + wantStdout: "XXX", 267 + }, 268 + { 269 + name: "set2 [X*] pads to length of set1", 270 + args: []string{"a-c", "[X*]"}, 271 + stdin: "abcdef", 272 + wantStdout: "XXXdef", 273 + }, 274 + { 275 + name: "set2 [X*N] explicit count", 276 + args: []string{"XYZ", "[a*3]"}, 277 + stdin: "aXbYcZ", 278 + wantStdout: "aabaca", 279 + }, 280 + { 281 + name: "set2 mixed segment then pad", 282 + args: []string{"a-e", "Y[Z*]"}, 283 + stdin: "abcde", 284 + wantStdout: "YZZZZ", 285 + }, 286 + { 287 + name: "set2 octal repeat count", 288 + args: []string{"abcd", "[X*04]"}, 289 + stdin: "abcd", 290 + wantStdout: "XXXX", 291 + }, 292 + { 293 + name: "set1 repeat construct rejected", 294 + args: []string{"[X*]", "a"}, 295 + stdin: "X", 296 + wantErr: true, 297 + wantErrSub: "may not appear in string1", 298 + }, 299 + { 300 + name: "invalid character class", 301 + args: []string{"[:foo:]", "X"}, 302 + stdin: "abc", 303 + wantErr: true, 304 + wantErrSub: "invalid character class", 305 + }, 306 + { 307 + name: "octal escape NUL deletion", 308 + args: []string{"-d", "\\000"}, 309 + stdin: "a\x00b\x00c", 310 + wantStdout: "abc", 311 + }, 312 + { 313 + name: "newline escape with actual newline input", 314 + args: []string{"\\n", " "}, 315 + stdin: "a\nb", 316 + wantStdout: "a b", 317 + }, 318 + { 319 + name: "POSIX example word splitter", 320 + args: []string{"-cs", "[:alpha:]", "[\\n*]"}, 321 + stdin: "hello, world! foo bar\n", 322 + wantStdout: "hello\nworld\nfoo\nbar\n", 323 + }, 324 + { 325 + name: "translate digit class to single char pads", 326 + args: []string{"[:digit:]", "#"}, 327 + stdin: "ab12cd", 328 + wantStdout: "ab##cd", 329 + }, 330 + { 331 + name: "translate range to range", 332 + args: []string{"a-z", "A-Z"}, 333 + stdin: "hello", 334 + wantStdout: "HELLO", 335 + }, 336 + { 337 + name: "complement -c with single replacement char pads", 338 + args: []string{"-c", "a-z", "X"}, 339 + stdin: "Hello, World!", 340 + wantStdout: "XelloXXXorldX", 341 + }, 172 342 } 173 343 174 344 for _, tt := range tests {