Changeset View
Changeset View
Standalone View
Standalone View
src/console/PhutilInteractiveEditor.php
- This file was added.
| <?php | |||||
| /** | |||||
| * Edit a document interactively, by launching $EDITOR (like vi or nano). | |||||
| * | |||||
| * $result = id(new InteractiveEditor($document)) | |||||
| * ->setName('shopping_list') | |||||
| * ->setLineOffset(15) | |||||
| * ->editInteractively(); | |||||
| * | |||||
| * This will launch the user's $EDITOR to edit the specified '$document', and | |||||
| * return their changes into '$result'. | |||||
| * | |||||
| * @task create Creating a New Editor | |||||
| * @task edit Editing Interactively | |||||
| * @task config Configuring Options | |||||
| */ | |||||
| final class PhutilInteractiveEditor extends Phobject { | |||||
| private $name = ''; | |||||
| private $content = ''; | |||||
| private $offset = 0; | |||||
| private $preferred; | |||||
| private $fallback; | |||||
| /* -( Creating a New Editor )---------------------------------------------- */ | |||||
| /** | |||||
| * Constructs an interactive editor, using the text of a document. | |||||
| * | |||||
| * @param string Document text. | |||||
| * @return $this | |||||
| * | |||||
| * @task create | |||||
| */ | |||||
| public function __construct($content) { | |||||
| $this->setContent($content); | |||||
| } | |||||
| /* -( Editing Interactively )----------------------------------------------- */ | |||||
| /** | |||||
| * Launch an editor and edit the content. The edited content will be | |||||
| * returned. | |||||
| * | |||||
| * @return string Edited content. | |||||
| * @throws Exception The editor exited abnormally or something untoward | |||||
| * occurred. | |||||
| * | |||||
| * @task edit | |||||
| */ | |||||
| public function editInteractively() { | |||||
| $name = $this->getName(); | |||||
| $content = $this->getContent(); | |||||
| if (phutil_is_windows()) { | |||||
| $content = str_replace("\n", "\r\n", $content); | |||||
| } | |||||
| $tmp = Filesystem::createTemporaryDirectory('edit.'); | |||||
| $path = $tmp.DIRECTORY_SEPARATOR.$name; | |||||
| try { | |||||
| Filesystem::writeFile($path, $content); | |||||
| } catch (Exception $ex) { | |||||
| Filesystem::remove($tmp); | |||||
| throw $ex; | |||||
| } | |||||
| $editor = $this->getEditor(); | |||||
| $offset = $this->getLineOffset(); | |||||
| $err = $this->invokeEditor($editor, $path, $offset); | |||||
| if ($err) { | |||||
| // See T13297. On macOS, "vi" and "vim" may exit with errors even though | |||||
| // the edit succeeded. If the binary is "vi" or "vim" and we get an exit | |||||
| // code, we perform an additional test on the binary. | |||||
| $vi_binaries = array( | |||||
| 'vi' => true, | |||||
| 'vim' => true, | |||||
| ); | |||||
| $binary = basename($editor); | |||||
| if (isset($vi_binaries[$binary])) { | |||||
| // This runs "Q" (an invalid command), then "q" (a valid command, | |||||
| // meaning "quit"). Vim binaries with behavior that makes them poor | |||||
| // interactive editors will exit "1". | |||||
| list($diagnostic_err) = exec_manual('%R +Q +q', $binary); | |||||
| // If we get an error back, the binary is badly behaved. Ignore the | |||||
| // original error and assume it's not meaningful, since it just | |||||
| // indicates the user made a typo in a command when editing | |||||
| // interactively, which is routine and unconcerning. | |||||
| if ($diagnostic_err) { | |||||
| $err = 0; | |||||
| } | |||||
| } | |||||
| } | |||||
| if ($err) { | |||||
| Filesystem::remove($tmp); | |||||
| throw new Exception(pht('Editor exited with an error code (#%d).', $err)); | |||||
| } | |||||
| try { | |||||
| $result = Filesystem::readFile($path); | |||||
| Filesystem::remove($tmp); | |||||
| } catch (Exception $ex) { | |||||
| Filesystem::remove($tmp); | |||||
| throw $ex; | |||||
| } | |||||
| if (phutil_is_windows()) { | |||||
| $result = str_replace("\r\n", "\n", $result); | |||||
| } | |||||
| $this->setContent($result); | |||||
| return $this->getContent(); | |||||
| } | |||||
| private function invokeEditor($editor, $path, $offset) { | |||||
| // NOTE: Popular Windows editors like Notepad++ and GitPad do not support | |||||
| // line offsets, so just ignore the offset feature on Windows. We rarely | |||||
| // use it anyway. | |||||
| $offset_flag = ''; | |||||
| if ($offset && !phutil_is_windows()) { | |||||
| $offset = (int)$offset; | |||||
| if (preg_match('/^mate/', $editor)) { | |||||
| $offset_flag = csprintf('-l %d', $offset); | |||||
| } else { | |||||
| $offset_flag = csprintf('+%d', $offset); | |||||
| } | |||||
| } | |||||
| $cmd = csprintf( | |||||
| '%C %C %s', | |||||
| $editor, | |||||
| $offset_flag, | |||||
| $path); | |||||
| return phutil_passthru('%C', $cmd); | |||||
| } | |||||
| /* -( Configuring Options )------------------------------------------------- */ | |||||
| /** | |||||
| * Set the line offset where the cursor should be positioned when the editor | |||||
| * opens. By default, the cursor will be positioned at the start of the | |||||
| * content. | |||||
| * | |||||
| * @param int Line number where the cursor should be positioned. | |||||
| * @return $this | |||||
| * | |||||
| * @task config | |||||
| */ | |||||
| public function setLineOffset($offset) { | |||||
| $this->offset = (int)$offset; | |||||
| return $this; | |||||
| } | |||||
| /** | |||||
| * Get the current line offset. See setLineOffset(). | |||||
| * | |||||
| * @return int Current line offset. | |||||
| * | |||||
| * @task config | |||||
| */ | |||||
| public function getLineOffset() { | |||||
| return $this->offset; | |||||
| } | |||||
| /** | |||||
| * Set the document name. Depending on the editor, this may be exposed to | |||||
| * the user and can give them a sense of what they're editing. | |||||
| * | |||||
| * @param string Document name. | |||||
| * @return $this | |||||
| * | |||||
| * @task config | |||||
| */ | |||||
| public function setName($name) { | |||||
| $name = preg_replace('/[^A-Z0-9._-]+/i', '', $name); | |||||
| $this->name = $name; | |||||
| return $this; | |||||
| } | |||||
| /** | |||||
| * Get the current document name. See @{method:setName} for details. | |||||
| * | |||||
| * @return string Current document name. | |||||
| * | |||||
| * @task config | |||||
| */ | |||||
| public function getName() { | |||||
| if (!strlen($this->name)) { | |||||
| return 'untitled'; | |||||
| } | |||||
| return $this->name; | |||||
| } | |||||
| /** | |||||
| * Set the text content to be edited. | |||||
| * | |||||
| * @param string New content. | |||||
| * @return $this | |||||
| * | |||||
| * @task config | |||||
| */ | |||||
| public function setContent($content) { | |||||
| $this->content = $content; | |||||
| return $this; | |||||
| } | |||||
| /** | |||||
| * Retrieve the current content. | |||||
| * | |||||
| * @return string | |||||
| * | |||||
| * @task config | |||||
| */ | |||||
| public function getContent() { | |||||
| return $this->content; | |||||
| } | |||||
| /** | |||||
| * Set the fallback editor program to be used if the env variable $EDITOR | |||||
| * is not available and there is no `editor` binary in PATH. | |||||
| * | |||||
| * @param string Command-line editing program (e.g. 'emacs', 'vi') | |||||
| * @return $this | |||||
| * | |||||
| * @task config | |||||
| */ | |||||
| public function setFallbackEditor($editor) { | |||||
| $this->fallback = $editor; | |||||
| return $this; | |||||
| } | |||||
| /** | |||||
| * Set the preferred editor program. If set, this will override all other | |||||
| * sources of editor configuration, like $EDITOR. | |||||
| * | |||||
| * @param string Command-line editing program (e.g. 'emacs', 'vi') | |||||
| * @return $this | |||||
| * | |||||
| * @task config | |||||
| */ | |||||
| public function setPreferredEditor($editor) { | |||||
| $this->preferred = $editor; | |||||
| return $this; | |||||
| } | |||||
| /** | |||||
| * Get the name of the editor program to use. The value of the environmental | |||||
| * variable $EDITOR will be used if available; otherwise, the `editor` binary | |||||
| * if present; otherwise the best editor will be selected. | |||||
| * | |||||
| * @return string Command-line editing program. | |||||
| * | |||||
| * @task config | |||||
| */ | |||||
| public function getEditor() { | |||||
| if ($this->preferred) { | |||||
| return $this->preferred; | |||||
| } | |||||
| $editor = getenv('EDITOR'); | |||||
| if ($editor) { | |||||
| return $editor; | |||||
| } | |||||
| if ($this->fallback) { | |||||
| return $this->fallback; | |||||
| } | |||||
| $candidates = array('editor', 'nano', 'sensible-editor', 'vi'); | |||||
| foreach ($candidates as $cmd) { | |||||
| if (Filesystem::binaryExists($cmd)) { | |||||
| return $cmd; | |||||
| } | |||||
| } | |||||
| throw new Exception( | |||||
| pht( | |||||
| 'Unable to launch an interactive text editor. Set the %s '. | |||||
| 'environment variable to an appropriate editor.', | |||||
| 'EDITOR')); | |||||
| } | |||||
| } | |||||