diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cde47a7 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +# +# no need to modify anything below +tool = tablizer +version = $(shell egrep "^var version = " cmd/root.go | cut -d'=' -f2 | cut -d'"' -f 2) +archs = android darwin freebsd linux netbsd openbsd windows + +all: + @echo "Type 'make install' to install $(tool)" + +install: + install -m 755 -d $(bindir) + install -m 755 -d $(linkdir) + install -m 755 $(tool) $(bindir)/$(tool)-$(version) + ln -sf $(bindir)/$(tool)-$(version) $(linkdir)/$(tool) + +release: + mkdir -p releases + $(foreach arch,$(archs), GOOS=$(arch) GOARCH=amd64 go build -x -o releases/$(tool)-$(arch)-amd64-$(version); sha256sum releases/$(tool)-$(arch)-amd64-$(version) | cut -d' ' -f1 > releases/$(tool)-$(arch)-amd64-$(version).sha256sum;) diff --git a/README.md b/README.md index 9dffe8d..a661a8d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,93 @@ -# tablizer -Manipulate tabular output of other programs +## tablizer - Manipulate tabular output of other programs + +Tablizer can be used to re-format tabular output of other +programs. While you could do this using standard unix tools, in some +cases it's a hard job. + +Let's take this output: +``` +% kubectl get pods -o wide +NAME READY STATUS RESTARTS AGE +repldepl-7bcd8d5b64-7zq4l 1/1 Running 1 (69m ago) 5h26m +repldepl-7bcd8d5b64-m48n8 1/1 Running 1 (69m ago) 5h26m +repldepl-7bcd8d5b64-q2bf4 1/1 Running 1 (69m ago) 5h26m +``` + +But you're only interested in the NAME and STATUS columns. Here's how +to do this with tablizer: + +``` +% kubectl get pods | ./tablizer +NAME(1) READY(2) STATUS(3) RESTARTS(4) AGE(5) +repldepl-7bcd8d5b64-7zq4l 1/1 Running 1 (69m ago) 5h26m +repldepl-7bcd8d5b64-m48n8 1/1 Running 1 (69m ago) 5h26m +repldepl-7bcd8d5b64-q2bf4 1/1 Running 1 (69m ago) 5h26m + +% kubectl get pods | ./tablizer -c 1,3 +NAME(1) STATUS(3) +repldepl-7bcd8d5b64-7zq4l Running +repldepl-7bcd8d5b64-m48n8 Running +repldepl-7bcd8d5b64-q2bf4 Running +``` + +Another use case is when the tabular output is so wide that lines are +being broken and the whole output is completely distorted. In such a +case you can use the `-x` flag to get an output similar to `\x` in `psql`: + +``` +% kubectl get pods | ./tablizer -x + NAME: repldepl-7bcd8d5b64-7zq4l + READY: 1/1 + STATUS: Running +RESTARTS: 1 (71m ago) + AGE: 5h28m + + NAME: repldepl-7bcd8d5b64-m48n8 + READY: 1/1 + STATUS: Running +RESTARTS: 1 (71m ago) + AGE: 5h28m + + NAME: repldepl-7bcd8d5b64-q2bf4 + READY: 1/1 + STATUS: Running +RESTARTS: 1 (71m ago) + AGE: 5h28m +``` + +Tablize can read one or more files or - if none specified - from STDIN. + +You can also specify a regex pattern to reduce the output: + +``` +% kubectl get pods | ./tablizer q2bf4 +NAME(1) READY(2) STATUS(3) RESTARTS(4) AGE(5) +repldepl-7bcd8d5b64-q2bf4 1/1 Running 1 (69m ago) 5h26m +``` + + +## Installation + +Download the latest release file for your architecture and put it into +a directory within your `$PATH`. + +## Getting help + +Although I'm happy to hear from udpxd users in private email, +that's the best way for me to forget to do something. + +In order to report a bug, unexpected behavior, feature requests +or to submit a patch, please open an issue on github: +https://github.com/TLINDEN/tablizer/issues. + +## Copyright and license + +This software is licensed under the GNU GENERAL PUBLIC LICENSE version 3. + +## Authors + +T.v.Dein + +## Project homepage + +https://github.com/TLINDEN/tablizer diff --git a/TODO b/TODO new file mode 100644 index 0000000..1402f14 --- /dev/null +++ b/TODO @@ -0,0 +1,4 @@ +Add a mode like FreeBSD stat(1): + +stat -s dead.letter +st_dev=170671546954750497 st_ino=159667 st_mode=0100644 st_nlink=1 st_uid=1001 st_gid=1001 st_rdev=18446744073709551615 st_size=573 st_atime=1661994007 st_mtime=1661961878 st_ctime=1661961878 st_birthtime=1658394900 st_blksize=4096 st_blocks=3 st_flags=2048 diff --git a/cmd/parser.go b/cmd/parser.go new file mode 100644 index 0000000..0b2d6f0 --- /dev/null +++ b/cmd/parser.go @@ -0,0 +1,152 @@ +package cmd + +import ( + "bufio" + "fmt" + "io" + "os" + "regexp" + "strings" +) + +// contains a whole parsed table +type Tabdata struct { + maxwidthHeader int // longest header + maxwidthPerCol []int // max width per column + columns int + headerIndices []map[string]int // [ {beg=>0, end=>17}, ... ] + headers []string // [ "ID", "NAME", ...] + entries [][]string +} + +func die(v ...interface{}) { + fmt.Fprintln(os.Stderr, v...) + os.Exit(1) +} + +/* + Parse tabular input. We split the header (first line) by 2 or more + spaces, remember the positions of the header fields. We then split + the data (everything after the first line) by those positions. That + way we can turn "tabular data" (with fields containing whitespaces) + into real tabular data. We re-tabulate our input if you will. +*/ +func parseFile(input io.Reader, pattern string) Tabdata { + data := Tabdata{} + + var scanner *bufio.Scanner + var spaces = `\s\s+|$` + + if len(Separator) > 0 { + spaces = Separator + } + + hadFirst := false + spacefinder := regexp.MustCompile(spaces) + beg := 0 + + scanner = bufio.NewScanner(input) + + for scanner.Scan() { + line := scanner.Text() + values := []string{} + + patternR, err := regexp.Compile(pattern) + if err != nil { + die(err) + } + + if !hadFirst { + // header processing + parts := spacefinder.FindAllStringIndex(line, -1) + data.columns = len(parts) + // if Debug { + // fmt.Println(parts) + // } + + // process all header fields + for _, part := range parts { + // if Debug { + // fmt.Printf("Part: <%s>\n", string(line[beg:part[0]])) + //} + + // current field + head := string(line[beg:part[0]]) + + // register begin and end of field within line + indices := make(map[string]int) + indices["beg"] = beg + if part[0] == part[1] { + indices["end"] = 0 + } else { + indices["end"] = part[1] - 1 + } + + // register widest header field + headerlen := len(head) + if headerlen > data.maxwidthHeader { + data.maxwidthHeader = headerlen + } + + // register fields data + data.headerIndices = append(data.headerIndices, indices) + data.headers = append(data.headers, head) + + // end of current field == begin of next one + beg = part[1] + + // done + hadFirst = true + } + // if Debug { + // fmt.Println(data.headerIndices) + // } + } else { + // data processing + if len(pattern) > 0 { + //fmt.Println(patternR.MatchString(line)) + if !patternR.MatchString(line) { + continue + } + } + + idx := 0 // we cannot use the header index, because we could exclude columns + + for _, index := range data.headerIndices { + value := "" + if index["end"] == 0 { + value = string(line[index["beg"]:]) + } else { + value = string(line[index["beg"]:index["end"]]) + } + + width := len(strings.TrimSpace(value)) + + if len(data.maxwidthPerCol)-1 < idx { + data.maxwidthPerCol = append(data.maxwidthPerCol, width) + } else { + if width > data.maxwidthPerCol[idx] { + data.maxwidthPerCol[idx] = width + } + } + + // if Debug { + // fmt.Printf("<%s> ", value) + // } + values = append(values, value) + + idx++ + } + if Debug { + fmt.Println() + } + data.entries = append(data.entries, values) + } + } + + if scanner.Err() != nil { + die(scanner.Err()) + } + + return data +} diff --git a/cmd/printer.go b/cmd/printer.go new file mode 100644 index 0000000..337c1bd --- /dev/null +++ b/cmd/printer.go @@ -0,0 +1,109 @@ +package cmd + +import ( + "fmt" + "strings" +) + +func printTable(data Tabdata) { + if XtendedOut { + printExtended(data) + return + } + + // needed for data output + var formats []string + + if len(data.entries) > 0 { + // headers + for i, head := range data.headers { + if len(Columns) > 0 { + if !contains(UseColumns, i+1) { + continue + } + } + + // calculate column width + var width int + var iwidth int + var format string + + // generate format string + if len(head) > data.maxwidthPerCol[i] { + width = len(head) + } else { + width = data.maxwidthPerCol[i] + } + + if NoNumbering { + iwidth = 0 + } else { + iwidth = len(fmt.Sprintf("%d", i)) // in case i > 9 + } + + format = fmt.Sprintf("%%-%ds", 3+iwidth+width) + + if NoNumbering { + fmt.Printf(format, fmt.Sprintf("%s ", head)) + } else { + fmt.Printf(format, fmt.Sprintf("%s(%d) ", head, i+1)) + } + + // register + formats = append(formats, format) + } + fmt.Println() + + // entries + var idx int + for _, entry := range data.entries { + idx = 0 + //fmt.Println(entry) + for i, value := range entry { + if len(Columns) > 0 { + if !contains(UseColumns, i+1) { + continue + } + } + fmt.Printf(formats[idx], strings.TrimSpace(value)) + idx++ + } + fmt.Println() + } + } +} + +/* + We simulate the \x command of psql (the PostgreSQL client) +*/ +func printExtended(data Tabdata) { + // needed for data output + format := fmt.Sprintf("%%%ds: %%s\n", data.maxwidthHeader) // FIXME: re-calculate if -c has been set + + if len(data.entries) > 0 { + var idx int + for _, entry := range data.entries { + idx = 0 + for i, value := range entry { + if len(Columns) > 0 { + if !contains(UseColumns, i+1) { + continue + } + } + + fmt.Printf(format, data.headers[idx], value) + idx++ + } + fmt.Println() + } + } +} + +func contains(s []int, e int) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..906103c --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,108 @@ +/* +Copyright © 2022 Thomas von Dein + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +package cmd + +import ( + "fmt" + "github.com/alecthomas/repr" + "github.com/spf13/cobra" + "os" + "strconv" + "strings" +) + +var version = "v1.0.0" + +var rootCmd = &cobra.Command{ + Use: "tablizer [regex] [file, ...]", + Short: "[Re-]tabularize tabular data", + Long: `Manipulate tabular output of other programs`, + Run: func(cmd *cobra.Command, args []string) { + if Version { + fmt.Printf("This is tablizer version %s\n", version) + return + } + + var pattern string + havefiles := false + + if len(Columns) > 0 { + for _, use := range strings.Split(Columns, ",") { + usenum, err := strconv.Atoi(use) + if err != nil { + die(err) + } + UseColumns = append(UseColumns, usenum) + } + } + + if len(args) > 0 { + if _, err := os.Stat(args[0]); err != nil { + pattern = args[0] + args = args[1:] + } + + if len(args) > 0 { + for _, file := range args { + fd, err := os.OpenFile(file, os.O_RDONLY, 0755) + if err != nil { + die(err) + } + + data := parseFile(fd, pattern) + if Debug { + repr.Print(data) + } + printTable(data) + } + havefiles = true + } + } + + if !havefiles { + data := parseFile(os.Stdin, pattern) + if Debug { + repr.Print(data) + } + printTable(data) + } + }, +} + +var Debug bool +var XtendedOut bool +var NoNumbering bool +var Version bool +var Columns string +var UseColumns []int +var Separator string + +func Execute() { + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +func init() { + rootCmd.PersistentFlags().BoolVarP(&Debug, "debug", "d", false, "Enable debugging") + rootCmd.PersistentFlags().BoolVarP(&XtendedOut, "extended", "x", false, "Enable extended output") + rootCmd.PersistentFlags().BoolVarP(&NoNumbering, "no-numbering", "n", false, "Disable header numbering") + rootCmd.PersistentFlags().BoolVarP(&Version, "version", "v", false, "Print program version") + rootCmd.PersistentFlags().StringVarP(&Separator, "separator", "s", "", "Custom field separator") + rootCmd.PersistentFlags().StringVarP(&Columns, "columns", "c", "", "Only show the speficied columns (separated by ,)") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4016ac5 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module daemon.de/tablizer + +go 1.18 + +require github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 + +require ( + github.com/inconshreveable/mousetrap v1.0.0 // indirect + github.com/spf13/cobra v1.5.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/testify v1.8.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2e9690d --- /dev/null +++ b/go.sum @@ -0,0 +1,25 @@ +github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY= +github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= +github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..53c565d --- /dev/null +++ b/main.go @@ -0,0 +1,9 @@ +package main + +import ( + "daemon.de/tablizer/cmd" +) + +func main() { + cmd.Execute() +}