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\Console\ConsoleMessageType as MessageType;
15: use Salient\Contract\Core\JsonSchemaInterface;
16: use Salient\Contract\Core\MessageLevel as Level;
17: use Salient\Core\Facade\Console;
18: use Salient\Utility\Exception\InvalidRuntimeConfigurationException;
19: use Salient\Utility\Arr;
20: use Salient\Utility\Env;
21: use Salient\Utility\Get;
22: use Salient\Utility\Json;
23: use Salient\Utility\Package;
24: use Salient\Utility\Regex;
25: use Salient\Utility\Str;
26: use Salient\Utility\Sys;
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 LogicException('Not running on 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: // - `memory_limit` is exceeded
69: ignore_user_abort(true);
70: set_time_limit(0);
71: ini_set('memory_limit', '-1');
72:
73: // Exit cleanly when 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: if (!($command = $this->get($node)) instanceof CliCommandInterface) {
125: throw new LogicException(sprintf(
126: 'Does not implement %s: %s',
127: CliCommandInterface::class,
128: $node,
129: ));
130: }
131: $command->setName($name ? explode(' ', $name) : []);
132:
133: return $command;
134: }
135:
136: /**
137: * Resolve an array of subcommand names to a node in the command tree
138: *
139: * Returns one of the following:
140: * - `null` if nothing has been added to the tree at `$name`
141: * - the name of the {@see CliCommandInterface} class registered at `$name`
142: * - an array that maps subcommands of `$name` to their respective nodes
143: * - `false` if a {@see CliCommandInterface} has been registered above
144: * `$name`, e.g. if `$name` is `["sync", "canvas", "from-sis"]` and a
145: * command has been registered at `["sync", "canvas"]`
146: *
147: * Nodes in the command tree are either subcommand arrays (branches) or
148: * {@see CliCommandInterface} class names (leaves).
149: *
150: * @param string[] $name
151: * @return array<string,class-string<CliCommandInterface>|mixed[]>|class-string<CliCommandInterface>|false|null
152: */
153: protected function getNode(array $name = [])
154: {
155: $tree = $this->CommandTree;
156:
157: foreach ($name as $subcommand) {
158: if ($tree === null) {
159: return null;
160: } elseif (!is_array($tree)) {
161: return false;
162: }
163:
164: $tree = $tree[$subcommand] ?? null;
165: }
166:
167: return $tree ?: null;
168: }
169:
170: /**
171: * @inheritDoc
172: */
173: public function oneCommand(string $id)
174: {
175: return $this->command([], $id);
176: }
177:
178: /**
179: * @inheritDoc
180: */
181: public function command(array $name, string $id)
182: {
183: foreach ($name as $subcommand) {
184: if (!Regex::match(self::COMMAND_REGEX, $subcommand)) {
185: throw new LogicException(sprintf(
186: 'Subcommand does not start with a letter, followed by zero or more letters, numbers, hyphens or underscores: %s',
187: $subcommand,
188: ));
189: }
190: }
191:
192: if ($this->getNode($name) !== null) {
193: throw new LogicException("Another command has been registered at '" . implode(' ', $name) . "'");
194: }
195:
196: $tree = &$this->CommandTree;
197: $branch = $name;
198: $leaf = array_pop($branch);
199:
200: foreach ($branch as $subcommand) {
201: if (!is_array($tree[$subcommand] ?? null)) {
202: $tree[$subcommand] = [];
203: }
204:
205: $tree = &$tree[$subcommand];
206: }
207:
208: if ($leaf !== null) {
209: $tree[$leaf] = $id;
210: } else {
211: $tree = $id;
212: }
213:
214: return $this;
215: }
216:
217: /**
218: * Get a help message for a command tree node
219: *
220: * @param array<string,class-string<CliCommandInterface>|mixed[]>|class-string<CliCommandInterface> $node
221: */
222: private function getHelp(string $name, $node, ?CliHelpStyle $style = null): ?string
223: {
224: $style ??= new CliHelpStyle(CliHelpTarget::NORMAL);
225:
226: $command = $this->getNodeCommand($name, $node);
227: if ($command) {
228: return $style->buildHelp($command->getHelp($style));
229: }
230:
231: if (!is_array($node)) {
232: return null;
233: }
234:
235: $progName = $this->getProgramName();
236: $fullName = trim("$progName $name");
237: $synopses = [];
238: /** @var array<string,class-string<CliCommandInterface>|mixed[]>|class-string<CliCommandInterface>|false|null $childNode */
239: foreach ($node as $childName => $childNode) {
240: $command = $this->getNodeCommand(trim("$name $childName"), $childNode);
241: if ($command) {
242: $synopses[] = '__' . $childName . '__ - ' . Formatter::escapeTags($command->getDescription());
243: } elseif (is_array($childNode)) {
244: $synopses[] = '__' . $childName . '__';
245: }
246: }
247:
248: return $style->buildHelp([
249: CliHelpSectionName::NAME => $fullName,
250: CliHelpSectionName::SYNOPSIS => '__' . $fullName . '__ <command>',
251: 'SUBCOMMANDS' => implode("\n", $synopses),
252: ]);
253: }
254:
255: /**
256: * Get usage information for a command tree node
257: *
258: * @param array<string,class-string<CliCommandInterface>|mixed[]>|class-string<CliCommandInterface> $node
259: */
260: private function getUsage(string $name, $node): ?string
261: {
262: $style = new CliHelpStyle(CliHelpTarget::PLAIN, CliHelpStyle::getConsoleWidth());
263:
264: $command = $this->getNodeCommand($name, $node);
265: $progName = $this->getProgramName();
266:
267: if ($command) {
268: return $command->getSynopsis($style)
269: . Formatter::escapeTags("\n\nSee '"
270: . ($name === '' ? "$progName --help" : "$progName help $name")
271: . "' for more information.");
272: }
273:
274: if (!is_array($node)) {
275: return null;
276: }
277:
278: $style = $style->withCollapseSynopsis();
279: $fullName = trim("$progName $name");
280: $synopses = [];
281: /** @var array<string,class-string<CliCommandInterface>|mixed[]>|class-string<CliCommandInterface>|false|null $childNode */
282: foreach ($node as $childName => $childNode) {
283: $command = $this->getNodeCommand(trim("$name $childName"), $childNode);
284: if ($command) {
285: $synopsis = $command->getSynopsis($style);
286: } elseif (is_array($childNode)) {
287: $synopsis = "$fullName $childName <command>";
288: $synopsis = Formatter::escapeTags($synopsis);
289: } else {
290: continue;
291: }
292: $synopses[] = $synopsis;
293: }
294:
295: return implode("\n", $synopses)
296: . Formatter::escapeTags("\n\nSee '"
297: . Arr::implode(' ', ["$progName help", $name, '<command>'])
298: . "' for more information.");
299: }
300:
301: /**
302: * @inheritDoc
303: */
304: public function run()
305: {
306: $this->LastExitStatus = 0;
307:
308: /** @disregard P1006 */
309: $args = array_slice($_SERVER['argv'], 1);
310:
311: $lastNode = null;
312: $lastName = null;
313: $node = $this->CommandTree;
314: $name = '';
315:
316: while (is_array($node)) {
317: $arg = array_shift($args);
318:
319: // Print usage info if the last remaining $arg is "--help"
320: if ($arg === '--help' && !$args) {
321: $usage = $this->getHelp($name, $node);
322: if ($usage !== null) {
323: Console::printStdout($usage);
324: }
325: return $this;
326: }
327:
328: // or version info if it's "--version"
329: if ($arg === '--version' && !$args) {
330: return $this->reportVersion(Level::INFO, true);
331: }
332:
333: // - If $args was empty before this iteration, print terse usage
334: // info and exit without error
335: // - If $arg cannot be a valid subcommand, print terse usage info
336: // and return a non-zero exit status
337: if (
338: $arg === null
339: || !Regex::match('/^[a-zA-Z][a-zA-Z0-9_-]*$/', $arg)
340: ) {
341: $usage = $this->getUsage($name, $node);
342: if ($usage !== null) {
343: Console::printOut($usage);
344: }
345: $this->LastExitStatus =
346: $arg === null
347: ? 0
348: : 1;
349: return $this;
350: }
351:
352: // Descend into the command tree if $arg is a registered subcommand
353: // or an unambiguous abbreviation thereof
354: $nodes = [];
355: foreach ($node as $childName => $childNode) {
356: if (strpos($childName, $arg) === 0) {
357: $nodes[$childName] = $childNode;
358: }
359: }
360: switch (count($nodes)) {
361: case 0:
362: // Push "--help" onto $args and continue if $arg is "help"
363: // or an abbreviation of "help"
364: if (strpos('help', $arg) === 0) {
365: $args[] = '--help';
366: continue 2;
367: }
368: break;
369: case 1:
370: // Expand unambiguous subcommands to their full names
371: $arg = array_key_first($nodes);
372: break;
373: }
374: $lastNode = $node;
375: $lastName = $name;
376: $node = $node[$arg] ?? null;
377: $name .= ($name === '' ? '' : ' ') . $arg;
378: }
379:
380: if ($args && $args[0] === '_md') {
381: array_shift($args);
382: $this->generateHelp($name, $node, CliHelpTarget::MARKDOWN, ...$args);
383: return $this;
384: }
385:
386: if ($args && $args[0] === '_man') {
387: array_shift($args);
388: $this->generateHelp($name, $node, CliHelpTarget::MAN_PAGE, ...$args);
389: return $this;
390: }
391:
392: $command = $this->getNodeCommand($name, $node);
393:
394: try {
395: if (!$command) {
396: throw new CliInvalidArgumentsException(
397: sprintf('no command registered: %s', $name)
398: );
399: }
400:
401: if ($args && $args[0] === '_json_schema') {
402: array_shift($args);
403: $schema = $command->getJsonSchema();
404: echo Json::prettyPrint([
405: '$schema' => $schema['$schema'] ?? JsonSchemaInterface::DRAFT_04_SCHEMA_ID,
406: 'title' => $args[0] ?? $schema['title'] ?? trim($this->getProgramName() . " $name") . ' options',
407: ] + $schema) . \PHP_EOL;
408: return $this;
409: }
410:
411: $this->RunningCommand = $command;
412: $this->LastExitStatus = $command(...$args);
413: return $this;
414: } catch (CliInvalidArgumentsException $ex) {
415: $ex->reportErrors();
416: if (!$node) {
417: $node = $lastNode;
418: $name = $lastName;
419: }
420: if (
421: $node
422: && $name !== null
423: && ($usage = $this->getUsage($name, $node)) !== null
424: ) {
425: Console::printOut("\n" . $usage);
426: }
427: $this->LastExitStatus = 1;
428: return $this;
429: } finally {
430: $this->RunningCommand = null;
431: if ($command !== null) {
432: $this->LastCommand = $command;
433: }
434: }
435: }
436:
437: /**
438: * @inheritDoc
439: *
440: * @codeCoverageIgnore
441: */
442: public function exit()
443: {
444: exit($this->LastExitStatus);
445: }
446:
447: /**
448: * @inheritDoc
449: *
450: * @codeCoverageIgnore
451: */
452: public function runAndExit()
453: {
454: $this->run()->exit();
455: }
456:
457: /**
458: * @inheritDoc
459: */
460: public function reportVersion(int $level = Level::INFO, bool $stdout = false)
461: {
462: $version = $this->getVersionString();
463:
464: if ($stdout) {
465: Console::printStdout($version, $level, MessageType::UNFORMATTED);
466: } else {
467: Console::print($version, $level, MessageType::UNFORMATTED);
468: }
469: return $this;
470: }
471:
472: /**
473: * @inheritDoc
474: */
475: public function getVersionString(): string
476: {
477: $ref = Package::ref();
478: return Arr::implode(' ', [
479: sprintf('%s %s', $this->getAppName(), Package::version(true, false)),
480: $ref !== null ? "($ref)" : null,
481: sprintf('PHP %s', \PHP_VERSION),
482: ]);
483: }
484:
485: /**
486: * @param array<string,class-string<CliCommandInterface>|mixed[]>|class-string<CliCommandInterface> $node
487: * @param int&CliHelpTarget::* $target
488: */
489: private function generateHelp(string $name, $node, int $target, string ...$args): void
490: {
491: $collapseSynopsis = null;
492:
493: // Make empty values `null`
494: $args = Arr::trim($args, null, false, true);
495:
496: switch ($target) {
497: case CliHelpTarget::MARKDOWN:
498: $formats = ConsoleMarkdownFormat::getTagFormats();
499: $collapseSynopsis = Get::boolean($args[0] ?? null);
500: break;
501:
502: case CliHelpTarget::MAN_PAGE:
503: $formats = ConsoleManPageFormat::getTagFormats();
504: $progName = $this->getProgramName();
505: printf(
506: '%% %s(%d) %s | %s%s',
507: Str::upper(str_replace(' ', '-', trim("$progName $name"))),
508: (int) ($args[0] ?? '1'),
509: $args[1] ?? Package::version(),
510: $args[2] ?? (($name === '' ? $progName : Package::name()) . ' Documentation'),
511: \PHP_EOL . \PHP_EOL,
512: );
513: break;
514:
515: default:
516: throw new LogicException(sprintf('Invalid CliHelpTarget: %d', $target));
517: }
518:
519: $formatter = new Formatter($formats, null, fn(): int => 80);
520: $style = new CliHelpStyle($target, 80, $formatter);
521:
522: if ($collapseSynopsis !== null) {
523: $style = $style->withCollapseSynopsis($collapseSynopsis);
524: }
525:
526: $usage = $this->getHelp($name, $node, $style);
527: if ($usage !== null) {
528: $usage = $formatter->format($usage);
529: $usage = Str::eolToNative($usage);
530: }
531: printf('%s%s', str_replace('\ ', "\u{00A0}", (string) $usage), \PHP_EOL);
532: }
533: }
534: