1<?php
2/*
3 *  $Id: Cli.php 2761 2007-10-07 23:42:29Z zYne $
4 *
5 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
6 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
7 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
8 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
9 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
10 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
11 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
12 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
13 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
14 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
15 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
16 *
17 * This software consists of voluntary contributions made by many individuals
18 * and is licensed under the LGPL. For more information, see
19 * <http://www.doctrine-project.org>.
20 */
21
22/**
23 * Command line interface class
24 *
25 * Interface for easily executing Doctrine_Task classes from a command line interface
26 *
27 * @package     Doctrine
28 * @subpackage  Cli
29 * @license     http://www.opensource.org/licenses/lgpl-license.php LGPL
30 * @link        www.doctrine-project.org
31 * @since       1.0
32 * @version     $Revision: 2761 $
33 * @author      Jonathan H. Wage <jwage@mac.com>
34 */
35class Doctrine_Cli
36{
37    /**
38     * The name of the Doctrine Task base class
39     *
40     * @var string
41     */
42    const TASK_BASE_CLASS = 'Doctrine_Task';
43
44    /**
45     * @var string
46     */
47    protected $_scriptName   = null;
48
49    /**
50     * @var array
51     */
52    private $_config;
53
54    /**
55     * @var object Doctrine_Cli_Formatter
56     */
57    private $_formatter;
58
59    /**
60     * An array, keyed on class name, containing task instances
61     *
62     * @var array
63     */
64    private $_registeredTask = array();
65
66    /**
67     * @var object Doctrine_Task
68     */
69    private $_taskInstance;
70
71    /**
72     * __construct
73     *
74     * @param array [$config=array()]
75     * @param object|null [$formatter=null] Doctrine_Cli_Formatter
76     */
77    public function __construct(array $config = array(), Doctrine_Cli_Formatter $formatter = null)
78    {
79        $this->setConfig($config);
80        $this->setFormatter($formatter ? $formatter : new Doctrine_Cli_AnsiColorFormatter());
81        $this->includeAndRegisterTaskClasses();
82    }
83
84    /**
85     * @param array $config
86     */
87    public function setConfig(array $config)
88    {
89        $this->_config = $config;
90    }
91
92    /**
93     * @return array
94     */
95    public function getConfig()
96    {
97        return $this->_config;
98    }
99
100    /**
101     * @param object $formatter Doctrine_Cli_Formatter
102     */
103    public function setFormatter(Doctrine_Cli_Formatter $formatter)
104    {
105        $this->_formatter = $formatter;
106    }
107
108    /**
109     * @return object Doctrine_Cli_Formatter
110     */
111    public function getFormatter()
112    {
113        return $this->_formatter;
114    }
115
116    /**
117     * Returns the specified value from the config, or the default value, if specified
118     *
119     * @param string $name
120     * @return mixed
121     * @throws OutOfBoundsException If the element does not exist in the config
122     */
123    public function getConfigValue($name/*, $defaultValue*/)
124    {
125        if (! isset($this->_config[$name])) {
126            if (func_num_args() > 1) {
127                return func_get_arg(1);
128            }
129
130            throw new OutOfBoundsException("The element \"{$name}\" does not exist in the config");
131        }
132
133        return $this->_config[$name];
134    }
135
136    /**
137     * Returns TRUE if the element in the config has the specified value, or FALSE otherwise
138     *
139     * If $value is not passed, this method will return TRUE if the specified element has _any_ value, or FALSE if the
140     * element is not set
141     *
142     * For strict checking, set $strict to TRUE - the default is FALSE
143     *
144     * @param string $name
145     * @param mixed [$value=null]
146     * @param bool [$strict=false]
147     * @return bool
148     */
149    public function hasConfigValue($name, $value = null, $strict = false)
150    {
151        if (isset($this->_config[$name])) {
152            if (func_num_args() < 2) {
153                return true;
154            }
155
156            if ($strict) {
157                return $this->_config[$name] === $value;
158            }
159
160            return $this->_config[$name] == $value;
161        }
162
163        return false;
164    }
165
166    /**
167     * Sets the array of registered tasks
168     *
169     * @param array $registeredTask
170     */
171    public function setRegisteredTasks(array $registeredTask)
172    {
173        $this->_registeredTask = $registeredTask;
174    }
175
176    /**
177     * Returns an array containing the registered tasks
178     *
179     * @return array
180     */
181    public function getRegisteredTasks()
182    {
183        return $this->_registeredTask;
184    }
185
186    /**
187     * Returns TRUE if the specified Task-class is registered, or FALSE otherwise
188     *
189     * @param string $className
190     * @return bool
191     */
192    public function taskClassIsRegistered($className)
193    {
194        return isset($this->_registeredTask[$className]);
195    }
196
197    /**
198     * Returns TRUE if a task with the specified name is registered, or FALSE otherwise
199     *
200     * If a matching task is found, $className is set with the name of the implementing class
201     *
202     * @param string $taskName
203     * @param string|null [&$className=null]
204     * @return bool
205     */
206    public function taskNameIsRegistered($taskName, &$className = null)
207    {
208        foreach ($this->getRegisteredTasks() as $currClassName => $task) {
209            if ($task->getTaskName() == $taskName) {
210                $className = $currClassName;
211                return true;
212            }
213        }
214
215        return false;
216    }
217
218    /**
219     * @param object $task Doctrine_Task
220     */
221    public function setTaskInstance(Doctrine_Task $task)
222    {
223        $this->_taskInstance = $task;
224    }
225
226    /**
227     * @return object Doctrine_Task
228     */
229    public function getTaskInstance()
230    {
231        return $this->_taskInstance;
232    }
233
234    /**
235     * Called by the constructor, this method includes and registers Doctrine core Tasks and then registers all other
236     * loaded Task classes
237     *
238     * The second round of registering will pick-up loaded custom Tasks.  Methods are provided that will allow users to
239     * register Tasks loaded after creating an instance of Doctrine_Cli.
240     */
241    protected function includeAndRegisterTaskClasses()
242    {
243        $this->includeAndRegisterDoctrineTaskClasses();
244
245        //Always autoregister custom tasks _unless_ we've been explicitly asked not to
246        if ($this->getConfigValue('autoregister_custom_tasks', true)) {
247            $this->registerIncludedTaskClasses();
248        }
249    }
250
251    /**
252     * Includes and registers Doctrine-style tasks from the specified directory / directories
253     *
254     * If no directory is given it looks in the default Doctrine/Task folder for the core tasks
255     *
256     * @param mixed [$directories=null] Can be a string path or array of paths
257     */
258    protected function includeAndRegisterDoctrineTaskClasses($directories = null)
259    {
260        if (is_null($directories)) {
261            $directories = Doctrine_Core::getPath() . DIRECTORY_SEPARATOR . 'Doctrine' . DIRECTORY_SEPARATOR . 'Task';
262        }
263
264        foreach ((array) $directories as $directory) {
265            foreach ($this->includeDoctrineTaskClasses($directory) as $className) {
266                $this->registerTaskClass($className);
267            }
268        }
269    }
270
271    /**
272     * Attempts to include Doctrine-style Task-classes from the specified directory - and nothing more besides
273     *
274     * Returns an array containing the names of Task classes included
275     *
276     * This method effectively makes two assumptions:
277     * - The directory contains only _Task_ class-files
278     * - The class files, and the class in each, follow the Doctrine naming conventions
279     *
280     * This means that a file called "Foo.php", say, will be expected to contain a Task class called
281     * "Doctrine_Task_Foo".  Hence the method's name, "include*Doctrine*TaskClasses".
282     *
283     * @param string $directory
284     * @return array $taskClassesIncluded
285     * @throws InvalidArgumentException If the directory does not exist
286     */
287    protected function includeDoctrineTaskClasses($directory)
288    {
289        if (! is_dir($directory)) {
290            throw new InvalidArgumentException("The directory \"{$directory}\" does not exist");
291        }
292
293        $taskClassesIncluded = array();
294
295        $iterator = new RecursiveIteratorIterator(
296            new RecursiveDirectoryIterator($directory),
297            RecursiveIteratorIterator::LEAVES_ONLY
298        );
299
300        foreach ($iterator as $file) {
301            $baseName = $file->getFileName();
302
303            /*
304             * Class-files must start with an uppercase letter.  This additional check will help prevent us
305             * accidentally running 'executable' scripts that may be mixed-in with the class files.
306             */
307            $matched = (bool) preg_match('/^([A-Z].*?)\.php$/', $baseName, $matches);
308
309            if ( ! ($matched && (strpos($baseName, '.inc') === false))) {
310                continue;
311            }
312
313            $expectedClassName = self::TASK_BASE_CLASS . '_' . $matches[1];
314
315            if ( ! class_exists($expectedClassName)) {
316                require_once($file->getPathName());
317            }
318
319            //So was the expected class included, and is it a task?  If so, we'll let the calling function know.
320            if (class_exists($expectedClassName, false) && $this->classIsTask($expectedClassName)) {
321                $taskClassesIncluded[] = $expectedClassName;
322            }
323        }
324
325        return $taskClassesIncluded;
326    }
327
328    /**
329     * Registers the specified _included_ task-class
330     *
331     * @param string $className
332     * @throws InvalidArgumentException If the class does not exist or the task-name is blank
333     * @throws DomainException If the class is not a Doctrine Task
334     */
335    public function registerTaskClass($className)
336    {
337        //Simply ignore registered classes
338        if ($this->taskClassIsRegistered($className)) {
339            return;
340        }
341
342        if ( ! class_exists($className/*, false*/)) {
343            throw new InvalidArgumentException("The task class \"{$className}\" does not exist");
344        }
345
346        if ( ! $this->classIsTask($className)) {
347            throw new DomainException("The class \"{$className}\" is not a Doctrine Task");
348        }
349
350        $this->_registeredTask[$className] = $this->createTaskInstance($className, $this);
351    }
352
353    /**
354     * Returns TRUE if the specified class is a Task, or FALSE otherwise
355     *
356     * @param string $className
357     * @return bool
358     */
359    protected function classIsTask($className)
360    {
361        $reflectionClass = new ReflectionClass($className);
362        return (bool) $reflectionClass->isSubclassOf(self::TASK_BASE_CLASS);
363    }
364
365    /**
366     * Creates, and returns, a new instance of the specified Task class
367     *
368     * Displays a message, and returns FALSE, if there were problems instantiating the class
369     *
370     * @param string $className
371     * @param object $cli Doctrine_Cli
372     * @return object Doctrine_Task
373     */
374    protected function createTaskInstance($className, Doctrine_Cli $cli)
375    {
376        return new $className($cli);
377    }
378
379    /**
380     * Registers all loaded classes - by default - or the specified loaded Task classes
381     *
382     * This method will skip registered task classes, so it can be safely called many times over
383     */
384    public function registerIncludedTaskClasses()
385    {
386        foreach (get_declared_classes() as $className) {
387            if ($this->classIsTask($className)) {
388                $this->registerTaskClass($className);
389            }
390        }
391    }
392
393    /**
394     * Notify the formatter of a message
395     *
396     * @param string $notification  The notification message
397     * @param string $style         Style to format the notification with(INFO, ERROR)
398     * @return void
399     */
400    public function notify($notification = null, $style = 'HEADER')
401    {
402        $formatter = $this->getFormatter();
403
404        echo(
405            $formatter->format($this->getTaskInstance()->getTaskName(), 'INFO') . ' - ' .
406            $formatter->format($notification, $style) . "\n"
407        );
408    }
409
410    /**
411     * Formats, and then returns, the message in the specified exception
412     *
413     * @param  Exception $exception
414     * @return string
415     */
416    protected function formatExceptionMessage(Exception $exception)
417    {
418        $message = $exception->getMessage();
419
420        if (Doctrine_Core::debug()) {
421            $message .= "\n" . $exception->getTraceAsString();
422        }
423
424        return $this->getFormatter()->format($message, 'ERROR') . "\n";
425    }
426
427    /**
428     * Notify the formatter of an exception
429     *
430     * N.B. This should really only be called by Doctrine_Cli::run().  Exceptions should be thrown when errors occur:
431     * it's up to Doctrine_Cli::run() to determine how those exceptions are reported.
432     *
433     * @param  Exception $exception
434     * @return void
435     */
436    protected function notifyException(Exception $exception)
437    {
438        echo $this->formatExceptionMessage($exception);
439    }
440
441    /**
442     * Public function to run the loaded task with the passed arguments
443     *
444     * @param  array $args
445     * @return void
446     * @throws Doctrine_Cli_Exception
447     * @todo Should know more about what we're attempting to run so feedback can be improved. Continue refactoring.
448     */
449    public function run(array $args)
450    {
451        try {
452            $this->_run($args);
453        } catch (Exception $exception) {
454            //Do not rethrow exceptions by default
455            if ($this->getConfigValue('rethrow_exceptions', false)) {
456                throw new $exception($this->formatExceptionMessage($exception));
457            }
458
459            $this->notifyException($exception);
460
461            //User error
462            if ($exception instanceof Doctrine_Cli_Exception) {
463                $this->printTasks();
464            }
465        }
466    }
467
468    /**
469     * Run the actual task execution with the passed arguments
470     *
471     * @param  array $args Array of arguments for this task being executed
472     * @return void
473     * @throws Doctrine_Cli_Exception If the requested task has not been registered or if required arguments are missing
474     * @todo Continue refactoring for testing
475     */
476    protected function _run(array $args)
477    {
478        $this->_scriptName = $args[0];
479
480        $requestedTaskName = isset($args[1]) ? $args[1] : null;
481
482        if ( ! $requestedTaskName || $requestedTaskName == 'help') {
483            $this->printTasks(null, $requestedTaskName == 'help' ? true : false);
484            return;
485        }
486
487        if ($requestedTaskName && isset($args[2]) && $args[2] === 'help') {
488            $this->printTasks($requestedTaskName, true);
489            return;
490        }
491
492        if (! $this->taskNameIsRegistered($requestedTaskName, $taskClassName)) {
493            throw new Doctrine_Cli_Exception("The task \"{$requestedTaskName}\" has not been registered");
494        }
495
496        $taskInstance = $this->createTaskInstance($taskClassName, $this);
497        $this->setTaskInstance($taskInstance);
498        $this->executeTask($taskInstance, $this->prepareArgs(array_slice($args, 2)));
499    }
500
501    /**
502     * Executes the task with the specified _prepared_ arguments
503     *
504     * @param object $task Doctrine_Task
505     * @param array $preparedArguments
506     * @throws Doctrine_Cli_Exception If required arguments are missing
507     */
508    protected function executeTask(Doctrine_Task $task, array $preparedArguments)
509    {
510        $task->setArguments($preparedArguments);
511
512        if (! $task->validate()) {
513            throw new Doctrine_Cli_Exception('Required arguments missing');
514        }
515
516        $task->execute();
517    }
518
519    /**
520     * Prepare the raw arguments for execution. Combines with the required and optional argument
521     * list in order to determine a complete array of arguments for the task
522     *
523     * @param  array $args      Array of raw arguments
524     * @return array $prepared  Array of prepared arguments
525     * @todo Continue refactoring for testing
526     */
527    protected function prepareArgs(array $args)
528    {
529        $taskInstance = $this->getTaskInstance();
530
531        $args = array_values($args);
532
533        // First lets load populate an array with all the possible arguments. required and optional
534        $prepared = array();
535
536        $requiredArguments = $taskInstance->getRequiredArguments();
537        foreach ($requiredArguments as $key => $arg) {
538            $prepared[$arg] = null;
539        }
540
541        $optionalArguments = $taskInstance->getOptionalArguments();
542        foreach ($optionalArguments as $key => $arg) {
543            $prepared[$arg] = null;
544        }
545
546        // If we have a config array then lets try and fill some of the arguments with the config values
547        foreach ($this->getConfig() as $key => $value) {
548            if (array_key_exists($key, $prepared)) {
549                $prepared[$key] = $value;
550            }
551        }
552
553        // Now lets fill in the entered arguments to the prepared array
554        $copy = $args;
555        foreach ($prepared as $key => $value) {
556            if ( ! $value && !empty($copy)) {
557                $prepared[$key] = $copy[0];
558                unset($copy[0]);
559                $copy = array_values($copy);
560            }
561        }
562
563        return $prepared;
564    }
565
566    /**
567     * Prints an index of all the available tasks in the CLI instance
568     *
569     * @param string|null [$taskName=null]
570     * @param bool [$full=false]
571     * @todo Continue refactoring for testing
572     */
573    public function printTasks($taskName = null, $full = false)
574    {
575        $formatter = $this->getFormatter();
576        $config = $this->getConfig();
577
578        $taskIndex = $formatter->format('Doctrine Command Line Interface', 'HEADER') . "\n\n";
579
580        foreach ($this->getRegisteredTasks() as $task) {
581            if ($taskName && (strtolower($taskName) != strtolower($task->getTaskName()))) {
582                continue;
583            }
584
585            $taskIndex .= $formatter->format($this->_scriptName . ' ' . $task->getTaskName(), 'INFO');
586
587            if ($full) {
588                $taskIndex .= ' - ' . $task->getDescription() . "\n";
589
590                $args = '';
591                $args .= $this->assembleArgumentList($task->getRequiredArgumentsDescriptions(), $config, $formatter);
592                $args .= $this->assembleArgumentList($task->getOptionalArgumentsDescriptions(), $config, $formatter);
593
594                if ($args) {
595                    $taskIndex .= "\n" . $formatter->format('Arguments:', 'HEADER') . "\n" . $args;
596                }
597            }
598
599            $taskIndex .= "\n";
600        }
601
602        echo $taskIndex;
603    }
604
605    /**
606     * @param array $argumentsDescriptions
607     * @param array $config
608     * @param object $formatter Doctrine_Cli_Formatter
609     * @return string
610     */
611    protected function assembleArgumentList(array $argumentsDescriptions, array $config, Doctrine_Cli_Formatter $formatter)
612    {
613        $argumentList = '';
614
615        foreach ($argumentsDescriptions as $name => $description) {
616            $argumentList .= $formatter->format($name, 'ERROR') . ' - ';
617
618            if (isset($config[$name])) {
619                $argumentList .= $formatter->format($config[$name], 'COMMENT');
620            } else {
621                $argumentList .= $description;
622            }
623
624            $argumentList .= "\n";
625        }
626
627        return $argumentList;
628    }
629
630    /**
631     * Used by Doctrine_Cli::loadTasks() and Doctrine_Cli::getLoadedTasks() to re-create their pre-refactoring behaviour
632     *
633     * @ignore
634     * @param array $registeredTask
635     * @return array
636     */
637    private function createOldStyleTaskList(array $registeredTask)
638    {
639        $taskNames = array();
640
641        foreach ($registeredTask as $className => $task) {
642            $taskName = $task->getTaskName();
643            $taskNames[$taskName] = $taskName;
644        }
645
646        return $taskNames;
647    }
648
649    /**
650     * Old method retained for backwards compatibility
651     *
652     * @deprecated
653     */
654    public function loadTasks($directory = null)
655    {
656        $this->includeAndRegisterDoctrineTaskClasses($directory);
657        return $this->createOldStyleTaskList($this->getRegisteredTasks());
658    }
659
660    /**
661     * Old method retained for backwards compatibility
662     *
663     * @deprecated
664     */
665    protected function _getTaskClassFromArgs(array $args)
666    {
667        return self::TASK_BASE_CLASS . '_' . Doctrine_Inflector::classify(str_replace('-', '_', $args[1]));
668    }
669
670    /**
671     * Old method retained for backwards compatibility
672     *
673     * @deprecated
674     */
675    public function getLoadedTasks()
676    {
677        return $this->createOldStyleTaskList($this->getRegisteredTasks());
678    }
679}