File: realign.go 1 /* 2 The MIT License (MIT) 3 4 Copyright (c) 2026 pacman64 5 6 Permission is hereby granted, free of charge, to any person obtaining a copy of 7 this software and associated documentation files (the "Software"), to deal 8 in the Software without restriction, including without limitation the rights to 9 use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 10 of the Software, and to permit persons to whom the Software is furnished to do 11 so, subject to the following conditions: 12 13 The above copyright notice and this permission notice shall be included in all 14 copies or substantial portions of the Software. 15 16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 SOFTWARE. 23 */ 24 25 /* 26 To compile a smaller-sized command-line app, you can use the `go` command as 27 follows: 28 29 go build -ldflags "-s -w" -trimpath realign.go 30 */ 31 32 package main 33 34 import ( 35 "bufio" 36 "errors" 37 "io" 38 "os" 39 "strings" 40 "unicode/utf8" 41 ) 42 43 const info = ` 44 realign [options...] [filenames...] 45 46 Realign all detected columns, right-aligning any detected numbers in any 47 column. ANSI style-codes are also kept as given. 48 49 The only option available is to show this help message, using any of 50 "-h", "--h", "-help", or "--help", without the quotes. 51 ` 52 53 func main() { 54 args := os.Args[1:] 55 56 if len(args) > 0 { 57 switch args[0] { 58 case `-h`, `--h`, `-help`, `--help`: 59 os.Stdout.WriteString(info[1:]) 60 return 61 62 case `--`: 63 args = args[1:] 64 } 65 } 66 67 if err := run(args); err != nil { 68 os.Stderr.WriteString(err.Error()) 69 os.Stderr.WriteString("\n") 70 os.Exit(1) 71 } 72 } 73 74 // table has all summary info gathered from the data, along with the row 75 // themselves, stored as lines/strings 76 type table struct { 77 Columns int 78 79 Rows []string 80 81 MaxWidth []int 82 83 MaxDotDecimals []int 84 85 LoopItems func(s string, max int, t *table, f itemFunc) 86 } 87 88 type itemFunc func(i int, s string, t *table) 89 90 func run(paths []string) error { 91 var res table 92 93 for _, p := range paths { 94 if err := handleFile(&res, p); err != nil { 95 return err 96 } 97 } 98 99 if len(paths) == 0 { 100 if err := handleReader(&res, os.Stdin); err != nil { 101 return err 102 } 103 } 104 105 bw := bufio.NewWriterSize(os.Stdout, 32*1024) 106 defer bw.Flush() 107 realign(bw, res) 108 return nil 109 } 110 111 func handleFile(res *table, path string) error { 112 f, err := os.Open(path) 113 if err != nil { 114 // on windows, file-not-found error messages may mention `CreateFile`, 115 // even when trying to open files in read-only mode 116 return errors.New(`can't open file named ` + path) 117 } 118 defer f.Close() 119 return handleReader(res, f) 120 } 121 122 func handleReader(t *table, r io.Reader) error { 123 const gb = 1024 * 1024 * 1024 124 sc := bufio.NewScanner(r) 125 sc.Buffer(nil, 8*gb) 126 127 for i := 0; sc.Scan(); i++ { 128 s := sc.Text() 129 if i == 0 && len(s) > 2 && s[0] == 0xef && s[1] == 0xbb && s[2] == 0xbf { 130 s = s[3:] 131 } 132 133 if len(s) == 0 { 134 if len(t.Rows) > 0 { 135 t.Rows = append(t.Rows, ``) 136 } 137 continue 138 } 139 140 t.Rows = append(t.Rows, s) 141 142 if t.Columns == 0 { 143 if t.LoopItems == nil { 144 if strings.IndexByte(s, '\t') >= 0 { 145 t.LoopItems = loopItemsTSV 146 } else { 147 t.LoopItems = loopItemsSSV 148 } 149 } 150 151 const maxInt = int(^uint(0) >> 1) 152 t.LoopItems(s, maxInt, t, updateColumnCount) 153 } 154 155 t.LoopItems(s, t.Columns, t, updateItem) 156 } 157 158 return sc.Err() 159 } 160 161 func updateColumnCount(i int, s string, t *table) { 162 t.Columns = i + 1 163 } 164 165 func updateItem(i int, s string, t *table) { 166 // ensure column-info-slices have enough room 167 if i >= len(t.MaxWidth) { 168 t.MaxWidth = append(t.MaxWidth, 0) 169 t.MaxDotDecimals = append(t.MaxDotDecimals, 0) 170 } 171 172 // keep track of widest rune-counts for each column 173 w := countWidth(s) 174 if t.MaxWidth[i] < w { 175 t.MaxWidth[i] = w 176 } 177 178 // update stats for numeric items 179 if isNumeric(s) { 180 dd := countDotDecimals(s) 181 if t.MaxDotDecimals[i] < dd { 182 t.MaxDotDecimals[i] = dd 183 } 184 } 185 } 186 187 // loopItemsSSV loops over a line's items, allocation-free style; when given 188 // empty strings, the callback func is never called 189 func loopItemsSSV(s string, max int, t *table, f itemFunc) { 190 s = trimTrailingSpaces(s) 191 192 for i := 0; true; i++ { 193 s = trimLeadingSpaces(s) 194 if len(s) == 0 { 195 return 196 } 197 198 if i+1 == max { 199 f(i, s, t) 200 return 201 } 202 203 j := strings.IndexByte(s, ' ') 204 if j < 0 { 205 f(i, s, t) 206 return 207 } 208 209 f(i, s[:j], t) 210 s = s[j+1:] 211 } 212 } 213 214 func trimLeadingSpaces(s string) string { 215 for len(s) > 0 && s[0] == ' ' { 216 s = s[1:] 217 } 218 return s 219 } 220 221 func trimTrailingSpaces(s string) string { 222 for len(s) > 0 && s[len(s)-1] == ' ' { 223 s = s[:len(s)-1] 224 } 225 return s 226 } 227 228 // loopItemsTSV loops over a line's tab-separated items, allocation-free style; 229 // when given empty strings, the callback func is never called 230 func loopItemsTSV(s string, max int, t *table, f itemFunc) { 231 if len(s) == 0 { 232 return 233 } 234 235 for i := 0; true; i++ { 236 if i+1 == max { 237 f(i, s, t) 238 return 239 } 240 241 j := strings.IndexByte(s, '\t') 242 if j < 0 { 243 f(i, s, t) 244 return 245 } 246 247 f(i, s[:j], t) 248 s = s[j+1:] 249 } 250 } 251 252 func skipLeadingEscapeSequences(s string) string { 253 for len(s) >= 2 { 254 if s[0] != '\x1b' { 255 return s 256 } 257 258 switch s[1] { 259 case '[': 260 s = skipSingleLeadingANSI(s[2:]) 261 262 case ']': 263 if len(s) < 3 || s[2] != '8' { 264 return s 265 } 266 s = skipSingleLeadingOSC(s[3:]) 267 268 default: 269 return s 270 } 271 } 272 273 return s 274 } 275 276 func skipSingleLeadingANSI(s string) string { 277 for len(s) > 0 { 278 upper := s[0] &^ 32 279 s = s[1:] 280 if 'A' <= upper && upper <= 'Z' { 281 break 282 } 283 } 284 285 return s 286 } 287 288 func skipSingleLeadingOSC(s string) string { 289 var prev byte 290 291 for len(s) > 0 { 292 b := s[0] 293 s = s[1:] 294 if prev == '\x1b' && b == '\\' { 295 break 296 } 297 prev = b 298 } 299 300 return s 301 } 302 303 // isNumeric checks if a string is valid/useable as a number 304 func isNumeric(s string) bool { 305 if len(s) == 0 { 306 return false 307 } 308 309 s = skipLeadingEscapeSequences(s) 310 if len(s) > 0 && (s[0] == '+' || s[0] == '-') { 311 s = s[1:] 312 } 313 314 s = skipLeadingEscapeSequences(s) 315 if len(s) == 0 { 316 return false 317 } 318 if s[0] == '.' { 319 return isDigits(s[1:]) 320 } 321 322 digits := 0 323 324 for { 325 s = skipLeadingEscapeSequences(s) 326 if len(s) == 0 { 327 break 328 } 329 330 if s[0] == '.' { 331 return isDigits(s[1:]) 332 } 333 334 if !('0' <= s[0] && s[0] <= '9') { 335 return false 336 } 337 338 digits++ 339 s = s[1:] 340 } 341 342 s = skipLeadingEscapeSequences(s) 343 return len(s) == 0 && digits > 0 344 } 345 346 func isDigits(s string) bool { 347 if len(s) == 0 { 348 return false 349 } 350 351 digits := 0 352 353 for { 354 s = skipLeadingEscapeSequences(s) 355 if len(s) == 0 { 356 break 357 } 358 359 if '0' <= s[0] && s[0] <= '9' { 360 s = s[1:] 361 digits++ 362 } else { 363 return false 364 } 365 } 366 367 s = skipLeadingEscapeSequences(s) 368 return len(s) == 0 && digits > 0 369 } 370 371 // countDecimals counts decimal digits from the string given, assuming it 372 // represents a valid/useable float64, when parsed 373 func countDecimals(s string) int { 374 dot := strings.IndexByte(s, '.') 375 if dot < 0 { 376 return 0 377 } 378 379 decs := 0 380 s = s[dot+1:] 381 382 for len(s) > 0 { 383 s = skipLeadingEscapeSequences(s) 384 if len(s) == 0 { 385 break 386 } 387 if '0' <= s[0] && s[0] <= '9' { 388 decs++ 389 } 390 s = s[1:] 391 } 392 393 return decs 394 } 395 396 // countDotDecimals is like func countDecimals, but this one also includes 397 // the dot, when any decimals are present, else the count stays at 0 398 func countDotDecimals(s string) int { 399 decs := countDecimals(s) 400 if decs > 0 { 401 return decs + 1 402 } 403 return decs 404 } 405 406 func countWidth(s string) int { 407 width := 0 408 409 for len(s) > 0 { 410 i := indexStartANSI(s) 411 if i < 0 { 412 width += utf8.RuneCountInString(s) 413 return width 414 } 415 416 width += utf8.RuneCountInString(s[:i]) 417 418 for len(s) > 0 { 419 upper := s[0] &^ 32 420 s = s[1:] 421 if 'A' <= upper && upper <= 'Z' { 422 break 423 } 424 } 425 } 426 427 return width 428 } 429 430 func indexStartANSI(s string) int { 431 var prev byte 432 433 for i := range s { 434 b := s[i] 435 if prev == '\x1b' && b == '[' { 436 return i - 1 437 } 438 prev = b 439 } 440 441 return -1 442 } 443 444 func realign(w *bufio.Writer, t table) { 445 due := 0 446 showItem := func(i int, s string, t *table) { 447 if i > 0 { 448 due += 2 449 } 450 451 if isNumeric(s) { 452 dd := countDotDecimals(s) 453 rpad := t.MaxDotDecimals[i] - dd 454 width := countWidth(s) 455 lpad := t.MaxWidth[i] - (width + rpad) + due 456 writeSpaces(w, lpad) 457 w.WriteString(s) 458 due = rpad 459 return 460 } 461 462 writeSpaces(w, due) 463 w.WriteString(s) 464 due = t.MaxWidth[i] - countWidth(s) 465 } 466 467 for _, line := range t.Rows { 468 due = 0 469 if len(line) > 0 { 470 t.LoopItems(line, t.Columns, &t, showItem) 471 } 472 if w.WriteByte('\n') != nil { 473 break 474 } 475 } 476 } 477 478 // writeSpaces does what it says, minimizing calls to write-like funcs 479 func writeSpaces(w *bufio.Writer, n int) { 480 const spaces = ` ` 481 if n < 1 { 482 return 483 } 484 485 for n >= len(spaces) { 486 w.WriteString(spaces) 487 n -= len(spaces) 488 } 489 w.WriteString(spaces[:n]) 490 }