File: ./config.go
   1 package main
   2 
   3 import (
   4     "math"
   5 )
   6 
   7 const (
   8     // tabstop is the space-count used for tab-expansion
   9     tabstop = 4
  10 
  11     // separator is the string put between adjacent columns
  12     separator = ` █ `
  13 
  14     // maxAutoWidth is the output max-width, chosen to fit very old monitors
  15     maxAutoWidth = 79
  16 )
  17 
  18 // chooseNumColumns implements heuristics to auto-pick the number of columns
  19 // to show: this func is used when the app is using data from standard-input
  20 func chooseNumColumns(lines []string) int {
  21     if len(lines) == 0 {
  22         return 1
  23     }
  24 
  25     // sepw is the separator width
  26     sepw := width(separator)
  27 
  28     // see if lines can even fit a single column
  29     if !columnsCanFit(1, lines, sepw) {
  30         return 1
  31     }
  32 
  33     // starting from the max possible columns which may fit, keep trying
  34     // with 1 fewer column, until the columns fit
  35     for ncols := int(maxAutoWidth / sepw); ncols > 1; ncols-- {
  36         if columnsCanFit(ncols, lines, sepw) {
  37             // success: found the most columns which fit
  38             return ncols
  39         }
  40     }
  41 
  42     // avoid multiple columns if some lines are too wide
  43     return 1
  44 }
  45 
  46 // columnsCanFit checks whether the number of columns given would fit the
  47 // display max-width constant
  48 func columnsCanFit(ncols int, lines []string, gap int) bool {
  49     if ncols < 1 {
  50         // avoid surprises when called with non-sense column counts
  51         return true
  52     }
  53 
  54     // stack-allocate the backing-array behind slice maxw
  55     var buf [maxAutoWidth / 2]int
  56     maxw := buf[:0]
  57 
  58     // find the column max-height, to chunk lines into columns
  59     h := int(math.Ceil(float64(len(lines)) / float64(ncols)))
  60 
  61     // find column max-width by looping over chunks of lines
  62     for len(lines) >= h {
  63         w := findMaxWidth(lines[:h])
  64         maxw = append(maxw, w)
  65         lines = lines[h:]
  66     }
  67 
  68     // don't forget the last column
  69     if len(lines) > 0 {
  70         w := findMaxWidth(lines)
  71         maxw = append(maxw, w)
  72     }
  73 
  74     // remember to add the gaps/separators between columns, along with
  75     // all the individual column max-widths
  76     w := (ncols - 1) * gap
  77     for _, n := range maxw {
  78         w += n
  79     }
  80 
  81     // do the columns fit?
  82     return w <= maxAutoWidth
  83 }
  84 
  85 // findMaxWidth finds the max width in the slice given, ignoring ANSI codes
  86 func findMaxWidth(lines []string) int {
  87     maxw := 0
  88     for _, s := range lines {
  89         w := width(s)
  90         if w > maxw {
  91             maxw = w
  92         }
  93     }
  94     return maxw
  95 }

     File: ./go.mod
   1 module sbs
   2 
   3 go 1.18

     File: ./info.txt
   1 sbs [columns...] [filenames...]
   2 
   3 
   4 Show lines Side By Side: this app is made for content which normally scrolls
   5 a long way downward. You can either just pipe text into it, or give multiple
   6 filenames to read lines from those: a common use-case is to pipe its results
   7 to `less -KiCRS` or some other viewer command.
   8 
   9 When given no filenames, this app reads from stdin; when giving it filenames,
  10 you can use a dash to use stdin along with the files.

     File: ./io.go
   1 package main
   2 
   3 import (
   4     "bufio"
   5     "errors"
   6     "io"
   7     "strings"
   8 )
   9 
  10 // errDoneWriting is a dummy error used to signal the app should quit early
  11 // without showing an actual error
  12 var errDoneWriting = errors.New(`done writing`)
  13 
  14 // padding is a ring-buffer only used by func pad, to minimize calls to Write
  15 var padding = [64]byte{
  16     ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ',
  17     ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ',
  18     ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ',
  19     ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ',
  20     ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ',
  21     ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ',
  22     ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ',
  23     ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ',
  24 }
  25 
  26 // writeSpaces emits the number of spaces given, while minimizing calls
  27 // to Write by reusing a ring-buffer
  28 func writeSpaces(w *bufio.Writer, spaces int) (n int, err error) {
  29     if spaces <= 0 {
  30         return 0, nil
  31     }
  32 
  33     // emit all full-buffer writes
  34     for l := len(padding); spaces >= l; spaces -= l {
  35         m, err := w.Write(padding[:])
  36         n += m
  37 
  38         if err != nil {
  39             return n, err
  40         }
  41     }
  42 
  43     // emit any remainder bytes
  44     if spaces > 0 {
  45         m, err := w.Write(padding[:spaces])
  46         return n + m, err
  47     }
  48     return n, nil
  49 }
  50 
  51 // newScanner standardizes how line-scanners are setup in this app
  52 func newScanner(r io.Reader) *bufio.Scanner {
  53     const maxbufsize = 8 * 1024 * 1024 * 1024
  54     sc := bufio.NewScanner(r)
  55     sc.Buffer(nil, maxbufsize)
  56     return sc
  57 }
  58 
  59 // padWrite emits the string given, following it with spaces to fill the
  60 // width given if string is shorter than that
  61 func padWrite(w *bufio.Writer, s string, n int) {
  62     w.WriteString(s)
  63     writeSpaces(w, n-width(s))
  64 }
  65 
  66 // writeItem emits the string given, followed by any padding needed, as well
  67 // as ANSI-style clearing, again if needed
  68 func writeItem(w *bufio.Writer, s string, width int) {
  69     padWrite(w, s, width)
  70     if needsStyleReset(s) {
  71         w.WriteString("\x1b[0m")
  72     }
  73 }
  74 
  75 func needsStyleReset(s string) bool {
  76     return strings.Contains(s, "\x1b[") && !strings.HasSuffix(s, "\x1b[0m")
  77 }

     File: ./io_test.go
   1 package main
   2 
   3 import (
   4     "bufio"
   5     "strconv"
   6     "strings"
   7     "testing"
   8 )
   9 
  10 func TestWriteSpaces(t *testing.T) {
  11     spaces := strings.Repeat(` `, 20_000)
  12 
  13     for n := -20; n < len(spaces); n++ {
  14         t.Run(strconv.Itoa(n), func(t *testing.T) {
  15             var sb strings.Builder
  16             w := bufio.NewWriter(&sb)
  17             writeSpaces(w, n)
  18             w.Flush()
  19 
  20             if n < 0 {
  21                 // avoid slicing with negative values
  22                 n = 0
  23             }
  24 
  25             expected := spaces[:n]
  26             got := sb.String()
  27 
  28             if got != expected {
  29                 const fs = `expected %d spaces, but got %d instead`
  30                 t.Fatalf(fs, len(expected), len(got))
  31             }
  32         })
  33     }
  34 }

     File: ./main.go
   1 package main
   2 
   3 import (
   4     "bufio"
   5     "errors"
   6     "io"
   7     "math"
   8     "os"
   9     "strconv"
  10     "strings"
  11 
  12     _ "embed"
  13 )
  14 
  15 // note: the code is avoiding using the fmt package to save hundreds of
  16 // kilobytes on the resulting executable, which is a noticeable difference
  17 
  18 //go:embed info.txt
  19 var usage string
  20 
  21 func main() {
  22     if len(os.Args) > 1 {
  23         switch os.Args[1] {
  24         case `-h`, `--h`, `-help`, `--help`:
  25             os.Stderr.WriteString(usage)
  26             os.Exit(0)
  27         }
  28     }
  29 
  30     err := run(os.Args[1:])
  31     if err != nil && err != errDoneWriting {
  32         os.Stderr.WriteString("\x1b[31m")
  33         os.Stderr.WriteString(err.Error())
  34         os.Stderr.WriteString("\x1b[0m\n")
  35         os.Exit(1)
  36     }
  37 }
  38 
  39 func run(args []string) error {
  40     w := bufio.NewWriterSize(os.Stdout, 64*1024)
  41     defer w.Flush()
  42 
  43     if len(args) == 0 {
  44         // return errors.New(`expected a leading number for the column count`)
  45         args = []string{`0`}
  46     }
  47 
  48     ncols := 0
  49     f, err := strconv.ParseFloat(args[0], 64)
  50     if err != nil || math.IsInf(f, 0) || math.IsNaN(f) {
  51         return errors.New(`first argument isn't a valid number of columns`)
  52     }
  53     ncols = int(f)
  54 
  55     paths := args[1:]
  56     if len(paths) == 0 {
  57         paths = []string{`-`}
  58     }
  59 
  60     lines, err := gatherLines(paths)
  61     if err != nil {
  62         return err
  63     }
  64 
  65     // choose a default number of columns, if not given an explicit one
  66     if ncols < 1 {
  67         ncols = chooseNumColumns(lines)
  68     }
  69 
  70     return handleLines(w, lines, ncols)
  71 }
  72 
  73 // gatherLines slurps all text-lines from all the filepaths given, expanding
  74 // any tabs found; a single dash as a pathname means stdin
  75 func gatherLines(paths []string) ([]string, error) {
  76     var lines []string
  77     var sb strings.Builder
  78 
  79     dashes := 0
  80     for _, s := range paths {
  81         if s == `-` {
  82             dashes++
  83         }
  84     }
  85     if dashes > 1 {
  86         return lines, errors.New(`can't use "-" (stdin) more than once`)
  87     }
  88 
  89     for _, s := range paths {
  90         err := handleNamedInput(s, func(r io.Reader) error {
  91             sc := newScanner(r)
  92 
  93             for sc.Scan() {
  94                 s := sc.Text()
  95                 if strings.Contains(s, "\t") {
  96                     sb.Reset()
  97                     expand(s, tabstop, &sb)
  98                     s = strings.Clone(sb.String())
  99                 }
 100                 lines = append(lines, s)
 101             }
 102             return sc.Err()
 103         })
 104 
 105         if err != nil {
 106             return lines, err
 107         }
 108     }
 109 
 110     return lines, nil
 111 }
 112 
 113 // handleNamedInput makes opening/closing files more convenient by using
 114 // callbacks to handle processing; this func also handles recognizing `-`
 115 // as meaning stdin
 116 func handleNamedInput(path string, fn func(r io.Reader) error) error {
 117     if path == `-` {
 118         return fn(os.Stdin)
 119     }
 120 
 121     f, err := os.Open(path)
 122     if err != nil {
 123         return err
 124     }
 125     defer f.Close()
 126     return fn(f)
 127 }
 128 
 129 // handleLines handles the use-case of showing/rearranging lines from a
 130 // single input source (presumably standard input) into several columns
 131 func handleLines(w *bufio.Writer, lines []string, ncols int) error {
 132     if ncols < 1 {
 133         return nil
 134     }
 135 
 136     if ncols == 1 {
 137         for _, s := range lines {
 138             w.WriteString(s)
 139             err := w.WriteByte('\n')
 140             if err != nil {
 141                 // assume error probably results from a closed stdout
 142                 // pipe, so quit the app right away without complaining
 143                 return err
 144             }
 145         }
 146         return nil
 147     }
 148 
 149     // nothing to show, so don't even bother
 150     if len(lines) == 0 {
 151         return nil
 152     }
 153 
 154     cols, height := splitLines(lines, ncols)
 155     widths := make([]int, 0, len(cols))
 156     for _, c := range cols {
 157         // find the max width of all lines of the current column
 158         maxw := 0
 159         for _, v := range c {
 160             w := width(v)
 161             if w > maxw {
 162                 maxw = w
 163             }
 164         }
 165 
 166         widths = append(widths, maxw)
 167     }
 168 
 169     // endSep is right-trimmed to avoid unneeded trailing spaces on output
 170     // lines whose last column is an empty/missing input line
 171     endSep := strings.TrimRight(separator, ` `)
 172 
 173     // show columns side by side
 174     for r := 0; r < height; r++ {
 175         for c := 0; c < len(cols); c++ {
 176             badr := r >= len(cols[c])
 177 
 178             // clearly separate columns visually
 179             if c > 0 {
 180                 if c == len(cols)-1 && (badr || cols[c][r] == ``) {
 181                     // avoid unneeded trailing spaces
 182                     w.WriteString(endSep)
 183                 } else {
 184                     w.WriteString(separator)
 185                 }
 186             }
 187 
 188             if badr {
 189                 // exceeding items for this (last) column
 190                 continue
 191             }
 192 
 193             // pad all columns, except the last
 194             width := 0
 195             if c < len(cols)-1 {
 196                 width = widths[c]
 197             }
 198 
 199             // emit maybe-padded column
 200             writeItem(w, cols[c][r], width)
 201         }
 202 
 203         // end the line
 204         err := w.WriteByte('\n')
 205         if err != nil {
 206             // probably a pipe was closed
 207             return nil
 208         }
 209     }
 210 
 211     return nil
 212 }

     File: ./strings.go
   1 package main
   2 
   3 import (
   4     "math"
   5     "strings"
   6     "unicode/utf8"
   7 )
   8 
   9 // expand replaces all tabs with correctly-padded tabstops, turning all tabs
  10 // each into 1 or more spaces, as appropriate
  11 func expand(s string, tabstop int, sb *strings.Builder) {
  12     sb.Reset()
  13     numrunes := 0
  14 
  15     for _, r := range s {
  16         switch r {
  17         case '\t':
  18             numspaces := tabstop - numrunes%tabstop
  19             for i := 0; i < numspaces; i++ {
  20                 sb.WriteRune(' ')
  21             }
  22             numrunes += numspaces
  23 
  24         default:
  25             sb.WriteRune(r)
  26             numrunes++
  27         }
  28     }
  29 }
  30 
  31 // width calculates visually-correct string widths
  32 func width(s string) int {
  33     return utf8.RuneCountInString(s) - ansiLength(s)
  34 }
  35 
  36 // ansiLength calculates how many bytes ANSI-codes take in the string given:
  37 // func width uses this to calculate visually-correct string widths
  38 func ansiLength(s string) int {
  39     n := 0
  40     prev := rune(0)
  41     ansi := false
  42     for _, r := range s {
  43         if ansi {
  44             n++
  45         }
  46 
  47         if ansi && r == 'm' {
  48             ansi = false
  49             continue
  50         }
  51 
  52         if prev == '\x1b' && r == '[' {
  53             n += 2 // count the 2-item starter-sequence `\x1b[`
  54             ansi = true
  55         }
  56         prev = r
  57     }
  58     return n
  59 }
  60 
  61 // splitLines turns an array of lines into sub-arrays of lines, so they can
  62 // be shown side by side later on
  63 func splitLines(lines []string, ncols int) (cols [][]string, maxheight int) {
  64     n := ncols
  65     hfrac := float64(len(lines)) / float64(n)
  66     h := int(math.Ceil(hfrac))
  67 
  68     cols = make([][]string, 0, n)
  69     for len(lines) > h {
  70         cols = append(cols, lines[:h])
  71         lines = lines[h:]
  72     }
  73     if len(lines) != 0 {
  74         cols = append(cols, lines)
  75     }
  76     return cols, h
  77 }
  78 
  79 // indexLine handles slice-indexing by returning an empty string when the
  80 // index given is invalid, which helps simplify other funcs' control-flow
  81 // func indexLine(lines []string, i int) (line string, ok bool) {
  82 //  if 0 <= i && i < len(lines) {
  83 //      return lines[i], true
  84 //  }
  85 //  return ``, false
  86 // }

     File: ./strings_test.go
   1 package main
   2 
   3 import (
   4     "strings"
   5     "testing"
   6 )
   7 
   8 func TestExpand(t *testing.T) {
   9     var tests = []struct {
  10         name     string
  11         input    string
  12         tabstop  int
  13         expected string
  14     }{
  15         {`empty`, ``, 4, ``},
  16         {`indent 1`, "\tabc", 4, `    abc`},
  17         {`indent 2`, "\t\tabc", 4, `        abc`},
  18         {`indent 2 (mix tab/space)`, "\t \tabc", 4, `        abc`},
  19     }
  20 
  21     for _, tc := range tests {
  22         t.Run(tc.name, func(t *testing.T) {
  23             var sb strings.Builder
  24             expand(tc.input, tc.tabstop, &sb)
  25 
  26             if got := strings.Clone(sb.String()); got != tc.expected {
  27                 const fs = `input %q, tabstop %d: got %q, instead of %q`
  28                 t.Fatalf(fs, tc.input, tc.tabstop, got, tc.expected)
  29             }
  30         })
  31     }
  32 }
  33 
  34 func TestANSILength(t *testing.T) {
  35     var tests = []struct {
  36         name     string
  37         input    string
  38         expected int
  39     }{
  40         {
  41             name:     `empty`,
  42             input:    ``,
  43             expected: 0,
  44         },
  45         {
  46             name:     `no ansi escapes`,
  47             input:    `abc def`,
  48             expected: 0,
  49         },
  50         {
  51             name:     `simple ansi escapes`,
  52             input:    "\x1b[38;5;120mabc def\x1b[0m",
  53             expected: 15,
  54         },
  55     }
  56 
  57     for _, tc := range tests {
  58         t.Run(tc.name, func(t *testing.T) {
  59             if got := ansiLength(tc.input); got != tc.expected {
  60                 const fs = `input %q: got %d, instead of %d`
  61                 t.Fatalf(fs, tc.input, got, tc.expected)
  62             }
  63         })
  64     }
  65 }