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(head): preserve trailing newline absence and add GNU forms

Implements GNU coreutils compatibility for head:

- Preserves absence of final newline when input has none
- -n -K and -c -K negative-count semantics ("all but
last K")
- -NUM shorthand (head -5 file)
- Size suffix multipliers on -c (b, kB, K, M, G, ...)
- Multi-file ==> name <== headers with -q suppress and
-v force-on-single

Retains GNU -c/--bytes, -q/--quiet, -v/--verbose.

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

Xe Iaso d99d870c 179ee5b4

+237 -27
+162 -15
command/internal/head/head.go
··· 39 39 fmt.Fprint(stderr, "Print the first 10 lines of each FILE to standard output.\n") 40 40 fmt.Fprint(stderr, "With more than one FILE, precede each with a header giving the file name.\n") 41 41 fmt.Fprint(stderr, "With no FILE, or when FILE is -, read standard input.\n\n") 42 - fmt.Fprint(stderr, " -c, --bytes=NUM print the first NUM bytes\n") 43 - fmt.Fprint(stderr, " -n, --lines=NUM print the first NUM lines (default 10)\n") 44 - fmt.Fprint(stderr, " -q, --quiet never print headers giving file names\n") 45 - fmt.Fprint(stderr, " -v, --verbose always print headers giving file names\n") 46 - fmt.Fprint(stderr, " --help display this help and exit\n") 42 + fmt.Fprint(stderr, " -c, --bytes=[-]NUM print the first NUM bytes of each file;\n") 43 + fmt.Fprint(stderr, " with the leading '-', print all but the last\n") 44 + fmt.Fprint(stderr, " NUM bytes of each file\n") 45 + fmt.Fprint(stderr, " -n, --lines=[-]NUM print the first NUM lines instead of the first 10;\n") 46 + fmt.Fprint(stderr, " with the leading '-', print all but the last\n") 47 + fmt.Fprint(stderr, " NUM lines of each file\n") 48 + fmt.Fprint(stderr, " -q, --quiet, --silent never print headers giving file names\n") 49 + fmt.Fprint(stderr, " -v, --verbose always print headers giving file names\n") 50 + fmt.Fprint(stderr, " --help display this help and exit\n\n") 51 + fmt.Fprint(stderr, "NUM may have a multiplier suffix:\n") 52 + fmt.Fprint(stderr, "b 512, kB 1000, K 1024, MB 1000*1000, M 1024*1024,\n") 53 + fmt.Fprint(stderr, "GB 1000*1000*1000, G 1024*1024*1024, and so on for T, P, E, Z, Y.\n") 47 54 } 48 55 set.SetUsage(usage) 49 56 ··· 67 74 } 68 75 69 76 lines := 10 77 + linesNeg := false 70 78 bytes := 0 79 + bytesNeg := false 71 80 bytesSet := false 72 81 73 82 if *bytesSpec != "" { 74 - n, err := strconv.Atoi(*bytesSpec) 75 - if err != nil || n < 0 { 76 - fmt.Fprint(stderr, "head: invalid number of bytes\n") 83 + n, neg, err := parseHeadCount(*bytesSpec) 84 + if err != nil { 85 + fmt.Fprintf(stderr, "head: invalid number of bytes: '%s'\n", *bytesSpec) 77 86 return interp.ExitStatus(1) 78 87 } 79 88 bytes = n 89 + bytesNeg = neg 80 90 bytesSet = true 81 91 } 82 92 if *linesSpec != "" { 83 - n, err := strconv.Atoi(*linesSpec) 84 - if err != nil || n < 0 { 85 - fmt.Fprint(stderr, "head: invalid number of lines\n") 93 + n, neg, err := parseHeadCount(*linesSpec) 94 + if err != nil { 95 + fmt.Fprintf(stderr, "head: invalid number of lines: '%s'\n", *linesSpec) 86 96 return interp.ExitStatus(1) 87 97 } 88 98 lines = n 99 + linesNeg = neg 89 100 } 90 101 91 102 isQuiet := *quiet || *silent ··· 96 107 if err != nil { 97 108 return err 98 109 } 99 - io.WriteString(stdout, getHead(content, lines, bytes, bytesSet)) 110 + io.WriteString(stdout, getHead(content, lines, linesNeg, bytes, bytesNeg, bytesSet)) 100 111 return nil 101 112 } 102 113 ··· 118 129 } 119 130 fmt.Fprintf(&output, "==> %s <==\n", file) 120 131 } 121 - output.WriteString(getHead(content, lines, bytes, bytesSet)) 132 + output.WriteString(getHead(content, lines, linesNeg, bytes, bytesNeg, bytesSet)) 122 133 filesProcessed++ 123 134 } 124 135 ··· 158 169 out = append(out, a) 159 170 continue 160 171 } 172 + // GNU shorthand: -NUM (digits only) means "-n NUM". 173 + // Note that -NUM with a non-digit suffix (e.g. -nK) is not handled here. 161 174 if len(a) >= 2 && a[0] == '-' && a[1] >= '0' && a[1] <= '9' { 162 175 allDigits := true 163 176 for _, c := range a[1:] { ··· 176 189 return out 177 190 } 178 191 179 - func getHead(content string, lines int, bytes int, bytesSet bool) string { 192 + // parseHeadCount parses a GNU-style count for -n/-c. It accepts an optional 193 + // leading '-' for "all but last K" semantics, optional size suffix 194 + // (b, kB, K, MB, M, GB, G, T, P, E, Z, Y; with optional trailing 'B' for 195 + // the binary forms), and returns (value, negative, error). 196 + func parseHeadCount(s string) (int, bool, error) { 197 + if s == "" { 198 + return 0, false, errors.New("empty") 199 + } 200 + neg := false 201 + if s[0] == '-' { 202 + neg = true 203 + s = s[1:] 204 + } else if s[0] == '+' { 205 + s = s[1:] 206 + } 207 + if s == "" { 208 + return 0, false, errors.New("missing number") 209 + } 210 + 211 + // Split numeric prefix from suffix. 212 + i := 0 213 + for i < len(s) && s[i] >= '0' && s[i] <= '9' { 214 + i++ 215 + } 216 + if i == 0 { 217 + return 0, false, errors.New("not a number") 218 + } 219 + numPart := s[:i] 220 + suf := s[i:] 221 + 222 + n, err := strconv.Atoi(numPart) 223 + if err != nil || n < 0 { 224 + return 0, false, errors.New("not a number") 225 + } 226 + 227 + mult, ok := sizeMultiplier(suf) 228 + if !ok { 229 + return 0, false, fmt.Errorf("invalid suffix: %q", suf) 230 + } 231 + return n * mult, neg, nil 232 + } 233 + 234 + // sizeMultiplier returns the GNU coreutils size suffix multiplier. 235 + // Recognizes (case-sensitive for kB vs K): 236 + // 237 + // "" = 1 238 + // b = 512 239 + // kB = 1000 K = 1024 240 + // MB = 1000^2 M = 1024^2 241 + // GB = 1000^3 G = 1024^3 242 + // ... up through Y. A trailing 'B' on the binary form (e.g. KB) is 243 + // accepted as an alias for the binary multiplier. 244 + func sizeMultiplier(suf string) (int, bool) { 245 + if suf == "" { 246 + return 1, true 247 + } 248 + // Special-case "b" = 512. 249 + if suf == "b" { 250 + return 512, true 251 + } 252 + // "kB" = 1000. 253 + if suf == "kB" { 254 + return 1000, true 255 + } 256 + 257 + // SI (decimal) suffixes: "MB", "GB", "TB", "PB", "EB", "ZB", "YB". 258 + siLetters := []byte{'M', 'G', 'T', 'P', 'E', 'Z', 'Y'} 259 + for idx, c := range siLetters { 260 + if len(suf) == 2 && suf[0] == c && suf[1] == 'B' { 261 + mult := 1 262 + for k := 0; k <= idx+1; k++ { 263 + mult *= 1000 264 + } 265 + return mult, true 266 + } 267 + } 268 + 269 + // IEC (binary) suffixes: "K", "M", "G", "T", "P", "E", "Z", "Y"; 270 + // also accept e.g. "KB" as alias for "K". 271 + binLetters := []byte{'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'} 272 + for idx, c := range binLetters { 273 + if suf == string(c) || suf == string(c)+"B" { 274 + mult := 1 275 + for k := 0; k <= idx; k++ { 276 + mult *= 1024 277 + } 278 + return mult, true 279 + } 280 + } 281 + return 0, false 282 + } 283 + 284 + func getHead(content string, lines int, linesNeg bool, bytes int, bytesNeg bool, bytesSet bool) string { 180 285 if bytesSet { 286 + if bytesNeg { 287 + // Print all but the last `bytes` bytes. 288 + if bytes >= len(content) { 289 + return "" 290 + } 291 + return content[:len(content)-bytes] 292 + } 181 293 if bytes >= len(content) { 182 294 return content 183 295 } 184 296 return content[:bytes] 185 297 } 298 + if linesNeg { 299 + // Print all but the last `lines` lines. 300 + // Count total complete lines (lines terminated by '\n'); a final 301 + // chunk without '\n' counts as a line for the purpose of dropping. 302 + if lines == 0 { 303 + return content 304 + } 305 + // Find positions of newlines so we can drop the last `lines` lines. 306 + // We treat the file as a sequence of lines separated by '\n'. A 307 + // trailing '\n' does NOT introduce an empty extra line; instead, 308 + // the absence of a final '\n' on the last chunk still counts as a 309 + // line. 310 + newlineIdx := []int{} 311 + for i := 0; i < len(content); i++ { 312 + if content[i] == '\n' { 313 + newlineIdx = append(newlineIdx, i) 314 + } 315 + } 316 + totalLines := len(newlineIdx) 317 + hasTrailingPartial := len(content) > 0 && content[len(content)-1] != '\n' 318 + if hasTrailingPartial { 319 + totalLines++ 320 + } 321 + keep := totalLines - lines 322 + if keep <= 0 { 323 + return "" 324 + } 325 + // Truncate content after the keep-th newline. 326 + if keep <= len(newlineIdx) { 327 + return content[:newlineIdx[keep-1]+1] 328 + } 329 + // keep > number of newlines means keep includes the partial last line. 330 + return content 331 + } 186 332 if lines == 0 { 187 333 return "" 188 334 } ··· 191 337 for pos < len(content) && lineCount < lines { 192 338 idx := strings.IndexByte(content[pos:], '\n') 193 339 if idx == -1 { 194 - return content + "\n" 340 + // Final line without a trailing newline: copy as-is, do not synthesize one. 341 + return content 195 342 } 196 343 lineCount++ 197 344 pos += idx + 1
+75 -12
command/internal/head/head_test.go
··· 28 28 write("nofinalnl.txt", []byte("x1\nx2\nx3")) 29 29 write("empty.txt", []byte("")) 30 30 write("bytes.txt", []byte("abcdefghij")) 31 + // File literally named "-n2" — used to verify "--" terminates option parsing. 32 + write("-n2", []byte("hello\nworld\n")) 31 33 return fs 32 34 } 33 35 ··· 115 117 wantStdout: "abcdefghij", 116 118 }, 117 119 { 118 - name: "no trailing newline gets one appended", 120 + name: "no trailing newline preserved", 119 121 args: []string{"-n", "10", "nofinalnl.txt"}, 120 - wantStdout: "x1\nx2\nx3\n", 122 + wantStdout: "x1\nx2\nx3", 123 + }, 124 + { 125 + name: "stdin without trailing newline preserved when truncated", 126 + args: []string{"-n", "2"}, 127 + stdin: "a\nb\nc", 128 + wantStdout: "a\nb\n", 129 + }, 130 + { 131 + name: "stdin with trailing newline keeps newline", 132 + args: []string{"-n", "1"}, 133 + stdin: "a\nb\n", 134 + wantStdout: "a\n", 135 + }, 136 + { 137 + name: "stdin shorter than n preserves missing final newline", 138 + args: []string{"-n", "5"}, 139 + stdin: "a\nb", 140 + wantStdout: "a\nb", 121 141 }, 122 142 { 123 143 name: "empty file produces empty output", ··· 151 171 wantStdout: "==> five.txt <==\na\n", 152 172 }, 153 173 { 174 + name: "verbose with multiple files prints headers", 175 + args: []string{"-v", "-n", "1", "five.txt", "twelve.txt"}, 176 + wantStdout: "==> five.txt <==\na\n\n==> twelve.txt <==\nL1\n", 177 + }, 178 + { 179 + name: "double-dash treats -n2 as filename", 180 + args: []string{"--", "-n2"}, 181 + wantStdout: "hello\nworld\n", 182 + }, 183 + { 184 + name: "double-dash with -n flag preceding still parses option", 185 + args: []string{"-n", "1", "--", "-n2"}, 186 + wantStdout: "hello\n", 187 + }, 188 + { 154 189 name: "missing file errors but other files still processed", 155 190 args: []string{"-n", "1", "nope.txt", "five.txt"}, 156 191 wantStdout: "==> five.txt <==\na\n", ··· 158 193 wantErr: true, 159 194 }, 160 195 { 161 - name: "negative lines is invalid", 162 - args: []string{"-n", "-5", "five.txt"}, 163 - wantErrSub: "invalid number of lines", 164 - wantErr: true, 196 + name: "negative lines drops trailing lines", 197 + args: []string{"-n", "-2", "five.txt"}, 198 + wantStdout: "a\nb\nc\n", 199 + }, 200 + { 201 + name: "negative lines larger than file produces empty", 202 + args: []string{"-n", "-99", "five.txt"}, 203 + wantStdout: "", 204 + }, 205 + { 206 + name: "negative lines with no trailing newline", 207 + args: []string{"-n", "-1", "nofinalnl.txt"}, 208 + wantStdout: "x1\nx2\n", 209 + }, 210 + { 211 + name: "negative bytes drops trailing bytes", 212 + args: []string{"-c", "-3", "bytes.txt"}, 213 + wantStdout: "abcdefg", 214 + }, 215 + { 216 + name: "negative bytes larger than file produces empty", 217 + args: []string{"-c", "-99", "bytes.txt"}, 218 + wantStdout: "", 219 + }, 220 + { 221 + name: "size suffix K bytes", 222 + args: []string{"-c", "1K", "bytes.txt"}, 223 + wantStdout: "abcdefghij", 224 + }, 225 + { 226 + name: "size suffix b bytes (512)", 227 + args: []string{"-c", "1b", "bytes.txt"}, 228 + wantStdout: "abcdefghij", 165 229 }, 166 230 { 167 - name: "negative bytes is invalid", 168 - args: []string{"-c", "-5", "five.txt"}, 169 - wantErrSub: "invalid number of bytes", 170 - wantErr: true, 231 + name: "size suffix kB decimal", 232 + args: []string{"-c", "1kB", "bytes.txt"}, 233 + wantStdout: "abcdefghij", 171 234 }, 172 235 { 173 236 name: "non-numeric lines is invalid", ··· 213 276 if !strings.Contains(stderr, "Usage: head [OPTION]... [FILE]...") { 214 277 t.Errorf("usage line missing from stderr: %q", stderr) 215 278 } 216 - if !strings.Contains(stderr, "--lines=NUM") { 279 + if !strings.Contains(stderr, "--lines=") { 217 280 t.Errorf("lines flag missing from help: %q", stderr) 218 281 } 219 - if !strings.Contains(stderr, "--bytes=NUM") { 282 + if !strings.Contains(stderr, "--bytes=") { 220 283 t.Errorf("bytes flag missing from help: %q", stderr) 221 284 } 222 285 }