Как стать автором
Обновить

Bash completion для консольных Yii приложений

Часто определенный вспомогательный функционал для веб-приложения удобно вынести в консольное, которое в то же время будет оставаться частью основного, разделяя с ним классы, конфигурацию и прочее.
Также может возникнуть необходимость в создании целого проекта для консоли. Мне, к примеру, довелось писать систему работников для распределения задач с помощью Gearman.
Так, или иначе, консольное приложение для пользователя bash остается неполноценным, если для него нету автодополнения и подсказок по табу.
Озаботившись данным недостатком, решил его убрать.
Поскольку все разрабатывается на базе фреймворка Yii, который, кроме всего остального, предоставляет неплохой функционал для создания консольных приложений, ниже приведен получившийся у меня код потомка CConsoleCommand добавляющий возможность автокомплита.


<?php
/**
 * LCompleteCommand adds bash complete functionality to Yii console application
 * To attach put the class file somewhere (for example in extensions application subdirectory) and add command definition to config file, for instance:
 * 'commandMap' => array(
        ...
        'complete' => array(
            'class' => 'ext.LCompleteCommand',
            //'bashFile' => '/etc/bash_completion.d/yii_applications' //Defaults to </etc/bash_completion.d/yii_applications>. May be changed if needed
        ),
        ...
    ),
 */
class LCompleteCommand extends CConsoleCommand {
    const BASH_COMPLETE_FUNCTION = '_yii_console_application()
{
    local script cur opts
    COMPREPLY=()
    script="${COMP_WORDS[0]}"
    cur="${COMP_WORDS[COMP_CWORD]}"

    SAVE_IFS=$IFS
    IFS=","
    BUFFER="${COMP_WORDS[*]}"
    IFS=$SAVE_IFS

    # Pipe is used to overcome strange bash behavior bug which happens while direct php calls. (Possibly Ubuntu only)
    opts=`echo "$BUFFER" | ${script} complete`

    COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
    return 0

}';
    const BASH_REGISTER_COMPLETE_TEMPLATE = "#Program %s\ncomplete -F _yii_console_application %s";
    const PROGRAM_REGEX = '/#Program\s(.*)/';

    /**
     * @var string Path to file to be loaded by bash for enabling completion of Yii applications.
     */
    public $bashFile = '/etc/bash_completion.d/yii_applications';

    /**
     * Generates bash completion suggestions based on user input.
     * @return void
     */
    public function actionIndex() {
        $suggestions = $this->getCommandNames();
        $input = $this->parseInput();

        if($input) {
            $commandName = false;
            $actionName = false;
            $args = array();
            foreach($input as &$item) {
                if($item == '-' || $item == '--') {
                    break;
                }
                elseif(strpos($item, '--') === 0) {
                    $args[] = $item;
                }
                else {
                    if(!$commandName) {
                        $commandName = $item;
                    }
                    elseif(!$actionName) {
                        $actionName = $item;
                    }
                    else {
                        continue;
                    }
                }
            }
            if($commandName) {
                if(isset($this->getCommandRunner()->commands[$commandName])) {
                    $commandObject = $this->getCommandRunner()->createCommand($commandName);
                    $commandActions = $this->getCommandActions($commandObject);
                    if(!$actionName || !in_array($actionName, $commandActions)) {
                        $suggestions = $this->getActionArgsSuggestions($commandObject, $commandObject->defaultAction, $args);
                        if(!$args){
                            $suggestions = array_merge($commandActions, $suggestions);
                        }
                    }
                    else {
                        $suggestions = $this->getActionArgsSuggestions($commandObject, $actionName, $args);
                    }
                }
            }


        }

        // Output the space-separated completion possibilities.
        echo implode(' ', $suggestions);
    }

    /**
     * Installs completion script for current program to {@link LCompleteCommand::$bashFile}
     * @return void
     */
    public function actionInstall() {
        $this->checkWriteAccess();
        $commands = $this->getRegisteredApplications();
        $commands[] = $this->getScriptName();
        $commands = array_unique($commands);
        $this->writeBashFile($commands);
    }

    /**
     * Removes completion script for current program from {@link LCompleteCommand::$bashFile}
     * @return void
     */
    public function actionUninstall(){
        $this->checkWriteAccess();
        $commands = $this->getRegisteredApplications();
        $commands = array_diff($commands, array($this->getScriptName()));
        $commands = array_unique($commands);
        $this->writeBashFile($commands);
    }

    /**
     * Install and Uninstall actions need write permissions on {@link LCompleteCommand::$bashFile}
     * This method performs permission check end exit script on fail
     * @return void
     */
    protected function checkWriteAccess(){
        if((file_exists($this->bashFile) && !is_writable($this->bashFile)) || (!is_writable(dirname($this->bashFile)))) {
            echo "Need to be a root or to have write permissions on <{$this->bashFile}> file", PHP_EOL;
            Yii::app()->end(1);
        }
    }

    /**
     * @return array List of registered commands for current application
     */
    public function getCommandNames() {
        $commandNames = array_keys($this->getCommandRunner()->commands);
        //Adding Yii built in help command to the list
        $commandNames[] = 'help';
        array_unique($commandNames);
        return $commandNames;
    }

    /**
     * @return array List of params user currently entered in terminal
     */
    protected function parseInput() {
        $input = file_get_contents('php://stdin');
        $params = explode(',', $input);
        array_shift($params);
        $newParams = array();

        $i = 0;

        //Here is some logic to handle default bash $COMP_WORDBREAKS containing "=" symbol.
        while($i<count($params)){
            $current = trim($params[$i]);
            $next = isset($params[$i+1]) ? trim($params[$i+1]) : null;
            if($next == '='){
                if(isset($params[$i+2]) && $params[$i+2]{0} != '-'){
                    $newParams[] = $current.$next.trim($params[$i+2]);
                    $i+=3;
                }
                else{
                    $newParams[] = $current.$next;
                    $i += 2;
                }
            }
            else{
                $newParams[] = $current;
                $i++;
            }
        }

        $params = array_filter($newParams);
        return $params;
    }

    /**
     * @param CConsoleCommand $command Command to analyze
     * @return array List of available actions in command
     */
    public function getCommandActions(CConsoleCommand $command) {

        $actions = array();
        $class=new ReflectionClass($command);
        foreach($class->getMethods(ReflectionMethod::IS_PUBLIC) as $method)
        {
            /** @var $method ReflectionMethod  */
        	$name=$method->getName();
        	if(!strncasecmp($name,'action',6) && strlen($name)>6)
        	{
        		$name=substr($name,6);
        		$name[0]=strtolower($name[0]);

                $actions[] = $name;
        	}
        }

        return $actions;
    }

    /**
     * Retrieves params suggestions for action
     * @param CConsoleCommand $command Command object to process
     * @param string $action Action name to process
     * @param array $args List of already entered arguments. Used to avoid repeatable suggestions
     * @return array List of suggestions based on action params
     */
    public function getActionArgsSuggestions(CConsoleCommand $command, $action, $args = array()) {
        $method = 'action' . $action;

        if(!method_exists($command, $method)){
            return array();
        }
        
        $reflectionAction = new ReflectionMethod($command, $method);
        $suggestions = array();
        foreach($reflectionAction->getParameters() as $parameter) {
            /** @var $parameter ReflectionParameter */
            if($parameter->getName() == 'args') continue;
            $suggestion = '--' . $parameter->getName();
            if(!($parameter->isDefaultValueAvailable() && $parameter->getDefaultValue() === false)) {
                $suggestion .= '=';
            }
            $alreadySuggested = false;
            if(!$parameter->isArray()){
                foreach($args as $arg){
                    if(strpos($arg, $suggestion) === 0){
                        $alreadySuggested = true;
                        break;
                    }
                }
            }
            if(!$alreadySuggested){
                $suggestions[] = $suggestion;
            }
        }
        return $suggestions;
    }

    /**
     * Writes completion script to {@link LCompleteCommand::$bashFile}
     * @param array $applications List of applications to register for bash completion using _yii_console_application()
     * @return int|bool Result of file_put_contents execution
     */
    protected function writeBashFile($applications){
        echo "Writing config to <{$this->bashFile}>", PHP_EOL;
        if(file_put_contents($this->bashFile, $this->formatCompleteScript($applications))){
            echo 'Success. Changes will be loaded to new bash sessions.', PHP_EOL;
        }
        else{
            echo 'Failed.', PHP_EOL;
            Yii::app()->end(1);
        }
    }

    /**
     * @param array $applications List of applications to register for bash completion using _yii_console_application()
     * @return string Formatted script to be loaded by bash.
     */
    protected function formatCompleteScript($applications) {
        if(!is_array($applications)) {
            $applications = array($applications);
        }
        $parts = array(self::BASH_COMPLETE_FUNCTION);
        foreach($applications as &$application) {
            $parts[] = sprintf(self::BASH_REGISTER_COMPLETE_TEMPLATE, $application, $application);
        }

        return implode(PHP_EOL.PHP_EOL, $parts);
    }

    /**
     * @return array List of applications already registered for bash completion using _yii_console_application() in {@link LCompleteCommand::$bashFile}
     */
    protected function getRegisteredApplications() {
        $return = array();
        if(is_file($this->bashFile)) {
            if(preg_match_all(self::PROGRAM_REGEX, file_get_contents($this->bashFile), $matches)) {
                return $matches[1];
            }
        }
        return $return;
    }

    /**
     * @return string Applications entry script name
     */
    protected function getScriptName(){
        return basename($this->getCommandRunner()->getScriptName());
    }


}


Код запускаемого скрипта выглядит стандартно, примерно так
#!/usr/bin/env php
<?php
/**
 * This is an executable application
 */
// change the following paths if necessary
$yii=dirname(__FILE__).'/../yii-1.1/framework/yii.php';
$config=dirname(__FILE__).'/config/main.php';

// remove the following lines when in production mode
defined('YII_DEBUG') or define('YII_DEBUG',true);
// specify how many levels of call stack should be shown in each log message
defined('YII_TRACE_LEVEL') or define('YII_TRACE_LEVEL',0);

require($yii);
Yii::createConsoleApplication($config)->run();


Код класса прокомментирован, но в целом остается только положить его кудато и добавить к конфигурации приложения в commandMap
'commandMap' => array(
        // ...
        'complete' => array(
            'class' => 'ext.LCompleteCommand',
            //'bashFile' => '/etc/bash_completion.d/yii_applications' //Defaults to </etc/bash_completion.d/yii_applications>. May be changed if needed
        ),
        // ...
    ),


и выполнить команду
./application complete install
от рута из директории приложения.

В новых сессиях bash по табу будут высвечиваться
  1. Для приложения — набор возможных команд
  2. Для команды — набор возможных действий (actions) и именованых параметров действия по умолчанию
  3. Для действия — подсказки по ее именованым параметрам


Естественно написание не обошлось без усиленого гугления. Для начала это была статья, дающая общее представление о том как работает bash completion.
Почти сразу же столкнулся с багом, который, судя по отзывам, встречается только в Ubuntu, и потратил некоторое время на то чтоб найти его решение (кроме пайпов, предлагается запускать приложение в режиме php5-cgi, но этого не понадобилось).

В итоге получилась команда, которая выдает bash подсказки к приложению к которому подключена и претендует на универсальность, ибо практически не зависит от него.
Писалось для Yii версии 1.1.7.

P.S. тег source lang=«php» почемуто не сработал, так что использовал pre
Теги:
Хабы:
Данная статья не подлежит комментированию, поскольку её автор ещё не является полноправным участником сообщества. Вы сможете связаться с автором только после того, как он получит приглашение от кого-либо из участников сообщества. До этого момента его username будет скрыт псевдонимом.