Часто определенный вспомогательный функционал для веб-приложения удобно вынести в консольное, которое в то же время будет оставаться частью основного, разделяя с ним классы, конфигурацию и прочее.
Также может возникнуть необходимость в создании целого проекта для консоли. Мне, к примеру, довелось писать систему работников для распределения задач с помощью Gearman.
Так, или иначе, консольное приложение для пользователя bash остается неполноценным, если для него нету автодополнения и подсказок по табу.
Озаботившись данным недостатком, решил его убрать.
Поскольку все разрабатывается на базе фреймворка Yii, который, кроме всего остального, предоставляет неплохой функционал для создания консольных приложений, ниже приведен получившийся у меня код потомка CConsoleCommand добавляющий возможность автокомплита.
Код запускаемого скрипта выглядит стандартно, примерно так
Код класса прокомментирован, но в целом остается только положить его кудато и добавить к конфигурации приложения в commandMap
и выполнить команду
от рута из директории приложения.
В новых сессиях bash по табу будут высвечиваться
Естественно написание не обошлось без усиленого гугления. Для начала это была статья, дающая общее представление о том как работает bash completion.
Почти сразу же столкнулся с багом, который, судя по отзывам, встречается только в Ubuntu, и потратил некоторое время на то чтоб найти его решение (кроме пайпов, предлагается запускать приложение в режиме php5-cgi, но этого не понадобилось).
В итоге получилась команда, которая выдает bash подсказки к приложению к которому подключена и претендует на универсальность, ибо практически не зависит от него.
Писалось для Yii версии 1.1.7.
P.S. тег source lang=«php» почемуто не сработал, так что использовал pre
Также может возникнуть необходимость в создании целого проекта для консоли. Мне, к примеру, довелось писать систему работников для распределения задач с помощью 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 по табу будут высвечиваться
- Для приложения — набор возможных команд
- Для команды — набор возможных действий (actions) и именованых параметров действия по умолчанию
- Для действия — подсказки по ее именованым параметрам
Естественно написание не обошлось без усиленого гугления. Для начала это была статья, дающая общее представление о том как работает bash completion.
Почти сразу же столкнулся с багом, который, судя по отзывам, встречается только в Ubuntu, и потратил некоторое время на то чтоб найти его решение (кроме пайпов, предлагается запускать приложение в режиме php5-cgi, но этого не понадобилось).
В итоге получилась команда, которая выдает bash подсказки к приложению к которому подключена и претендует на универсальность, ибо практически не зависит от него.
Писалось для Yii версии 1.1.7.
P.S. тег source lang=«php» почемуто не сработал, так что использовал pre