Skip to content

Commit 22c6f67

Browse files
authored
Merge branch 'main' into copilot/enhance-table-format
2 parents c3ff813 + f3963aa commit 22c6f67

File tree

9 files changed

+279
-14
lines changed

9 files changed

+279
-14
lines changed

.github/workflows/copilot-setup-steps.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,17 @@ jobs:
1717

1818
steps:
1919
- name: Checkout code
20-
uses: actions/checkout@v6
20+
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
2121

2222
- name: Check existence of composer.json file
2323
id: check_composer_file
24-
uses: andstor/file-existence-action@v3
24+
uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v3
2525
with:
2626
files: "composer.json"
2727

2828
- name: Set up PHP environment
2929
if: steps.check_composer_file.outputs.files_exists == 'true'
30-
uses: shivammathur/setup-php@v2
30+
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2
3131
with:
3232
php-version: 'latest'
3333
ini-values: zend.assertions=1, error_reporting=-1, display_errors=On
@@ -38,7 +38,7 @@ jobs:
3838

3939
- name: Install Composer dependencies & cache dependencies
4040
if: steps.check_composer_file.outputs.files_exists == 'true'
41-
uses: ramsey/composer-install@v3
41+
uses: ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520 # v3
4242
env:
4343
COMPOSER_ROOT_VERSION: dev-${{ github.event.repository.default_branch }}
4444
with:

.github/workflows/issue-triage.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ name: Issue and PR Triage
1313
required: false
1414
type: string
1515

16+
permissions:
17+
issues: write
18+
pull-requests: write
19+
actions: write
20+
contents: read
21+
models: read
22+
1623
jobs:
1724
issue-triage:
1825
uses: wp-cli/.github/.github/workflows/reusable-issue-triage.yml@main

.github/workflows/regenerate-readme.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ on:
1010
- "features/**"
1111
- "README.md"
1212

13+
permissions:
14+
contents: write
15+
pull-requests: write
16+
1317
jobs:
1418
regenerate-readme:
1519
uses: wp-cli/.github/.github/workflows/reusable-regenerate-readme.yml@main

.github/workflows/welcome-new-contributors.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ on:
77
- main
88
- master
99

10+
permissions:
11+
pull-requests: write
12+
1013
jobs:
1114
welcome:
1215
uses: wp-cli/.github/.github/workflows/reusable-welcome-new-contributors.yml@main

lib/cli/Colors.php

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,4 +280,96 @@ static public function getStringCache() {
280280
static public function clearStringCache() {
281281
self::$_string_cache = array();
282282
}
283+
284+
/**
285+
* Get the ANSI reset code.
286+
*
287+
* @return string The ANSI reset code.
288+
*/
289+
static public function getResetCode() {
290+
return "\x1b[0m";
291+
}
292+
293+
/**
294+
* Wrap a pre-colorized string at a specific width, preserving color codes.
295+
*
296+
* This function wraps text that contains ANSI color codes, ensuring that:
297+
* 1. Color codes are never split in the middle
298+
* 2. Active colors are properly terminated and restored across line breaks
299+
* 3. The wrapped segments maintain the correct display width
300+
*
301+
* Note: This implementation tracks only the most recent ANSI code and does not
302+
* support layered formatting (e.g., bold + color). When multiple formatting
303+
* codes are applied, only the last one will be preserved across line breaks.
304+
*
305+
* @param string $string The string to wrap (with ANSI codes).
306+
* @param int $width The maximum display width per line.
307+
* @param string|bool $encoding Optional. The encoding of the string. Default false.
308+
* @return array Array of wrapped string segments.
309+
*/
310+
static public function wrapPreColorized( $string, $width, $encoding = false ) {
311+
$wrapped = array();
312+
$current_line = '';
313+
$current_width = 0;
314+
$active_color = '';
315+
316+
// Pattern to match ANSI escape sequences
317+
$ansi_pattern = '/(\x1b\[[0-9;]*m)/';
318+
319+
// Split the string into parts: ANSI codes and text
320+
$parts = preg_split( $ansi_pattern, $string, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY );
321+
322+
foreach ( $parts as $part ) {
323+
// Check if this part is an ANSI code
324+
if ( preg_match( $ansi_pattern, $part ) ) {
325+
// It's an ANSI code, add it to current line without counting width
326+
$current_line .= $part;
327+
328+
// Track the active color - check for reset codes consistently
329+
if ( preg_match( '/\x1b\[0m/', $part ) ) {
330+
// Reset code (ESC[0m)
331+
$active_color = '';
332+
} elseif ( preg_match( '/\x1b\[([0-9;]+)m/', $part, $matches ) ) {
333+
// Non-reset color/formatting code
334+
$active_color = $part;
335+
}
336+
} else {
337+
// It's text content, process it character by character
338+
$text_length = \cli\safe_strlen( $part, $encoding );
339+
$offset = 0;
340+
341+
while ( $offset < $text_length ) {
342+
$char = \cli\safe_substr( $part, $offset, 1, false, $encoding );
343+
$char_width = \cli\strwidth( $char, $encoding );
344+
345+
// Check if adding this character would exceed the width
346+
if ( $current_width + $char_width > $width && $current_width > 0 ) {
347+
// Need to wrap - finish current line
348+
if ( $active_color ) {
349+
$current_line .= self::getResetCode();
350+
}
351+
$wrapped[] = $current_line;
352+
353+
// Start new line
354+
$current_line = $active_color ? $active_color : '';
355+
$current_width = 0;
356+
}
357+
358+
// Add the character
359+
$current_line .= $char;
360+
$current_width += $char_width;
361+
$offset++;
362+
}
363+
}
364+
}
365+
366+
// Add the last line if there's any displayable content
367+
$visible_content = preg_replace( $ansi_pattern, '', $current_line );
368+
$visible_width = $visible_content !== null ? \cli\strwidth( $visible_content, $encoding ) : 0;
369+
if ( $visible_width > 0 ) {
370+
$wrapped[] = $current_line;
371+
}
372+
373+
return $wrapped;
374+
}
283375
}

lib/cli/Table.php

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,17 @@ public function resetTable()
9797
return $this;
9898
}
9999

100+
/**
101+
* Resets only the rows in the table, keeping headers, footers, and width information.
102+
*
103+
* @return $this
104+
*/
105+
public function resetRows()
106+
{
107+
$this->_rows = array();
108+
return $this;
109+
}
110+
100111
/**
101112
* Sets the renderer used by this table.
102113
*
@@ -142,6 +153,33 @@ public function display() {
142153
}
143154
}
144155

156+
/**
157+
* Display a single row without headers or top border.
158+
*
159+
* This method is useful for adding rows incrementally to an already-rendered table.
160+
* It will display the row with side borders and a bottom border (if using Ascii renderer).
161+
*
162+
* @param array $row The row data to display.
163+
*/
164+
public function displayRow(array $row) {
165+
// Update widths if this row has wider content
166+
$row = $this->checkRow($row);
167+
168+
// Recalculate widths for the renderer
169+
$this->_renderer->setWidths($this->_width, false);
170+
171+
$rendered_row = $this->_renderer->row($row);
172+
$row_lines = explode( PHP_EOL, $rendered_row );
173+
foreach ( $row_lines as $line ) {
174+
Streams::line( $line );
175+
}
176+
177+
$border = $this->_renderer->border();
178+
if (isset($border)) {
179+
Streams::line( $border );
180+
}
181+
}
182+
145183
/**
146184
* Get the table lines to output.
147185
*
@@ -171,7 +209,8 @@ public function getDisplayLines() {
171209
$out = array_merge( $out, $row );
172210
}
173211

174-
if (isset($border)) {
212+
// Only add final border if there are rows
213+
if (!empty($this->_rows) && isset($border)) {
175214
$out[] = $border;
176215
}
177216

lib/cli/table/Ascii.php

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ public function setWidths(array $widths, $fallback = false) {
8383
}
8484

8585
$this->_widths = $widths;
86+
// Reset border cache when widths change
87+
$this->_border = null;
8688
}
8789

8890
/**
@@ -146,14 +148,21 @@ public function row( array $row ) {
146148

147149
$wrapped_lines = [];
148150
foreach ( $split_lines as $line ) {
149-
do {
150-
$wrapped_value = \cli\safe_substr( $line, 0, $col_width, true /*is_width*/, $encoding );
151-
$val_width = Colors::width( $wrapped_value, self::isPreColorized( $col ), $encoding );
152-
if ( $val_width ) {
153-
$wrapped_lines[] = $wrapped_value;
154-
$line = \cli\safe_substr( $line, \cli\safe_strlen( $wrapped_value, $encoding ), null /*length*/, false /*is_width*/, $encoding );
155-
}
156-
} while ( $line );
151+
// Use the new color-aware wrapping for pre-colorized content
152+
if ( self::isPreColorized( $col ) && Colors::width( $line, true, $encoding ) > $col_width ) {
153+
$line_wrapped = Colors::wrapPreColorized( $line, $col_width, $encoding );
154+
$wrapped_lines = array_merge( $wrapped_lines, $line_wrapped );
155+
} else {
156+
// For non-colorized content, use the original logic
157+
do {
158+
$wrapped_value = \cli\safe_substr( $line, 0, $col_width, true /*is_width*/, $encoding );
159+
$val_width = Colors::width( $wrapped_value, self::isPreColorized( $col ), $encoding );
160+
if ( $val_width ) {
161+
$wrapped_lines[] = $wrapped_value;
162+
$line = \cli\safe_substr( $line, \cli\safe_strlen( $wrapped_value, $encoding ), null /*length*/, false /*is_width*/, $encoding );
163+
}
164+
} while ( $line );
165+
}
157166
}
158167

159168
$row[ $col ] = array_shift( $wrapped_lines );

tests/Test_Table.php

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,5 +379,78 @@ public function test_alignment_before_headers() {
379379
// Should be right-aligned - "Name" is 4 chars, "LongName" is 8 chars, so column width is 8
380380
$this->assertStringContainsString( '| Name |', $out[1] );
381381
$this->assertStringContainsString( '| LongName |', $out[3] );
382+
public function test_resetRows() {
383+
$table = new cli\Table();
384+
$table->setHeaders( array( 'Name', 'Age' ) );
385+
$table->addRow( array( 'Alice', '30' ) );
386+
$table->addRow( array( 'Bob', '25' ) );
387+
388+
$this->assertEquals( 2, $table->countRows() );
389+
390+
$table->resetRows();
391+
392+
$this->assertEquals( 0, $table->countRows() );
393+
394+
// Headers should still be intact
395+
$out = $table->getDisplayLines();
396+
$this->assertGreaterThan( 0, count( $out ) );
397+
}
398+
399+
public function test_displayRow_ascii() {
400+
$mockFile = tempnam( sys_get_temp_dir(), 'temp' );
401+
$resource = fopen( $mockFile, 'wb' );
402+
403+
try {
404+
\cli\Streams::setStream( 'out', $resource );
405+
406+
$table = new cli\Table();
407+
$renderer = new cli\Table\Ascii();
408+
$table->setRenderer( $renderer );
409+
$table->setHeaders( array( 'Name', 'Age' ) );
410+
411+
// Display a single row
412+
$table->displayRow( array( 'Alice', '30' ) );
413+
414+
$output = file_get_contents( $mockFile );
415+
416+
// Should contain the row data
417+
$this->assertStringContainsString( 'Alice', $output );
418+
$this->assertStringContainsString( '30', $output );
419+
420+
// Should contain borders
421+
$this->assertStringContainsString( '|', $output );
422+
$this->assertStringContainsString( '+', $output );
423+
} finally {
424+
if ( $mockFile && file_exists( $mockFile ) ) {
425+
unlink( $mockFile );
426+
}
427+
}
428+
}
429+
430+
public function test_displayRow_tabular() {
431+
$mockFile = tempnam( sys_get_temp_dir(), 'temp' );
432+
$resource = fopen( $mockFile, 'wb' );
433+
434+
try {
435+
\cli\Streams::setStream( 'out', $resource );
436+
437+
$table = new cli\Table();
438+
$renderer = new cli\Table\Tabular();
439+
$table->setRenderer( $renderer );
440+
$table->setHeaders( array( 'Name', 'Age' ) );
441+
442+
// Display a single row
443+
$table->displayRow( array( 'Alice', '30' ) );
444+
445+
$output = file_get_contents( $mockFile );
446+
447+
// Should contain the row data with tabs
448+
$this->assertStringContainsString( 'Alice', $output );
449+
$this->assertStringContainsString( '30', $output );
450+
} finally {
451+
if ( $mockFile && file_exists( $mockFile ) ) {
452+
unlink( $mockFile );
453+
}
454+
}
382455
}
383456
}

tests/Test_Table_Ascii.php

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,45 @@ public function testDrawOneColumnColorDisabledTable() {
114114
$this->assertInOutEquals(array($headers, $rows), $output);
115115
}
116116

117+
/**
118+
* Test that colorized text wraps correctly while maintaining color codes.
119+
*/
120+
public function testWrappedColorizedText() {
121+
Colors::enable( true );
122+
$headers = array('Column 1', 'Column 2');
123+
$green_code = "\x1b\x5b\x33\x32\x3b\x31\x6d"; // Green + bright
124+
$reset_code = "\x1b\x5b\x30\x6d"; // Reset
125+
126+
// Create a long colorized string that will wrap
127+
$long_text = Colors::colorize('%GThis is a long green text%n', true);
128+
129+
$rows = array(
130+
array('Short', $long_text),
131+
);
132+
133+
// Expected output with wrapped text maintaining colors
134+
// The color codes are preserved across wrapped lines
135+
$output = <<<OUT
136+
+------------+--------------+
137+
| Column 1 | Column 2 |
138+
+------------+--------------+
139+
| Short | {$green_code}This is a lo{$reset_code} |
140+
| | {$green_code}ng green tex{$reset_code} |
141+
| | {$green_code}t{$reset_code} |
142+
+------------+--------------+
143+
144+
OUT;
145+
146+
$this->_instance->setHeaders($headers);
147+
$this->_instance->setRows($rows);
148+
$renderer = new Ascii([10, 12]);
149+
$renderer->setConstraintWidth(30);
150+
$this->_instance->setRenderer($renderer);
151+
$this->_instance->setAsciiPreColorized(true);
152+
$this->_instance->display();
153+
$this->assertOutFileEqualsWith($output);
154+
}
155+
117156
/**
118157
* Checks that spacing and borders are handled correctly in table
119158
*/
@@ -249,7 +288,6 @@ public function testDrawWithHeadersNoData() {
249288
+----------+----------+
250289
| header 1 | header 2 |
251290
+----------+----------+
252-
+----------+----------+
253291

254292
OUT;
255293
$this->assertInOutEquals(array($headers, $rows), $output);

0 commit comments

Comments
 (0)