1<?php 2 3/* 4 * This file is part of Psy Shell. 5 * 6 * (c) 2012-2020 Justin Hileman 7 * 8 * For the full copyright and license information, please view the LICENSE 9 * file that was distributed with this source code. 10 */ 11 12namespace Psy\Command; 13 14use Psy\Context; 15use Psy\ContextAware; 16use Symfony\Component\Console\Input\InputArgument; 17use Symfony\Component\Console\Input\InputInterface; 18use Symfony\Component\Console\Input\InputOption; 19use Symfony\Component\Console\Output\OutputInterface; 20 21class EditCommand extends Command implements ContextAware 22{ 23 /** 24 * @var string 25 */ 26 private $runtimeDir = ''; 27 28 /** 29 * @var Context 30 */ 31 private $context; 32 33 /** 34 * Constructor. 35 * 36 * @param string $runtimeDir The directory to use for temporary files 37 * @param string|null $name The name of the command; passing null means it must be set in configure() 38 * 39 * @throws \Symfony\Component\Console\Exception\LogicException When the command name is empty 40 */ 41 public function __construct($runtimeDir, $name = null) 42 { 43 parent::__construct($name); 44 45 $this->runtimeDir = $runtimeDir; 46 } 47 48 protected function configure() 49 { 50 $this 51 ->setName('edit') 52 ->setDefinition([ 53 new InputArgument('file', InputArgument::OPTIONAL, 'The file to open for editing. If this is not given, edits a temporary file.', null), 54 new InputOption( 55 'exec', 56 'e', 57 InputOption::VALUE_NONE, 58 'Execute the file content after editing. This is the default when a file name argument is not given.', 59 null 60 ), 61 new InputOption( 62 'no-exec', 63 'E', 64 InputOption::VALUE_NONE, 65 'Do not execute the file content after editing. This is the default when a file name argument is given.', 66 null 67 ), 68 ]) 69 ->setDescription('Open an external editor. Afterwards, get produced code in input buffer.') 70 ->setHelp('Set the EDITOR environment variable to something you\'d like to use.'); 71 } 72 73 /** 74 * @param InputInterface $input 75 * @param OutputInterface $output 76 * 77 * @throws \InvalidArgumentException when both exec and no-exec flags are given or if a given variable is not found in the current context 78 * @throws \UnexpectedValueException if file_get_contents on the edited file returns false instead of a string 79 */ 80 protected function execute(InputInterface $input, OutputInterface $output) 81 { 82 if ($input->getOption('exec') && 83 $input->getOption('no-exec')) { 84 throw new \InvalidArgumentException('The --exec and --no-exec flags are mutually exclusive'); 85 } 86 87 $filePath = $this->extractFilePath($input->getArgument('file')); 88 89 $execute = $this->shouldExecuteFile( 90 $input->getOption('exec'), 91 $input->getOption('no-exec'), 92 $filePath 93 ); 94 95 $shouldRemoveFile = false; 96 97 if ($filePath === null) { 98 $filePath = \tempnam($this->runtimeDir, 'psysh-edit-command'); 99 $shouldRemoveFile = true; 100 } 101 102 $editedContent = $this->editFile($filePath, $shouldRemoveFile); 103 104 if ($execute) { 105 $this->getApplication()->addInput($editedContent); 106 } 107 108 return 0; 109 } 110 111 /** 112 * @param bool $execOption 113 * @param bool $noExecOption 114 * @param string|null $filePath 115 * 116 * @return bool 117 */ 118 private function shouldExecuteFile($execOption, $noExecOption, $filePath) 119 { 120 if ($execOption) { 121 return true; 122 } 123 124 if ($noExecOption) { 125 return false; 126 } 127 128 // By default, code that is edited is executed if there was no given input file path 129 return $filePath === null; 130 } 131 132 /** 133 * @param string|null $fileArgument 134 * 135 * @return string|null The file path to edit, null if the input was null, or the value of the referenced variable 136 * 137 * @throws \InvalidArgumentException If the variable is not found in the current context 138 */ 139 private function extractFilePath($fileArgument) 140 { 141 // If the file argument was a variable, get it from the context 142 if ($fileArgument !== null && 143 \strlen($fileArgument) > 0 && 144 $fileArgument[0] === '$') { 145 $fileArgument = $this->context->get(\preg_replace('/^\$/', '', $fileArgument)); 146 } 147 148 return $fileArgument; 149 } 150 151 /** 152 * @param string $filePath 153 * @param bool $shouldRemoveFile 154 * 155 * @return string 156 * 157 * @throws \UnexpectedValueException if file_get_contents on $filePath returns false instead of a string 158 */ 159 private function editFile($filePath, $shouldRemoveFile) 160 { 161 $escapedFilePath = \escapeshellarg($filePath); 162 $editor = (isset($_SERVER['EDITOR']) && $_SERVER['EDITOR']) ? $_SERVER['EDITOR'] : 'nano'; 163 164 $pipes = []; 165 $proc = \proc_open("{$editor} {$escapedFilePath}", [\STDIN, \STDOUT, \STDERR], $pipes); 166 \proc_close($proc); 167 168 $editedContent = @\file_get_contents($filePath); 169 170 if ($shouldRemoveFile) { 171 @\unlink($filePath); 172 } 173 174 if ($editedContent === false) { 175 throw new \UnexpectedValueException("Reading {$filePath} returned false"); 176 } 177 178 return $editedContent; 179 } 180 181 /** 182 * Set the Context reference. 183 * 184 * @param Context $context 185 */ 186 public function setContext(Context $context) 187 { 188 $this->context = $context; 189 } 190} 191