Skip to content

Commit 2e1fbac

Browse files
refactor: share CLI rendering helpers (#762)
Co-authored-by: homeboy-ci[bot] <266378653+homeboy-ci[bot]@users.noreply.github.com>
1 parent b3127bd commit 2e1fbac

6 files changed

Lines changed: 180 additions & 54 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
/**
3+
* Repeatable WP-CLI option parser.
4+
*
5+
* @package DataMachineCode\Cli
6+
*/
7+
8+
namespace DataMachineCode\Cli;
9+
10+
defined('ABSPATH') || exit;
11+
12+
final class CliRepeatableOptionParser {
13+
14+
/**
15+
* Collect every occurrence of a repeatable assoc flag from raw argv.
16+
*
17+
* WP-CLI's parsed assoc args are last-wins for repeated flags, so commands
18+
* that accept repeatable options need to inspect raw argv to preserve order.
19+
* Supports both `--flag=value` and `--flag value` forms.
20+
*
21+
* @param string $flag Flag name without leading `--`.
22+
* @param array<int,mixed>|null $argv Raw argv tokens. Defaults to `$GLOBALS['argv']`.
23+
* @return string[] Values in argv order, with empty values omitted.
24+
*/
25+
public static function collect( string $flag, ?array $argv = null ): array {
26+
$argv = $argv ?? ( is_array($GLOBALS['argv'] ?? null) ? $GLOBALS['argv'] : array() );
27+
28+
$flag = ltrim($flag, '-');
29+
$long_flag = '--' . $flag;
30+
$assignment = $long_flag . '=';
31+
$values = array();
32+
$count = count($argv);
33+
34+
for ( $i = 0; $i < $count; ++$i ) {
35+
$token = $argv[ $i ];
36+
if ( ! is_string($token) ) {
37+
continue;
38+
}
39+
40+
if ( 0 === strpos($token, $assignment) ) {
41+
$value = substr($token, strlen($assignment));
42+
if ( '' !== $value ) {
43+
$values[] = $value;
44+
}
45+
continue;
46+
}
47+
48+
if ( $long_flag !== $token ) {
49+
continue;
50+
}
51+
52+
$next = $argv[ $i + 1 ] ?? null;
53+
if ( ! is_string($next) || '' === $next || str_starts_with($next, '--') ) {
54+
continue;
55+
}
56+
57+
$values[] = $next;
58+
++$i;
59+
}
60+
61+
return $values;
62+
}
63+
}

inc/Cli/CliResponseRenderer.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
/**
3+
* Shared WP-CLI response rendering helpers.
4+
*
5+
* @package DataMachineCode\Cli
6+
*/
7+
8+
namespace DataMachineCode\Cli;
9+
10+
use WP_CLI;
11+
12+
defined('ABSPATH') || exit;
13+
14+
final class CliResponseRenderer {
15+
16+
/**
17+
* Render a payload as pretty JSON.
18+
*
19+
* @param mixed $payload Response payload.
20+
*/
21+
public function json( mixed $payload ): void {
22+
WP_CLI::line( (string) wp_json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) );
23+
}
24+
25+
/**
26+
* Render rows with WP-CLI's native item formatter.
27+
*
28+
* @param array<int,array<string,mixed>> $items Rows to render.
29+
* @param string[] $fields Field order.
30+
* @param array<string,mixed> $assoc_args CLI assoc args.
31+
* @param string $default_format Default format.
32+
*/
33+
public function items( array $items, array $fields, array $assoc_args, string $default_format = 'table' ): void {
34+
$format = (string) ( $assoc_args['format'] ?? $default_format );
35+
36+
if ( function_exists('WP_CLI\\Utils\\format_items') ) {
37+
\WP_CLI\Utils\format_items($format, $items, $fields);
38+
return;
39+
}
40+
41+
foreach ( $items as $item ) {
42+
WP_CLI::line(implode("\t", array_map(static fn( string $field ): string => (string) ( $item[ $field ] ?? '' ), $fields)));
43+
}
44+
}
45+
}

inc/Cli/Commands/CodeTaskCommand.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
namespace DataMachineCode\Cli\Commands;
99

10+
use DataMachineCode\Cli\CliResponseRenderer;
1011
use DataMachine\Cli\BaseCommand;
1112
use WP_CLI;
1213

@@ -81,11 +82,11 @@ public function create( array $args, array $assoc_args ): void {
8182
}
8283

8384
if ( 'json' === ( $assoc_args['format'] ?? 'table' ) ) {
84-
WP_CLI::log(wp_json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
85+
$this->renderer()->json($result);
8586
return;
8687
}
8788

88-
$this->format_items(
89+
$this->renderer()->items(
8990
array(
9091
array(
9192
'repo' => $result['repo'] ?? '',
@@ -96,11 +97,14 @@ public function create( array $args, array $assoc_args ): void {
9697
),
9798
),
9899
array( 'repo', 'branch', 'handle', 'prompt_path', 'source_url' ),
99-
$assoc_args,
100-
'repo'
100+
$assoc_args
101101
);
102102
}
103103

104+
private function renderer(): CliResponseRenderer {
105+
return new CliResponseRenderer();
106+
}
107+
104108
/**
105109
* @return array<string,mixed>
106110
*/

inc/Cli/Commands/GitHubCommand.php

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use WP_CLI;
1515
use DataMachine\Cli\BaseCommand;
16+
use DataMachineCode\Cli\CliResponseRenderer;
1617
use DataMachineCode\Abilities\GitHubAbilities;
1718
use DataMachineCode\GitHub\PrReviewFlowInstaller;
1819
use DataMachineCode\GitHub\PrReviewFlowScaffold;
@@ -86,7 +87,7 @@ public function issues( array $args, array $assoc_args ): void {
8687
$format = $assoc_args['format'] ?? 'table';
8788

8889
if ( 'json' === $format ) {
89-
WP_CLI::line( (string) \wp_json_encode($result, JSON_PRETTY_PRINT));
90+
$this->renderer()->json($result);
9091
return;
9192
}
9293

@@ -110,7 +111,7 @@ public function issues( array $args, array $assoc_args ): void {
110111
);
111112
}
112113

113-
$this->format_items($items, array( 'number', 'state', 'title', 'labels', 'comments', 'created_at' ), $assoc_args);
114+
$this->renderer()->items($items, array( 'number', 'state', 'title', 'labels', 'comments', 'created_at' ), $assoc_args);
114115
WP_CLI::log(sprintf('%d issue(s) returned.', count($items)));
115116
}
116117

@@ -163,7 +164,7 @@ public function view( array $args, array $assoc_args ): void {
163164
$format = $assoc_args['format'] ?? 'table';
164165

165166
if ( 'json' === $format ) {
166-
WP_CLI::line( (string) \wp_json_encode($result, JSON_PRETTY_PRINT));
167+
$this->renderer()->json($result);
167168
return;
168169
}
169170

@@ -351,7 +352,7 @@ public function pulls( array $args, array $assoc_args ): void {
351352
$format = $assoc_args['format'] ?? 'table';
352353

353354
if ( 'json' === $format ) {
354-
WP_CLI::line( (string) \wp_json_encode($result, JSON_PRETTY_PRINT));
355+
$this->renderer()->json($result);
355356
return;
356357
}
357358

@@ -381,7 +382,7 @@ public function pulls( array $args, array $assoc_args ): void {
381382
);
382383
}
383384

384-
$this->format_items($items, array( 'number', 'status', 'title', 'branch', 'user', 'created_at' ), $assoc_args);
385+
$this->renderer()->items($items, array( 'number', 'status', 'title', 'branch', 'user', 'created_at' ), $assoc_args);
385386
WP_CLI::log(sprintf('%d PR(s) returned.', count($items)));
386387
}
387388

@@ -568,7 +569,7 @@ public function repos( array $args, array $assoc_args ): void {
568569
$format = $assoc_args['format'] ?? 'table';
569570

570571
if ( 'json' === $format ) {
571-
WP_CLI::line( (string) \wp_json_encode($result, JSON_PRETTY_PRINT));
572+
$this->renderer()->json($result);
572573
return;
573574
}
574575

@@ -591,10 +592,14 @@ public function repos( array $args, array $assoc_args ): void {
591592
);
592593
}
593594

594-
$this->format_items($items, array( 'repo', 'language', 'stars', 'open_issues', 'private', 'last_push' ), $assoc_args);
595+
$this->renderer()->items($items, array( 'repo', 'language', 'stars', 'open_issues', 'private', 'last_push' ), $assoc_args);
595596
WP_CLI::log(sprintf('%d repo(s) returned.', count($items)));
596597
}
597598

599+
private function renderer(): CliResponseRenderer {
600+
return new CliResponseRenderer();
601+
}
602+
598603
/**
599604
* Check GitHub integration status.
600605
*

inc/Cli/Commands/WorkspaceCommand.php

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

1818
use WP_CLI;
1919
use DataMachine\Cli\BaseCommand;
20+
use DataMachineCode\Cli\CliRepeatableOptionParser;
2021
use DataMachineCode\Cleanup\CleanupRunEvidenceStoreInterface;
2122
use DataMachineCode\Cleanup\DataMachineJobCleanupRunEvidenceStore;
2223
use DataMachineCode\Workspace\Workspace;
@@ -2820,46 +2821,6 @@ public function delete( array $args, array $assoc_args ): void {
28202821
);
28212822
}
28222823

2823-
/**
2824-
* Collect every `--<flag>=<value>` occurrence from the raw process argv.
2825-
*
2826-
* WP-CLI's parsed `$assoc_args` is last-wins for repeated assoc flags —
2827-
* it never returns an array, even when the synopsis declares the flag as
2828-
* repeatable (`...`). For commands that document a flag as repeatable
2829-
* (`--rel`, `--include`, etc.), we walk $GLOBALS['argv'] directly so
2830-
* every occurrence is preserved in argv order.
2831-
*
2832-
* Empty values are filtered out. Bare `--<flag>` (no value) is ignored
2833-
* because it's ambiguous in the assoc-flag context.
2834-
*
2835-
* @param string $flag Flag name without the leading `--` (e.g. 'rel').
2836-
* @return string[] Every value, in argv order, for the named flag.
2837-
*/
2838-
private function collectRepeatableFlag( string $flag ): array {
2839-
$argv = $GLOBALS['argv'] ?? array();
2840-
if ( ! is_array($argv) ) {
2841-
return array();
2842-
}
2843-
2844-
$prefix = '--' . $flag . '=';
2845-
$prefix_len = strlen($prefix);
2846-
$values = array();
2847-
2848-
foreach ( $argv as $token ) {
2849-
if ( ! is_string($token) ) {
2850-
continue;
2851-
}
2852-
if ( 0 === strpos($token, $prefix) ) {
2853-
$value = substr($token, $prefix_len);
2854-
if ( '' !== $value ) {
2855-
$values[] = $value;
2856-
}
2857-
}
2858-
}
2859-
2860-
return $values;
2861-
}
2862-
28632824
/**
28642825
* Resolve @file syntax — if a string starts with @, read file contents.
28652826
*
@@ -3026,7 +2987,7 @@ public function git( array $args, array $assoc_args ): void {
30262987
}
30272988

30282989
if ( 'add' === $operation ) {
3029-
$input['paths'] = $this->collectRepeatableFlag('rel');
2990+
$input['paths'] = CliRepeatableOptionParser::collect('rel');
30302991

30312992
if ( empty($input['paths']) ) {
30322993
WP_CLI::error('git add requires at least one --rel=<relative/path>.');
@@ -3094,7 +3055,7 @@ public function git( array $args, array $assoc_args ): void {
30943055
if ( ! empty($assoc_args['squash']) ) {
30953056
$input['squash'] = true;
30963057
}
3097-
$drop_paths = $this->collectRepeatableFlag('drop-path');
3058+
$drop_paths = CliRepeatableOptionParser::collect('drop-path');
30983059
if ( ! empty($drop_paths) ) {
30993060
$input['drop_paths'] = $drop_paths;
31003061
}
@@ -3116,7 +3077,7 @@ public function git( array $args, array $assoc_args ): void {
31163077
if ( ! empty($assoc_args['staged']) ) {
31173078
$input['staged'] = true;
31183079
}
3119-
$diff_paths = $this->collectRepeatableFlag('rel');
3080+
$diff_paths = CliRepeatableOptionParser::collect('rel');
31203081
if ( ! empty($diff_paths) ) {
31213082
$input['path'] = (string) $diff_paths[0];
31223083
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
if ( ! defined('ABSPATH') ) {
6+
define('ABSPATH', __DIR__ . '/fixtures/');
7+
}
8+
9+
require_once dirname(__DIR__) . '/inc/Cli/CliRepeatableOptionParser.php';
10+
11+
use DataMachineCode\Cli\CliRepeatableOptionParser;
12+
13+
function assert_same( array $expected, array $actual, string $message ): void {
14+
if ( $expected !== $actual ) {
15+
throw new RuntimeException(
16+
sprintf(
17+
"%s\nExpected: %s\nActual: %s",
18+
$message,
19+
json_encode($expected),
20+
json_encode($actual)
21+
)
22+
);
23+
}
24+
}
25+
26+
assert_same(
27+
array( 'one.php', 'two.php', 'three.php' ),
28+
CliRepeatableOptionParser::collect(
29+
'rel',
30+
array( 'wp', 'datamachine-code', 'workspace', 'git', 'add', 'repo', '--rel=one.php', '--rel', 'two.php', '--other=value', '--rel=three.php' )
31+
),
32+
'Collects both assignment and separated repeatable option forms in order.'
33+
);
34+
35+
assert_same(
36+
array( 'kept' ),
37+
CliRepeatableOptionParser::collect('rel', array( '--rel=', '--rel', '--next=value', '--rel', '', '--rel', 'kept' )),
38+
'Ignores empty values and bare flags without a following value.'
39+
);
40+
41+
$GLOBALS['argv'] = array( 'wp', 'cmd', '--drop-path', 'a.txt', '--drop-path=b.txt' );
42+
assert_same(
43+
array( 'a.txt', 'b.txt' ),
44+
CliRepeatableOptionParser::collect('drop-path'),
45+
'Defaults to global argv when no argv is provided.'
46+
);
47+
48+
echo "cli-repeatable-option-parser: ok\n";

0 commit comments

Comments
 (0)