1: <?php declare(strict_types=1);
2:
3: namespace Salient\Cli;
4:
5: use Salient\Cli\Exception\CliInvalidArgumentsException;
6: use Salient\Console\Support\ConsoleManPageFormat;
7: use Salient\Console\Support\ConsoleMarkdownFormat;
8: use Salient\Console\ConsoleFormatter as Formatter;
9: use Salient\Container\Application;
10: use Salient\Contract\Cli\CliApplicationInterface;
11: use Salient\Contract\Cli\CliCommandInterface;
12: use Salient\Contract\Cli\CliHelpSectionName;
13: use Salient\Contract\Cli\CliHelpTarget;
14: use Salient\Contract\Core\HasJsonSchema;
15: use Salient\Core\Facade\Console;
16: use Salient\Utility\Exception\InvalidRuntimeConfigurationException;
17: use Salient\Utility\Exception\ShouldNotHappenException;
18: use Salient\Utility\Arr;
19: use Salient\Utility\Env;
20: use Salient\Utility\Get;
21: use Salient\Utility\Json;
22: use Salient\Utility\Package;
23: use Salient\Utility\Regex;
24: use Salient\Utility\Str;
25: use Salient\Utility\Sys;
26: use InvalidArgumentException;
27: use LogicException;
28:
29: /**
30: * A service container for CLI applications
31: */
32: class CliApplication extends Application implements CliApplicationInterface
33: {
34: private const COMMAND_REGEX = '/^[a-z][a-z0-9_-]*$/iD';
35:
36: /** @var array<string,class-string<CliCommandInterface>|mixed[]> */
37: private $CommandTree = [];
38: private ?CliCommandInterface $RunningCommand = null;
39: private ?CliCommandInterface $LastCommand = null;
40: private int $LastExitStatus = 0;
41:
42: /**
43: * @inheritDoc
44: */
45: public function __construct(
46: ?string $basePath = null,
47: ?string $appName = null,
48: int $envFlags = Env::APPLY_ALL,
49: ?string $configDir = 'config'
50: ) {
51: parent::__construct($basePath, $appName, $envFlags, $configDir);
52:
53: if (\PHP_SAPI !== 'cli') {
54: // @codeCoverageIgnoreStart
55: throw new ShouldNotHappenException('Not running via PHP CLI');
56: // @codeCoverageIgnoreEnd
57: }
58:
59: if (!ini_get('register_argc_argv')) {
60: // @codeCoverageIgnoreStart
61: throw new InvalidRuntimeConfigurationException('register_argc_argv must be enabled');
62: // @codeCoverageIgnoreEnd
63: }
64:
65: // Keep running, even if:
66: // - the TTY disconnects
67: // - `max_execution_time` is non-zero
68: // - the configured `memory_limit` is exceeded
69: ignore_user_abort(true);
70: set_time_limit(0);
71: ini_set('memory_limit', '-1');
72:
73: // Exit cleanly if interrupted
74: Sys::handleExitSignals();
75: }
76:
77: /**
78: * @inheritDoc
79: */
80: public function getProgramName(): string
81: {
82: return Sys::getProgramBasename();
83: }
84:
85: /**
86: * @inheritDoc
87: */
88: public function getRunningCommand(): ?CliCommandInterface
89: {
90: return $this->RunningCommand;
91: }
92:
93: /**
94: * @inheritDoc
95: */
96: public function getLastCommand(): ?CliCommandInterface
97: {
98: return $this->LastCommand;
99: }
100:
101: /**
102: * @inheritDoc
103: */
104: public function getLastExitStatus(): int
105: {
106: return $this->LastExitStatus;
107: }
108:
109: /**
110: * Get a command instance from the given node in the command tree
111: *
112: * Returns `null` if no command is registered at the given node.
113: *
114: * @param string $name The name of the node as a space-delimited list of
115: * subcommands.
116: * @param array<string,class-string<CliCommandInterface>|mixed[]>|class-string<CliCommandInterface>|false|null $node The node as returned by {@see CliApplication::getNode()}.
117: */
118: protected function getNodeCommand(string $name, $node): ?CliCommandInterface
119: {
120: if (!is_string($node)) {
121: return null;
122: }
123:
124: $command = $this->get($node);
125: $command->setName($name === '' ? [] : explode(' ', $name));
126: return $command;
127: }
128:
129: /**
130: * Resolve an array of subcommand names to a node in the command tree
131: *
132: * Returns one of the following:
133: * - `null` if nothing has been added to the tree at `$name`
134: * - the name of the {@see CliCommandInterface} class registered at `$name`
135: * - an array that maps subcommands of `$name` to their respective nodes
136: * - `false` if a {@see CliCommandInterface} has been registered above
137: * `$name`, e.g. if `$name` is `["sync", "canvas", "from-sis"]` and a
138: * command has been registered at `["sync", "canvas"]`
139: *
140: * Nodes in the command tree are either subcommand arrays (branches) or
141: * {@see CliCommandInterface} class names (leaves).
142: *
143: * @param string[] $name
144: * @return array<string,class-string<CliCommandInterface>|mixed[]>|class-string<CliCommandInterface>|false|null
145: */
146: protected function getNode(array $name = [])
147: {
148: $tree = $this->CommandTree;
149:
150: foreach ($name as $subcommand) {
151: if ($tree === null) {
152: return null;
153: } elseif (!is_array($tree)) {
154: return false;
155: }
156:
157: $tree = $tree[$subcommand] ?? null;
158: }
159:
160: // @phpstan-ignore return.type
161: return $tree ?: null;
162: }
163:
164: /**
165: * @inheritDoc
166: */
167: public function oneCommand(string $id)
168: {
169: return $this->command([], $id);
170: }
171:
172: /**
173: * @inheritDoc
174: */
175: public function command(array $name, string $id)
176: {
177: foreach ($name as $subcommand) {
178: if (!Regex::match(self::COMMAND_REGEX, $subcommand)) {
179: throw new InvalidArgumentException(sprintf(
180: 'Subcommand does not start with a letter, followed by zero or more letters, numbers, hyphens or underscores: %s',
181: $subcommand,
182: ));
183: }
184: }
185:
186: if ($this->getNode($name) !== null) {
187: throw new LogicException("Another command has been registered at '" . implode(' ', $name) . "'");
188: }
189:
190: $tree = &$this->CommandTree;
191: $branch = $name;
192: $leaf = array_pop($branch);
193:
194: foreach ($branch as $subcommand) {
195: if (!is_array($tree[$subcommand] ?? null)) {
196: $tree[$subcommand] = [];
197: }
198:
199: $tree = &$tree[$subcommand];
200: }
201:
202: if ($leaf !== null) {
203: $tree[$leaf] = $id;
204: } else {
205: $tree = $id;
206: }
207:
208: return $this;
209: }
210:
211: /**
212: * Get a help message for a command tree node
213: *
214: * @param array<string,class-string<CliCommandInterface>|mixed[]>|class-string<CliCommandInterface> $node
215: */
216: private function getHelp(string $name, $node, ?CliHelpStyle $style = null): ?string
217: {
218: $style ??= new CliHelpStyle(CliHelpTarget::NORMAL);
219:
220: $command = $this->getNodeCommand($name, $node);
221: if ($command) {
222: return $style->buildHelp($command->getHelp($style));
223: }
224:
225: if (!is_array($node)) {
226: return null;
227: }
228:
229: $progName = $this->getProgramName();
230: $fullName = trim("$progName $name");
231: $synopses = [];
232: /** @var array<string,class-string<CliCommandInterface>|mixed[]>|class-string<CliCommandInterface>|false|null $childNode */
233: foreach ($node as $childName => $childNode) {
234: $command = $this->getNodeCommand(trim("$name $childName"), $childNode);
235: if ($command) {
236: $synopses[] = '__' . $childName . '__ - ' . Formatter::escapeTags($command->getDescription());
237: } elseif (is_array($childNode)) {
238: $synopses[] = '__' . $childName . '__';
239: }
240: }
241:
242: return $style->buildHelp([
243: CliHelpSectionName::NAME => $fullName,
244: CliHelpSectionName::SYNOPSIS => '__' . $fullName . '__ <command>',
245: 'SUBCOMMANDS' => implode("\n", $synopses),
246: ]);
247: }
248:
249: /**
250: * Get usage information for a command tree node
251: *
252: * @param array<string,class-string<CliCommandInterface>|mixed[]>|class-string<CliCommandInterface> $node
253: */
254: private function getUsage(string $name, $node): ?string
255: {
256: $style = new CliHelpStyle(CliHelpTarget::PLAIN, CliHelpStyle::getConsoleWidth());
257:
258: $command = $this->getNodeCommand($name, $node);
259: $progName = $this->getProgramName();
260:
261: if ($command) {
262: return $command->getSynopsis($style)
263: . Formatter::escapeTags("\n\nSee '"
264: . ($name === '' ? "$progName --help" : "$progName help $name")
265: . "' for more information.");
266: }
267:
268: if (!is_array($node)) {
269: return null;
270: }
271:
272: $style = $style->withCollapseSynopsis();
273: $fullName = trim("$progName $name");
274: $synopses = [];
275: /** @var array<string,class-string<CliCommandInterface>|mixed[]>|class-string<CliCommandInterface>|false|null $childNode */
276: foreach ($node as $childName => $childNode) {
277: $command = $this->getNodeCommand(trim("$name $childName"), $childNode);
278: if ($command) {
279: $synopsis = $command->getSynopsis($style);
280: } elseif (is_array($childNode)) {
281: $synopsis = "$fullName $childName <command>";
282: $synopsis = Formatter::escapeTags($synopsis);
283: } else {
284: continue;
285: }
286: $synopses[] = $synopsis;
287: }
288:
289: return implode("\n", $synopses)
290: . Formatter::escapeTags("\n\nSee '"
291: . Arr::implode(' ', ["$progName help", $name, '<command>'])
292: . "' for more information.");
293: }
294:
295: /**
296: * @inheritDoc
297: */
298: public function run()
299: {
300: $this->LastExitStatus = 0;
301:
302: /** @var string[] */
303: $args = $_SERVER['argv'];
304: $args = array_slice($args, 1);
305:
306: $lastNode = null;
307: $lastName = null;
308: $node = $this->CommandTree;
309: $name = '';
310:
311: while (is_array($node)) {
312: $arg = array_shift($args);
313:
314: // Print usage info if the last remaining $arg is "--help"
315: if ($arg === '--help' && !$args) {
316: $usage = $this->getHelp($name, $node);
317: if ($usage !== null) {
318: Console::printStdout($usage);
319: }
320: return $this;
321: }
322:
323: // or version info if it's "--version"
324: if ($arg === '--version' && !$args) {
325: return $this->reportVersion(Console::LEVEL_INFO, true);
326: }
327:
328: // - If $args was empty before this iteration, print terse usage
329: // info and exit without error
330: // - If $arg cannot be a valid subcommand, print terse usage info
331: // and return a non-zero exit status
332: if (
333: $arg === null
334: || !Regex::match('/^[a-zA-Z][a-zA-Z0-9_-]*$/', $arg)
335: ) {
336: $usage = $this->getUsage($name, $node);
337: if ($usage !== null) {
338: Console::printOut($usage);
339: }
340: $this->LastExitStatus =
341: $arg === null
342: ? 0
343: : 1;
344: return $this;
345: }
346:
347: // Descend into the command tree if $arg is a registered subcommand
348: // or an unambiguous abbreviation thereof
349: $nodes = [];
350: foreach ($node as $childName => $childNode) {
351: if (strpos($childName, $arg) === 0) {
352: $nodes[$childName] = $childNode;
353: }
354: }
355: switch (count($nodes)) {
356: case 0:
357: // Push "--help" onto $args and continue if $arg is "help"
358: // or an abbreviation of "help"
359: if (strpos('help', $arg) === 0) {
360: $args[] = '--help';
361: continue 2;
362: }
363: break;
364: case 1:
365: // Expand unambiguous subcommands to their full names
366: $arg = array_key_first($nodes);
367: break;
368: }
369: $lastNode = $node;
370: $lastName = $name;
371: /** @var array<string,class-string<CliCommandInterface>|mixed[]>|class-string<CliCommandInterface> */
372: $node = $node[$arg] ?? null;
373: $name .= ($name === '' ? '' : ' ') . $arg;
374: }
375:
376: if ($args && $args[0] === '_md') {
377: array_shift($args);
378: $this->generateHelp($name, $node, CliHelpTarget::MARKDOWN, ...$args);
379: return $this;
380: }
381:
382: if ($args && $args[0] === '_man') {
383: array_shift($args);
384: $this->generateHelp($name, $node, CliHelpTarget::MAN_PAGE, ...$args);
385: return $this;
386: }
387:
388: $command = $this->getNodeCommand($name, $node);
389:
390: try {
391: if (!$command) {
392: throw new CliInvalidArgumentsException(
393: sprintf('no command registered: %s', $name)
394: );
395: }
396:
397: if ($args && $args[0] === '_json_schema') {
398: array_shift($args);
399: $schema = $command->getJsonSchema();
400: echo Json::prettyPrint([
401: '$schema' => $schema['$schema'] ?? HasJsonSchema::DRAFT_04_SCHEMA_ID,
402: 'title' => $args[0] ?? $schema['title'] ?? trim($this->getProgramName() . " $name") . ' options',
403: ] + $schema) . \PHP_EOL;
404: return $this;
405: }
406:
407: $this->RunningCommand = $command;
408: $this->LastExitStatus = $command(...$args);
409: return $this;
410: } catch (CliInvalidArgumentsException $ex) {
411: $ex->reportErrors(Console::getInstance());
412: if (!$node) {
413: $node = $lastNode;
414: $name = $lastName;
415: }
416: if (
417: $node
418: && $name !== null
419: && ($usage = $this->getUsage($name, $node)) !== null
420: ) {
421: Console::printOut("\n" . $usage);
422: }
423: $this->LastExitStatus = 1;
424: return $this;
425: } finally {
426: $this->RunningCommand = null;
427: if ($command !== null) {
428: $this->LastCommand = $command;
429: }
430: }
431: }
432:
433: /**
434: * @inheritDoc
435: *
436: * @codeCoverageIgnore
437: */
438: public function exit()
439: {
440: exit($this->LastExitStatus);
441: }
442:
443: /**
444: * @inheritDoc
445: *
446: * @codeCoverageIgnore
447: */
448: public function runAndExit()
449: {
450: $this->run()->exit();
451: }
452:
453: /**
454: * @inheritDoc
455: */
456: public function reportVersion(int $level = Console::LEVEL_INFO, bool $stdout = false)
457: {
458: $version = $this->getVersionString();
459:
460: if ($stdout) {
461: Console::printStdout($version, $level, Console::TYPE_UNFORMATTED);
462: } else {
463: Console::print($version, $level, Console::TYPE_UNFORMATTED);
464: }
465: return $this;
466: }
467:
468: /**
469: * @inheritDoc
470: */
471: public function getVersionString(): string
472: {
473: $version = Package::version(true, false);
474: $ref = Package::ref();
475: return Arr::implode(' ', [
476: sprintf('%s %s', $this->getAppName(), $version),
477: $ref !== null && !Str::startsWith($version, ['dev-' . $ref, $ref])
478: ? "($ref)"
479: : null,
480: sprintf('PHP %s', \PHP_VERSION),
481: ]);
482: }
483:
484: /**
485: * @param array<string,class-string<CliCommandInterface>|mixed[]>|class-string<CliCommandInterface> $node
486: * @param int&CliHelpTarget::* $target
487: */
488: private function generateHelp(string $name, $node, int $target, string ...$args): void
489: {
490: $collapseSynopsis = null;
491:
492: // Make empty values `null`
493: $args = Arr::trim($args, null, false, true);
494:
495: switch ($target) {
496: case CliHelpTarget::MARKDOWN:
497: $formats = ConsoleMarkdownFormat::getTagFormats();
498: $collapseSynopsis = Get::boolean($args[0] ?? null);
499: break;
500:
501: case CliHelpTarget::MAN_PAGE:
502: $formats = ConsoleManPageFormat::getTagFormats();
503: $progName = $this->getProgramName();
504: printf(
505: '%% %s(%d) %s | %s%s',
506: Str::upper(str_replace(' ', '-', trim("$progName $name"))),
507: (int) ($args[0] ?? '1'),
508: $args[1] ?? Package::version(),
509: $args[2] ?? (($name === '' ? $progName : Package::name()) . ' Documentation'),
510: \PHP_EOL . \PHP_EOL,
511: );
512: break;
513:
514: default:
515: throw new InvalidArgumentException(sprintf('Invalid CliHelpTarget: %d', $target));
516: }
517:
518: $formatter = new Formatter($formats, null, fn(): int => 80);
519: $style = new CliHelpStyle($target, 80, $formatter);
520:
521: if ($collapseSynopsis !== null) {
522: $style = $style->withCollapseSynopsis($collapseSynopsis);
523: }
524:
525: $usage = $this->getHelp($name, $node, $style);
526: if ($usage !== null) {
527: $usage = $formatter->format($usage);
528: $usage = Str::eolToNative($usage);
529: }
530: printf('%s%s', str_replace('\ ', "\u{00A0}", (string) $usage), \PHP_EOL);
531: }
532: }
533: