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