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 }