Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.19% covered (success)
96.19%
278 / 289
70.00% covered (success)
70.00%
7 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Command
96.19% covered (success)
96.19%
278 / 289
70.00% covered (success)
70.00%
7 / 10
171
0.00% covered (danger)
0.00%
0 / 1
 clientToCommand
89.19% covered (success)
89.19%
66 / 74
0.00% covered (danger)
0.00%
0 / 1
54.29
 commandToClient
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
6
 tokenize
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
10
 parseCommandOptions
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
14
 extractCommandOptionValues
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
20
 convertCommandOptions
98.08% covered (success)
98.08%
102 / 104
0.00% covered (danger)
0.00%
0 / 1
56
 urlEncodedContentType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 lastNonNullOptionValue
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 trimQuotes
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
5
 addQuotes
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Http\Client\Handler\Curl;
16
17use Pop\Http\Auth;
18use Pop\Http\Client;
19use Pop\Http\Client\Request;
20use Pop\Http\Client\Handler\Curl;
21use Pop\Http\Promise;
22
23/**
24 * HTTP client curl command class
25 *
26 * @category   Pop
27 * @package    Pop\Http
28 * @author     Nick Sagona, III <nick@popphp.org>
29 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
30 * @license    https://www.popphp.org/license     New BSD License
31 * @version    6.0.0
32 */
33class Command
34{
35
36    /**
37     * Create a compatible command string to execute with the curl CLI application
38     *
39     * @param  Client $client
40     * @return string
41     */
42    public static function clientToCommand(Client $client): string
43    {
44        $command        = 'curl';
45        $currentOptions = [];
46
47        // If client has a Curl handler, get current options before reset
48        if (($client->hasHandler()) && ($client->getHandler() instanceof Curl) && ($client->getHandler()->hasOptions())) {
49            $currentOptions = $client->getHandler()->getOptions();
50        }
51
52        $client->prepare();
53
54        if (!($client->getHandler() instanceof Curl)) {
55            throw new Exception('Error: The client object must use a Curl handler.');
56        }
57
58        $request = $client->getRequest();
59
60        // Set return header
61        if ($client->getHandler()->isReturnHeader()) {
62            $command .= ' -i';
63        }
64
65        // Set method
66        $method   = $request->getMethod();
67        $command .= ' -X ' . $method;
68
69        // Handle insecure settings
70        if (($client->hasOption('verify_peer') && ($client->getOption('verify_peer'))) ||
71            ($client->hasOption('allow_self_signed') && ($client->getOption('allow_self_signed'))) ||
72            ($client->getHandler()->hasOption(CURLOPT_SSL_VERIFYHOST) && (!$client->getHandler()->getOption(CURLOPT_SSL_VERIFYHOST))) ||
73            ($client->getHandler()->hasOption(CURLOPT_SSL_VERIFYPEER) && (!$client->getHandler()->getOption(CURLOPT_SSL_VERIFYPEER)))) {
74            $command .= ' --insecure';
75        }
76
77        // Handle basic auth
78        if (($client->hasAuth()) && ($client->getAuth()->isBasic())) {
79            $command .= ' --basic -u "' . $client->getAuth()->getUsername() . ':' .  $client->getAuth()->getPassword() . '"';
80            if ($request->hasHeader('Authorization')) {
81                $request->removeHeader('Authorization');
82            }
83        }
84
85        // Handle headers
86        if ($request->hasHeaders()) {
87            foreach ($request->getHeaderObjects() as $header) {
88                if ((!str_contains($header->getValueAsString(), 'multipart/form-data')) &&
89                    (!str_contains($header->getValueAsString(), 'x-www-form-urlencoded')) && ($header->getName() != 'Content-Length')) {
90                    $command .= ' --header "' . $header  . '"';
91                }
92            }
93        }
94
95        // Handle data
96        if ($request->hasData()) {
97            // Multipart form data
98            if ($request->isMultipart()) {
99                $data = $request->getData()->toArray();
100                foreach ($data as $key => $value) {
101                    $command .= (isset($value['filename']) && file_exists($value['filename'])) ?
102                        ' -F "' . $key . '=@' . $value['filename'] . '"' :
103                        ' -F "' . http_build_query([$key => $value]) . '"';
104                }
105            // JSON data
106            } else if ($request->isJson()) {
107                $data = $request->getData()->toArray();
108                foreach ($data as $key => $datum) {
109                    if (isset($datum['filename']) && file_exists($datum['filename'])) {
110                        $command .= ' --data @' . $datum['filename'];
111                        unset($data[$key]);
112                    }
113                }
114                if (!empty($data)) {
115                    $json = json_encode($data);
116                    if (str_contains($json, "'")) {
117                        $json = str_replace("'", "\\'", $json);
118                    }
119                    $command .= " --data '" . $json . "'";
120                }
121            // XML data
122            } else if ($request->isXml()) {
123                $data = $request->getData()->toArray();
124                foreach ($data as $key => $datum) {
125                    if (isset($datum['filename']) && file_exists($datum['filename'])) {
126                        $command .= ' --data @' . $datum['filename'];
127                        unset($data[$key]);
128                    }
129                }
130
131                if (!empty($data)) {
132                    foreach ($data as $datum) {
133                        if (str_contains($datum, "'")) {
134                            $datum = str_replace("'", "\\'", $datum);
135                        }
136                        $command .= " --data '" . $datum . "'";
137                    }
138                }
139            // URL-encoded data
140            } else if (($request->getMethod() == 'GET') || ($request->isUrlEncoded()) ||
141                !($request->hasRequestType())) {
142                $command .= ' --data "' . $request->getData()->prepare()->getDataContent()  . '"';
143            }
144        }
145
146        // Add body content as data
147        if ($request->hasBody()) {
148            $body = $request->getBodyContent();
149            if (str_contains($body, "'")) {
150                $body = str_replace("'", "\\'", $body);
151            }
152            $command .= " --data '" . $body . "'";
153        }
154
155        // Handle all other options
156        $curlOptions =  $client->getHandler()->getOptions() + $currentOptions;
157        foreach ($curlOptions as $curlOption => $curlOptionValue) {
158            $curlOptionName = Options::getOptionNameByValue($curlOption);
159            if (!Options::isOmitOption($curlOptionName)) {
160                $commandOption = Options::getPhpOption($curlOptionName);
161                $command .= (is_array($commandOption) && isset($commandOption[0])) ?
162                    ' ' . $commandOption[0] : ' ' . $commandOption;
163                if (Options::isValueOption($curlOptionName) && !empty($curlOptionValue)) {
164                    $command .= ' ' . self::addQuotes((string)$curlOptionValue);
165                }
166            }
167        }
168
169        $command .= ' ' . self::addQuotes($request->getUriAsString());
170
171        return $command;
172    }
173
174    /**
175     * Create a client object from a command string from the Curl CLI application
176     *
177     * @param  string $command
178     * @return Client
179     */
180    public static function commandToClient(string $command): Client
181    {
182        $command = trim($command);
183
184        if (!str_starts_with($command, 'curl')) {
185            throw new Exception("Error: The command isn't a valid cURL command.");
186        }
187
188        $command = substr($command, 4);
189        $options = [];
190
191        // No options
192        if (!str_contains($command, '-')) {
193            $requestUri = trim($command);
194        // Else, parse options
195        } else {
196            $optionString = substr($command, 0, strrpos($command, ' '));
197            $requestUri   = substr($command, (strrpos($command, ' ') + 1));
198            $options      = self::parseCommandOptions($optionString);
199        }
200
201        $request = new Request(self::trimQuotes($requestUri));
202        $curl    = new Curl();
203        $files   = null;
204
205        if (!empty($options)) {
206            [$auth, $files] = self::convertCommandOptions($options, $curl, $request);
207        }
208
209        $client = new Client($request, $curl);
210
211        if (!empty($auth)) {
212            $client->setAuth($auth);
213        }
214        if (!empty($files)) {
215            $client->setFiles($files, false);
216        }
217
218        return $client;
219    }
220
221    /**
222     * Tokenize a command-line string into words, respecting single/double
223     * quotes so a space-then-dash inside a quoted value isn't mistaken for
224     * the start of a new option.
225     *
226     * Each token is returned as ['value' => string, 'quoted' => bool] rather
227     * than a plain string: the quote characters themselves are consumed here
228     * and never appear in 'value', so once tokenizing is done there is no way
229     * to tell from the value alone whether a token like '-foo=bar' was a
230     * quoted value or a real flag - callers (parseCommandOptions()) need the
231     * 'quoted' flag to make that distinction. 'quoted' is true only when the
232     * token's very FIRST character came from inside a quote (e.g. '-foo=bar'
233     * or "-foo=bar"), not merely whether a quote appears anywhere in the
234     * token - a compact flag+value like -d"foo=bar" (no space, unquoted -d
235     * prefix) must still be recognized as starting with a real, unquoted
236     * dash.
237     *
238     * @param  string $string
239     * @return array
240     */
241    protected static function tokenize(string $string): array
242    {
243        $tokens    = [];
244        $current   = '';
245        $inQuote   = null;
246        $hasToken  = false;
247        $quoted    = false;
248
249        for ($i = 0, $length = strlen($string); $i < $length; $i++) {
250            $char = $string[$i];
251
252            if ($inQuote !== null) {
253                if ($char === $inQuote) {
254                    $inQuote = null;
255                } else {
256                    $current .= $char;
257                }
258                continue;
259            }
260
261            if (($char === '"') || ($char === "'")) {
262                // Only mark the token as "quoted" if this quote is the very first
263                // character of the token - a quote appearing after some already-consumed
264                // unquoted characters (e.g. -d"foo=bar") doesn't retroactively make the
265                // token's leading dash a quoted one.
266                if (!$hasToken) {
267                    $quoted = true;
268                }
269                // Opening a quote starts a token even if its contents end up
270                // empty (e.g. an empty '' or "" argument), so it isn't lost.
271                $inQuote  = $char;
272                $hasToken = true;
273                continue;
274            }
275
276            if ($char === ' ') {
277                if ($hasToken) {
278                    $tokens[]  = ['value' => $current, 'quoted' => $quoted];
279                    $current   = '';
280                    $hasToken  = false;
281                    $quoted    = false;
282                }
283                continue;
284            }
285
286            $current  .= $char;
287            $hasToken  = true;
288        }
289
290        if ($hasToken) {
291            $tokens[] = ['value' => $current, 'quoted' => $quoted];
292        }
293
294        return $tokens;
295    }
296
297    /**
298     * Parse the CLI command options string
299     *
300     * @param  string $optionString
301     * @return array
302     */
303    public static function parseCommandOptions(string $optionString): array
304    {
305        $tokens  = self::tokenize($optionString);
306        $options = [];
307        $current = null;
308
309        foreach ($tokens as $token) {
310            $value  = $token['value'];
311            $quoted = $token['quoted'];
312
313            // A quoted token that happens to start with '-' (e.g. --data '-foo=bar') is
314            // normally a value continuation, never a new flag - only an UNQUOTED leading
315            // dash starts a new option. Without this, a quoted value like '-foo=bar' gets
316            // misread as a stray flag, leaving the preceding option (e.g. --data) with no
317            // value at all.
318            //
319            // The exception: a quoted token that is ALSO a real, recognized CLI flag
320            // (e.g. curl '-X' POST, or -X POST "-H" "Accept: text/plain") must still be
321            // treated as a new flag - curl itself doesn't care whether a flag was quoted
322            // on the shell, only whether the currently-accumulated option is still
323            // awaiting its own value. $awaitingValue captures that: right after starting
324            // $current = '--data' (no space yet), the very next token - even if quoted
325            // and shaped like a real flag - is still --data's value, matching curl's
326            // real semantics where a value-taking option always consumes whatever
327            // immediately follows it. Once $current has a space in it (e.g. '-X POST'),
328            // it's "complete," so the next quoted-and-recognized-flag token correctly
329            // starts a NEW option. A null $current (the very first token) is never
330            // "awaiting a value," so a quoted, recognized flag as the first token is
331            // correctly treated as starting the first option.
332            //
333            // Critically, $current only "awaits a value" when it's actually a value-taking
334            // flag (per Options::isValueOption()). A bare boolean flag like '-L' also has
335            // no space right after it starts, but it never takes a value - so the very next
336            // token, even if quoted and shaped like a real flag (e.g. curl '-L' '-X' POST),
337            // must still be read as a NEW option rather than being swallowed as '-L's
338            // (nonexistent) value.
339            $isRecognizedFlag = str_starts_with($value, '-') && Options::isCommandOption($value);
340            $awaitingValue    = ($current !== null) && !str_contains($current, ' ') && Options::isValueOption($current);
341
342            if ((!$quoted && str_starts_with($value, '-')) || ($quoted && $isRecognizedFlag && !$awaitingValue)) {
343                if ($current !== null) {
344                    $options[] = $current;
345                }
346                $current = $value;
347            } else if ($current !== null) {
348                $current .= ' ' . self::addQuotes($value, str_contains($value, ' ') ? '"' : '');
349            }
350        }
351
352        if ($current !== null) {
353            $options[] = $current;
354        }
355
356        return $options;
357    }
358
359    /**
360     * Extract command option values
361     *
362     * @param  array $options
363     * @return array
364     */
365    public static function extractCommandOptionValues(array $options): array
366    {
367        $optionValues = [];
368
369        foreach ($options as $option) {
370            $opt = null;
371            $val = null;
372            if (str_starts_with($option, '--')) {
373                if (str_contains($option, ' ')) {
374                    $opt = substr($option, 0, strpos($option, ' '));
375                    $val = substr($option, (strpos($option, ' ') + 1));
376                } else {
377                    $opt = $option;
378                }
379            } else {
380                if (strlen($option) > 2) {
381                    if (substr($option, 2, 1) == ' ') {
382                        $opt = substr($option, 0, 2);
383                        $val = substr($option, 3);
384                    } else {
385                        $opt = substr($option, 0, 2);
386                        $val = substr($option, 2);
387                    }
388                } else {
389                    $opt = $option;
390                }
391            }
392
393            if (($opt == '-d') || ($opt == '--data') || ($opt == '-F') || ($opt == '--form')) {
394                if ((($opt == '-F') || ($opt == '--form')) && ($val === null)) {
395                    // A valueless -F/--form occurrence (nothing follows the flag - e.g. it's
396                    // the last token before the request URI, as in 'curl -F "foo=bar" -F
397                    // http://x/') contributes no real form field at all. Unlike -d/--data
398                    // (which still merges a coalesced '' as raw string data below - see the
399                    // defense-in-depth comment there), -F/--form must skip this occurrence
400                    // entirely rather than merging a placeholder value into the array: once
401                    // merged, a placeholder is structurally indistinguishable downstream from
402                    // a legitimate field (parse_str() casts numeric-string keys like "0" to
403                    // real int keys, and an intentionally empty value like -F "foo=" is a
404                    // valid field too), so there's no way to filter it back out later without
405                    // false-positiving on real data. Skipping it here, before it ever enters
406                    // $optionValues, avoids the ambiguity altogether.
407                    continue;
408                }
409                // Defense-in-depth: $val can still legitimately be null here for -d/--data
410                // (e.g. a valueless -d/--data with truly nothing following it, such as
411                // 'curl -X POST --data http://x/'). Coalesce to '' instead of letting a null
412                // reach trimQuotes()'s string-typed parameter, which would throw a confusing
413                // TypeError from an unrelated method rather than degrading gracefully.
414                $val = self::trimQuotes($val ?? '');
415                // Only -F/--form values are parsed into key/value pairs here: convertCommandOptions()
416                // needs them pre-split into an array (see its "Handle form data" block). -d/--data is
417                // left as the raw (unquoted) string so convertCommandOptions() can decide how to
418                // interpret it based on the request's actual Content-Type, rather than guessing from
419                // whether the value happens to contain an '=' (which false-positives on JSON bodies
420                // like '{"filter":"a=b"}').
421                if (($opt == '-F') || ($opt == '--form')) {
422                    if (str_contains($val, '=') && !str_contains($val, '<?xml')) {
423                        parse_str(self::trimQuotes($val), $val);
424                    }
425                }
426            }
427
428            if (isset($optionValues[$opt])) {
429                if (!is_array($optionValues[$opt])) {
430                    $optionValues[$opt] = [$optionValues[$opt]];
431                }
432                if (is_array($val)) {
433                    $optionValues[$opt] = array_merge($optionValues[$opt], $val);
434                } else {
435                    $optionValues[$opt][] = $val;
436                }
437            } else {
438                $optionValues[$opt] = $val;
439            }
440        }
441
442        return $optionValues;
443    }
444
445    /**
446     * Convert CLI options to usable values for the Curl handler and request
447     *
448     * @param  array   $options
449     * @param  Curl    $curl
450     * @param  Request $request
451     * @return array
452     */
453    public static function convertCommandOptions(array $options, Curl $curl, Request $request): array
454    {
455        $optionValues = self::extractCommandOptionValues($options);
456        $auth         = null;
457        $files        = [];
458
459        // Handle method
460        // If forced GET method
461        if (array_key_exists('-G', $optionValues) || array_key_exists('--get', $optionValues)) {
462            $request->setMethod('GET');
463            if (array_key_exists('-G', $optionValues)) {
464                unset($optionValues['-G']);
465            } else {
466                unset($optionValues['--get']);
467            }
468            // If HEAD method
469        } else if (array_key_exists('-I', $optionValues) || array_key_exists('--head', $optionValues)) {
470            $request->setMethod('HEAD');
471            if (array_key_exists('-I', $optionValues)) {
472                unset($optionValues['-I']);
473            } else {
474                unset($optionValues['--head']);
475            }
476        // All other methods
477        } else if (isset($optionValues['-X']) || isset($optionValues['--request'])) {
478            // -X/--request is a "last occurrence wins" flag, like -u/--user and -A/--user-agent,
479            // not cumulative - a repeated occurrence collects every value into an array (see
480            // extractCommandOptionValues()), which Request::setMethod()'s strict `string $method`
481            // typehint can't accept. Normalize via the same helper used for -u/--user and the
482            // generic options loop; fall back to 'GET', matching Request's own default method,
483            // for the (unlikely) case every repeated occurrence was valueless.
484            $request->setMethod(self::lastNonNullOptionValue($optionValues['-X'] ?? $optionValues['--request']) ?? 'GET');
485            if (isset($optionValues['-X'])) {
486                unset($optionValues['-X']);
487            } else {
488                unset($optionValues['--request']);
489            }
490        }
491
492        // Handle insecure settings
493        if (array_key_exists('-k', $optionValues) || array_key_exists('--insecure', $optionValues)) {
494            $curl->setOption(CURLOPT_SSL_VERIFYHOST, 0);
495            $curl->setOption(CURLOPT_SSL_VERIFYPEER, 0);
496        }
497
498        // Handle headers
499        if (isset($optionValues['-H']) || isset($optionValues['--header'])) {
500            $headerOpts = ($optionValues['-H'] ?? $optionValues['--header']);
501            if (is_array($headerOpts)) {
502                // A repeated -H/--header flag with one valueless occurrence collects a null
503                // into this array alongside the real values (see extractCommandOptionValues()).
504                // Drop those null entries rather than coercing them to '' and handing an empty
505                // string to trimQuotes()/addHeaders() - an empty header string isn't a valid
506                // 'Name: Value' pair and would blow up downstream in Header::parse() instead.
507                $headers = array_map(function ($value) {
508                    return Command::trimQuotes($value);
509                }, array_filter($headerOpts, function ($value) {
510                    return $value !== null;
511                }));
512            } else {
513                $headers = [Command::trimQuotes($headerOpts ?? '')];
514            }
515
516            $request->addHeaders($headers);
517
518            if (isset($optionValues['-H'])) {
519                unset($optionValues['-H']);
520            } else {
521                unset($optionValues['--header']);
522            }
523        }
524
525        // Handle basic auth
526        if ((!array_key_exists('--digest', $optionValues) || array_key_exists('--basic', $optionValues) || array_key_exists('--anyauth', $optionValues)) &&
527            (isset($optionValues['-u']) || isset($optionValues['--user']))) {
528            $userData = self::lastNonNullOptionValue($optionValues['-u'] ?? $optionValues['--user']);
529            if (($userData !== null) && str_contains($userData, ':')) {
530                [$username, $password] = explode(':', self::trimQuotes($userData), 2);
531                $auth = Auth::createBasic($username, $password);
532                if (isset($optionValues['-u'])) {
533                    unset($optionValues['-u']);
534                } else {
535                    unset($optionValues['--user']);
536                }
537                if (array_key_exists('--basic', $optionValues)) {
538                    unset($optionValues['--basic']);
539                }
540            }
541        }
542
543        // Handle JSON request
544        if (array_key_exists('--json', $optionValues)) {
545            $request->addHeaders([
546                'Content-Type: application/json',
547                'Accept: application/json'
548            ]);
549            unset($optionValues['--json']);
550        }
551
552        // Handle data
553        if (isset($optionValues['-d']) || isset($optionValues['--data'])) {
554            $data        = ($optionValues['-d'] ?? $optionValues['--data']);
555            $contentType = $request->hasHeader('Content-Type') ? $request->getHeaderValueAsString('Content-Type') : null;
556
557            // Multiple -d/--data occurrences are extracted as an array of raw string chunks
558            // (curl's own behavior is to join repeated -d values with '&'), so collapse that
559            // back into a single string before applying the same Content-Type dispatch used
560            // for a single occurrence below.
561            if (is_array($data) && (count($data) === count(array_filter($data, 'is_string')))) {
562                $data = implode('&', $data);
563            }
564
565            if (is_string($data)) {
566                if (str_starts_with($data, '@')) {
567                    //&& file_exists(getcwd() . DIRECTORY_SEPARATOR . substr($data, 1))) {
568                    $file = substr($data, 1);
569                    if (!str_starts_with($file, '/')) {
570                        $file = getcwd() . DIRECTORY_SEPARATOR . substr($data, 1);
571                    }
572                    if (file_exists($file)) {
573                        $files[] = $file;
574                    }
575                } else if (($contentType !== null) && str_contains($contentType, 'json')) {
576                    // A declared JSON Content-Type doesn't guarantee the body is actually valid
577                    // JSON (e.g. a mismatched -d value). json_decode() returns null on failure,
578                    // and passing null through to $request->setData() would throw a TypeError
579                    // from Data::__construct() three calls away with no indication of what went
580                    // wrong - so on decode failure, fall back to leaving $data as the raw string,
581                    // matching this block's existing "leave as raw string" fallback philosophy.
582                    // json_decode('null') is also VALID JSON (JSON_ERROR_NONE) that legitimately
583                    // decodes to PHP null, so the success check alone isn't enough - a literal
584                    // 'null' body must fall through to the same raw-string fallback too, rather
585                    // than passing null through to setData() and crashing the same way.
586                    $decoded = json_decode($data, true);
587                    if ((json_last_error() === JSON_ERROR_NONE) && ($decoded !== null)) {
588                        $data = $decoded;
589                    }
590                } else if (($contentType !== null) && str_contains($contentType, self::urlEncodedContentType())) {
591                    parse_str($data, $data);
592                } else if (($contentType === null) && str_contains($data, '=') && !str_contains($data, '<?xml')) {
593                    // No explicit Content-Type: curl's own default behavior for -d/--data is to
594                    // treat the value as application/x-www-form-urlencoded name=value pairs, so
595                    // match that here (same shape heuristic previously applied during extraction)
596                    // rather than leaving it as an opaque raw string.
597                    parse_str($data, $data);
598                }
599                // Any other Content-Type (e.g. XML, or a custom type), or a plain value with no
600                // '=' and no Content-Type: leave $data as the raw string, unchanged.
601            }
602            $request->setData($data);
603            if (isset($optionValues['-d'])) {
604                unset($optionValues['-d']);
605            } else {
606                unset($optionValues['--data']);
607            }
608        }
609
610        // Handle form data
611        if (isset($optionValues['-F']) || isset($optionValues['--form'])) {
612            $data     = [];
613            $formData = ($optionValues['-F'] ?? $optionValues['--form']);
614            if (is_array($formData)) {
615                // Note: a repeated -F/--form flag with one valueless occurrence no longer
616                // leaves a bogus placeholder in this array at all - extractCommandOptionValues()
617                // now skips a valueless -F/--form occurrence entirely at the source, since a
618                // downstream filter here can't reliably tell a placeholder apart from a
619                // legitimate field (parse_str() casts a numeric-string key like "0" to a real
620                // int key, and -F "foo=" is a legitimately empty-but-real value) - see the
621                // comment in extractCommandOptionValues() for the full reasoning.
622                foreach ($formData as $key => $formDatum) {
623                    if (str_starts_with($formDatum, '@')) {
624                        $data[$key] = [
625                            'filename'    => getcwd() . DIRECTORY_SEPARATOR . substr($formDatum, 1),
626                            'contentType' => Client\Data::getMimeTypeFromFilename(substr($formDatum, 1))
627                        ];
628                    } else {
629                        $data[$key] = $formDatum;
630                    }
631                }
632            }
633
634            $request->setData($data)
635                ->setRequestType(Request::MULTIPART);
636
637            if (isset($optionValues['-F'])) {
638                unset($optionValues['-F']);
639            } else {
640                unset($optionValues['--form']);
641            }
642        }
643
644        // Handle all other options
645        //
646        // extractCommandOptionValues() only ever produces complete, exact CLI flags as keys
647        // (a full '--long-flag', or exactly the first 2 characters of a short flag) - never an
648        // abbreviated/partial one - so the option always matches a $commandOptions key exactly.
649        // That makes Options::getCommandOption() (an O(1) hash lookup, already keyed by exact
650        // flag) equivalent to - and far cheaper than - scanning every entry of the ~150+-entry
651        // inverse getPhpOptions() map with substring matching. The substring matching also had
652        // a latent false-positive risk (e.g. '--cookie' is a substring of '--cookie-jar').
653        foreach ($optionValues as $option => $value) {
654            // A repeated single-value flag (e.g. -A/--user-agent) collects every occurrence
655            // into an array (see extractCommandOptionValues()), but curl's real semantics for
656            // these generic options is "last occurrence wins" - normalize once here so both
657            // trimQuotes() calls below always receive a string|null, never an array.
658            $value = self::lastNonNullOptionValue($value);
659
660            if (Options::isOmitOption($option)) {
661                continue;
662            }
663
664            $phpConstants = Options::getCommandOption($option);
665            if ($phpConstants === null) {
666                continue;
667            }
668
669            // A $commandOptions entry mapping one flag to multiple constants (e.g.
670            // --connect-timeout => [CURLOPT_CONNECTTIMEOUT, CURLOPT_TIMEOUT, CURLOPT_TIMEOUT_MS])
671            // is ordered by preference - only the first-listed constant is the semantically
672            // correct one to actually set here (see the inline comments in $commandOptions).
673            $phpConstant = is_array($phpConstants) ? reset($phpConstants) : $phpConstants;
674
675            $optionValue = (Options::isValueOption($option))
676                ? (Options::getValueOption($option) ?? self::trimQuotes($value ?? ''))
677                : true;
678
679            $curl->setOption(constant($phpConstant), $optionValue);
680        }
681
682        return [$auth, $files];
683    }
684
685    /**
686     * The application/x-www-form-urlencoded content type string, broken out
687     * as its own method purely so it isn't a magic string duplicated inline.
688     *
689     * @return string
690     */
691    protected static function urlEncodedContentType(): string
692    {
693        return Request::URLENCODED;
694    }
695
696    /**
697     * extractCommandOptionValues() collects every occurrence of a repeated CLI flag into
698     * an array. Most single-value options follow curl's own "last occurrence wins"
699     * semantics when repeated (e.g. -u/--user, -A/--user-agent) - this extracts the last
700     * non-null string from such an array, a bare scalar value unchanged, or null if every
701     * repeated occurrence was valueless. Cumulative flags (-H/--header, -d/--data,
702     * -F/--form) have their own dedicated array-handling logic and don't use this helper.
703     *
704     * @param  mixed $value
705     * @return string|null
706     */
707    protected static function lastNonNullOptionValue(mixed $value): ?string
708    {
709        if (!is_array($value)) {
710            return $value;
711        }
712        $strings = array_filter($value, 'is_string');
713        return !empty($strings) ? end($strings) : null;
714    }
715
716    /**
717     * Trim quotes from value
718     *
719     * @param  string  $value
720     * @return string
721     */
722    public static function trimQuotes(string $value): string
723    {
724        if ((str_starts_with($value, '"') && str_ends_with($value, '"')) || (str_starts_with($value, "'") && str_ends_with($value, "'"))) {
725            $value = substr($value, 1);
726            $value = substr($value, 0, -1);
727        }
728
729        return $value;
730    }
731
732    /**
733     * Trim quotes from value
734     *
735     * @param  string $value
736     * @param  string $quote
737     * @return string
738     */
739    public static function addQuotes(string $value, string $quote = '"'): string
740    {
741        if (!str_starts_with($value, '"') && !str_ends_with($value, '"') && !str_starts_with($value, "'") && !str_ends_with($value, "'")) {
742            $value = $quote . $value . $quote;
743        }
744
745        return $value;
746    }
747
748}