File: sbs/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: sbs/go.mod 1 module sbs 2 3 go 1.18 File: sbs/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: sbs/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: sbs/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: sbs/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, 16*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: sbs/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: sbs/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 }