github.com/jfrog/jfrog-cli-core/v2@v2.51.0/utils/coreutils/tableutils.go (about)

     1  package coreutils
     2  
     3  import (
     4  	"bufio"
     5  	"errors"
     6  	"fmt"
     7  	"math"
     8  	"os"
     9  	"reflect"
    10  	"strings"
    11  
    12  	"github.com/jedib0t/go-pretty/v6/table"
    13  	"github.com/jfrog/jfrog-client-go/utils/log"
    14  	"golang.org/x/term"
    15  )
    16  
    17  // Controls the max col width when printing to a non-terminal. See the PrintTable description for more info.
    18  var DefaultMaxColWidth = 25
    19  
    20  // PrintTable prints a slice of rows in a table.
    21  // The parameter rows MUST be a slice, otherwise the method panics.
    22  // How to use this method (with an example):
    23  // The fields of the struct must have one of the tags: 'col-name' or 'embed-table' in order to be printed.
    24  // Fields without any of these tags will be skipped.
    25  // The tag 'col-name' can be set on string fields only. The column name is the 'col-name' tag value.
    26  // On terminal, the maximum column width is calculated (terminal width equally divided between columns),
    27  // while on non-terminal the value of the DefaultMaxColWidth variable is used.
    28  // If the cell content exceeds the defined max column width, the content will be broken into two (or more) lines.
    29  // In case the struct you want to print contains a field that is a slice of other structs,
    30  // you can print it in the table too with the 'embed-table' tag which can be set on slices of structs only.
    31  // Fields with the 'extended' tag will be printed iff the 'printExtended' bool input is true.
    32  // You can merge cells horizontally with the 'auto-merge' tag, it will merge cells with the same value.
    33  //
    34  // Example:
    35  // These are the structs Customer and Product:
    36  //
    37  //	type Customer struct {
    38  //	    name     string    `col-name:"Name"`
    39  //	    age      string    `col-name:"Age"`
    40  //	    products []Product `embed-table:"true"`
    41  //	}
    42  //
    43  //	type Product struct {
    44  //	    title string `col-name:"Product Title"`
    45  //	    CatNumber string `col-name:"Product\nCatalog #"`
    46  //	    Color string `col-name:"Color" extended:"true"`
    47  //	}
    48  //
    49  // We'll use it, and run these commands (var DefaultMaxColWidth = 25):
    50  //
    51  //	customersSlice := []Customer{
    52  //	    {name: "Gai", age: "350", products: []Product{{title: "SpiderFrog Shirt - Medium", CatNumber: "123456", Color: "Green"}, {title: "Floral Bottle", CatNumber: "147585", Color: "Blue"}}},
    53  //	    {name: "Noah", age: "21", products: []Product{{title: "Pouch", CatNumber: "456789", Color: "Red"}, {title: "Ching Ching", CatNumber: "963852", Color: "Gold"}}},
    54  //	}
    55  //
    56  // err := coreutils.PrintTable(customersSlice, "Customers", "No customers were found", false)
    57  //
    58  // That's the table printed:
    59  //
    60  // Customers
    61  // ┌──────┬─────┬─────────────────────────┬───────────┐
    62  // │ NAME │ AGE │ PRODUCT TITLE           │ PRODUCT   │
    63  // │      │     │                         │ CATALOG # │
    64  // ├──────┼─────┼─────────────────────────┼───────────┤
    65  // │ Gai  │ 350 │ SpiderFrog Shirt - Medi │ 123456    │
    66  // │      │     │ um                      │           │
    67  // │      │     │ Floral Bottle           │ 147585    │
    68  // ├──────┼─────┼─────────────────────────┼───────────┤
    69  // │ Noah │ 21  │ Pouch                   │ 456789    │
    70  // │      │     │ Ching Ching             │ 963852    │
    71  // └──────┴─────┴─────────────────────────┴───────────┘
    72  //
    73  // If printExtended=true:
    74  //
    75  // err := coreutils.PrintTable(customersSlice, "Customers", "No customers were found", true)
    76  //
    77  // Customers
    78  // ┌──────┬─────┬─────────────────────────┬───────────┬───────────┐
    79  // │ NAME │ AGE │ PRODUCT TITLE           │ PRODUCT   │ Color     │
    80  // │      │     │                         │ CATALOG # │           │
    81  // ├──────┼─────┼─────────────────────────┼───────────┼───────────┤
    82  // │ Gai  │ 350 │ SpiderFrog Shirt - Medi │ 123456    │ Green     │
    83  // │      │     │ um                      │           │           │
    84  // │      │     │ Floral Bottle           │ 147585    │ Blue      │
    85  // ├──────┼─────┼─────────────────────────┼───────────┼───────────┤
    86  // │ Noah │ 21  │ Pouch                   │ 456789    │ Red       │
    87  // │      │     │ Ching Ching             │ 963852    │ Gold      │
    88  // └──────┴─────┴─────────────────────────┴───────────┴───────────┘
    89  //
    90  // If customersSlice was empty, emptyTableMessage would have been printed instead:
    91  //
    92  // Customers
    93  // ┌─────────────────────────┐
    94  // │ No customers were found │
    95  // └─────────────────────────┘
    96  //
    97  // Example(auto-merge):
    98  // These are the structs Customer:
    99  //
   100  //	type Customer struct {
   101  //	    name     string    `col-name:"Name" auto-merge:"true"`
   102  //	    age       string   `col-name:"Age" auto-merge:"true"`
   103  //	    title     string   `col-name:"Product Title" auto-merge:"true"`
   104  //	    CatNumber string   `col-name:"Product\nCatalog #" auto-merge:"true"`
   105  //	    Color     string   `col-name:"Color" extended:"true" auto-merge:"true"`
   106  //	}
   107  //
   108  //  customersSlice := []Customer{
   109  //	    {name: "Gai", age: "350", title: "SpiderFrog Shirt - Medium", CatNumber: "123456", Color: "Green"},
   110  //      {name: "Gai", age: "350", title: "Floral Bottle", CatNumber: "147585", Color: "Blue"},
   111  //	    {name: "Noah", age: "21", title: "Pouch", CatNumber: "456789", Color: "Red"},
   112  // }
   113  //
   114  // Customers
   115  // ┌──────┬─────┬───────────────────────────┬───────────┐
   116  // │ NAME │ AGE │ PRODUCT TITLE             │ PRODUCT   │
   117  // │      │     │                           │ CATALOG # │
   118  // ├──────┼─────┼───────────────────────────┼───────────┤
   119  // │ Gai  │ 350 │ SpiderFrog Shirt - Medium │ 123456    │
   120  // │      │     ├───────────────────────────┼───────────┤
   121  // │      │     │ Floral Bottle             │ 147585    │
   122  // ├──────┼─────┼───────────────────────────┼───────────┤
   123  // │ Noah │ 21  │ Pouch                     │ 456789    │
   124  // └──────┴─────┴───────────────────────────┴───────────┘
   125  
   126  func PrintTable(rows interface{}, title string, emptyTableMessage string, printExtended bool) (err error) {
   127  	if title != "" {
   128  		log.Output(title)
   129  	}
   130  	tableWriter, err := PrepareTable(rows, emptyTableMessage, printExtended)
   131  	if err != nil || tableWriter == nil {
   132  		return
   133  	}
   134  	if log.IsStdOutTerminal() || os.Getenv("GITLAB_CI") == "" {
   135  		tableWriter.SetStyle(table.StyleLight)
   136  	}
   137  	tableWriter.Style().Options.SeparateRows = true
   138  	stdoutWriter := bufio.NewWriter(os.Stdout)
   139  	defer func() {
   140  		err = errors.Join(err, stdoutWriter.Flush())
   141  	}()
   142  	tableWriter.SetOutputMirror(stdoutWriter)
   143  	tableWriter.Render()
   144  	return
   145  }
   146  
   147  // Creates table following the logic described in PrintTable.
   148  // Returns:
   149  // Table Writer (table.Writer) - Can be used to adjust style, output mirror, render type, etc.
   150  // Error if occurred.
   151  func PrepareTable(rows interface{}, emptyTableMessage string, printExtended bool) (table.Writer, error) {
   152  	tableWriter := table.NewWriter()
   153  
   154  	rowsSliceValue := reflect.ValueOf(rows)
   155  	if rowsSliceValue.Len() == 0 && emptyTableMessage != "" {
   156  		PrintMessage(emptyTableMessage)
   157  		return nil, nil
   158  	}
   159  
   160  	rowType := reflect.TypeOf(rows).Elem()
   161  	fieldsCount := rowType.NumField()
   162  	var columnsNames []interface{}
   163  	var fieldsProperties []fieldProperties
   164  	var columnConfigs []table.ColumnConfig
   165  	for i := 0; i < fieldsCount; i++ {
   166  		field := rowType.Field(i)
   167  		columnName, columnNameExist := field.Tag.Lookup("col-name")
   168  		embedTable, embedTableExist := field.Tag.Lookup("embed-table")
   169  		extended, extendedExist := field.Tag.Lookup("extended")
   170  		_, autoMerge := field.Tag.Lookup("auto-merge")
   171  		_, omitEmptyColumn := field.Tag.Lookup("omitempty")
   172  		if !printExtended && extendedExist && extended == "true" {
   173  			continue
   174  		}
   175  		if !columnNameExist && !embedTableExist {
   176  			continue
   177  		}
   178  		if omitEmptyColumn && isColumnEmpty(rowsSliceValue, i) {
   179  			continue
   180  		}
   181  		if embedTable == "true" {
   182  			var subfieldsProperties []subfieldProperties
   183  			var err error
   184  			columnsNames, columnConfigs, subfieldsProperties = appendEmbeddedTableFields(columnsNames, columnConfigs, field, printExtended)
   185  			if err != nil {
   186  				return nil, err
   187  			}
   188  			fieldsProperties = append(fieldsProperties, fieldProperties{index: i, subfields: subfieldsProperties})
   189  		} else {
   190  			columnsNames = append(columnsNames, columnName)
   191  			fieldsProperties = append(fieldsProperties, fieldProperties{index: i})
   192  			columnConfigs = append(columnConfigs, table.ColumnConfig{Name: columnName, AutoMerge: autoMerge})
   193  		}
   194  	}
   195  	tableWriter.AppendHeader(columnsNames)
   196  	err := setColMaxWidth(columnConfigs, fieldsProperties)
   197  	if err != nil {
   198  		return nil, err
   199  	}
   200  	tableWriter.SetColumnConfigs(columnConfigs)
   201  
   202  	for i := 0; i < rowsSliceValue.Len(); i++ {
   203  		var rowValues []interface{}
   204  		currRowValue := rowsSliceValue.Index(i)
   205  		for _, fieldProps := range fieldsProperties {
   206  			currField := currRowValue.Field(fieldProps.index)
   207  			if len(fieldProps.subfields) > 0 {
   208  				rowValues = appendEmbeddedTableStrings(rowValues, currField, fieldProps.subfields)
   209  			} else {
   210  				rowValues = append(rowValues, currField.String())
   211  			}
   212  		}
   213  		tableWriter.AppendRow(rowValues)
   214  	}
   215  
   216  	return tableWriter, nil
   217  }
   218  
   219  func isColumnEmpty(rows reflect.Value, fieldIndex int) bool {
   220  	for i := 0; i < rows.Len(); i++ {
   221  		currRowValue := rows.Index(i)
   222  		currField := currRowValue.Field(fieldIndex)
   223  		if currField.String() != "" {
   224  			return false
   225  		}
   226  	}
   227  	return true
   228  }
   229  
   230  type fieldProperties struct {
   231  	index     int                  // The location of the field inside the row struct
   232  	subfields []subfieldProperties // If this field is an embedded table, this will contain the fields in it
   233  }
   234  
   235  type subfieldProperties struct {
   236  	index    int
   237  	maxWidth int
   238  }
   239  
   240  func setColMaxWidth(columnConfigs []table.ColumnConfig, fieldsProperties []fieldProperties) error {
   241  	colMaxWidth := DefaultMaxColWidth
   242  
   243  	// If terminal, calculate the max width.
   244  	if log.IsStdOutTerminal() {
   245  		colNum := len(columnConfigs)
   246  		termWidth, err := getTerminalAllowedWidth(colNum)
   247  		if err != nil {
   248  			return err
   249  		}
   250  		if termWidth > 0 {
   251  			// Terminal width should be a positive number, if it's not then we couldn't get the terminal width successfully.
   252  			colMaxWidth = int(math.Floor(float64(termWidth) / float64(colNum)))
   253  		}
   254  	}
   255  
   256  	// Set the max width on every column and cell.
   257  	for i := range columnConfigs {
   258  		columnConfigs[i].WidthMax = colMaxWidth
   259  	}
   260  	for i := range fieldsProperties {
   261  		subfields := fieldsProperties[i].subfields
   262  		for j := range subfields {
   263  			subfields[j].maxWidth = colMaxWidth
   264  		}
   265  	}
   266  	return nil
   267  }
   268  
   269  func getTerminalAllowedWidth(colNum int) (int, error) {
   270  	width, _, err := term.GetSize(int(os.Stdout.Fd()))
   271  	if err != nil {
   272  		return 0, err
   273  	}
   274  	// Subtract the table's grid chars (3 chars between every two columns and 1 char at both edges of the table).
   275  	subtraction := (colNum-1)*3 + 2
   276  	return width - subtraction, nil
   277  }
   278  
   279  func appendEmbeddedTableFields(columnsNames []interface{}, columnConfigs []table.ColumnConfig, field reflect.StructField, printExtended bool) ([]interface{}, []table.ColumnConfig, []subfieldProperties) {
   280  	rowType := field.Type.Elem()
   281  	fieldsCount := rowType.NumField()
   282  	var subfieldsProperties []subfieldProperties
   283  	for i := 0; i < fieldsCount; i++ {
   284  		innerField := rowType.Field(i)
   285  		columnName, columnNameExist := innerField.Tag.Lookup("col-name")
   286  		extended, extendedExist := innerField.Tag.Lookup("extended")
   287  		if !printExtended && extendedExist && extended == "true" {
   288  			continue
   289  		}
   290  		if !columnNameExist {
   291  			continue
   292  		}
   293  		columnsNames = append(columnsNames, columnName)
   294  		columnConfigs = append(columnConfigs, table.ColumnConfig{Name: columnName})
   295  		subfieldsProperties = append(subfieldsProperties, subfieldProperties{index: i})
   296  	}
   297  	return columnsNames, columnConfigs, subfieldsProperties
   298  }
   299  
   300  func appendEmbeddedTableStrings(rowValues []interface{}, fieldValue reflect.Value, subfieldsProperties []subfieldProperties) []interface{} {
   301  	sliceLen := fieldValue.Len()
   302  	numberOfColumns := len(subfieldsProperties)
   303  	tableStrings := make([]string, numberOfColumns)
   304  
   305  	for rowIndex := 0; rowIndex < sliceLen; rowIndex++ {
   306  		currRowCells := make([]embeddedTableCell, numberOfColumns)
   307  		maxNumberOfLines := 0
   308  
   309  		// Check if all elements in the row are empty.
   310  		shouldSkip := true
   311  		for _, subfieldProps := range subfieldsProperties {
   312  			currCellContent := fieldValue.Index(rowIndex).Field(subfieldProps.index).String()
   313  			if currCellContent != "" {
   314  				shouldSkip = false
   315  				break
   316  			}
   317  		}
   318  		// Skip row if no non-empty cell was found.
   319  		if shouldSkip {
   320  			continue
   321  		}
   322  
   323  		// Find the highest number of lines in the row
   324  		for subfieldIndex, subfieldProps := range subfieldsProperties {
   325  			currCellContent := fieldValue.Index(rowIndex).Field(subfieldProps.index).String()
   326  			currRowCells[subfieldIndex] = embeddedTableCell{content: currCellContent, numberOfLines: countLinesInCell(currCellContent, subfieldProps.maxWidth)}
   327  			if currRowCells[subfieldIndex].numberOfLines > maxNumberOfLines {
   328  				maxNumberOfLines = currRowCells[subfieldIndex].numberOfLines
   329  			}
   330  		}
   331  
   332  		// Add newlines to cells with less lines than maxNumberOfLines
   333  		for colIndex, currCell := range currRowCells {
   334  			cellContent := currCell.content
   335  			for i := 0; i < maxNumberOfLines-currCell.numberOfLines; i++ {
   336  				cellContent = fmt.Sprintf("%s\n", cellContent)
   337  			}
   338  			tableStrings[colIndex] = fmt.Sprintf("%s%s\n", tableStrings[colIndex], cellContent)
   339  		}
   340  	}
   341  	for _, tableString := range tableStrings {
   342  		trimmedString := strings.TrimSuffix(tableString, "\n")
   343  		rowValues = append(rowValues, trimmedString)
   344  	}
   345  	return rowValues
   346  }
   347  
   348  func countLinesInCell(content string, maxWidth int) int {
   349  	if maxWidth == 0 {
   350  		return strings.Count(content, "\n") + 1
   351  	}
   352  	lines := strings.Split(content, "\n")
   353  	numberOfLines := 0
   354  	for _, line := range lines {
   355  		numberOfLines += len(line) / maxWidth
   356  		if len(line)%maxWidth > 0 {
   357  			numberOfLines++
   358  		}
   359  	}
   360  	return numberOfLines
   361  }
   362  
   363  type embeddedTableCell struct {
   364  	content       string
   365  	numberOfLines int
   366  }
   367  
   368  // PrintMessage prints message in a frame (which is actually a table with a single table).
   369  // For example:
   370  // ┌─────────────────────────────────────────┐
   371  // │ An example of a message in a nice frame │
   372  // └─────────────────────────────────────────┘
   373  func PrintMessage(message string) {
   374  	tableWriter := table.NewWriter()
   375  	tableWriter.SetOutputMirror(os.Stdout)
   376  	if log.IsStdOutTerminal() {
   377  		tableWriter.SetStyle(table.StyleLight)
   378  	}
   379  	// Remove emojis from non-supported terminals
   380  	message = RemoveEmojisIfNonSupportedTerminal(message)
   381  	tableWriter.AppendRow(table.Row{message})
   382  	tableWriter.Render()
   383  }