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(wc): decouple -c/-m, count runes for -m, add -L

Implements GNU coreutils compatibility for wc:

- -c (bytes) and -m (chars) are now independent columns;
previously conflated
- -m counts runes via utf8.RuneCount, not bytes
- Column order matches GNU regardless of flag order:
lines, words, chars, bytes, max-line-length
- -L/--max-line-length with proper width semantics:
- \\n ends a line, \\r resets the column
- \\t advances to next multiple of 8
- \\b decrements, control chars are zero-width
- East Asian Wide/Fullwidth runes count as width 2
- Multi-file totals use max for -L, sum for everything else

Word splitting uses unicode.IsSpace.

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

Xe Iaso cbbc3384 21c399af

+234 -35
+107 -24
command/internal/wc/wc.go
··· 8 8 "path" 9 9 "strconv" 10 10 "strings" 11 + "unicode" 12 + "unicode/utf8" 11 13 12 14 "github.com/pborman/getopt/v2" 15 + "golang.org/x/text/width" 13 16 "mvdan.cc/sh/v3/interp" 14 17 "tangled.org/xeiaso.net/kefka/command" 15 18 ) ··· 17 20 type Impl struct{} 18 21 19 22 type stats struct { 20 - lines int 21 - words int 22 - chars int 23 + lines int 24 + words int 25 + chars int 26 + bytes int 27 + maxLine int 23 28 } 24 29 25 30 type fileResult struct { ··· 48 53 usage := func() { 49 54 fmt.Fprint(stderr, "Usage: wc [OPTION]... [FILE]...\n") 50 55 fmt.Fprint(stderr, "Print newline, word, and byte counts for each FILE.\n\n") 51 - fmt.Fprint(stderr, " -c, --bytes print the byte counts\n") 52 - fmt.Fprint(stderr, " -m, --chars print the character counts\n") 53 - fmt.Fprint(stderr, " -l, --lines print the newline counts\n") 54 - fmt.Fprint(stderr, " -w, --words print the word counts\n") 55 - fmt.Fprint(stderr, " --help display this help and exit\n") 56 + fmt.Fprint(stderr, " -c, --bytes print the byte counts\n") 57 + fmt.Fprint(stderr, " -m, --chars print the character counts\n") 58 + fmt.Fprint(stderr, " -l, --lines print the newline counts\n") 59 + fmt.Fprint(stderr, " -w, --words print the word counts\n") 60 + fmt.Fprint(stderr, " -L, --max-line-length print the maximum display width\n") 61 + fmt.Fprint(stderr, " --help display this help and exit\n") 56 62 } 57 63 set.SetUsage(usage) 58 64 ··· 60 66 wordsFlag := set.BoolLong("words", 'w', "print the word counts") 61 67 bytesFlag := set.BoolLong("bytes", 'c', "print the byte counts") 62 68 charsFlag := set.BoolLong("chars", 'm', "print the character counts") 69 + maxLineFlag := set.BoolLong("max-line-length", 'L', "print the maximum display width") 63 70 helpFlag := set.BoolLong("help", 0, "display this help and exit") 64 71 65 72 if err := set.Getopt(append([]string{"wc"}, args...), nil); err != nil { ··· 75 82 76 83 showLines := *linesFlag 77 84 showWords := *wordsFlag 78 - showChars := *bytesFlag || *charsFlag 85 + showBytes := *bytesFlag 86 + showChars := *charsFlag 87 + showMaxLine := *maxLineFlag 79 88 80 - if !showLines && !showWords && !showChars { 89 + if !showLines && !showWords && !showBytes && !showChars && !showMaxLine { 81 90 showLines = true 82 91 showWords = true 83 - showChars = true 92 + showBytes = true 84 93 } 85 94 86 95 files := set.Args() ··· 92 101 return interp.ExitStatus(1) 93 102 } 94 103 s := countStats(data) 95 - io.WriteString(stdout, formatStats(s, showLines, showWords, showChars, "", 0)+"\n") 104 + io.WriteString(stdout, formatStats(s, showLines, showWords, showChars, showBytes, showMaxLine, "", 0)+"\n") 96 105 return nil 97 106 } 98 107 ··· 103 112 for _, file := range files { 104 113 data, err := readFile(ec, file) 105 114 if err != nil { 106 - fmt.Fprintf(stderr, "wc: %s: No such file or directory\n", file) 115 + fmt.Fprintf(stderr, "wc: %s: %s\n", file, err) 107 116 exitCode = 1 108 117 continue 109 118 } ··· 111 120 total.lines += s.lines 112 121 total.words += s.words 113 122 total.chars += s.chars 123 + total.bytes += s.bytes 124 + if s.maxLine > total.maxLine { 125 + total.maxLine = s.maxLine 126 + } 114 127 results = append(results, fileResult{filename: file, stats: s}) 115 128 } 116 129 117 130 maxLines := 0 118 131 maxWords := 0 119 132 maxChars := 0 133 + maxBytes := 0 134 + maxL := 0 120 135 if len(files) > 1 { 121 136 maxLines = total.lines 122 137 maxWords = total.words 123 138 maxChars = total.chars 139 + maxBytes = total.bytes 140 + maxL = total.maxLine 124 141 } else { 125 142 for _, r := range results { 126 143 if r.stats.lines > maxLines { ··· 131 148 } 132 149 if r.stats.chars > maxChars { 133 150 maxChars = r.stats.chars 151 + } 152 + if r.stats.bytes > maxBytes { 153 + maxBytes = r.stats.bytes 154 + } 155 + if r.stats.maxLine > maxL { 156 + maxL = r.stats.maxLine 134 157 } 135 158 } 136 159 } ··· 154 177 maxWidth = w 155 178 } 156 179 } 180 + if showBytes { 181 + if w := len(strconv.Itoa(maxBytes)); w > maxWidth { 182 + maxWidth = w 183 + } 184 + } 185 + if showMaxLine { 186 + if w := len(strconv.Itoa(maxL)); w > maxWidth { 187 + maxWidth = w 188 + } 189 + } 157 190 158 191 var out strings.Builder 159 192 for _, r := range results { 160 - out.WriteString(formatStats(r.stats, showLines, showWords, showChars, r.filename, maxWidth)) 193 + out.WriteString(formatStats(r.stats, showLines, showWords, showChars, showBytes, showMaxLine, r.filename, maxWidth)) 161 194 out.WriteByte('\n') 162 195 } 163 196 164 197 if len(files) > 1 { 165 - out.WriteString(formatStats(total, showLines, showWords, showChars, "total", maxWidth)) 198 + out.WriteString(formatStats(total, showLines, showWords, showChars, showBytes, showMaxLine, "total", maxWidth)) 166 199 out.WriteByte('\n') 167 200 } 168 201 ··· 198 231 199 232 func countStats(content []byte) stats { 200 233 var s stats 201 - s.chars = len(content) 234 + s.bytes = len(content) 235 + s.chars = utf8.RuneCount(content) 236 + 202 237 inWord := false 203 - for _, c := range content { 204 - switch c { 238 + col := 0 239 + lineMax := 0 240 + for _, r := range string(content) { 241 + switch r { 205 242 case '\n': 206 243 s.lines++ 207 - if inWord { 208 - s.words++ 209 - inWord = false 244 + if col > lineMax { 245 + lineMax = col 246 + } 247 + if lineMax > s.maxLine { 248 + s.maxLine = lineMax 249 + } 250 + lineMax = 0 251 + col = 0 252 + case '\r': 253 + if col > lineMax { 254 + lineMax = col 210 255 } 211 - case ' ', '\t', '\r': 256 + col = 0 257 + case '\t': 258 + col = (col/8 + 1) * 8 259 + case '\b': 260 + if col > 0 { 261 + col-- 262 + } 263 + default: 264 + col += runeWidth(r) 265 + } 266 + 267 + if unicode.IsSpace(r) { 212 268 if inWord { 213 269 s.words++ 214 270 inWord = false 215 271 } 216 - default: 272 + } else { 217 273 inWord = true 218 274 } 219 275 } 220 276 if inWord { 221 277 s.words++ 222 278 } 279 + if col > lineMax { 280 + lineMax = col 281 + } 282 + if lineMax > s.maxLine { 283 + s.maxLine = lineMax 284 + } 223 285 return s 224 286 } 225 287 226 - func formatStats(s stats, showLines, showWords, showChars bool, filename string, minWidth int) string { 288 + // runeWidth returns the display width of r in fixed-width cells, mirroring the 289 + // behaviour wc uses when computing -L: control characters contribute zero, 290 + // East Asian Wide / Fullwidth runes contribute two, and everything else 291 + // contributes one. 292 + func runeWidth(r rune) int { 293 + if r < 0x20 || r == 0x7f { 294 + return 0 295 + } 296 + switch width.LookupRune(r).Kind() { 297 + case width.EastAsianWide, width.EastAsianFullwidth: 298 + return 2 299 + } 300 + return 1 301 + } 302 + 303 + func formatStats(s stats, showLines, showWords, showChars, showBytes, showMaxLine bool, filename string, minWidth int) string { 227 304 var values []string 228 305 if showLines { 229 306 values = append(values, padLeft(strconv.Itoa(s.lines), minWidth)) ··· 233 310 } 234 311 if showChars { 235 312 values = append(values, padLeft(strconv.Itoa(s.chars), minWidth)) 313 + } 314 + if showBytes { 315 + values = append(values, padLeft(strconv.Itoa(s.bytes), minWidth)) 316 + } 317 + if showMaxLine { 318 + values = append(values, padLeft(strconv.Itoa(s.maxLine), minWidth)) 236 319 } 237 320 result := strings.Join(values, " ") 238 321 if filename != "" {
+127 -11
command/internal/wc/wc_test.go
··· 100 100 wantStdout: "5\n", 101 101 }, 102 102 { 103 - name: "chars flag treats bytes the same as -c", 103 + name: "chars flag on ASCII matches byte count", 104 104 args: []string{"-m"}, 105 105 stdin: "abcde", 106 106 wantStdout: "5\n", 107 107 }, 108 108 { 109 + name: "chars flag counts runes for multibyte input", 110 + args: []string{"-m"}, 111 + stdin: "λλλ", 112 + wantStdout: "3\n", 113 + }, 114 + { 115 + name: "bytes flag counts bytes for multibyte input", 116 + args: []string{"-c"}, 117 + stdin: "λλλ", 118 + wantStdout: "6\n", 119 + }, 120 + { 121 + name: "bytes and chars together show both columns", 122 + args: []string{"-c", "-m"}, 123 + stdin: "λλλ", 124 + wantStdout: "3 6\n", 125 + }, 126 + { 127 + name: "lines words chars bytes ordering", 128 + args: []string{"-lwmc"}, 129 + stdin: "λλλ\n", 130 + wantStdout: "1 1 4 7\n", 131 + }, 132 + { 109 133 name: "long lines flag", 110 134 args: []string{"--lines"}, 111 135 stdin: "a\nb\n", ··· 124 148 wantStdout: "4\n", 125 149 }, 126 150 { 151 + name: "nbsp splits words", 152 + args: []string{"-w"}, 153 + stdin: "alpha beta gamma", 154 + wantStdout: "3\n", 155 + }, 156 + { 157 + name: "ascii word splitting unchanged", 158 + args: []string{"-w"}, 159 + stdin: " one two\tthree\nfour ", 160 + wantStdout: "4\n", 161 + }, 162 + { 163 + name: "max line length flag", 164 + args: []string{"-L"}, 165 + stdin: "short\nverylonger\nmid\n", 166 + wantStdout: "10\n", 167 + }, 168 + { 169 + name: "max line length long form", 170 + args: []string{"--max-line-length"}, 171 + stdin: "abc\ndefgh\n", 172 + wantStdout: "5\n", 173 + }, 174 + { 175 + name: "max line length expands tabs to next stop", 176 + args: []string{"-L"}, 177 + stdin: "a\tb\n", 178 + wantStdout: "9\n", 179 + }, 180 + { 181 + name: "max line length counts wide chars as width 2", 182 + args: []string{"-L"}, 183 + stdin: "日本\n", 184 + wantStdout: "4\n", 185 + }, 186 + { 187 + name: "max line length combines with other flags in fixed order", 188 + args: []string{"-lwL"}, 189 + stdin: "abc def\nhi\n", 190 + wantStdout: "2 3 7\n", 191 + }, 192 + { 193 + name: "max line length on file shows length", 194 + args: []string{"-L", "multi.txt"}, 195 + stdin: "", 196 + wantStdout: "13 multi.txt\n", 197 + }, 198 + { 199 + name: "max line length multi-file uses max not sum", 200 + args: []string{"-L", "hello.txt", "multi.txt"}, 201 + wantStdout: " 11 hello.txt\n 13 multi.txt\n 13 total\n", 202 + }, 203 + { 204 + name: "no trailing newline reports zero lines", 205 + args: []string{"-l"}, 206 + stdin: "no newline here", 207 + wantStdout: "0\n", 208 + }, 209 + { 210 + name: "single line with trailing newline reports one line", 211 + args: []string{"-l"}, 212 + stdin: "with newline\n", 213 + wantStdout: "1\n", 214 + }, 215 + { 216 + name: "no trailing newline file reports zero lines", 217 + args: []string{"-l", "nolf.txt"}, 218 + wantStdout: "0 nolf.txt\n", 219 + }, 220 + { 127 221 name: "multi file totals", 128 222 args: []string{"hello.txt", "multi.txt"}, 129 223 wantStdout: " 1 2 12 hello.txt\n 3 6 28 multi.txt\n 4 8 40 total\n", ··· 137 231 name: "missing file reports error", 138 232 args: []string{"nope.txt"}, 139 233 wantStdout: "", 140 - wantErrSub: "wc: nope.txt: No such file or directory", 234 + wantErrSub: "wc: nope.txt: ", 141 235 wantErr: true, 142 236 }, 143 237 { 144 238 name: "missing file in list still counts present files", 145 239 args: []string{"hello.txt", "nope.txt"}, 146 240 wantStdout: " 1 2 12 hello.txt\n 1 2 12 total\n", 147 - wantErrSub: "wc: nope.txt: No such file or directory", 241 + wantErrSub: "wc: nope.txt: ", 148 242 wantErr: true, 149 243 }, 150 244 { ··· 174 268 } 175 269 } 176 270 271 + func TestMissingFileDiagnosticSurfacesErrno(t *testing.T) { 272 + _, stderr, err := run(t, []string{"nope.txt"}, "", newFS(t)) 273 + if err == nil { 274 + t.Fatalf("expected error, got nil; stderr=%q", stderr) 275 + } 276 + if !strings.HasPrefix(stderr, "wc: nope.txt: ") { 277 + t.Errorf("stderr should start with %q, got %q", "wc: nope.txt: ", stderr) 278 + } 279 + if strings.Contains(stderr, "No such file or directory") { 280 + t.Errorf("stderr should not hardcode 'No such file or directory'; got %q", stderr) 281 + } 282 + suffix := strings.TrimPrefix(strings.TrimRight(stderr, "\n"), "wc: nope.txt: ") 283 + if suffix == "" { 284 + t.Errorf("expected an underlying error message after %q, got %q", "wc: nope.txt: ", stderr) 285 + } 286 + } 287 + 177 288 func TestHelp(t *testing.T) { 178 289 stdout, stderr, err := run(t, []string{"--help"}, "", newFS(t)) 179 290 if err != nil { ··· 205 316 input string 206 317 want stats 207 318 }{ 208 - {"empty", "", stats{lines: 0, words: 0, chars: 0}}, 209 - {"single word no newline", "hello", stats{lines: 0, words: 1, chars: 5}}, 210 - {"single line with newline", "hello\n", stats{lines: 1, words: 1, chars: 6}}, 211 - {"multiple words", "a b c", stats{lines: 0, words: 3, chars: 5}}, 212 - {"tab separated", "a\tb\tc", stats{lines: 0, words: 3, chars: 5}}, 213 - {"mixed whitespace", " a b ", stats{lines: 0, words: 2, chars: 8}}, 214 - {"three lines", "one\ntwo\nthree\n", stats{lines: 3, words: 3, chars: 14}}, 215 - {"only newlines", "\n\n\n", stats{lines: 3, words: 0, chars: 3}}, 319 + {"empty", "", stats{}}, 320 + {"single word no newline", "hello", stats{lines: 0, words: 1, chars: 5, bytes: 5, maxLine: 5}}, 321 + {"single line with newline", "hello\n", stats{lines: 1, words: 1, chars: 6, bytes: 6, maxLine: 5}}, 322 + {"multiple words", "a b c", stats{lines: 0, words: 3, chars: 5, bytes: 5, maxLine: 5}}, 323 + {"tab separated", "a\tb\tc", stats{lines: 0, words: 3, chars: 5, bytes: 5, maxLine: 17}}, 324 + {"mixed whitespace", " a b ", stats{lines: 0, words: 2, chars: 8, bytes: 8, maxLine: 8}}, 325 + {"three lines", "one\ntwo\nthree\n", stats{lines: 3, words: 3, chars: 14, bytes: 14, maxLine: 5}}, 326 + {"only newlines", "\n\n\n", stats{lines: 3, words: 0, chars: 3, bytes: 3, maxLine: 0}}, 327 + {"multibyte chars vs bytes", "λλλ", stats{lines: 0, words: 1, chars: 3, bytes: 6, maxLine: 3}}, 328 + {"nbsp word separator", "a b", stats{lines: 0, words: 2, chars: 3, bytes: 4, maxLine: 3}}, 329 + {"cr resets column", "aa\rbbbbb\n", stats{lines: 1, words: 2, chars: 9, bytes: 9, maxLine: 5}}, 330 + {"east asian wide width", "日本\n", stats{lines: 1, words: 1, chars: 3, bytes: 7, maxLine: 4}}, 331 + {"tab to column 9 with letter", "a\tb\n", stats{lines: 1, words: 2, chars: 4, bytes: 4, maxLine: 9}}, 216 332 } 217 333 for _, tt := range tests { 218 334 t.Run(tt.name, func(t *testing.T) {