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.

fix(fold): reset column on CR and use east-asian rune width

Implements GNU coreutils compatibility for fold:

- Carriage return (\r) resets the column counter to 0
rather than acting as backspace-like decrement
- Tab stops at columns 1, 9, 17, ... (every 8) with
correct re-computation after a fold
- East Asian Wide and Fullwidth runes count as two
columns via golang.org/x/text/width

Retains -b (bytes), -s (break at spaces), -w (width).

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

Xe Iaso 179ee5b4 7b206c72

+152 -24
+62 -24
command/internal/fold/fold.go
··· 11 11 "unicode/utf8" 12 12 13 13 "github.com/pborman/getopt/v2" 14 + "golang.org/x/text/width" 14 15 "mvdan.cc/sh/v3/interp" 15 16 "tangled.org/xeiaso.net/kefka/command" 16 17 ) ··· 61 62 return nil 62 63 } 63 64 64 - width, err := strconv.Atoi(*widthSpec) 65 - if err != nil || width < 1 { 65 + maxWidth, err := strconv.Atoi(*widthSpec) 66 + if err != nil || maxWidth < 1 { 66 67 fmt.Fprintf(stderr, "fold: invalid number of columns: '%s'\n", *widthSpec) 67 68 return interp.ExitStatus(1) 68 69 } ··· 76 77 if err != nil { 77 78 return err 78 79 } 79 - output.WriteString(processContent(content, width, *breakAtSpaces, *countBytes)) 80 + output.WriteString(processContent(content, maxWidth, *breakAtSpaces, *countBytes)) 80 81 } else { 81 82 for _, file := range files { 82 83 content, err := readFile(ec, file, stderr) ··· 84 85 io.WriteString(stdout, output.String()) 85 86 return err 86 87 } 87 - output.WriteString(processContent(content, width, *breakAtSpaces, *countBytes)) 88 + output.WriteString(processContent(content, maxWidth, *breakAtSpaces, *countBytes)) 88 89 } 89 90 } 90 91 ··· 92 93 return execErr 93 94 } 94 95 95 - func processContent(content string, width int, breakAtSpaces, countBytes bool) string { 96 + func processContent(content string, maxWidth int, breakAtSpaces, countBytes bool) string { 96 97 if content == "" { 97 98 return "" 98 99 } ··· 106 107 if i > 0 { 107 108 out.WriteByte('\n') 108 109 } 109 - out.WriteString(foldLine(line, width, breakAtSpaces, countBytes)) 110 + out.WriteString(foldLine(line, maxWidth, breakAtSpaces, countBytes)) 110 111 } 111 112 if hasTrailingNewline { 112 113 out.WriteByte('\n') ··· 114 115 return out.String() 115 116 } 116 117 117 - func foldLine(line string, width int, breakAtSpaces, countBytes bool) string { 118 + func runeWidth(r rune) int { 119 + switch width.LookupRune(r).Kind() { 120 + case width.EastAsianWide, width.EastAsianFullwidth: 121 + return 2 122 + } 123 + return 1 124 + } 125 + 126 + func foldLine(line string, maxWidth int, breakAtSpaces, countBytes bool) string { 118 127 if line == "" { 119 128 return line 120 129 } ··· 127 136 lastSpaceCol int 128 137 ) 129 138 130 - emit := func(charWidth int, isSpace bool, ch []byte) { 131 - if currentColumn+charWidth > width && len(currentLine) > 0 { 139 + flush := func() { 140 + result = append(result, string(currentLine)) 141 + currentLine = currentLine[:0] 142 + currentColumn = 0 143 + lastSpace = -1 144 + lastSpaceCol = 0 145 + } 146 + 147 + // emit appends ch to the current segment, folding first if needed. 148 + // isTab=true means charWidth is recomputed against the current column 149 + // after any fold so tab stops always land on multiples of 8. 150 + emit := func(charWidth int, isSpace, isTab bool, ch []byte) { 151 + if currentColumn+charWidth > maxWidth && len(currentLine) > 0 { 132 152 if breakAtSpaces && lastSpace >= 0 { 133 - result = append(result, string(currentLine[:lastSpace+1])) 153 + head := string(currentLine[:lastSpace+1]) 134 154 rest := append([]byte(nil), currentLine[lastSpace+1:]...) 155 + colAfterSpace := lastSpaceCol + 1 156 + result = append(result, head) 135 157 currentLine = append(rest, ch...) 136 - currentColumn = currentColumn - lastSpaceCol - 1 + charWidth 158 + if isTab { 159 + colInRest := currentColumn - colAfterSpace 160 + charWidth = 8 - (colInRest % 8) 161 + currentColumn = colInRest + charWidth 162 + } else { 163 + currentColumn = currentColumn - colAfterSpace + charWidth 164 + } 137 165 lastSpace = -1 138 166 lastSpaceCol = 0 139 167 return 140 168 } 141 - result = append(result, string(currentLine)) 142 - currentLine = append(currentLine[:0:0], ch...) 143 - currentColumn = charWidth 144 - lastSpace = -1 145 - lastSpaceCol = 0 146 - return 169 + flush() 170 + if isTab { 171 + charWidth = 8 - (currentColumn % 8) 172 + } 147 173 } 148 174 preLen := len(currentLine) 149 175 currentLine = append(currentLine, ch...) ··· 157 183 if countBytes { 158 184 for i := 0; i < len(line); i++ { 159 185 b := line[i] 160 - emit(1, b == ' ' || b == '\t', []byte{b}) 186 + emit(1, b == ' ' || b == '\t', false, []byte{b}) 161 187 } 162 188 } else { 163 189 var buf [utf8.UTFMax]byte 164 190 for _, r := range line { 165 - charWidth := 1 191 + n := utf8.EncodeRune(buf[:], r) 192 + ch := buf[:n] 166 193 switch r { 194 + case '\r': 195 + currentLine = append(currentLine, ch...) 196 + currentColumn = 0 197 + lastSpace = -1 198 + lastSpaceCol = 0 199 + continue 200 + case '\b': 201 + currentLine = append(currentLine, ch...) 202 + if currentColumn > 0 { 203 + currentColumn-- 204 + } 205 + continue 167 206 case '\t': 168 - charWidth = 8 - (currentColumn % 8) 169 - case '\b': 170 - charWidth = -1 207 + advance := 8 - (currentColumn % 8) 208 + emit(advance, true, true, ch) 209 + continue 171 210 } 172 - n := utf8.EncodeRune(buf[:], r) 173 - emit(charWidth, r == ' ' || r == '\t', buf[:n]) 211 + emit(runeWidth(r), r == ' ', false, ch) 174 212 } 175 213 } 176 214
+90
command/internal/fold/fold_test.go
··· 183 183 args: []string{"--no-such-flag"}, 184 184 wantErr: true, 185 185 }, 186 + { 187 + name: "carriage return resets column", 188 + args: []string{"-w", "5"}, 189 + stdin: "a\rb", 190 + wantStdout: "a\rb", 191 + }, 192 + { 193 + name: "carriage return after long run resets column", 194 + args: []string{"-w", "3"}, 195 + stdin: "abcd\rxy", 196 + wantStdout: "abc\nd\rxy", 197 + }, 198 + { 199 + name: "tab at column zero exceeding width folds immediately", 200 + args: []string{"-w", "4"}, 201 + stdin: "\tab", 202 + wantStdout: "\t\nab", 203 + }, 204 + { 205 + name: "narrow runes count as one column each", 206 + args: []string{"-w", "3"}, 207 + stdin: "λλλ", 208 + wantStdout: "λλλ", 209 + }, 210 + { 211 + name: "east asian wide runes count as two columns", 212 + args: []string{"-w", "3"}, 213 + stdin: "中中中", 214 + wantStdout: "中\n中\n中", 215 + }, 216 + { 217 + name: "east asian wide rune fits exactly", 218 + args: []string{"-w", "4"}, 219 + stdin: "中中", 220 + wantStdout: "中中", 221 + }, 222 + { 223 + name: "backspace decrements column", 224 + args: []string{"-w", "2"}, 225 + stdin: "a\bbc", 226 + wantStdout: "a\bbc", 227 + }, 228 + { 229 + name: "byte mode treats wide rune bytes individually", 230 + args: []string{"-b", "-w", "3"}, 231 + stdin: "中中", 232 + wantStdout: "中\n中", 233 + }, 234 + { 235 + // After a fold, the tab's advance is recomputed against 236 + // column 0 of the new segment. With width 5, a tab from 237 + // column 0 still wants column 8, so it fills its own 238 + // segment before the next char wraps again. 239 + name: "tab after fold recomputes width from new segment", 240 + args: []string{"-w", "5"}, 241 + stdin: "aaa\tb", 242 + wantStdout: "aaa\n\t\nb", 243 + }, 244 + { 245 + // With width 9 the tab should land on column 8, then fit 246 + // b at column 9 without folding. This regression-checks 247 + // that the tab's advance is correct when no fold occurs. 248 + name: "tab fits within width without fold", 249 + args: []string{"-w", "9"}, 250 + stdin: "aaa\tb", 251 + wantStdout: "aaa\tb", 252 + }, 253 + { 254 + name: "carriage return at end of line keeps column zero", 255 + args: []string{"-w", "3"}, 256 + stdin: "abc\rxyz", 257 + wantStdout: "abc\rxyz", 258 + }, 259 + { 260 + // Backspace decrements the column, so col 0 + wide(2) = 2 261 + // fits within width 2 without folding. 262 + name: "backspace before wide char that fits stays on line", 263 + args: []string{"-w", "2"}, 264 + stdin: "a\b中", 265 + wantStdout: "a\b中", 266 + }, 267 + { 268 + // Wide char after backspace exceeds width and triggers a 269 + // fold, exercising the "unless the following character has 270 + // a width greater than 1" rule. 271 + name: "wide char after backspace folds when too wide", 272 + args: []string{"-w", "1"}, 273 + stdin: "a\b中", 274 + wantStdout: "a\b\n中", 275 + }, 186 276 } 187 277 188 278 for _, tt := range tests {