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 }