github.com/lmorg/murex@v0.0.0-20240217211045-e081c89cd4ef/builtins/core/tabulate/tabulate.go (about)

     1  package tabulate
     2  
     3  import (
     4  	"bufio"
     5  	"encoding/csv"
     6  	"fmt"
     7  	"regexp"
     8  	"strings"
     9  
    10  	"github.com/lmorg/murex/config/defaults"
    11  	"github.com/lmorg/murex/lang"
    12  	"github.com/lmorg/murex/lang/parameters"
    13  	"github.com/lmorg/murex/lang/types"
    14  	"github.com/lmorg/murex/utils/json"
    15  )
    16  
    17  func init() {
    18  	lang.DefineMethod("tabulate", cmdTabulate, types.Generic, types.Any)
    19  
    20  	defaults.AppendProfile(`
    21  		autocomplete set tabulate { [{
    22  			"DynamicDesc": ({ tabulate --help }),
    23  			"AllowMultiple": true
    24  		}] }
    25  	`)
    26  }
    27  
    28  const (
    29  	constSeparator = `(\t|\s[\s]+)+`
    30  )
    31  
    32  var (
    33  	rxSplitComma = regexp.MustCompile(`[\s\t]*,[\s\t]*`)
    34  	rxSplitSpace = regexp.MustCompile(`[\s\t]+-`)
    35  )
    36  
    37  // flags
    38  
    39  const (
    40  	fSeparator   = "--separator"
    41  	fSplitComma  = "--split-comma"
    42  	fSplitSpace  = "--split-space"
    43  	fKeyIncHint  = "--key-inc-hint"
    44  	fKeyVal      = "--key-value"
    45  	fMap         = "--map"
    46  	fJoiner      = "--joiner"
    47  	fColumnWraps = "--column-wraps"
    48  	fHelp        = "--help"
    49  )
    50  
    51  var flags = map[string]string{
    52  	fSeparator:   types.String,
    53  	fSplitComma:  types.Boolean,
    54  	fSplitSpace:  types.Boolean,
    55  	fKeyIncHint:  types.Boolean,
    56  	fKeyVal:      types.Boolean,
    57  	fMap:         types.Boolean,
    58  	fJoiner:      types.String,
    59  	fColumnWraps: types.Boolean,
    60  	fHelp:        types.Boolean,
    61  }
    62  
    63  var desc = map[string]string{
    64  	fSeparator:   "String, custom regex pattern for splitting fields (default: `" + constSeparator + "`)",
    65  	fSplitComma:  "Boolean, split first field and duplicate the line if comma found in first field (eg parsing flags in help pages)",
    66  	fSplitSpace:  "Boolean, split first field and duplicate the line if white space found in first field (eg parsing flags in help pages)",
    67  	fKeyIncHint:  "Boolean, used with " + fMap + " to split any space or equal delimited hints/examples (eg parsing flags)",
    68  	fKeyVal:      "Boolean, discard any records that don't appear key value pairs (auto-enabled when " + fMap + " used)",
    69  	fMap:         "Boolean, return JSON map instead of table",
    70  	fJoiner:      "String, used with " + fMap + " to concatenate any trailing records in a given field",
    71  	fColumnWraps: "Boolean, used with " + fMap + " or " + fKeyVal + " to merge trailing lines if the text wraps within the same column",
    72  	fHelp:        "Boolean, displays this help message",
    73  }
    74  
    75  func cmdTabulate(p *lang.Process) error {
    76  	f, _, err := p.Parameters.ParseFlags(
    77  		&parameters.Arguments{
    78  			Flags:           flags,
    79  			AllowAdditional: false,
    80  		},
    81  	)
    82  
    83  	if err != nil {
    84  		return err
    85  	}
    86  
    87  	var (
    88  		separator   = constSeparator
    89  		splitComma  bool
    90  		splitSpace  bool
    91  		keyIncHint  bool
    92  		keyVal      = false
    93  		joiner      = " "
    94  		columnWraps = false
    95  		colWrapsBuf string // buffer for wrapped columns
    96  		keys        []string
    97  		w           writer
    98  		last        string
    99  		split       []string
   100  		//iKeyStart   int // where the key starts when column wraps and keyVal used
   101  		processKey bool
   102  		//iValStart   int // where the value starts when column wraps and keyVal used
   103  	)
   104  
   105  	for flag, value := range f {
   106  		switch flag {
   107  		case fSeparator:
   108  			separator = value
   109  		case fSplitComma:
   110  			splitComma = true
   111  		case fSplitSpace:
   112  			splitSpace = true
   113  		case fKeyIncHint:
   114  			keyIncHint = true
   115  		case fKeyVal:
   116  			keyVal = true
   117  		case fJoiner:
   118  			joiner = value
   119  		case fMap:
   120  			keyVal = true
   121  		case fColumnWraps:
   122  			columnWraps = true
   123  		case fHelp:
   124  			return help(p)
   125  		}
   126  	}
   127  
   128  	if splitSpace && splitComma {
   129  		return fmt.Errorf("cannot have %s and %s both enabled. Please pick one or the other", fSplitComma, fSplitSpace)
   130  	}
   131  
   132  	if !keyVal && keyIncHint {
   133  		return fmt.Errorf("cannot use %s without %s or %s being set", fKeyIncHint, fKeyVal, fMap)
   134  	}
   135  
   136  	if !keyVal && columnWraps {
   137  		return fmt.Errorf("cannot use %s without %s or %s being set", fColumnWraps, fKeyVal, fMap)
   138  	}
   139  
   140  	if err := p.ErrIfNotAMethod(); err != nil {
   141  		p.Stdout.SetDataType(types.Null)
   142  		return err
   143  	}
   144  
   145  	dt := p.Stdin.GetDataType()
   146  	if dt != types.Generic && dt != types.String {
   147  		p.Stdout.SetDataType(types.Null)
   148  		return fmt.Errorf("`%s` is designed to only take string (%s) or generic (%s) data-types from STDIN. Instead it received '%s'",
   149  			p.Name.String(), types.String, types.Generic, dt)
   150  	}
   151  
   152  	if f[fMap] == "" {
   153  		p.Stdout.SetDataType("csv")
   154  		w = csv.NewWriter(p.Stdout)
   155  	} else {
   156  		p.Stdout.SetDataType(types.Json)
   157  		w = newMapWriter(p.Stdout, joiner)
   158  	}
   159  
   160  	rxTableSplit, err := regexp.Compile(separator)
   161  	if err != nil {
   162  		return err
   163  	}
   164  
   165  	if err := p.ErrIfNotAMethod(); err != nil {
   166  		return err
   167  	}
   168  
   169  	scanner := bufio.NewScanner(p.Stdin)
   170  	for scanner.Scan() {
   171  		s := scanner.Text()
   172  
   173  		// not a table row
   174  		if !rxTableSplit.MatchString(s) {
   175  			continue
   176  		}
   177  
   178  		// still not a table row
   179  		split = rxTableSplit.Split(s, -1)
   180  		if len(split) == 0 {
   181  			continue
   182  		}
   183  
   184  		// table has indentation, lets remove that
   185  		if len(split) > 1 && split[0] == "" {
   186  			split = split[1:]
   187  		}
   188  
   189  		if keyVal && (len(split) < 2 || split[0] == "") {
   190  			// is this a wrapped column?
   191  			if columnWraps && last != "" {
   192  				// is it a wrapped key? Check if indented flag
   193  				for i := 0; i < len(s); i++ {
   194  					if s[i] == '-' && i > 0 {
   195  						// it's a key
   196  						processKey = true
   197  						if len(split) == 1 {
   198  							split = []string{split[0], ""}
   199  						}
   200  
   201  						break
   202  					}
   203  					if s[i] != ' ' && s[i] != '\t' {
   204  						break
   205  					}
   206  				}
   207  
   208  				// look like it's just a wrapped column
   209  				if !processKey {
   210  					if len(colWrapsBuf) == 0 || colWrapsBuf[len(colWrapsBuf)-1] == ' ' {
   211  						colWrapsBuf += strings.Join(split, joiner)
   212  					} else {
   213  						colWrapsBuf += joiner + strings.Join(split, joiner)
   214  					}
   215  				}
   216  			}
   217  
   218  			// else silently ignore heading
   219  			if !processKey {
   220  				continue
   221  			}
   222  		}
   223  
   224  		if len(split) > 1 || processKey { // recheck because we've redefined the length of split
   225  			processKey = false
   226  
   227  			// looks like there's a new key, so lets write the colWrapsBuf
   228  			if columnWraps && last != "" {
   229  				if len(keys) == 0 {
   230  
   231  					err = w.Write([]string{last, colWrapsBuf})
   232  					if err != nil {
   233  						return err
   234  					}
   235  
   236  				} else {
   237  
   238  					for i := range keys {
   239  						err = w.Write([]string{keys[i], colWrapsBuf})
   240  						if err != nil {
   241  							return err
   242  						}
   243  					}
   244  				}
   245  
   246  				colWrapsBuf = ""
   247  
   248  				/*for i, r := range s {
   249  					if r != ' ' && r != '\t' {
   250  						iKeyStart = i
   251  						break
   252  					}
   253  				}*/
   254  			}
   255  
   256  			// split keys by comma
   257  			if splitComma {
   258  				keys = rxSplitComma.Split(split[0], -1)
   259  			}
   260  
   261  			// split keys by space
   262  			if splitSpace {
   263  				keys = rxSplitSpace.Split(split[0], 2)
   264  				if len(keys) == 2 {
   265  					keys[1] = "-" + keys[1]
   266  				}
   267  			}
   268  
   269  			// remove the hint stuff
   270  			if keyIncHint {
   271  				var hint string
   272  				if len(keys) != 0 {
   273  					_, hint = stripKeyHint(keys)
   274  				} else {
   275  					split[0], hint = stripKeyHint([]string{split[0]})
   276  				}
   277  				if len(hint) != 0 {
   278  					split[1] = "(args: " + hint + ") " + split[1]
   279  				}
   280  			}
   281  
   282  		}
   283  
   284  		if keyVal {
   285  			last = split[0]
   286  		}
   287  
   288  		// only write if columns not wrapped, otherwise loop round to check for
   289  		// any wrapped columns
   290  		if !columnWraps {
   291  			if len(keys) == 0 {
   292  
   293  				err = w.Write(split)
   294  				if err != nil {
   295  					return err
   296  				}
   297  
   298  			} else {
   299  
   300  				for i := range keys {
   301  					split[0] = keys[i]
   302  					err = w.Write(split)
   303  					if err != nil {
   304  						return err
   305  					}
   306  				}
   307  			}
   308  
   309  			keys = nil
   310  
   311  		} else {
   312  			colWrapsBuf = strings.Join(split[1:], joiner)
   313  		}
   314  	}
   315  
   316  	// clean up any trailing wrapped columns
   317  	if columnWraps && len(colWrapsBuf) != 0 {
   318  		if len(keys) == 0 {
   319  
   320  			err = w.Write([]string{last, colWrapsBuf})
   321  			if err != nil {
   322  				return err
   323  			}
   324  
   325  		} else {
   326  
   327  			for i := range keys {
   328  				err = w.Write([]string{keys[i], colWrapsBuf})
   329  				if err != nil {
   330  					return err
   331  				}
   332  			}
   333  		}
   334  	}
   335  
   336  	if err := scanner.Err(); err != nil {
   337  		return err
   338  	}
   339  
   340  	w.Flush()
   341  	return w.Error()
   342  }
   343  
   344  var rxSquareHints = regexp.MustCompile(`\[.*\]$`)
   345  
   346  func stripKeyHint(keys []string) (string, string) {
   347  	var (
   348  		space, equ, square []string
   349  		hint               string
   350  	)
   351  
   352  	for i := range keys {
   353  		square = rxSquareHints.FindStringSubmatch(keys[i])
   354  		if len(square) != 0 {
   355  			keys[i] = strings.Replace(keys[i], square[0], "", 1)
   356  		}
   357  
   358  		space = strings.SplitN(keys[i], " ", 2)
   359  		keys[i] = space[0]
   360  		if strings.Contains(space[0], "=") {
   361  			equ = strings.SplitN(keys[i], "=", 2)
   362  			keys[i] = equ[0] + "="
   363  		}
   364  	}
   365  
   366  	switch {
   367  	case len(square) != 0:
   368  		hint = square[0]
   369  
   370  	case len(space) == 2:
   371  		hint = space[1]
   372  
   373  	case len(equ) == 2:
   374  		hint = equ[1]
   375  	}
   376  
   377  	return keys[0], hint
   378  }
   379  
   380  func help(p *lang.Process) error {
   381  	p.Stdout.SetDataType(types.Json)
   382  	b, err := json.Marshal(desc, p.Stdout.IsTTY())
   383  	if err != nil {
   384  		return err
   385  	}
   386  
   387  	_, err = p.Stdout.Write(b)
   388  	return err
   389  }