File: realign.go
   1 /*
   2 The MIT License (MIT)
   3 
   4 Copyright © 2025 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     if len(os.Args) > 1 {
  55         switch os.Args[1] {
  56         case `-h`, `--h`, `-help`, `--help`:
  57             os.Stderr.WriteString(info[1:])
  58             return
  59         }
  60     }
  61 
  62     args := os.Args[1:]
  63     if len(args) > 0 && args[0] == `--` {
  64         args = args[1:]
  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 func run(paths []string) error {
  75     var res table
  76 
  77     for _, p := range paths {
  78         if err := handleFile(&res, p); err != nil {
  79             return err
  80         }
  81     }
  82 
  83     if len(paths) == 0 {
  84         if err := handleReader(&res, os.Stdin); err != nil {
  85             return err
  86         }
  87     }
  88 
  89     bw := bufio.NewWriter(os.Stdout)
  90     defer bw.Flush()
  91     realign(bw, res)
  92     return nil
  93 }
  94 
  95 func handleFile(res *table, path string) error {
  96     f, err := os.Open(path)
  97     if err != nil {
  98         // on windows, file-not-found error messages may mention `CreateFile`,
  99         // even when trying to open files in read-only mode
 100         return errors.New(`can't open file named ` + path)
 101     }
 102     defer f.Close()
 103     return handleReader(res, f)
 104 }
 105 
 106 func handleReader(res *table, r io.Reader) error {
 107     const gb = 1024 * 1024 * 1024
 108     sc := bufio.NewScanner(r)
 109     sc.Buffer(nil, 8*gb)
 110 
 111     for sc.Scan() {
 112         res.update(sc.Text())
 113     }
 114     return sc.Err()
 115 }
 116 
 117 // loopItemsSSV loops over a line's items, allocation-free style; when given
 118 // empty strings, the callback func is never called
 119 func loopItemsSSV(s string, max int, f func(i int, s string)) {
 120     for len(s) > 0 && s[len(s)-1] == ' ' {
 121         s = s[:len(s)-1]
 122     }
 123 
 124     for i := 0; true; i++ {
 125         for len(s) > 0 && s[0] == ' ' {
 126             s = s[1:]
 127         }
 128 
 129         if len(s) == 0 {
 130             return
 131         }
 132 
 133         if i+1 == max {
 134             f(i, s)
 135             return
 136         }
 137 
 138         if j := strings.IndexByte(s, ' '); j >= 0 {
 139             f(i, s[:j])
 140             s = s[j+1:]
 141             continue
 142         }
 143 
 144         f(i, s)
 145         return
 146     }
 147 }
 148 
 149 // loopItemsTSV loops over a line's tab-separated items, allocation-free style;
 150 // when given empty strings, the callback func is never called
 151 func loopItemsTSV(s string, max int, f func(i int, s string)) {
 152     if len(s) == 0 {
 153         return
 154     }
 155 
 156     for i := 0; true; i++ {
 157         if i+1 == max {
 158             f(i, s)
 159             return
 160         }
 161 
 162         if j := strings.IndexByte(s, '\t'); j >= 0 {
 163             f(i, s[:j])
 164             s = s[j+1:]
 165             continue
 166         }
 167 
 168         f(i, s)
 169         return
 170     }
 171 }
 172 
 173 func skipSingleLeadingANSI(s string) string {
 174     if len(s) < 3 || s[0] != '\x1b' || s[1] != '[' {
 175         return s
 176     }
 177 
 178     s = s[2:]
 179 
 180     for len(s) > 0 {
 181         b := s[0]
 182         s = s[1:]
 183         if ('A' <= b && b <= 'Z') || ('a' <= b && b <= 'z') {
 184             break
 185         }
 186     }
 187 
 188     return s
 189 }
 190 
 191 func skipLeadingANSI(s string) string {
 192     prev := len(s)
 193 
 194     for len(s) > 0 {
 195         s = skipSingleLeadingANSI(s)
 196         if len(s) == prev {
 197             return s
 198         }
 199         prev = len(s)
 200     }
 201 
 202     return s
 203 }
 204 
 205 // isNumeric checks if a string is a valid/useable float64, which excludes
 206 // NaNs and the infinities
 207 // func isNumeric(s string) bool {
 208 //  f, err := strconv.ParseFloat(s, 64)
 209 //  return err == nil && !math.IsNaN(f) && !math.IsInf(f, 0)
 210 // }
 211 
 212 func isDigits(s string) bool {
 213     if len(s) == 0 {
 214         return false
 215     }
 216 
 217     digits := 0
 218 
 219     for {
 220         s = skipLeadingANSI(s)
 221         if len(s) == 0 {
 222             break
 223         }
 224 
 225         if '0' <= s[0] && s[0] <= '9' {
 226             s = s[1:]
 227             digits++
 228         } else {
 229             return false
 230         }
 231     }
 232 
 233     s = skipLeadingANSI(s)
 234     return len(s) == 0 && digits > 0
 235 }
 236 
 237 // isNumeric checks if a string is valid/useable as a number
 238 func isNumeric(s string) bool {
 239     if len(s) == 0 {
 240         return false
 241     }
 242 
 243     s = skipLeadingANSI(s)
 244     if len(s) > 0 && (s[0] == '+' || s[0] == '-') {
 245         s = s[1:]
 246     }
 247 
 248     s = skipLeadingANSI(s)
 249     if len(s) == 0 {
 250         return false
 251     }
 252     if s[0] == '.' {
 253         return isDigits(s[1:])
 254     }
 255 
 256     digits := 0
 257 
 258     for {
 259         s = skipLeadingANSI(s)
 260         if len(s) == 0 {
 261             break
 262         }
 263 
 264         if s[0] == '.' {
 265             return isDigits(s[1:])
 266         }
 267 
 268         if !('0' <= s[0] && s[0] <= '9') {
 269             return false
 270         }
 271 
 272         digits++
 273         s = s[1:]
 274     }
 275 
 276     s = skipLeadingANSI(s)
 277     return len(s) == 0 && digits > 0
 278 }
 279 
 280 // // countDecimals counts decimal digits from the string given, assuming it
 281 // // represents a valid/useable float64, when parsed
 282 // func countDecimals(s string) int {
 283 //  if dot := strings.IndexByte(s, '.'); dot >= 0 {
 284 //      return len(s) - dot - 1
 285 //  }
 286 //  return 0
 287 // }
 288 
 289 // countDecimals counts decimal digits from the string given, assuming it
 290 // represents a valid/useable float64, when parsed
 291 func countDecimals(s string) int {
 292     dot := strings.IndexByte(s, '.')
 293     if dot < 0 {
 294         return 0
 295     }
 296 
 297     decs := 0
 298     s = s[dot+1:]
 299 
 300     for len(s) > 0 {
 301         s = skipLeadingANSI(s)
 302         if len(s) == 0 {
 303             break
 304         }
 305         if '0' <= s[0] && s[0] <= '9' {
 306             decs++
 307         }
 308         s = s[1:]
 309     }
 310 
 311     return decs
 312 }
 313 
 314 // countDotDecimals is like func countDecimals, but this one also includes
 315 // the dot, when any decimals are present, else the count stays at 0
 316 func countDotDecimals(s string) int {
 317     decs := countDecimals(s)
 318     if decs > 0 {
 319         return decs + 1
 320     }
 321     return decs
 322 }
 323 
 324 // func countWidth(s string) int{
 325 //  return utf8.RuneCountInString(s)
 326 // }
 327 
 328 func countWidth(s string) int {
 329     c := 0
 330     for len(s) > 0 {
 331         i := strings.Index(s, "\x1b[")
 332         if i < 0 {
 333             c += utf8.RuneCountInString(s)
 334             return c
 335         }
 336 
 337         c += utf8.RuneCountInString(s[:i])
 338         s = s[i+2:]
 339 
 340         for len(s) > 0 {
 341             b := s[0]
 342             s = s[1:]
 343             if ('A' <= b && b <= 'Z') || ('a' <= b && b <= 'z') {
 344                 break
 345             }
 346         }
 347     }
 348 
 349     return c
 350 }
 351 
 352 // table has all summary info gathered from the data, along with the row
 353 // themselves, stored as lines/strings
 354 type table struct {
 355     Columns int
 356 
 357     Rows []string
 358 
 359     MaxWidth []int
 360 
 361     MaxDotDecimals []int
 362 
 363     LoopItems func(line string, items int, f func(i int, s string))
 364 }
 365 
 366 func (t *table) update(line string) {
 367     if len(line) == 0 {
 368         return
 369     }
 370 
 371     t.Rows = append(t.Rows, line)
 372 
 373     if t.LoopItems == nil {
 374         if strings.ContainsRune(line, '\t') {
 375             t.LoopItems = loopItemsTSV
 376         } else {
 377             t.LoopItems = loopItemsSSV
 378         }
 379         t.LoopItems(line, t.Columns, func(i int, s string) {
 380             t.Columns++
 381         })
 382     }
 383 
 384     t.LoopItems(line, t.Columns, func(i int, s string) {
 385         // ensure column-info-slices have enough room
 386         if i >= len(t.MaxWidth) {
 387             t.MaxWidth = append(t.MaxWidth, 0)
 388             t.MaxDotDecimals = append(t.MaxDotDecimals, 0)
 389         }
 390 
 391         // keep track of widest rune-counts for each column
 392         w := countWidth(s)
 393         if t.MaxWidth[i] < w {
 394             t.MaxWidth[i] = w
 395         }
 396 
 397         // update stats for numeric items
 398         if isNumeric(s) {
 399             dd := countDotDecimals(s)
 400             if t.MaxDotDecimals[i] < dd {
 401                 t.MaxDotDecimals[i] = dd
 402             }
 403         }
 404     })
 405 }
 406 
 407 func realign(w *bufio.Writer, t table) {
 408     for _, line := range t.Rows {
 409         due := 0
 410 
 411         t.LoopItems(line, t.Columns, func(i int, s string) {
 412             if i > 0 {
 413                 due += 2
 414             }
 415 
 416             if isNumeric(s) {
 417                 dd := countDotDecimals(s)
 418                 rpad := t.MaxDotDecimals[i] - dd
 419                 width := countWidth(s)
 420                 lpad := t.MaxWidth[i] - (width + rpad) + due
 421                 writeSpaces(w, lpad)
 422                 w.WriteString(s)
 423                 due = rpad
 424                 return
 425             }
 426 
 427             writeSpaces(w, due)
 428             w.WriteString(s)
 429             due = t.MaxWidth[i] - countWidth(s)
 430         })
 431 
 432         if w.WriteByte('\n') != nil {
 433             return
 434         }
 435     }
 436 }
 437 
 438 // writeSpaces does what it says, minimizing calls to write-like funcs
 439 func writeSpaces(w *bufio.Writer, n int) {
 440     const spaces = `                                `
 441     if n < 1 {
 442         return
 443     }
 444 
 445     for n >= len(spaces) {
 446         w.WriteString(spaces)
 447         n -= len(spaces)
 448     }
 449     w.WriteString(spaces[:n])
 450 }