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 }