1<?php
2
3namespace splitbrain\phpcli;
4
5/**
6 * Class CLIBase
7 *
8 * All base functionality is implemented here.
9 *
10 * Your commandline should not inherit from this class, but from one of the *CLI* classes
11 *
12 * @author Andreas Gohr <andi@splitbrain.org>
13 * @license MIT
14 */
15abstract class Base
16{
17    /** @var string the executed script itself */
18    protected $bin;
19    /** @var  Options the option parser */
20    protected $options;
21    /** @var  Colors */
22    public $colors;
23
24    /** @var array PSR-3 compatible loglevels and their prefix, color, output channel, enabled status */
25    protected $loglevel = array(
26        'debug' => array(
27            'icon' => '',
28            'color' => Colors::C_RESET,
29            'channel' => STDOUT,
30            'enabled' => true
31        ),
32        'info' => array(
33            'icon' => 'ℹ ',
34            'color' => Colors::C_CYAN,
35            'channel' => STDOUT,
36            'enabled' => true
37        ),
38        'notice' => array(
39            'icon' => '☛ ',
40            'color' => Colors::C_CYAN,
41            'channel' => STDOUT,
42            'enabled' => true
43        ),
44        'success' => array(
45            'icon' => '✓ ',
46            'color' => Colors::C_GREEN,
47            'channel' => STDOUT,
48            'enabled' => true
49        ),
50        'warning' => array(
51            'icon' => '⚠ ',
52            'color' => Colors::C_BROWN,
53            'channel' => STDERR,
54            'enabled' => true
55        ),
56        'error' => array(
57            'icon' => '✗ ',
58            'color' => Colors::C_RED,
59            'channel' => STDERR,
60            'enabled' => true
61        ),
62        'critical' => array(
63            'icon' => '☠ ',
64            'color' => Colors::C_LIGHTRED,
65            'channel' => STDERR,
66            'enabled' => true
67        ),
68        'alert' => array(
69            'icon' => '✖ ',
70            'color' => Colors::C_LIGHTRED,
71            'channel' => STDERR,
72            'enabled' => true
73        ),
74        'emergency' => array(
75            'icon' => '✘ ',
76            'color' => Colors::C_LIGHTRED,
77            'channel' => STDERR,
78            'enabled' => true
79        ),
80    );
81
82    /** @var string default log level */
83    protected $logdefault = 'info';
84
85    /**
86     * constructor
87     *
88     * Initialize the arguments, set up helper classes and set up the CLI environment
89     *
90     * @param bool $autocatch should exceptions be catched and handled automatically?
91     */
92    public function __construct($autocatch = true)
93    {
94        if ($autocatch) {
95            set_exception_handler(array($this, 'fatal'));
96        }
97
98        $this->colors = new Colors();
99        $this->options = new Options($this->colors);
100    }
101
102    /**
103     * Register options and arguments on the given $options object
104     *
105     * @param Options $options
106     * @return void
107     *
108     * @throws Exception
109     */
110    abstract protected function setup(Options $options);
111
112    /**
113     * Your main program
114     *
115     * Arguments and options have been parsed when this is run
116     *
117     * @param Options $options
118     * @return void
119     *
120     * @throws Exception
121     */
122    abstract protected function main(Options $options);
123
124    /**
125     * Execute the CLI program
126     *
127     * Executes the setup() routine, adds default options, initiate the options parsing and argument checking
128     * and finally executes main() - Each part is split into their own protected function below, so behaviour
129     * can easily be overwritten
130     *
131     * @throws Exception
132     */
133    public function run()
134    {
135        if ('cli' != php_sapi_name()) {
136            throw new Exception('This has to be run from the command line');
137        }
138
139        $this->setup($this->options);
140        $this->registerDefaultOptions();
141        $this->parseOptions();
142        $this->handleDefaultOptions();
143        $this->setupLogging();
144        $this->checkArguments();
145        $this->execute();
146    }
147
148    // region run handlers - for easier overriding
149
150    /**
151     * Add the default help, color and log options
152     */
153    protected function registerDefaultOptions()
154    {
155        $this->options->registerOption(
156            'help',
157            'Display this help screen and exit immediately.',
158            'h'
159        );
160        $this->options->registerOption(
161            'no-colors',
162            'Do not use any colors in output. Useful when piping output to other tools or files.'
163        );
164        $this->options->registerOption(
165            'loglevel',
166            'Minimum level of messages to display. Default is ' . $this->colors->wrap($this->logdefault, Colors::C_CYAN) . '. ' .
167            'Valid levels are: debug, info, notice, success, warning, error, critical, alert, emergency.',
168            null,
169            'level'
170        );
171    }
172
173    /**
174     * Handle the default options
175     */
176    protected function handleDefaultOptions()
177    {
178        if ($this->options->getOpt('no-colors')) {
179            $this->colors->disable();
180        }
181        if ($this->options->getOpt('help')) {
182            echo $this->options->help();
183            exit(0);
184        }
185    }
186
187    /**
188     * Handle the logging options
189     */
190    protected function setupLogging()
191    {
192        $level = $this->options->getOpt('loglevel', $this->logdefault);
193        $this->setLogLevel($level);
194    }
195
196    /**
197     * Wrapper around the option parsing
198     */
199    protected function parseOptions()
200    {
201        $this->options->parseOptions();
202    }
203
204    /**
205     * Wrapper around the argument checking
206     */
207    protected function checkArguments()
208    {
209        $this->options->checkArguments();
210    }
211
212    /**
213     * Wrapper around main
214     */
215    protected function execute()
216    {
217        $this->main($this->options);
218    }
219
220    // endregion
221
222    // region logging
223
224    /**
225     * Set the current log level
226     *
227     * @param string $level
228     */
229    public function setLogLevel($level)
230    {
231        if (!isset($this->loglevel[$level])) $this->fatal('Unknown log level');
232        $enable = false;
233        foreach (array_keys($this->loglevel) as $l) {
234            if ($l == $level) $enable = true;
235            $this->loglevel[$l]['enabled'] = $enable;
236        }
237    }
238
239    /**
240     * Check if a message with the given level should be logged
241     *
242     * @param string $level
243     * @return bool
244     */
245    public function isLogLevelEnabled($level)
246    {
247        if (!isset($this->loglevel[$level])) $this->fatal('Unknown log level');
248        return $this->loglevel[$level]['enabled'];
249    }
250
251    /**
252     * Exits the program on a fatal error
253     *
254     * @param \Exception|string $error either an exception or an error message
255     * @param array $context
256     */
257    public function fatal($error, array $context = array())
258    {
259        $code = 0;
260        if (is_object($error) && is_a($error, 'Exception')) {
261            /** @var Exception $error */
262            $this->logMessage('debug', get_class($error) . ' caught in ' . $error->getFile() . ':' . $error->getLine());
263            $this->logMessage('debug', $error->getTraceAsString());
264            $code = $error->getCode();
265            $error = $error->getMessage();
266
267        }
268        if (!$code) {
269            $code = Exception::E_ANY;
270        }
271
272        $this->logMessage('critical', $error, $context);
273        exit($code);
274    }
275
276    /**
277     * Normal, positive outcome (This is not a PSR-3 level)
278     *
279     * @param string $string
280     * @param array $context
281     */
282    public function success($string, array $context = array())
283    {
284        $this->logMessage('success', $string, $context);
285    }
286
287    /**
288     * @param string $level
289     * @param string $message
290     * @param array $context
291     */
292    protected function logMessage($level, $message, array $context = array())
293    {
294        // unknown level is always an error
295        if (!isset($this->loglevel[$level])) $level = 'error';
296
297        $info = $this->loglevel[$level];
298        if (!$this->isLogLevelEnabled($level)) return; // no logging for this level
299
300        $message = $this->interpolate($message, $context);
301
302        // when colors are wanted, we also add the icon
303        if ($this->colors->isEnabled()) {
304            $message = $info['icon'] . $message;
305        }
306
307        $this->colors->ptln($message, $info['color'], $info['channel']);
308    }
309
310    /**
311     * Interpolates context values into the message placeholders.
312     *
313     * @param $message
314     * @param array $context
315     * @return string
316     */
317    protected function interpolate($message, array $context = array())
318    {
319        // build a replacement array with braces around the context keys
320        $replace = array();
321        foreach ($context as $key => $val) {
322            // check that the value can be casted to string
323            if (!is_array($val) && (!is_object($val) || method_exists($val, '__toString'))) {
324                $replace['{' . $key . '}'] = $val;
325            }
326        }
327
328        // interpolate replacement values into the message and return
329        return strtr((string)$message, $replace);
330    }
331
332    // endregion
333}
334