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