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

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

Xe Iaso a7559a84 a23a618e

+1621
+1229
command/internal/printf/printf.go
··· 1 + package printf 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "math" 9 + "regexp" 10 + "slices" 11 + "strconv" 12 + "strings" 13 + "time" 14 + "unicode/utf8" 15 + 16 + "mvdan.cc/sh/v3/expand" 17 + "mvdan.cc/sh/v3/interp" 18 + "tangled.org/xeiaso.net/kefka/command" 19 + ) 20 + 21 + type Impl struct{} 22 + 23 + func (Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 24 + if ec == nil { 25 + return errors.New("printf: nil ExecContext") 26 + } 27 + 28 + stdout := ec.Stdout 29 + if stdout == nil { 30 + stdout = io.Discard 31 + } 32 + stderr := ec.Stderr 33 + if stderr == nil { 34 + stderr = io.Discard 35 + } 36 + 37 + if slices.Contains(args, "--help") { 38 + printHelp(stderr) 39 + return nil 40 + } 41 + 42 + if len(args) == 0 { 43 + fmt.Fprint(stderr, "printf: usage: printf format [arguments]\n") 44 + return interp.ExitStatus(2) 45 + } 46 + 47 + var targetVar string 48 + hasTargetVar := false 49 + argIdx := 0 50 + 51 + for argIdx < len(args) { 52 + arg := args[argIdx] 53 + if arg == "--" { 54 + argIdx++ 55 + break 56 + } 57 + if arg == "-v" { 58 + if argIdx+1 >= len(args) { 59 + fmt.Fprint(stderr, "printf: -v: option requires an argument\n") 60 + return interp.ExitStatus(1) 61 + } 62 + targetVar = args[argIdx+1] 63 + hasTargetVar = true 64 + if !validIdentifier(targetVar) { 65 + fmt.Fprintf(stderr, "printf: `%s': not a valid identifier\n", targetVar) 66 + return interp.ExitStatus(2) 67 + } 68 + argIdx += 2 69 + continue 70 + } 71 + break 72 + } 73 + 74 + if argIdx >= len(args) { 75 + fmt.Fprint(stderr, "printf: usage: printf format [arguments]\n") 76 + return interp.ExitStatus(1) 77 + } 78 + 79 + format := args[argIdx] 80 + formatArgs := args[argIdx+1:] 81 + processed := processEscapes(format) 82 + 83 + tz := "" 84 + if ec.Environ != nil { 85 + if v := ec.Environ.Get("TZ"); v.IsSet() { 86 + tz = v.String() 87 + } 88 + } 89 + 90 + var output strings.Builder 91 + var errMsg string 92 + hadError := false 93 + argPos := 0 94 + 95 + for { 96 + out, consumed, gotErr, gotMsg, stopped := formatOnce(processed, formatArgs, argPos, tz) 97 + output.WriteString(out) 98 + argPos += consumed 99 + if gotErr { 100 + hadError = true 101 + if gotMsg != "" { 102 + errMsg = gotMsg 103 + } 104 + } 105 + if stopped { 106 + break 107 + } 108 + if consumed == 0 || argPos >= len(formatArgs) { 109 + break 110 + } 111 + } 112 + 113 + if errMsg != "" { 114 + fmt.Fprint(stderr, errMsg) 115 + } 116 + 117 + if hasTargetVar { 118 + if err := assignVar(ec.Environ, targetVar, output.String()); err != nil { 119 + fmt.Fprintf(stderr, "printf: %s\n", err) 120 + return interp.ExitStatus(1) 121 + } 122 + } else { 123 + io.WriteString(stdout, output.String()) 124 + } 125 + 126 + if hadError { 127 + return interp.ExitStatus(1) 128 + } 129 + return nil 130 + } 131 + 132 + func printHelp(w io.Writer) { 133 + io.WriteString(w, "Usage: printf [-v var] FORMAT [ARGUMENT...]\n") 134 + io.WriteString(w, "Format and print data.\n\n") 135 + io.WriteString(w, " -v var assign the output to shell variable VAR rather than display it\n") 136 + io.WriteString(w, " --help display this help and exit\n\n") 137 + io.WriteString(w, "FORMAT controls the output like in C printf.\n") 138 + io.WriteString(w, "Escape sequences: \\n (newline), \\t (tab), \\\\ (backslash)\n") 139 + io.WriteString(w, "Format specifiers: %s (string), %d (integer), %f (float), %x (hex), %o (octal), %% (literal %)\n") 140 + io.WriteString(w, "Width and precision: %10s (width 10), %.2f (2 decimal places), %010d (zero-padded)\n") 141 + io.WriteString(w, "Flags: %- (left-justify), %+ (show sign), %0 (zero-pad)\n") 142 + } 143 + 144 + var identifierRe = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*(\[[a-zA-Z0-9_@*"'$]+\])?$`) 145 + 146 + func validIdentifier(s string) bool { 147 + return identifierRe.MatchString(s) 148 + } 149 + 150 + var dollarVarRe = regexp.MustCompile(`\$([a-zA-Z_][a-zA-Z0-9_]*)`) 151 + 152 + // parseArraySubscript matches name[key], name['key'], or name["key"]. Go's RE2 153 + // does not support the backreference the original TS regex used, so this is a 154 + // manual parse. 155 + func parseArraySubscript(s string) (name, key string, ok bool) { 156 + open := strings.IndexByte(s, '[') 157 + if open < 1 || !strings.HasSuffix(s, "]") { 158 + return "", "", false 159 + } 160 + name = s[:open] 161 + inner := s[open+1 : len(s)-1] 162 + if len(inner) >= 2 { 163 + first, last := inner[0], inner[len(inner)-1] 164 + if (first == '\'' && last == '\'') || (first == '"' && last == '"') { 165 + inner = inner[1 : len(inner)-1] 166 + } 167 + } 168 + return name, inner, true 169 + } 170 + 171 + func assignVar(env expand.Environ, name, value string) error { 172 + we, ok := env.(expand.WriteEnviron) 173 + if !ok { 174 + return errors.New("cannot assign: environment is read-only") 175 + } 176 + if arrayName, key, ok := parseArraySubscript(name); ok { 177 + key = dollarVarRe.ReplaceAllStringFunc(key, func(s string) string { 178 + varName := s[1:] 179 + if v := env.Get(varName); v.IsSet() { 180 + return v.String() 181 + } 182 + return "" 183 + }) 184 + return we.Set(arrayName+"_"+key, expand.Variable{Set: true, Kind: expand.String, Str: value}) 185 + } 186 + return we.Set(name, expand.Variable{Set: true, Kind: expand.String, Str: value}) 187 + } 188 + 189 + var strftimeSpecRe = regexp.MustCompile(`^%(-?\d*)(?:\.(\d+))?\(([^)]*)\)T`) 190 + 191 + func formatOnce(format string, args []string, argPos int, tz string) (string, int, bool, string, bool) { 192 + var result strings.Builder 193 + consumed := 0 194 + hadError := false 195 + errMsg := "" 196 + 197 + i := 0 198 + for i < len(format) { 199 + if format[i] != '%' || i+1 >= len(format) { 200 + result.WriteByte(format[i]) 201 + i++ 202 + continue 203 + } 204 + 205 + specStart := i 206 + i++ // skip % 207 + 208 + if format[i] == '%' { 209 + result.WriteByte('%') 210 + i++ 211 + continue 212 + } 213 + 214 + if m := strftimeSpecRe.FindStringSubmatch(format[specStart:]); m != nil { 215 + width := 0 216 + if m[1] != "" { 217 + width, _ = strconv.Atoi(m[1]) 218 + } 219 + precision := -1 220 + if m[2] != "" { 221 + precision, _ = strconv.Atoi(m[2]) 222 + } 223 + strftimeFmt := m[3] 224 + fullMatch := m[0] 225 + 226 + arg := "" 227 + if argPos+consumed < len(args) { 228 + arg = args[argPos+consumed] 229 + } 230 + consumed++ 231 + 232 + var ts time.Time 233 + if arg == "" || arg == "-1" || arg == "-2" { 234 + ts = time.Now() 235 + } else if n, err := strconv.ParseInt(arg, 10, 64); err == nil { 236 + ts = time.Unix(n, 0) 237 + } else { 238 + ts = time.Unix(0, 0) 239 + } 240 + 241 + formatted := formatStrftime(strftimeFmt, ts, tz) 242 + if precision >= 0 && len(formatted) > precision { 243 + formatted = formatted[:precision] 244 + } 245 + if width != 0 { 246 + abs := width 247 + if abs < 0 { 248 + abs = -abs 249 + } 250 + if len(formatted) < abs { 251 + if width < 0 { 252 + formatted = padRight(formatted, abs, ' ') 253 + } else { 254 + formatted = padLeft(formatted, abs, ' ') 255 + } 256 + } 257 + } 258 + result.WriteString(formatted) 259 + i = specStart + len(fullMatch) 260 + continue 261 + } 262 + 263 + // Flags 264 + for i < len(format) && strings.ContainsRune("+-0 #'", rune(format[i])) { 265 + i++ 266 + } 267 + 268 + // Width (* or digits) 269 + widthFromArg := false 270 + if i < len(format) && format[i] == '*' { 271 + widthFromArg = true 272 + i++ 273 + } else { 274 + for i < len(format) && format[i] >= '0' && format[i] <= '9' { 275 + i++ 276 + } 277 + } 278 + 279 + // Precision (. then * or digits) 280 + precisionFromArg := false 281 + if i < len(format) && format[i] == '.' { 282 + i++ 283 + if i < len(format) && format[i] == '*' { 284 + precisionFromArg = true 285 + i++ 286 + } else { 287 + for i < len(format) && format[i] >= '0' && format[i] <= '9' { 288 + i++ 289 + } 290 + } 291 + } 292 + 293 + // Length modifier 294 + if i < len(format) && strings.ContainsRune("hlL", rune(format[i])) { 295 + i++ 296 + } 297 + 298 + if i >= len(format) { 299 + result.WriteString(format[specStart:]) 300 + break 301 + } 302 + 303 + specifier := format[i] 304 + i++ 305 + 306 + fullSpec := format[specStart:i] 307 + adjustedSpec := fullSpec 308 + 309 + if widthFromArg { 310 + w := 0 311 + if argPos+consumed < len(args) { 312 + w, _ = strconv.Atoi(args[argPos+consumed]) 313 + } 314 + consumed++ 315 + adjustedSpec = strings.Replace(adjustedSpec, "*", strconv.Itoa(w), 1) 316 + } 317 + if precisionFromArg { 318 + p := 0 319 + if argPos+consumed < len(args) { 320 + p, _ = strconv.Atoi(args[argPos+consumed]) 321 + } 322 + consumed++ 323 + adjustedSpec = strings.Replace(adjustedSpec, ".*", "."+strconv.Itoa(p), 1) 324 + } 325 + 326 + arg := "" 327 + if argPos+consumed < len(args) { 328 + arg = args[argPos+consumed] 329 + } 330 + consumed++ 331 + 332 + val, gotErr, gotMsg, stopped := formatValue(adjustedSpec, specifier, arg) 333 + result.WriteString(val) 334 + if gotErr { 335 + hadError = true 336 + if gotMsg != "" { 337 + errMsg = gotMsg 338 + } 339 + } 340 + if stopped { 341 + return result.String(), consumed, hadError, errMsg, true 342 + } 343 + } 344 + 345 + return result.String(), consumed, hadError, errMsg, false 346 + } 347 + 348 + var ( 349 + intSpecRe = regexp.MustCompile(`^%([- +#0']*)(\d*)(\.(\d*))?[diu]$`) 350 + octalSpecRe = regexp.MustCompile(`^%([- +#0']*)(\d*)(\.(\d*))?o$`) 351 + hexSpecRe = regexp.MustCompile(`^%([- +#0']*)(\d*)(\.(\d*))?[xX]$`) 352 + floatSpecRe = regexp.MustCompile(`^%([- +#0']*)(\d*)(\.(\d*))?[eEfFgG]$`) 353 + stringSpecRe = regexp.MustCompile(`^%(-?)(\d*)(\.(\d*))?s$`) 354 + quotedSpecRe = regexp.MustCompile(`^%(-?)(\d*)q$`) 355 + ) 356 + 357 + func formatValue(spec string, specifier byte, arg string) (string, bool, string, bool) { 358 + switch specifier { 359 + case 'd', 'i': 360 + num, parseErr := parseIntArg(arg) 361 + var msg string 362 + if parseErr { 363 + msg = fmt.Sprintf("printf: %s: invalid number\n", arg) 364 + } 365 + return formatInteger(spec, num), parseErr, msg, false 366 + case 'o': 367 + num, parseErr := parseIntArg(arg) 368 + var msg string 369 + if parseErr { 370 + msg = fmt.Sprintf("printf: %s: invalid number\n", arg) 371 + } 372 + return formatOctal(spec, num), parseErr, msg, false 373 + case 'u': 374 + num, parseErr := parseIntArg(arg) 375 + var msg string 376 + if parseErr { 377 + msg = fmt.Sprintf("printf: %s: invalid number\n", arg) 378 + } 379 + unsigned := num 380 + if num < 0 { 381 + unsigned = int64(uint32(num)) 382 + } 383 + return formatInteger(strings.Replace(spec, "u", "d", 1), unsigned), parseErr, msg, false 384 + case 'x', 'X': 385 + num, parseErr := parseIntArg(arg) 386 + var msg string 387 + if parseErr { 388 + msg = fmt.Sprintf("printf: %s: invalid number\n", arg) 389 + } 390 + return formatHex(spec, num), parseErr, msg, false 391 + case 'e', 'E', 'f', 'F', 'g', 'G': 392 + num, _ := strconv.ParseFloat(arg, 64) 393 + return formatFloat(spec, specifier, num), false, "", false 394 + case 'c': 395 + if arg == "" { 396 + return "", false, "", false 397 + } 398 + return string(rune(arg[0])), false, "", false 399 + case 's': 400 + return formatString(spec, arg), false, "", false 401 + case 'q': 402 + return formatQuoted(spec, arg), false, "", false 403 + case 'b': 404 + val, stopped := processBEscapes(arg) 405 + return val, false, "", stopped 406 + default: 407 + return "", true, fmt.Sprintf("printf: %%%c: invalid directive\n", specifier), false 408 + } 409 + } 410 + 411 + func parseIntArg(arg string) (int64, bool) { 412 + trimmed := strings.TrimLeft(arg, " \t\n\r\v\f") 413 + hasTrailing := trimmed != strings.TrimRight(trimmed, " \t\n\r\v\f") 414 + arg = strings.TrimRight(trimmed, " \t\n\r\v\f") 415 + 416 + // Character notation: 'x' or "x" or \'x or \"x 417 + if strings.HasPrefix(arg, "\\'") && len(arg) >= 3 { 418 + r, _ := utf8.DecodeRuneInString(arg[2:]) 419 + return int64(r), false 420 + } 421 + if strings.HasPrefix(arg, "\\\"") && len(arg) >= 3 { 422 + r, _ := utf8.DecodeRuneInString(arg[2:]) 423 + return int64(r), false 424 + } 425 + if strings.HasPrefix(arg, "'") && len(arg) >= 2 { 426 + r, _ := utf8.DecodeRuneInString(arg[1:]) 427 + return int64(r), false 428 + } 429 + if strings.HasPrefix(arg, "\"") && len(arg) >= 2 { 430 + r, _ := utf8.DecodeRuneInString(arg[1:]) 431 + return int64(r), false 432 + } 433 + 434 + if arg == "" { 435 + return 0, false 436 + } 437 + 438 + arg = strings.TrimPrefix(arg, "+") 439 + 440 + if strings.HasPrefix(arg, "0x") || strings.HasPrefix(arg, "0X") { 441 + n, err := strconv.ParseInt(arg[2:], 16, 64) 442 + if err != nil { 443 + return 0, true 444 + } 445 + return n, hasTrailing 446 + } 447 + if strings.HasPrefix(arg, "-0x") || strings.HasPrefix(arg, "-0X") { 448 + n, err := strconv.ParseInt(arg[3:], 16, 64) 449 + if err != nil { 450 + return 0, true 451 + } 452 + return -n, hasTrailing 453 + } 454 + 455 + // Octal: leading 0 followed by octal digits 456 + if octalNumRe.MatchString(arg) { 457 + n, _ := strconv.ParseInt(arg, 8, 64) 458 + return n, hasTrailing 459 + } 460 + 461 + // Reject base notation like 64#a 462 + if m := baseNotationRe.FindStringSubmatch(arg); m != nil { 463 + n, _ := strconv.ParseInt(m[1], 10, 64) 464 + return n, true 465 + } 466 + 467 + if !decimalRe.MatchString(arg) { 468 + // Try to parse what we can (bash behavior: 3abc -> 3) 469 + n, err := strconv.ParseInt(arg, 10, 64) 470 + if err != nil { 471 + n = bestEffortInt(arg) 472 + } 473 + return n, true 474 + } 475 + 476 + n, _ := strconv.ParseInt(arg, 10, 64) 477 + return n, hasTrailing 478 + } 479 + 480 + var ( 481 + octalNumRe = regexp.MustCompile(`^-?0[0-7]+$`) 482 + baseNotationRe = regexp.MustCompile(`^(\d+)#`) 483 + decimalRe = regexp.MustCompile(`^-?\d+$`) 484 + leadingIntRe = regexp.MustCompile(`^-?\d+`) 485 + ) 486 + 487 + func bestEffortInt(s string) int64 { 488 + m := leadingIntRe.FindString(s) 489 + if m == "" { 490 + return 0 491 + } 492 + n, _ := strconv.ParseInt(m, 10, 64) 493 + return n 494 + } 495 + 496 + func formatInteger(spec string, num int64) string { 497 + m := intSpecRe.FindStringSubmatch(spec) 498 + if m == nil { 499 + return fmt.Sprintf("%d", num) 500 + } 501 + flags := m[1] 502 + width := 0 503 + if m[2] != "" { 504 + width, _ = strconv.Atoi(m[2]) 505 + } 506 + precision := -1 507 + if m[3] != "" { 508 + if m[4] == "" { 509 + precision = 0 510 + } else { 511 + precision, _ = strconv.Atoi(m[4]) 512 + } 513 + } 514 + 515 + negative := num < 0 516 + abs := num 517 + if negative { 518 + abs = -num 519 + } 520 + numStr := strconv.FormatInt(abs, 10) 521 + 522 + if precision >= 0 { 523 + numStr = padLeft(numStr, precision, '0') 524 + } 525 + 526 + sign := "" 527 + switch { 528 + case negative: 529 + sign = "-" 530 + case strings.Contains(flags, "+"): 531 + sign = "+" 532 + case strings.Contains(flags, " "): 533 + sign = " " 534 + } 535 + 536 + result := sign + numStr 537 + if width > len(result) { 538 + switch { 539 + case strings.Contains(flags, "-"): 540 + result = padRight(result, width, ' ') 541 + case strings.Contains(flags, "0") && precision < 0: 542 + result = sign + padLeft(numStr, width-len(sign), '0') 543 + default: 544 + result = padLeft(result, width, ' ') 545 + } 546 + } 547 + return result 548 + } 549 + 550 + func formatOctal(spec string, num int64) string { 551 + m := octalSpecRe.FindStringSubmatch(spec) 552 + if m == nil { 553 + return strconv.FormatInt(num, 8) 554 + } 555 + flags := m[1] 556 + width := 0 557 + if m[2] != "" { 558 + width, _ = strconv.Atoi(m[2]) 559 + } 560 + precision := -1 561 + if m[3] != "" { 562 + if m[4] == "" { 563 + precision = 0 564 + } else { 565 + precision, _ = strconv.Atoi(m[4]) 566 + } 567 + } 568 + 569 + abs := num 570 + if abs < 0 { 571 + abs = -abs 572 + } 573 + numStr := strconv.FormatInt(abs, 8) 574 + 575 + if precision >= 0 { 576 + numStr = padLeft(numStr, precision, '0') 577 + } 578 + if strings.Contains(flags, "#") && !strings.HasPrefix(numStr, "0") { 579 + numStr = "0" + numStr 580 + } 581 + 582 + result := numStr 583 + if width > len(result) { 584 + switch { 585 + case strings.Contains(flags, "-"): 586 + result = padRight(result, width, ' ') 587 + case strings.Contains(flags, "0") && precision < 0: 588 + result = padLeft(result, width, '0') 589 + default: 590 + result = padLeft(result, width, ' ') 591 + } 592 + } 593 + return result 594 + } 595 + 596 + func formatHex(spec string, num int64) string { 597 + m := hexSpecRe.FindStringSubmatch(spec) 598 + upper := strings.Contains(spec, "X") 599 + if m == nil { 600 + if upper { 601 + return strings.ToUpper(strconv.FormatInt(num, 16)) 602 + } 603 + return strconv.FormatInt(num, 16) 604 + } 605 + flags := m[1] 606 + width := 0 607 + if m[2] != "" { 608 + width, _ = strconv.Atoi(m[2]) 609 + } 610 + precision := -1 611 + if m[3] != "" { 612 + if m[4] == "" { 613 + precision = 0 614 + } else { 615 + precision, _ = strconv.Atoi(m[4]) 616 + } 617 + } 618 + 619 + abs := num 620 + if abs < 0 { 621 + abs = -abs 622 + } 623 + numStr := strconv.FormatInt(abs, 16) 624 + if upper { 625 + numStr = strings.ToUpper(numStr) 626 + } 627 + if precision >= 0 { 628 + numStr = padLeft(numStr, precision, '0') 629 + } 630 + 631 + prefix := "" 632 + if strings.Contains(flags, "#") && num != 0 { 633 + if upper { 634 + prefix = "0X" 635 + } else { 636 + prefix = "0x" 637 + } 638 + } 639 + 640 + result := prefix + numStr 641 + if width > len(result) { 642 + switch { 643 + case strings.Contains(flags, "-"): 644 + result = padRight(result, width, ' ') 645 + case strings.Contains(flags, "0") && precision < 0: 646 + result = prefix + padLeft(numStr, width-len(prefix), '0') 647 + default: 648 + result = padLeft(result, width, ' ') 649 + } 650 + } 651 + return result 652 + } 653 + 654 + func formatFloat(spec string, specifier byte, num float64) string { 655 + m := floatSpecRe.FindStringSubmatch(spec) 656 + if m == nil { 657 + return strconv.FormatFloat(num, byte(specifier), 6, 64) 658 + } 659 + flags := m[1] 660 + width := 0 661 + if m[2] != "" { 662 + width, _ = strconv.Atoi(m[2]) 663 + } 664 + precision := 6 665 + if m[3] != "" { 666 + if m[4] == "" { 667 + precision = 0 668 + } else { 669 + precision, _ = strconv.Atoi(m[4]) 670 + } 671 + } 672 + 673 + var result string 674 + lower := specifier 675 + if lower >= 'A' && lower <= 'Z' { 676 + lower += 32 677 + } 678 + 679 + switch lower { 680 + case 'e': 681 + result = strconv.FormatFloat(num, 'e', precision, 64) 682 + result = ensureExponentTwoDigits(result) 683 + if specifier == 'E' { 684 + result = strings.ToUpper(result) 685 + } 686 + case 'f': 687 + result = strconv.FormatFloat(num, 'f', precision, 64) 688 + if strings.Contains(flags, "#") && precision == 0 && !strings.Contains(result, ".") { 689 + result += "." 690 + } 691 + case 'g': 692 + p := precision 693 + if p == 0 { 694 + p = 1 695 + } 696 + result = strconv.FormatFloat(num, 'g', p, 64) 697 + if !strings.Contains(flags, "#") { 698 + // Go's %g already trims trailing zeros, no-op 699 + } 700 + result = ensureExponentTwoDigits(result) 701 + if specifier == 'G' { 702 + result = strings.ToUpper(result) 703 + } 704 + default: 705 + result = strconv.FormatFloat(num, 'g', -1, 64) 706 + } 707 + 708 + if num >= 0 && !math.IsNaN(num) { 709 + switch { 710 + case strings.Contains(flags, "+"): 711 + result = "+" + result 712 + case strings.Contains(flags, " "): 713 + result = " " + result 714 + } 715 + } 716 + 717 + if width > len(result) { 718 + switch { 719 + case strings.Contains(flags, "-"): 720 + result = padRight(result, width, ' ') 721 + case strings.Contains(flags, "0"): 722 + signPrefix := "" 723 + rest := result 724 + if len(result) > 0 && (result[0] == '+' || result[0] == '-' || result[0] == ' ') { 725 + signPrefix = result[:1] 726 + rest = result[1:] 727 + } 728 + result = signPrefix + padLeft(rest, width-len(signPrefix), '0') 729 + default: 730 + result = padLeft(result, width, ' ') 731 + } 732 + } 733 + return result 734 + } 735 + 736 + var expDigitRe = regexp.MustCompile(`e([+-])(\d)$`) 737 + 738 + func ensureExponentTwoDigits(s string) string { 739 + return expDigitRe.ReplaceAllString(s, "e${1}0${2}") 740 + } 741 + 742 + func formatString(spec string, str string) string { 743 + m := stringSpecRe.FindStringSubmatch(spec) 744 + if m == nil { 745 + return str 746 + } 747 + leftJustify := m[1] == "-" 748 + width := 0 749 + if m[2] != "" { 750 + width, _ = strconv.Atoi(m[2]) 751 + } 752 + precision := -1 753 + if m[3] != "" { 754 + if m[4] == "" { 755 + precision = 0 756 + } else { 757 + precision, _ = strconv.Atoi(m[4]) 758 + } 759 + } 760 + 761 + if precision >= 0 && len(str) > precision { 762 + str = str[:precision] 763 + } 764 + if width > len(str) { 765 + if leftJustify { 766 + str = padRight(str, width, ' ') 767 + } else { 768 + str = padLeft(str, width, ' ') 769 + } 770 + } 771 + return str 772 + } 773 + 774 + func formatQuoted(spec string, str string) string { 775 + quoted := shellQuote(str) 776 + m := quotedSpecRe.FindStringSubmatch(spec) 777 + if m == nil { 778 + return quoted 779 + } 780 + leftJustify := m[1] == "-" 781 + width := 0 782 + if m[2] != "" { 783 + width, _ = strconv.Atoi(m[2]) 784 + } 785 + if width > len(quoted) { 786 + if leftJustify { 787 + return padRight(quoted, width, ' ') 788 + } 789 + return padLeft(quoted, width, ' ') 790 + } 791 + return quoted 792 + } 793 + 794 + var safeQuoteRe = regexp.MustCompile(`^[a-zA-Z0-9_./-]+$`) 795 + 796 + func shellQuote(s string) string { 797 + if s == "" { 798 + return "''" 799 + } 800 + if safeQuoteRe.MatchString(s) { 801 + return s 802 + } 803 + 804 + needsDollar := false 805 + for _, c := range s { 806 + if c < 0x20 || (c >= 0x7f && c <= 0xff) { 807 + needsDollar = true 808 + break 809 + } 810 + } 811 + 812 + var b strings.Builder 813 + if needsDollar { 814 + b.WriteString("$'") 815 + for _, c := range s { 816 + switch { 817 + case c == '\'': 818 + b.WriteString("\\'") 819 + case c == '\\': 820 + b.WriteString("\\\\") 821 + case c == '\n': 822 + b.WriteString("\\n") 823 + case c == '\t': 824 + b.WriteString("\\t") 825 + case c == '\r': 826 + b.WriteString("\\r") 827 + case c == 0x07: 828 + b.WriteString("\\a") 829 + case c == '\b': 830 + b.WriteString("\\b") 831 + case c == '\f': 832 + b.WriteString("\\f") 833 + case c == '\v': 834 + b.WriteString("\\v") 835 + case c == 0x1b: 836 + b.WriteString("\\E") 837 + case c < 0x20 || (c >= 0x7f && c <= 0xff): 838 + fmt.Fprintf(&b, "\\%03o", c) 839 + case c == '"': 840 + b.WriteString("\\\"") 841 + default: 842 + b.WriteRune(c) 843 + } 844 + } 845 + b.WriteByte('\'') 846 + return b.String() 847 + } 848 + 849 + specials := " \t|&;<>()$`\\\"'*?[#~=%!{}" 850 + for _, c := range s { 851 + if strings.ContainsRune(specials, c) { 852 + b.WriteByte('\\') 853 + } 854 + b.WriteRune(c) 855 + } 856 + return b.String() 857 + } 858 + 859 + // processEscapes interprets backslash escapes in a printf format string. 860 + // Supports \n \t \r \\ \a \b \f \v \e \E \NNN \xHH \uHHHH \UHHHHHHHH. 861 + func processEscapes(s string) string { 862 + var b strings.Builder 863 + i := 0 864 + for i < len(s) { 865 + if s[i] != '\\' || i+1 >= len(s) { 866 + b.WriteByte(s[i]) 867 + i++ 868 + continue 869 + } 870 + next := s[i+1] 871 + switch next { 872 + case 'n': 873 + b.WriteByte('\n') 874 + i += 2 875 + case 't': 876 + b.WriteByte('\t') 877 + i += 2 878 + case 'r': 879 + b.WriteByte('\r') 880 + i += 2 881 + case '\\': 882 + b.WriteByte('\\') 883 + i += 2 884 + case 'a': 885 + b.WriteByte(0x07) 886 + i += 2 887 + case 'b': 888 + b.WriteByte('\b') 889 + i += 2 890 + case 'f': 891 + b.WriteByte('\f') 892 + i += 2 893 + case 'v': 894 + b.WriteByte('\v') 895 + i += 2 896 + case 'e', 'E': 897 + b.WriteByte(0x1b) 898 + i += 2 899 + case '0', '1', '2', '3', '4', '5', '6', '7': 900 + oct, end := readOctalEscape(s, i+1, 3) 901 + b.WriteByte(byte(oct)) 902 + i = end 903 + case 'x': 904 + bytes := []byte{} 905 + j := i 906 + for j+1 < len(s) && s[j] == '\\' && s[j+1] == 'x' { 907 + hex := "" 908 + k := j + 2 909 + for k < len(s) && k < j+4 && isHex(s[k]) { 910 + hex += string(s[k]) 911 + k++ 912 + } 913 + if hex == "" { 914 + break 915 + } 916 + v, _ := strconv.ParseInt(hex, 16, 32) 917 + bytes = append(bytes, byte(v)) 918 + j = k 919 + } 920 + if len(bytes) > 0 { 921 + if utf8.Valid(bytes) { 922 + b.Write(bytes) 923 + } else { 924 + for _, c := range bytes { 925 + b.WriteRune(rune(c)) 926 + } 927 + } 928 + i = j 929 + } else { 930 + b.WriteByte(s[i]) 931 + i++ 932 + } 933 + case 'u': 934 + hex, end := readHexEscape(s, i+2, 4) 935 + if hex != "" { 936 + v, _ := strconv.ParseInt(hex, 16, 32) 937 + b.WriteRune(rune(v)) 938 + i = end 939 + } else { 940 + b.WriteString("\\u") 941 + i += 2 942 + } 943 + case 'U': 944 + hex, end := readHexEscape(s, i+2, 8) 945 + if hex != "" { 946 + v, _ := strconv.ParseInt(hex, 16, 32) 947 + b.WriteRune(rune(v)) 948 + i = end 949 + } else { 950 + b.WriteString("\\U") 951 + i += 2 952 + } 953 + default: 954 + b.WriteByte(s[i]) 955 + i++ 956 + } 957 + } 958 + return b.String() 959 + } 960 + 961 + func readOctalEscape(s string, start, max int) (int, int) { 962 + var b strings.Builder 963 + j := start 964 + for j < len(s) && j < start+max && s[j] >= '0' && s[j] <= '7' { 965 + b.WriteByte(s[j]) 966 + j++ 967 + } 968 + if b.Len() == 0 { 969 + return 0, start 970 + } 971 + v, _ := strconv.ParseInt(b.String(), 8, 32) 972 + return int(v), j 973 + } 974 + 975 + func readHexEscape(s string, start, max int) (string, int) { 976 + var b strings.Builder 977 + j := start 978 + for j < len(s) && j < start+max && isHex(s[j]) { 979 + b.WriteByte(s[j]) 980 + j++ 981 + } 982 + return b.String(), j 983 + } 984 + 985 + func isHex(c byte) bool { 986 + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') 987 + } 988 + 989 + // processBEscapes interprets escapes in a %b argument. Returns (value, stopped) 990 + // where stopped=true means \c was encountered and output should halt. 991 + func processBEscapes(s string) (string, bool) { 992 + var b strings.Builder 993 + i := 0 994 + for i < len(s) { 995 + if s[i] != '\\' || i+1 >= len(s) { 996 + b.WriteByte(s[i]) 997 + i++ 998 + continue 999 + } 1000 + next := s[i+1] 1001 + switch next { 1002 + case 'n': 1003 + b.WriteByte('\n') 1004 + i += 2 1005 + case 't': 1006 + b.WriteByte('\t') 1007 + i += 2 1008 + case 'r': 1009 + b.WriteByte('\r') 1010 + i += 2 1011 + case '\\': 1012 + b.WriteByte('\\') 1013 + i += 2 1014 + case 'a': 1015 + b.WriteByte(0x07) 1016 + i += 2 1017 + case 'b': 1018 + b.WriteByte('\b') 1019 + i += 2 1020 + case 'f': 1021 + b.WriteByte('\f') 1022 + i += 2 1023 + case 'v': 1024 + b.WriteByte('\v') 1025 + i += 2 1026 + case 'c': 1027 + return b.String(), true 1028 + case 'x': 1029 + bytes := []byte{} 1030 + j := i 1031 + for j+1 < len(s) && s[j] == '\\' && s[j+1] == 'x' { 1032 + hex, k := readHexEscape(s, j+2, 2) 1033 + if hex == "" { 1034 + break 1035 + } 1036 + v, _ := strconv.ParseInt(hex, 16, 32) 1037 + bytes = append(bytes, byte(v)) 1038 + j = k 1039 + } 1040 + if len(bytes) > 0 { 1041 + if utf8.Valid(bytes) { 1042 + b.Write(bytes) 1043 + } else { 1044 + for _, c := range bytes { 1045 + b.WriteRune(rune(c)) 1046 + } 1047 + } 1048 + i = j 1049 + } else { 1050 + b.WriteString("\\x") 1051 + i += 2 1052 + } 1053 + case 'u': 1054 + hex, end := readHexEscape(s, i+2, 4) 1055 + if hex != "" { 1056 + v, _ := strconv.ParseInt(hex, 16, 32) 1057 + b.WriteRune(rune(v)) 1058 + i = end 1059 + } else { 1060 + b.WriteString("\\u") 1061 + i += 2 1062 + } 1063 + case '0': 1064 + oct, end := readOctalEscape(s, i+2, 3) 1065 + if end > i+2 { 1066 + b.WriteByte(byte(oct)) 1067 + i = end 1068 + } else { 1069 + b.WriteByte(0) 1070 + i += 2 1071 + } 1072 + case '1', '2', '3', '4', '5', '6', '7': 1073 + oct, end := readOctalEscape(s, i+1, 3) 1074 + b.WriteByte(byte(oct)) 1075 + i = end 1076 + default: 1077 + b.WriteByte(s[i]) 1078 + i++ 1079 + } 1080 + } 1081 + return b.String(), false 1082 + } 1083 + 1084 + func padLeft(s string, width int, pad byte) string { 1085 + if len(s) >= width { 1086 + return s 1087 + } 1088 + return strings.Repeat(string(pad), width-len(s)) + s 1089 + } 1090 + 1091 + func padRight(s string, width int, pad byte) string { 1092 + if len(s) >= width { 1093 + return s 1094 + } 1095 + return s + strings.Repeat(string(pad), width-len(s)) 1096 + } 1097 + 1098 + // formatStrftime formats a Unix timestamp using a strftime-style format string. 1099 + // Mirrors the date command's UTC-only semantics; tz is accepted but ignored. 1100 + func formatStrftime(format string, t time.Time, tz string) string { 1101 + t = t.UTC() 1102 + if tz != "" { 1103 + if loc, err := time.LoadLocation(tz); err == nil { 1104 + t = t.In(loc) 1105 + } 1106 + } 1107 + 1108 + var b strings.Builder 1109 + for i := 0; i < len(format); i++ { 1110 + if format[i] != '%' || i+1 >= len(format) { 1111 + b.WriteByte(format[i]) 1112 + continue 1113 + } 1114 + i++ 1115 + switch format[i] { 1116 + case 'a': 1117 + b.WriteString(t.Weekday().String()[:3]) 1118 + case 'A': 1119 + b.WriteString(t.Weekday().String()) 1120 + case 'b', 'h': 1121 + b.WriteString(t.Month().String()[:3]) 1122 + case 'B': 1123 + b.WriteString(t.Month().String()) 1124 + case 'c': 1125 + fmt.Fprintf(&b, "%s %s %2d %02d:%02d:%02d %d", 1126 + t.Weekday().String()[:3], t.Month().String()[:3], 1127 + t.Day(), t.Hour(), t.Minute(), t.Second(), t.Year()) 1128 + case 'C': 1129 + fmt.Fprintf(&b, "%02d", t.Year()/100) 1130 + case 'd': 1131 + fmt.Fprintf(&b, "%02d", t.Day()) 1132 + case 'D': 1133 + fmt.Fprintf(&b, "%02d/%02d/%02d", int(t.Month()), t.Day(), t.Year()%100) 1134 + case 'e': 1135 + fmt.Fprintf(&b, "%2d", t.Day()) 1136 + case 'F': 1137 + fmt.Fprintf(&b, "%d-%02d-%02d", t.Year(), int(t.Month()), t.Day()) 1138 + case 'H': 1139 + fmt.Fprintf(&b, "%02d", t.Hour()) 1140 + case 'I': 1141 + h := t.Hour() % 12 1142 + if h == 0 { 1143 + h = 12 1144 + } 1145 + fmt.Fprintf(&b, "%02d", h) 1146 + case 'j': 1147 + fmt.Fprintf(&b, "%03d", t.YearDay()) 1148 + case 'k': 1149 + fmt.Fprintf(&b, "%2d", t.Hour()) 1150 + case 'l': 1151 + h := t.Hour() % 12 1152 + if h == 0 { 1153 + h = 12 1154 + } 1155 + fmt.Fprintf(&b, "%2d", h) 1156 + case 'm': 1157 + fmt.Fprintf(&b, "%02d", int(t.Month())) 1158 + case 'M': 1159 + fmt.Fprintf(&b, "%02d", t.Minute()) 1160 + case 'n': 1161 + b.WriteByte('\n') 1162 + case 'N': 1163 + b.WriteString("000000000") 1164 + case 'p': 1165 + if t.Hour() < 12 { 1166 + b.WriteString("AM") 1167 + } else { 1168 + b.WriteString("PM") 1169 + } 1170 + case 'P': 1171 + if t.Hour() < 12 { 1172 + b.WriteString("am") 1173 + } else { 1174 + b.WriteString("pm") 1175 + } 1176 + case 'r': 1177 + h := t.Hour() % 12 1178 + if h == 0 { 1179 + h = 12 1180 + } 1181 + ap := "AM" 1182 + if t.Hour() >= 12 { 1183 + ap = "PM" 1184 + } 1185 + fmt.Fprintf(&b, "%02d:%02d:%02d %s", h, t.Minute(), t.Second(), ap) 1186 + case 'R': 1187 + fmt.Fprintf(&b, "%02d:%02d", t.Hour(), t.Minute()) 1188 + case 's': 1189 + fmt.Fprintf(&b, "%d", t.Unix()) 1190 + case 'S': 1191 + fmt.Fprintf(&b, "%02d", t.Second()) 1192 + case 't': 1193 + b.WriteByte('\t') 1194 + case 'T', 'X': 1195 + fmt.Fprintf(&b, "%02d:%02d:%02d", t.Hour(), t.Minute(), t.Second()) 1196 + case 'u': 1197 + w := int(t.Weekday()) 1198 + if w == 0 { 1199 + w = 7 1200 + } 1201 + fmt.Fprintf(&b, "%d", w) 1202 + case 'w': 1203 + fmt.Fprintf(&b, "%d", int(t.Weekday())) 1204 + case 'x': 1205 + fmt.Fprintf(&b, "%02d/%02d/%02d", int(t.Month()), t.Day(), t.Year()%100) 1206 + case 'y': 1207 + fmt.Fprintf(&b, "%02d", t.Year()%100) 1208 + case 'Y': 1209 + fmt.Fprintf(&b, "%d", t.Year()) 1210 + case 'z': 1211 + _, off := t.Zone() 1212 + sign := "+" 1213 + if off < 0 { 1214 + sign = "-" 1215 + off = -off 1216 + } 1217 + fmt.Fprintf(&b, "%s%02d%02d", sign, off/3600, (off%3600)/60) 1218 + case 'Z': 1219 + name, _ := t.Zone() 1220 + b.WriteString(name) 1221 + case '%': 1222 + b.WriteByte('%') 1223 + default: 1224 + b.WriteByte('%') 1225 + b.WriteByte(format[i]) 1226 + } 1227 + } 1228 + return b.String() 1229 + }
+392
command/internal/printf/printf_test.go
··· 1 + package printf 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "strings" 7 + "testing" 8 + 9 + "mvdan.cc/sh/v3/expand" 10 + "tangled.org/xeiaso.net/kefka/command" 11 + ) 12 + 13 + type writeEnv struct { 14 + vars map[string]expand.Variable 15 + } 16 + 17 + func newWriteEnv() *writeEnv { 18 + return &writeEnv{vars: map[string]expand.Variable{}} 19 + } 20 + 21 + func (w *writeEnv) Get(name string) expand.Variable { 22 + return w.vars[name] 23 + } 24 + 25 + func (w *writeEnv) Each(fn func(name string, vr expand.Variable) bool) { 26 + for k, v := range w.vars { 27 + if !fn(k, v) { 28 + return 29 + } 30 + } 31 + } 32 + 33 + func (w *writeEnv) Set(name string, vr expand.Variable) error { 34 + w.vars[name] = vr 35 + return nil 36 + } 37 + 38 + func run(t *testing.T, args []string) (string, string, error) { 39 + t.Helper() 40 + return runWithEnv(t, args, nil) 41 + } 42 + 43 + func runWithEnv(t *testing.T, args []string, env expand.Environ) (string, string, error) { 44 + t.Helper() 45 + var stdout, stderr bytes.Buffer 46 + ec := &command.ExecContext{ 47 + Stdin: strings.NewReader(""), 48 + Stdout: &stdout, 49 + Stderr: &stderr, 50 + Dir: ".", 51 + Environ: env, 52 + } 53 + err := Impl{}.Exec(context.Background(), ec, args) 54 + return stdout.String(), stderr.String(), err 55 + } 56 + 57 + func TestPrintf(t *testing.T) { 58 + tests := []struct { 59 + name string 60 + args []string 61 + wantStdout string 62 + wantErr bool 63 + }{ 64 + { 65 + name: "literal string no specifiers", 66 + args: []string{"hello world"}, 67 + wantStdout: "hello world", 68 + }, 69 + { 70 + name: "newline escape", 71 + args: []string{"hello\\n"}, 72 + wantStdout: "hello\n", 73 + }, 74 + { 75 + name: "tab escape", 76 + args: []string{"a\\tb"}, 77 + wantStdout: "a\tb", 78 + }, 79 + { 80 + name: "backslash escape", 81 + args: []string{"a\\\\b"}, 82 + wantStdout: "a\\b", 83 + }, 84 + { 85 + name: "octal escape", 86 + args: []string{"\\101"}, 87 + wantStdout: "A", 88 + }, 89 + { 90 + name: "hex escape ascii", 91 + args: []string{"\\x41"}, 92 + wantStdout: "A", 93 + }, 94 + { 95 + name: "hex escape utf-8 sequence", 96 + args: []string{"\\xc3\\xa9"}, 97 + wantStdout: "é", 98 + }, 99 + { 100 + name: "unicode escape", 101 + args: []string{"\\u00e9"}, 102 + wantStdout: "é", 103 + }, 104 + { 105 + name: "literal percent", 106 + args: []string{"100%%"}, 107 + wantStdout: "100%", 108 + }, 109 + { 110 + name: "format string %s", 111 + args: []string{"%s\\n", "hello"}, 112 + wantStdout: "hello\n", 113 + }, 114 + { 115 + name: "format string %d", 116 + args: []string{"%d\\n", "42"}, 117 + wantStdout: "42\n", 118 + }, 119 + { 120 + name: "format negative integer", 121 + args: []string{"%d", "-7"}, 122 + wantStdout: "-7", 123 + }, 124 + { 125 + name: "format hex lower", 126 + args: []string{"%x", "255"}, 127 + wantStdout: "ff", 128 + }, 129 + { 130 + name: "format hex upper", 131 + args: []string{"%X", "255"}, 132 + wantStdout: "FF", 133 + }, 134 + { 135 + name: "format hex with # flag", 136 + args: []string{"%#x", "255"}, 137 + wantStdout: "0xff", 138 + }, 139 + { 140 + name: "format octal", 141 + args: []string{"%o", "8"}, 142 + wantStdout: "10", 143 + }, 144 + { 145 + name: "format octal with # flag", 146 + args: []string{"%#o", "8"}, 147 + wantStdout: "010", 148 + }, 149 + { 150 + name: "format float default precision", 151 + args: []string{"%f", "3.14"}, 152 + wantStdout: "3.140000", 153 + }, 154 + { 155 + name: "format float with precision", 156 + args: []string{"%.2f", "3.14159"}, 157 + wantStdout: "3.14", 158 + }, 159 + { 160 + name: "format float with width and precision", 161 + args: []string{"%8.2f", "3.14"}, 162 + wantStdout: " 3.14", 163 + }, 164 + { 165 + name: "string width right justify", 166 + args: []string{"%5s", "hi"}, 167 + wantStdout: " hi", 168 + }, 169 + { 170 + name: "string width left justify", 171 + args: []string{"%-5s|", "hi"}, 172 + wantStdout: "hi |", 173 + }, 174 + { 175 + name: "string precision truncates", 176 + args: []string{"%.3s", "hello"}, 177 + wantStdout: "hel", 178 + }, 179 + { 180 + name: "integer zero pad", 181 + args: []string{"%05d", "42"}, 182 + wantStdout: "00042", 183 + }, 184 + { 185 + name: "integer plus flag positive", 186 + args: []string{"%+d", "42"}, 187 + wantStdout: "+42", 188 + }, 189 + { 190 + name: "integer plus flag negative", 191 + args: []string{"%+d", "-42"}, 192 + wantStdout: "-42", 193 + }, 194 + { 195 + name: "integer space flag positive", 196 + args: []string{"% d", "42"}, 197 + wantStdout: " 42", 198 + }, 199 + { 200 + name: "char specifier", 201 + args: []string{"%c", "ABC"}, 202 + wantStdout: "A", 203 + }, 204 + { 205 + name: "format reused with multiple args", 206 + args: []string{"%s\\n", "a", "b", "c"}, 207 + wantStdout: "a\nb\nc\n", 208 + }, 209 + { 210 + name: "format reused mixed types", 211 + args: []string{"[%d]", "1", "2", "3"}, 212 + wantStdout: "[1][2][3]", 213 + }, 214 + { 215 + name: "missing arg defaults to zero", 216 + args: []string{"%d"}, 217 + wantStdout: "0", 218 + }, 219 + { 220 + name: "missing arg defaults to empty string", 221 + args: []string{"<%s>"}, 222 + wantStdout: "<>", 223 + }, 224 + { 225 + name: "width from arg", 226 + args: []string{"%*d", "5", "42"}, 227 + wantStdout: " 42", 228 + }, 229 + { 230 + name: "precision from arg", 231 + args: []string{"%.*f", "2", "3.14159"}, 232 + wantStdout: "3.14", 233 + }, 234 + { 235 + name: "%b interprets escapes", 236 + args: []string{"%b", "hello\\nworld"}, 237 + wantStdout: "hello\nworld", 238 + }, 239 + { 240 + name: "%b with c stops output", 241 + args: []string{"%b\\n", "hi\\cdropped"}, 242 + wantStdout: "hi", 243 + }, 244 + { 245 + name: "hex input parsed", 246 + args: []string{"%d", "0x1f"}, 247 + wantStdout: "31", 248 + }, 249 + { 250 + name: "octal input parsed", 251 + args: []string{"%d", "010"}, 252 + wantStdout: "8", 253 + }, 254 + { 255 + name: "char notation single quote", 256 + args: []string{"%d", "'A"}, 257 + wantStdout: "65", 258 + }, 259 + { 260 + name: "%q empty string", 261 + args: []string{"%q", ""}, 262 + wantStdout: "''", 263 + }, 264 + { 265 + name: "%q safe string", 266 + args: []string{"%q", "hello"}, 267 + wantStdout: "hello", 268 + }, 269 + { 270 + name: "%q with space", 271 + args: []string{"%q", "hello world"}, 272 + wantStdout: "hello\\ world", 273 + }, 274 + { 275 + name: "%q with newline uses dollar quote", 276 + args: []string{"%q", "a\nb"}, 277 + wantStdout: "$'a\\nb'", 278 + }, 279 + { 280 + name: "stop at -- separator", 281 + args: []string{"--", "%s", "hello"}, 282 + wantStdout: "hello", 283 + }, 284 + { 285 + name: "no args returns usage error", 286 + args: nil, 287 + wantStdout: "", 288 + wantErr: true, 289 + }, 290 + { 291 + name: "unknown directive errors", 292 + args: []string{"%z", "x"}, 293 + wantStdout: "", 294 + wantErr: true, 295 + }, 296 + } 297 + 298 + for _, tt := range tests { 299 + t.Run(tt.name, func(t *testing.T) { 300 + stdout, _, err := run(t, tt.args) 301 + if (err != nil) != tt.wantErr { 302 + t.Errorf("err = %v, wantErr = %v", err, tt.wantErr) 303 + } 304 + if stdout != tt.wantStdout { 305 + t.Errorf("stdout = %q, want %q", stdout, tt.wantStdout) 306 + } 307 + }) 308 + } 309 + } 310 + 311 + func TestPrintfHelp(t *testing.T) { 312 + stdout, stderr, err := run(t, []string{"--help"}) 313 + if err != nil { 314 + t.Fatalf("unexpected error: %v", err) 315 + } 316 + if stdout != "" { 317 + t.Errorf("stdout should be empty, got %q", stdout) 318 + } 319 + if !strings.Contains(stderr, "Usage: printf") { 320 + t.Errorf("stderr missing usage line, got %q", stderr) 321 + } 322 + } 323 + 324 + func TestPrintfMissingArg(t *testing.T) { 325 + _, stderr, err := run(t, nil) 326 + if err == nil { 327 + t.Fatal("expected error, got nil") 328 + } 329 + if !strings.Contains(stderr, "usage: printf format") { 330 + t.Errorf("stderr missing usage line, got %q", stderr) 331 + } 332 + } 333 + 334 + func TestPrintfVarAssignment(t *testing.T) { 335 + env := newWriteEnv() 336 + stdout, stderr, err := runWithEnv(t, []string{"-v", "myvar", "%s", "hello"}, env) 337 + if err != nil { 338 + t.Fatalf("unexpected error: %v\nstderr: %s", err, stderr) 339 + } 340 + if stdout != "" { 341 + t.Errorf("stdout should be empty when -v is used, got %q", stdout) 342 + } 343 + got := env.Get("myvar") 344 + if !got.IsSet() || got.String() != "hello" { 345 + t.Errorf("myvar = %q, want %q", got.String(), "hello") 346 + } 347 + } 348 + 349 + func TestPrintfVarInvalidIdentifier(t *testing.T) { 350 + env := newWriteEnv() 351 + _, stderr, err := runWithEnv(t, []string{"-v", "1bad", "%s", "x"}, env) 352 + if err == nil { 353 + t.Fatal("expected error, got nil") 354 + } 355 + if !strings.Contains(stderr, "not a valid identifier") { 356 + t.Errorf("stderr missing identifier error, got %q", stderr) 357 + } 358 + } 359 + 360 + func TestPrintfVarMissingValue(t *testing.T) { 361 + env := newWriteEnv() 362 + _, stderr, err := runWithEnv(t, []string{"-v"}, env) 363 + if err == nil { 364 + t.Fatal("expected error, got nil") 365 + } 366 + if !strings.Contains(stderr, "option requires an argument") { 367 + t.Errorf("stderr missing option-requires-arg error, got %q", stderr) 368 + } 369 + } 370 + 371 + func TestPrintfVarArraySubscript(t *testing.T) { 372 + env := newWriteEnv() 373 + _, stderr, err := runWithEnv(t, []string{"-v", "arr[key]", "%s", "value"}, env) 374 + if err != nil { 375 + t.Fatalf("unexpected error: %v\nstderr: %s", err, stderr) 376 + } 377 + got := env.Get("arr_key") 378 + if !got.IsSet() || got.String() != "value" { 379 + t.Errorf("arr_key = %q, want %q", got.String(), "value") 380 + } 381 + } 382 + 383 + func TestPrintfStrftime(t *testing.T) { 384 + // 2025-01-15T12:00:00Z = unix 1736942400 385 + stdout, _, err := run(t, []string{"%(%Y-%m-%d)T", "1736942400"}) 386 + if err != nil { 387 + t.Fatalf("unexpected error: %v", err) 388 + } 389 + if stdout != "2025-01-15" { 390 + t.Errorf("stdout = %q, want %q", stdout, "2025-01-15") 391 + } 392 + }