Repository: ampproject/amp-toolbox-php (php)

Issue:
Add simplified use of the table formatter
The current implementation of the `TableFormatter` is cumbersome to use.

It would be great to have a simplified mechanism that takes all of the tabular data, checks the max character length for each column and then adjusts the size of the column automatically. This might produce results bigger than the current screen, but is much easier to use and will be "good enough" most of the time.

Pull request:
Add a new simplified table formatter API 
This PR adds a new public API to the `TableFormatter` to make it easy to use. With this we will able to format table like this,

```php
$items = [
    [ 'html', 'HTML', 'Markup Language' ],
    [ 'css', 'CSS', 'Style Sheet' ],
    [ 'js', 'JavaScript', 'Programming language' ],
];

$headers = [ 'Name', 'Title', 'Description' ];

// With headers
$tableFormatter = new TableFormatter();
$table = $tableFormatter->formatTable( $items, $headers );

/* output
+------+------------+----------------------+
| Name | Title      | Description          |
+------+------------+----------------------+
| html | HTML       | Markup Language      |
| css  | CSS        | Style Sheet          |
| js   | JavaScript | Programming language |
+------+------------+----------------------+
*/

// Without headers
$tableFormatter = new TableFormatter();
$table = $tableFormatter->formatTable( $items );

/* output
+------+------------+----------------------+
| html | HTML       | Markup Language      |
| css  | CSS        | Style Sheet          |
| js   | JavaScript | Programming language |
+------+------------+----------------------+
*/
```

Closes #453 

### To-do:
- [x] Handle long texts that exceed the width of the terminal.
- [x] Add more tests.
- [x] Add docblocks.
- [x] Fix any PHPCS or PHP Stan issues.

Patch:
diff --git a/src/Cli/TableFormatter.php b/src/Cli/TableFormatter.php
index 261f93d6c..49a7960c6 100644
--- a/src/Cli/TableFormatter.php
+++ b/src/Cli/TableFormatter.php
@@ -25,6 +25,13 @@ class TableFormatter
      */
     protected $border = ' ';
 
+    /**
+     * Padding around the border.
+     *
+     * @var int
+     */
+    protected $padding = 0;
+
     /**
      * The terminal width in characters.
      *
@@ -41,6 +48,27 @@ class TableFormatter
      */
     protected $colors;
 
+    /**
+     * Width of each column size based on the content length.
+     *
+     * @var array
+     */
+    protected $tableColumnWidths = [];
+
+    /**
+     * Maximum length of the table content.
+     *
+     * @var int
+     */
+    protected $maxColumnWidth = 0;
+
+    /**
+     * Whether to wrap the table with borders.
+     *
+     * @var bool
+     */
+    protected $isBorderedTable = false;
+
     /**
      * TableFormatter constructor.
      *
@@ -82,6 +110,18 @@ public function setBorder($border)
         $this->border = $border;
     }
 
+    /**
+     * Set the padding.
+     *
+     * The padding around the border is added to the column widths.
+     *
+     * @param int $padding Padding to set.
+     */
+    public function setPadding($padding)
+    {
+        $this->padding = $padding;
+    }
+
     /**
      * Width of the terminal in characters.
      *
@@ -131,12 +171,23 @@ public function format($columns, $texts, $colors = [])
         $output = '';
         for ($index = 0; $index < $maxLength; $index++) {
             foreach ($columns as $column => $width) {
+                if ($this->isBorderedTable && $column === 0) {
+                    $output .= $this->border . str_repeat(' ', $this->padding);
+                    $width = $width - strlen($this->border) - $this->padding;
+                }
+
                 if (isset($wrapped[$column][$index])) {
                     $value = $wrapped[$column][$index];
                 } else {
                     $value = '';
                 }
+
+                if ($this->isBorderedTable && $column === $last && $width > $this->tableColumnWidths[$last]) {
+                    $width = $this->tableColumnWidths[$last];
+                }
+
                 $chunk = $this->pad($value, $width);
+
                 if (isset($colors[$column]) && $colors[$column]) {
                     $chunk = $this->colors->wrap($chunk, $colors[$column]);
                 }
@@ -144,7 +195,11 @@ public function format($columns, $texts, $colors = [])
 
                 // Add border in-between columns.
                 if ($column != $last) {
-                    $output .= $this->border;
+                    $output .= str_repeat(' ', $this->padding) . $this->border . str_repeat(' ', $this->padding);
+                }
+
+                if ($this->isBorderedTable && $column === $last) {
+                    $output .= str_repeat(' ', $this->padding) . $this->border;
                 }
             }
             $output .= "\n";
@@ -365,4 +420,165 @@ protected function wordwrap($string, $width = 75, $break = "\n", $cut = false)
 
         return implode($break, $lines);
     }
+
+    /**
+     * Format the rows in a bordered table.
+     *
+     * @param array<array<string>> $rows    List of texts for each column.
+     * @param array<string>        $headers Optional. List of texts used in the table header.
+     *
+     * @return string A borered table containing the given rows.
+     */
+    public function formatTable($rows, $headers = [])
+    {
+        $this->setBorder('|');
+        $this->setPadding(1);
+        $this->setIsBorderedTable(true);
+
+        if (! empty($headers)) {
+            $this->calculateTableColumnWidths($headers);
+        }
+
+        foreach ($rows as $row) {
+            $this->calculateTableColumnWidths($row);
+        }
+
+        $numberOfColumns  = count($this->tableColumnWidths);
+
+        $columns = array_map(function ($width, $index) {
+            // Add extra padding to the first and last columns.
+            if ($index === 0 || $index === count($this->tableColumnWidths) - 1) {
+                $width = $width + strlen($this->border) + $this->padding;
+            }
+
+            return $width;
+        }, $this->tableColumnWidths, array_keys($this->tableColumnWidths));
+
+        // For a three column table, we'll have have "| " at start and " |" at the end,
+        // and in-between two " | ". So in total "| " + " | " + " | " + " |" = 10 chars.
+        $borderCharWidth  = strlen($this->border);
+        $totalBorderWidth = 2 * ($borderCharWidth + $this->padding)
+            + ($numberOfColumns - 1)
+            * ($borderCharWidth + ($this->padding * 2));
+        $estimatedColumnWidth = $numberOfColumns * $this->maxColumnWidth;
+        $estimatedTotalWidth  = $totalBorderWidth + $estimatedColumnWidth;
+
+        if ($estimatedTotalWidth > $this->maxWidth) {
+            $maxWidthWithoutBorders = $this->maxWidth - $totalBorderWidth;
+
+            $avrg          = floor($maxWidthWithoutBorders / $numberOfColumns);
+            $resizedWidths = [];
+            $extraWidth    = 0;
+
+            foreach ($this->tableColumnWidths as $width) {
+                if ($width > $avrg) {
+                    $resizedWidths[] = $width;
+                } else {
+                    $extraWidth = $extraWidth + ($avrg - $width);
+                }
+            }
+
+            if (! empty($resizedWidths) && $extraWidth) {
+                $avrgExtraWidth = floor($extraWidth / count($resizedWidths));
+
+                foreach ($this->tableColumnWidths as $i => &$width) {
+                    if (in_array($width, $resizedWidths, true)) {
+                        $width = $avrg + $avrgExtraWidth;
+                        array_shift($resizedWidths);
+                        if (empty($resizedWidths)) {
+                            $width = 0; // Zero it so not in sum.
+                            $width = $maxWidthWithoutBorders - array_sum($this->tableColumnWidths);
+                        }
+                    }
+
+                    if ($i === 0 || $i === $numberOfColumns - 1) {
+                        $width = $width + strlen($this->border) + $this->padding;
+                    }
+
+                    $columns[$i] = intval($width);
+                }
+            }
+        }
+
+        $horizontalBorder = $this->getTableHorizontalBorder($rows[0], $columns);
+
+        $table = $horizontalBorder . "\n";
+
+        if (! empty($headers)) {
+            $table .= $this->getTableRow($headers, $columns);
+            $table .= $horizontalBorder . "\n";
+        }
+
+        foreach ($rows as $row) {
+            $table .= $this->getTableRow($row, $columns);
+        }
+
+        $table .= $horizontalBorder;
+
+        return $table;
+    }
+
+    /**
+     * Whether the table is wrapped with borders or not.
+     *
+     * @param bool $isBorderedTable Whether the table is wrapped with borders or not.
+     */
+    public function setIsBorderedTable($isBorderedTable)
+    {
+        $this->isBorderedTable = $isBorderedTable;
+    }
+
+    /**
+     * Calculate table column widths based on the column content length.
+     *
+     * @param array<string> $row List of texts for each column.
+     */
+    protected function calculateTableColumnWidths($row)
+    {
+        foreach ($row as $i => $rowContent) {
+            $width = strlen($rowContent);
+
+            if ($width > $this->maxColumnWidth) {
+                $this->maxColumnWidth = $width;
+            }
+
+            if (! isset($this->tableColumnWidths[$i]) || $width > $this->tableColumnWidths[$i]) {
+                $this->tableColumnWidths[$i] = $width;
+            }
+        }
+    }
+
+    /**
+     * Get the table row.
+     *
+     * @param array<string> $row     List of texts for each column.
+     * @param array<int>    $columns List of maximum column widths.
+     *
+     * @return string Table row.
+     */
+    protected function getTableRow($row, $columns)
+    {
+        return trim($this->format($columns, $row)) . "\n";
+    }
+
+    /**
+     * Get the table horizontal border.
+     *
+     * @param array<string> $row     List of texts for each column.
+     * @param array<int>    $columns List of maximum column widths.
+     *
+     * @return string Table border.
+     */
+    protected function getTableHorizontalBorder($row, $columns)
+    {
+        $tableRow = $this->getTableRow($row, $columns);
+        $tableRow = explode("\n", $tableRow);
+        $firstRow = array_shift($tableRow);
+        $firstRow = trim($firstRow);
+        $borderChar = preg_quote($this->border, '/');
+        $border     = preg_replace("/[^{$borderChar}]/", '-', $firstRow);
+        $border     = preg_replace("/[{$borderChar}]/", '+', $border);
+
+        return $border;
+    }
 }


Test patch:
diff --git a/tests/Cli/TableFormatterTest.php b/tests/Cli/TableFormatterTest.php
index 10817d805..0e95f42ec 100644
--- a/tests/Cli/TableFormatterTest.php
+++ b/tests/Cli/TableFormatterTest.php
@@ -142,4 +142,112 @@ public function testOneWrap()
         $result = $tableFormatter->format([5, '*'], [$column1, $column2]);
         $this->assertEquals($expected, $result);
     }
+
+    public function testFormatTable()
+    {
+        $headers = ['Name', 'Title', 'Description'];
+        $rows    = [
+            ['html', 'HTML', 'Markup Language'],
+            ['css', 'CSS', 'Style Sheet'],
+            ['js', 'JavaScript', 'Programming language'],
+        ];
+
+        $tableFormatter = new TableFormatter();
+
+        // With headers.
+        $expected = <<<OUTPUT
++------+------------+----------------------+
+| Name | Title      | Description          |
++------+------------+----------------------+
+| html | HTML       | Markup Language      |
+| css  | CSS        | Style Sheet          |
+| js   | JavaScript | Programming language |
++------+------------+----------------------+
+OUTPUT;
+
+        $table = $tableFormatter->formatTable($rows, $headers);
+        $this->assertEquals($expected, $table);
+
+        // Without headers.
+        $expected = <<<OUTPUT
++------+------------+----------------------+
+| html | HTML       | Markup Language      |
+| css  | CSS        | Style Sheet          |
+| js   | JavaScript | Programming language |
++------+------------+----------------------+
+OUTPUT;
+
+        $table = $tableFormatter->formatTable($rows);
+        $this->assertEquals($expected, $table);
+    }
+
+    public function testFormatTableWithLongText()
+    {
+        $column4 = str_repeat('Column3', 20);
+        $headers = ['Column1', 'Column2', $column4, 'Column4'];
+
+        $item13 = rtrim(str_repeat('item13 ', 20));
+        $rows = [
+            ['item11', 'item12', $item13, 'item14'],
+            ['item21', 'item22', 'item23', 'item24'],
+            ['item31', 'item32', 'item33', 'item34'],
+            ['item41', 'item42', 'item43', 'item44'],
+        ];
+
+        $tableFormatter = new TableFormatter();
+        $tableFormatter->setMaxWidth(100);
+
+        $expected = <<<OUTPUT
++---------+---------+------------------------------------------------------------------+-----------+
+| Column1 | Column2 | Column3Column3Column3Column3Column3Column3Column3Column3Column3C | Column4   |
+|         |         | olumn3Column3Column3Column3Column3Column3Column3Column3Column3Co |           |
+|         |         | lumn3Column3                                                     |           |
++---------+---------+------------------------------------------------------------------+-----------+
+| item11  | item12  | item13 item13 item13 item13 item13 item13 item13 item13 item13   | item14    |
+|         |         | item13 item13 item13 item13 item13 item13 item13 item13 item13   |           |
+|         |         | item13 item13                                                    |           |
+| item21  | item22  | item23                                                           | item24    |
+| item31  | item32  | item33                                                           | item34    |
+| item41  | item42  | item43                                                           | item44    |
++---------+---------+------------------------------------------------------------------+-----------+
+OUTPUT;
+
+        $table = $tableFormatter->formatTable($rows, $headers);
+        $this->assertEquals($expected, $table);
+
+        $item32 = rtrim(str_repeat('item32 ', 20));
+        $item42 = rtrim(str_repeat('item42 ', 30));
+        $rows = [
+            ['item11', 'item12', 'item13', 'item14'],
+            ['item21', 'item22', 'item23', 'item24'],
+            ['item31', $item32, 'item33', 'item34'],
+            ['item41', $item42, 'item43', 'item44'],
+        ];
+        $tableFormatter = new TableFormatter();
+        $tableFormatter->setMaxWidth(120);
+
+        $expected = <<<OUTPUT
++---------+-----------------------------------------------+------------------------------------------------+-----------+
+| Column1 | Column2                                       | Column3Column3Column3Column3Column3Column3Colu | Column4   |
+|         |                                               | mn3Column3Column3Column3Column3Column3Column3C |           |
+|         |                                               | olumn3Column3Column3Column3Column3Column3Colum |           |
+|         |                                               | n3                                             |           |
++---------+-----------------------------------------------+------------------------------------------------+-----------+
+| item11  | item12                                        | item13                                         | item14    |
+| item21  | item22                                        | item23                                         | item24    |
+| item31  | item32 item32 item32 item32 item32 item32     | item33                                         | item34    |
+|         | item32 item32 item32 item32 item32 item32     |                                                |           |
+|         | item32 item32 item32 item32 item32 item32     |                                                |           |
+|         | item32 item32                                 |                                                |           |
+| item41  | item42 item42 item42 item42 item42 item42     | item43                                         | item44    |
+|         | item42 item42 item42 item42 item42 item42     |                                                |           |
+|         | item42 item42 item42 item42 item42 item42     |                                                |           |
+|         | item42 item42 item42 item42 item42 item42     |                                                |           |
+|         | item42 item42 item42 item42 item42 item42     |                                                |           |
++---------+-----------------------------------------------+------------------------------------------------+-----------+
+OUTPUT;
+
+        $table = $tableFormatter->formatTable($rows, $headers);
+        $this->assertEquals($expected, $table);
+    }
 }